Smeargle 0.7.0
parent
78f679966d
commit
e97b943689
|
@ -0,0 +1,42 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
"""
|
||||||
|
Super basic simple stupid script to interpolate 1bpp font graphics from smeargle/porygon
|
||||||
|
"""
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
raise ValueError("syntax is girafarig.py infile.bin outfile.bin")
|
||||||
|
in_filename = sys.argv[1]
|
||||||
|
outfile = sys.argv[2]
|
||||||
|
|
||||||
|
with open(in_filename, "rb") as binary_file:
|
||||||
|
# Read the whole file at once
|
||||||
|
data = bytearray(binary_file.read())
|
||||||
|
|
||||||
|
output = bytearray(len(data))
|
||||||
|
|
||||||
|
read_base = 0
|
||||||
|
while read_base < len(data):
|
||||||
|
# process row
|
||||||
|
for i in range(0x10):
|
||||||
|
top_half_origin = read_base + (i*0x08)
|
||||||
|
bot_half_origin = read_base + (i*0x08) + 0x80
|
||||||
|
top_half_dest = read_base + (i*0x10)
|
||||||
|
bot_half_dest = read_base + (i*0x10) + 0x08
|
||||||
|
# process character
|
||||||
|
q = 1
|
||||||
|
for j in range(0x08):
|
||||||
|
top_half = data[top_half_origin+j]
|
||||||
|
bot_half = data[bot_half_origin+j]
|
||||||
|
output[top_half_dest+j] = top_half
|
||||||
|
output[bot_half_dest+j] = bot_half
|
||||||
|
read_base += 0x100
|
||||||
|
|
||||||
|
with open(outfile, "wb") as out:
|
||||||
|
out.write(output)
|
||||||
|
print ("Done")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
16
readme.txt
16
readme.txt
|
@ -1,4 +1,4 @@
|
||||||
Smeargle 0.6.0 readme
|
Smeargle 0.7.0 readme
|
||||||
---------------------
|
---------------------
|
||||||
Usage: smeargle.py game.json
|
Usage: smeargle.py game.json
|
||||||
|
|
||||||
|
@ -33,6 +33,7 @@ in each object or array.
|
||||||
"test.txt": { // Script filename.
|
"test.txt": { // Script filename.
|
||||||
"font": "Melissa 8", // Reference to the font table, above.
|
"font": "Melissa 8", // Reference to the font table, above.
|
||||||
"max_tiles_per_line": 8, // Optional: set to 0 for unlimited tiles.
|
"max_tiles_per_line": 8, // Optional: set to 0 for unlimited tiles.
|
||||||
|
"min_tiles_per_line": 0, // Optional: Non-zero enforces a minimum tile-wise width.
|
||||||
"output_format": "thingy", // Optional: Output format for tilemap. "thingy", "atlas"
|
"output_format": "thingy", // Optional: Output format for tilemap. "thingy", "atlas"
|
||||||
"leading_zeroes": true, // Optional: Forces 16-bit tilemap output (i.e. 0x0012 instead of 0x12)
|
"leading_zeroes": true, // Optional: Forces 16-bit tilemap output (i.e. 0x0012 instead of 0x12)
|
||||||
"tile_offset": 256, // Optional: Constant to add to tile index (first tile: 0x0000 + 256 = 0x0100)
|
"tile_offset": 256, // Optional: Constant to add to tile index (first tile: 0x0000 + 256 = 0x0100)
|
||||||
|
@ -76,12 +77,19 @@ arguments to see what formats are available.
|
||||||
|
|
||||||
Changelog
|
Changelog
|
||||||
---------
|
---------
|
||||||
|
0.7.0
|
||||||
|
* Add an optional argument to script JSON:
|
||||||
|
** min_tiles_per_line: enforce a minimum tile count per line.
|
||||||
|
* Split classes out to separate files.
|
||||||
|
* Add girafarig, a simple script for interpolating 1bpp graphics.
|
||||||
|
|
||||||
0.6.0
|
0.6.0
|
||||||
* Adds several optional arguments to script json elements:
|
* Adds several optional arguments to script json elements:
|
||||||
** output_format: determines how the tilemap text file gets rendered. Possible values are "atlas", "thingy", null
|
** output_format: determines how the tilemap text file gets rendered. Possible
|
||||||
|
values are "atlas", "thingy", null
|
||||||
** leading_zeroes: Forces tilemap output to always be 16-bit.
|
** leading_zeroes: Forces tilemap output to always be 16-bit.
|
||||||
** tile_offset: Adds a constant value to the tile index.
|
** tile_offset: Adds a constant value to the tile index. Useful if you want the
|
||||||
Useful if you want the tilemap to start counting somewhere other than zero.
|
tilemap to start counting somewhere other than zero.
|
||||||
** raw_fn: Filename for raw tile graphic png output.
|
** raw_fn: Filename for raw tile graphic png output.
|
||||||
** deduped_fn: Filename for compressed tile graphic png output.
|
** deduped_fn: Filename for compressed tile graphic png output.
|
||||||
** tilemap_fn: Filename for text index tilemap file.
|
** tilemap_fn: Filename for text index tilemap file.
|
||||||
|
|
370
smeargle.py
370
smeargle.py
|
@ -1,352 +1,40 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2018 Kiyoshi Aman
|
||||||
import json
|
#
|
||||||
from math import ceil, floor
|
# Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
# purpose with or without fee is hereby granted, provided that the above
|
||||||
from PyQt5.QtGui import QGuiApplication, QPixmap, QImage, QColor, QPainter
|
# copyright notice and this permission notice appear in all copies.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
class Font:
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
"""A simple class for managing Smeargle's font data."""
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
def __init__(self, filename):
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
"""Creates the font object.
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
|
# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
Takes a filename pointing at the JSON metadata for a font.
|
|
||||||
"""
|
import sys
|
||||||
with open(filename, mode='rb') as f:
|
import os
|
||||||
self._json = json.load(f)
|
|
||||||
|
from PyQt5.QtGui import QGuiApplication
|
||||||
self._image = QPixmap(self._json['filename'])
|
|
||||||
self._colors = []
|
from smeargle.game import Game
|
||||||
|
|
||||||
if 'palette' in self._json:
|
if len(sys.argv) < 1:
|
||||||
for color in self._json['palette']:
|
|
||||||
if isinstance(color, (list, tuple)):
|
|
||||||
self._colors.append(QColor(*color))
|
|
||||||
elif isinstance(color, str):
|
|
||||||
red = int(color[0:2], 16)
|
|
||||||
green = int(color[2:4], 16)
|
|
||||||
blue = int(color[4:6], 16)
|
|
||||||
self._colors.append(QColor(red, green, blue).rgb())
|
|
||||||
else:
|
|
||||||
raise ValueError('unsupported color format: {}'.format(color))
|
|
||||||
else:
|
|
||||||
print("WARNING: No palette was provided with this font. Output palette order cannot be guaranteed.")
|
|
||||||
tile = self.index(self.table[' ']['index'])
|
|
||||||
self._colors = [tile.toImage().pixel(0, 0).rgb()]
|
|
||||||
|
|
||||||
def index(self, idx):
|
|
||||||
"""Given an index, returns the character at that location in the font.
|
|
||||||
|
|
||||||
Please note that this function assumes that even variable-width fonts
|
|
||||||
are stored in a fixed-width grid.
|
|
||||||
"""
|
|
||||||
tpr = int(self._image.width() / self.width)
|
|
||||||
row = int(idx / tpr)
|
|
||||||
column = idx % tpr
|
|
||||||
|
|
||||||
x = column * self.width
|
|
||||||
y = row * self.height
|
|
||||||
|
|
||||||
if (x > self._image.width()) or (y > self._image.height()):
|
|
||||||
raise ValueError('out of bounds: {}'.format(idx))
|
|
||||||
|
|
||||||
return self._image.copy(x, y, self.width, self.height).toImage()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def palette(self):
|
|
||||||
return self._colors
|
|
||||||
|
|
||||||
@property
|
|
||||||
def width(self):
|
|
||||||
return self._json['width']
|
|
||||||
|
|
||||||
@property
|
|
||||||
def height(self):
|
|
||||||
return self._json['height']
|
|
||||||
|
|
||||||
@property
|
|
||||||
def table(self):
|
|
||||||
return self._json['map']
|
|
||||||
|
|
||||||
def length(self, text):
|
|
||||||
"""Calculate the pixel-wise length of the given string."""
|
|
||||||
return sum(self.table[x]['width'] for x in text)
|
|
||||||
|
|
||||||
|
|
||||||
class Script:
|
|
||||||
def __init__(self, filename, raw_fn=None, deduped_fn=None, tilemap_fn=None,
|
|
||||||
max_tiles=0, output_format=None, tile_offset=0, leading_zeroes=False):
|
|
||||||
self.max_tiles = max_tiles
|
|
||||||
self.output_format = output_format
|
|
||||||
self.tile_offset = tile_offset
|
|
||||||
self.leading_zeroes = leading_zeroes
|
|
||||||
self.raw_fn = raw_fn
|
|
||||||
self.deduped_fn = deduped_fn
|
|
||||||
self.tilemap_fn = tilemap_fn
|
|
||||||
with open(filename, mode='r', encoding='UTF-8') as f:
|
|
||||||
self._text = f.read().split('\n')
|
|
||||||
|
|
||||||
self._painter = QPainter()
|
|
||||||
|
|
||||||
def render_lines(self, font):
|
|
||||||
table = font.table
|
|
||||||
lines = []
|
|
||||||
max_tiles = self.max_tiles * font.width
|
|
||||||
|
|
||||||
for line in self._text:
|
|
||||||
if len(line) < 1:
|
|
||||||
continue
|
|
||||||
length = font.length(line)
|
|
||||||
length = ceil(length / font.width) * font.width
|
|
||||||
if 0 < max_tiles < length:
|
|
||||||
print('WARNING: "{}" exceeds {} tiles by {}px; truncating.'.format(
|
|
||||||
line,
|
|
||||||
int(max_tiles / font.width),
|
|
||||||
length - max_tiles
|
|
||||||
))
|
|
||||||
length = max_tiles
|
|
||||||
image = QImage(length, font.height, QImage.Format_RGB32)
|
|
||||||
image.fill(font.palette[0])
|
|
||||||
pos = 0
|
|
||||||
|
|
||||||
self._painter.begin(image)
|
|
||||||
for glyph in line:
|
|
||||||
width = font.table[glyph]['width']
|
|
||||||
if pos + width >= max_tiles:
|
|
||||||
break
|
|
||||||
self._painter.drawImage(pos, 0, font.index(font.table[glyph]['index'] - 1))
|
|
||||||
|
|
||||||
pos += width
|
|
||||||
self._painter.end()
|
|
||||||
|
|
||||||
lines.append((line, image, length, len(lines)))
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def generate_tilemap(self, font, lines):
|
|
||||||
tilemap = {}
|
|
||||||
raw_tiles = []
|
|
||||||
compressed_tiles = []
|
|
||||||
map_idx = {}
|
|
||||||
unique = total = 0
|
|
||||||
indexes = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
(text, image, length, lineno) = line
|
|
||||||
tile_idx = []
|
|
||||||
|
|
||||||
# number of tiles in this line
|
|
||||||
count = int(length / font.width)
|
|
||||||
|
|
||||||
column = 0
|
|
||||||
|
|
||||||
while count > 0:
|
|
||||||
tile = image.copy(column, 0, font.width, font.height)
|
|
||||||
if len(font.palette) > 1:
|
|
||||||
tile = tile.convertToFormat(QImage.Format_Indexed8, font.palette)
|
|
||||||
else:
|
|
||||||
tile = tile.convertToFormat(QImage.Format_Indexed8)
|
|
||||||
data = bytearray()
|
|
||||||
|
|
||||||
for y in range(tile.height()):
|
|
||||||
for x in range(tile.width()):
|
|
||||||
data.append(tile.pixelIndex(x, y))
|
|
||||||
|
|
||||||
data = bytes(data)
|
|
||||||
|
|
||||||
if data not in tilemap.keys():
|
|
||||||
tilemap[data] = tile
|
|
||||||
compressed_tiles.append(tile)
|
|
||||||
if self.output_format == 'atlas':
|
|
||||||
index = unique + self.tile_offset
|
|
||||||
upper_val = int(floor(index / 256))
|
|
||||||
lower_val = int(index % 256)
|
|
||||||
if upper_val > 0 or self.leading_zeroes is True:
|
|
||||||
map_idx[data] = "<${:02x}><${:02x}>".format(upper_val, lower_val)
|
|
||||||
else:
|
|
||||||
map_idx[data] = "<${:02x}>".format(lower_val)
|
|
||||||
elif self.output_format == 'thingy':
|
|
||||||
index = unique + self.tile_offset
|
|
||||||
upper_val = int(floor(index / 256))
|
|
||||||
lower_val = int(index % 256)
|
|
||||||
if upper_val > 0 or self.leading_zeroes is True:
|
|
||||||
map_idx[data] = "{:02x}{:02x}".format(upper_val, lower_val)
|
|
||||||
else:
|
|
||||||
map_idx[data] = "{:02x}".format(lower_val)
|
|
||||||
else:
|
|
||||||
if self.leading_zeroes:
|
|
||||||
map_idx[data] = '0x{:04x}'.format(unique + self.tile_offset)
|
|
||||||
else:
|
|
||||||
map_idx[data] = '0x{:02x}'.format(unique + self.tile_offset)
|
|
||||||
unique += 1
|
|
||||||
|
|
||||||
raw_tiles.append(tile)
|
|
||||||
tile_idx.append(map_idx[data])
|
|
||||||
total += 1
|
|
||||||
column += font.width
|
|
||||||
count -= 1
|
|
||||||
|
|
||||||
if self.output_format is None:
|
|
||||||
indexes.append((text, ' '.join(tile_idx)))
|
|
||||||
else:
|
|
||||||
indexes.append((text, ''.join(tile_idx)))
|
|
||||||
return compressed_tiles, raw_tiles, map_idx, indexes, total, unique
|
|
||||||
|
|
||||||
def render_tiles(self, font, tiles):
|
|
||||||
image = QImage(font.width * 16, ceil(len(tiles) / 16) * font.height, QImage.Format_RGB32)
|
|
||||||
image.fill(font.palette[0])
|
|
||||||
|
|
||||||
(row, column) = (0, 0)
|
|
||||||
|
|
||||||
self._painter.begin(image)
|
|
||||||
for tile in tiles:
|
|
||||||
self._painter.drawImage(column, row, tile)
|
|
||||||
|
|
||||||
if column < (font.width * 15):
|
|
||||||
column += font.width
|
|
||||||
else:
|
|
||||||
column = 0
|
|
||||||
row += font.height
|
|
||||||
self._painter.end()
|
|
||||||
|
|
||||||
if len(font.palette) > 1:
|
|
||||||
return image.convertToFormat(QImage.Format_Indexed8, font.palette)
|
|
||||||
else:
|
|
||||||
return image.convertToFormat(QImage.Format_Indexed8)
|
|
||||||
|
|
||||||
def render_tiles_to_file(self, font, tiles, filename):
|
|
||||||
self.render_tiles(font, tiles).save(filename, 'PNG')
|
|
||||||
|
|
||||||
|
|
||||||
class Game:
|
|
||||||
def __init__(self, filename):
|
|
||||||
with open(filename, mode='rb') as f:
|
|
||||||
self._data = json.load(f)
|
|
||||||
|
|
||||||
self._fonts = {}
|
|
||||||
self._scripts = {}
|
|
||||||
|
|
||||||
for name, file in self._data['fonts'].items():
|
|
||||||
self._fonts[name] = Font(file)
|
|
||||||
|
|
||||||
valid_formats = ['thingy', 'atlas', None]
|
|
||||||
defaults = {
|
|
||||||
'max_tiles_per_line': 0,
|
|
||||||
'output_format': None,
|
|
||||||
'tile_offset': 0,
|
|
||||||
'leading_zeroes': False,
|
|
||||||
'raw_fn': None,
|
|
||||||
'deduped_fn': None,
|
|
||||||
'tilemap_fn': None
|
|
||||||
}
|
|
||||||
|
|
||||||
for script, data in self._data['scripts'].items():
|
|
||||||
|
|
||||||
# Add defaults to script data if not present
|
|
||||||
for k, v in defaults.items():
|
|
||||||
if k not in data:
|
|
||||||
data[k] = v
|
|
||||||
|
|
||||||
if data['output_format'] not in valid_formats:
|
|
||||||
raise ValueError("output_format must be one of {} or omitted entirely".format(valid_formats[:-1]))
|
|
||||||
|
|
||||||
self._scripts[script] = (
|
|
||||||
Script(filename=script,
|
|
||||||
raw_fn=data['raw_fn'],
|
|
||||||
deduped_fn=data['deduped_fn'],
|
|
||||||
tilemap_fn=data['tilemap_fn'],
|
|
||||||
max_tiles=data['max_tiles_per_line'],
|
|
||||||
output_format=data['output_format'],
|
|
||||||
tile_offset=data['tile_offset'],
|
|
||||||
leading_zeroes=data['leading_zeroes']),
|
|
||||||
self._fonts[data['font']]
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fonts(self):
|
|
||||||
return tuple(self._fonts.keys())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scripts(self):
|
|
||||||
return tuple(self._scripts.keys())
|
|
||||||
|
|
||||||
def render_script(self, script, render_path, output=False):
|
|
||||||
if script not in self._scripts.keys():
|
|
||||||
raise KeyError('unknown script')
|
|
||||||
|
|
||||||
filebase = os.path.split(script)[-1]
|
|
||||||
name, ext = os.path.splitext(filebase)
|
|
||||||
|
|
||||||
script, font = self._scripts[script]
|
|
||||||
|
|
||||||
if script.raw_fn is None:
|
|
||||||
output_raw = os.path.join(render_path, name + '_raw.png')
|
|
||||||
else:
|
|
||||||
output_raw = os.path.join(render_path, script.raw_fn)
|
|
||||||
|
|
||||||
if script.deduped_fn is None:
|
|
||||||
output_comp = os.path.join(render_path, name + '_compressed.png')
|
|
||||||
else:
|
|
||||||
output_comp = os.path.join(render_path, script.deduped_fn)
|
|
||||||
|
|
||||||
if script.tilemap_fn is None:
|
|
||||||
output_map = os.path.join(render_path, name + '_index.txt')
|
|
||||||
else:
|
|
||||||
output_map = os.path.join(render_path, script.tilemap_fn)
|
|
||||||
|
|
||||||
if output: print('Rendering text...')
|
|
||||||
lines = script.render_lines(font)
|
|
||||||
if output: print('Text rendered.')
|
|
||||||
|
|
||||||
if output: print("Generating tilemap...", end='')
|
|
||||||
(compressed, raw, map_index, indexes, total, unique) = script.generate_tilemap(font, lines)
|
|
||||||
if output: print("{} tiles generated, {} unique.".format(total, unique))
|
|
||||||
|
|
||||||
if output: print('Writing compressed tiles...', end='')
|
|
||||||
script.render_tiles_to_file(font, compressed, output_comp)
|
|
||||||
if output: print('done.')
|
|
||||||
|
|
||||||
if output: print('Writing raw tiles...', end='')
|
|
||||||
script.render_tiles_to_file(font, raw, output_raw)
|
|
||||||
if output: print('done.')
|
|
||||||
|
|
||||||
if output: print('Writing map index...', end='')
|
|
||||||
with open(output_map, mode='wt') as f:
|
|
||||||
for text, index in indexes:
|
|
||||||
if script.output_format == 'thingy':
|
|
||||||
f.write('{}={}\n'.format(index, text))
|
|
||||||
else:
|
|
||||||
f.write('{} = {}\n'.format(text, index))
|
|
||||||
if output: print('done.')
|
|
||||||
|
|
||||||
if output:
|
|
||||||
print()
|
|
||||||
print('Raw tiles: ', output_raw)
|
|
||||||
print('Compressed: ', output_comp)
|
|
||||||
print('Tile<->text: ', output_map)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
if len(sys.argv) < 1:
|
|
||||||
print('Usage: smeargle.py game.json [output_directory]')
|
print('Usage: smeargle.py game.json [output_directory]')
|
||||||
print('\nPlease see the included readme.txt for documentation on file formats.')
|
print('\nPlease see the included readme.txt for documentation on file formats.')
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
app = QGuiApplication(sys.argv)
|
app = QGuiApplication(sys.argv)
|
||||||
render_path = sys.argv[2] if len(sys.argv) > 2 else 'output'
|
render_path = sys.argv[2] if len(sys.argv) > 2 else 'output'
|
||||||
if not os.path.exists(render_path):
|
if not os.path.exists(render_path):
|
||||||
os.mkdir(render_path, mode=0o644)
|
os.mkdir(render_path, mode=0o644)
|
||||||
|
|
||||||
print('Loading game data from {}...'.format(sys.argv[1]), end='')
|
print('Loading game data from {}...'.format(sys.argv[1]), end='')
|
||||||
game = Game(sys.argv[1])
|
game = Game(sys.argv[1])
|
||||||
print('done.')
|
print('done.')
|
||||||
|
|
||||||
for script in game.scripts:
|
for script in game.scripts:
|
||||||
print('Processing {}...'.format(script))
|
print('Processing {}...'.format(script))
|
||||||
game.render_script(script, render_path, output=True)
|
game.render_script(script, render_path, output=True)
|
||||||
print('{} processed.'.format(script))
|
print('{} processed.'.format(script))
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Copyright 2018 Kiyoshi Aman
|
||||||
|
#
|
||||||
|
# Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
# purpose with or without fee is hereby granted, provided that the above
|
||||||
|
# copyright notice and this permission notice appear in all copies.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
|
# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from PyQt5.QtGui import QPixmap, QColor
|
||||||
|
|
||||||
|
class Font:
|
||||||
|
"""A simple class for managing Smeargle's font data."""
|
||||||
|
|
||||||
|
def __init__(self, filename):
|
||||||
|
"""Creates the font object.
|
||||||
|
|
||||||
|
Takes a filename pointing at the JSON metadata for a font.
|
||||||
|
"""
|
||||||
|
with open(filename, mode='rb') as f:
|
||||||
|
self._json = json.load(f)
|
||||||
|
|
||||||
|
self._image = QPixmap(self._json['filename'])
|
||||||
|
self._colors = []
|
||||||
|
|
||||||
|
if 'palette' in self._json:
|
||||||
|
for color in self._json['palette']:
|
||||||
|
if isinstance(color, (list, tuple)):
|
||||||
|
self._colors.append(QColor(*color))
|
||||||
|
elif isinstance(color, str):
|
||||||
|
red = int(color[0:2], 16)
|
||||||
|
green = int(color[2:4], 16)
|
||||||
|
blue = int(color[4:6], 16)
|
||||||
|
self._colors.append(QColor(red, green, blue).rgb())
|
||||||
|
else:
|
||||||
|
raise ValueError('unsupported color format: {}'.format(color))
|
||||||
|
else:
|
||||||
|
print("WARNING: No palette was provided with this font. Output palette order cannot be guaranteed.")
|
||||||
|
tile = self.index(self.table[' ']['index'])
|
||||||
|
self._colors = [tile.toImage().pixel(0, 0).rgb()]
|
||||||
|
|
||||||
|
def index(self, idx):
|
||||||
|
"""Given an index, returns the character at that location in the font.
|
||||||
|
|
||||||
|
Please note that this function assumes that even variable-width fonts
|
||||||
|
are stored in a fixed-width grid.
|
||||||
|
"""
|
||||||
|
tpr = int(self._image.width() / self.width)
|
||||||
|
row = int(idx / tpr)
|
||||||
|
column = idx % tpr
|
||||||
|
|
||||||
|
x = column * self.width
|
||||||
|
y = row * self.height
|
||||||
|
|
||||||
|
if (x > self._image.width()) or (y > self._image.height()):
|
||||||
|
raise ValueError('out of bounds: {}'.format(idx))
|
||||||
|
|
||||||
|
return self._image.copy(x, y, self.width, self.height).toImage()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def palette(self):
|
||||||
|
return self._colors
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self):
|
||||||
|
return self._json['width']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self):
|
||||||
|
return self._json['height']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def table(self):
|
||||||
|
return self._json['map']
|
||||||
|
|
||||||
|
def length(self, text):
|
||||||
|
"""Calculate the pixel-wise length of the given string."""
|
||||||
|
return sum(self.table[x]['width'] for x in text)
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Copyright 2018 Kiyoshi Aman
|
||||||
|
#
|
||||||
|
# Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
# purpose with or without fee is hereby granted, provided that the above
|
||||||
|
# copyright notice and this permission notice appear in all copies.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
|
# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from smeargle.font import Font
|
||||||
|
from smeargle.script import Script
|
||||||
|
|
||||||
|
class Game:
|
||||||
|
def __init__(self, filename):
|
||||||
|
with open(filename, mode='rb') as f:
|
||||||
|
self._data = json.load(f)
|
||||||
|
|
||||||
|
self._fonts = {}
|
||||||
|
self._scripts = {}
|
||||||
|
|
||||||
|
for name, file in self._data['fonts'].items():
|
||||||
|
self._fonts[name] = Font(file)
|
||||||
|
|
||||||
|
valid_formats = ['thingy', 'atlas', None]
|
||||||
|
defaults = {
|
||||||
|
'max_tiles_per_line': 0,
|
||||||
|
'min_tiles_per_line': 0,
|
||||||
|
'output_format': None,
|
||||||
|
'tile_offset': 0,
|
||||||
|
'leading_zeroes': False,
|
||||||
|
'raw_fn': None,
|
||||||
|
'deduped_fn': None,
|
||||||
|
'tilemap_fn': None
|
||||||
|
}
|
||||||
|
|
||||||
|
for script, data in self._data['scripts'].items():
|
||||||
|
|
||||||
|
# Add defaults to script data if not present
|
||||||
|
for k, v in defaults.items():
|
||||||
|
if k not in data:
|
||||||
|
data[k] = v
|
||||||
|
|
||||||
|
if data['output_format'] not in valid_formats:
|
||||||
|
raise ValueError("output_format must be one of {} or omitted entirely".format(valid_formats[:-1]))
|
||||||
|
|
||||||
|
self._scripts[script] = (
|
||||||
|
Script(filename=script, **data),
|
||||||
|
self._fonts[data['font']]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fonts(self):
|
||||||
|
return tuple(self._fonts.keys())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scripts(self):
|
||||||
|
return tuple(self._scripts.keys())
|
||||||
|
|
||||||
|
def render_script(self, script, render_path, output=False):
|
||||||
|
if script not in self._scripts.keys():
|
||||||
|
raise KeyError('unknown script')
|
||||||
|
|
||||||
|
filebase = os.path.split(script)[-1]
|
||||||
|
name, ext = os.path.splitext(filebase)
|
||||||
|
|
||||||
|
script, font = self._scripts[script]
|
||||||
|
|
||||||
|
if script.raw_fn is None:
|
||||||
|
output_raw = os.path.join(render_path, name + '_raw.png')
|
||||||
|
else:
|
||||||
|
output_raw = os.path.join(render_path, script.raw_fn)
|
||||||
|
|
||||||
|
if script.deduped_fn is None:
|
||||||
|
output_comp = os.path.join(render_path, name + '_compressed.png')
|
||||||
|
else:
|
||||||
|
output_comp = os.path.join(render_path, script.deduped_fn)
|
||||||
|
|
||||||
|
if script.tilemap_fn is None:
|
||||||
|
output_map = os.path.join(render_path, name + '_index.txt')
|
||||||
|
else:
|
||||||
|
output_map = os.path.join(render_path, script.tilemap_fn)
|
||||||
|
|
||||||
|
if output: print('Rendering text...')
|
||||||
|
lines = script.render_lines(font)
|
||||||
|
if output: print('Text rendered.')
|
||||||
|
|
||||||
|
if output: print("Generating tilemap...", end='')
|
||||||
|
(compressed, raw, map_index, indexes, total, unique) = script.generate_tilemap(font, lines)
|
||||||
|
if output: print("{} tiles generated, {} unique.".format(total, unique))
|
||||||
|
|
||||||
|
if output: print('Writing compressed tiles...', end='')
|
||||||
|
script.render_tiles_to_file(font, compressed, output_comp)
|
||||||
|
if output: print('done.')
|
||||||
|
|
||||||
|
if output: print('Writing raw tiles...', end='')
|
||||||
|
script.render_tiles_to_file(font, raw, output_raw)
|
||||||
|
if output: print('done.')
|
||||||
|
|
||||||
|
if output: print('Writing map index...', end='')
|
||||||
|
with open(output_map, mode='wt') as f:
|
||||||
|
for text, index in indexes:
|
||||||
|
if script.output_format == 'thingy':
|
||||||
|
f.write('{}={}\n'.format(index, text))
|
||||||
|
else:
|
||||||
|
f.write('{} = {}\n'.format(text, index))
|
||||||
|
if output: print('done.')
|
||||||
|
|
||||||
|
if output:
|
||||||
|
print()
|
||||||
|
print('Raw tiles: ', output_raw)
|
||||||
|
print('Compressed: ', output_comp)
|
||||||
|
print('Tile<->text: ', output_map)
|
|
@ -0,0 +1,213 @@
|
||||||
|
# Copyright 2018 Kiyoshi Aman
|
||||||
|
#
|
||||||
|
# Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
# purpose with or without fee is hereby granted, provided that the above
|
||||||
|
# copyright notice and this permission notice appear in all copies.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
|
# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
from math import floor, ceil
|
||||||
|
|
||||||
|
from PyQt5.QtGui import QPainter, QImage
|
||||||
|
|
||||||
|
from smeargle.font import Font
|
||||||
|
|
||||||
|
def get_or_default(d, key, default):
|
||||||
|
if key not in d:
|
||||||
|
return default
|
||||||
|
return d[key]
|
||||||
|
|
||||||
|
class Script:
|
||||||
|
def __init__(self, filename, **kwargs):
|
||||||
|
self._cfg = {
|
||||||
|
'max_tiles': get_or_default(kwargs, 'max_tiles_per_line', 0),
|
||||||
|
'min_tiles': get_or_default(kwargs, 'min_tiles_per_line', 0),
|
||||||
|
'output_format': get_or_default(kwargs, 'output_format', None),
|
||||||
|
'tile_offset': get_or_default(kwargs, 'tile_offset', 0),
|
||||||
|
'leading_zeroes': get_or_default(kwargs, 'leading_zeroes', False),
|
||||||
|
'raw_fn': get_or_default(kwargs, 'raw_fn', None),
|
||||||
|
'deduped_fn': get_or_default(kwargs, 'deduped_fn', None),
|
||||||
|
'tilemap_fn': get_or_default(kwargs, 'tilemap_fn', None)
|
||||||
|
}
|
||||||
|
mint = self._cfg['min_tiles']
|
||||||
|
maxt = self._cfg['max_tiles']
|
||||||
|
|
||||||
|
if mint > maxt and maxt != 0:
|
||||||
|
raise ValueError('minimum tiles per line higher than maximum')
|
||||||
|
|
||||||
|
with open(filename, mode='r', encoding='UTF-8') as f:
|
||||||
|
self._text = f.read().split('\n')
|
||||||
|
|
||||||
|
self._painter = QPainter()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw_fn(self):
|
||||||
|
return self._cfg['raw_fn']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deduped_fn(self):
|
||||||
|
return self._cfg['deduped_fn']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tilemap_fn(self):
|
||||||
|
return self._cfg['tilemap_fn']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_format(self):
|
||||||
|
return self._cfg['output_format']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def leading_zeroes(self):
|
||||||
|
return self._cfg['leading_zeroes']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tile_offset(self):
|
||||||
|
return self._cfg['tile_offset']
|
||||||
|
|
||||||
|
def render_lines(self, font):
|
||||||
|
table = font.table
|
||||||
|
lines = []
|
||||||
|
max_tiles = self._cfg['max_tiles'] * font.width
|
||||||
|
min_tiles = self._cfg['min_tiles'] * font.width
|
||||||
|
|
||||||
|
for line in self._text:
|
||||||
|
if len(line) < 1:
|
||||||
|
continue
|
||||||
|
length = font.length(line)
|
||||||
|
length = ceil(length / font.width) * font.width
|
||||||
|
|
||||||
|
if max_tiles > 0:
|
||||||
|
if 0 < max_tiles < length:
|
||||||
|
print('WARNING: "{}" exceeds {} tiles by {}px; truncating.'.format(
|
||||||
|
line,
|
||||||
|
int(max_tiles / font.width),
|
||||||
|
length - max_tiles
|
||||||
|
))
|
||||||
|
length = max_tiles
|
||||||
|
elif min_tiles > 0:
|
||||||
|
if 0 < length < min_tiles:
|
||||||
|
print('INFO: "{}" is shorter than {} tiles by {}px'.format(
|
||||||
|
line,
|
||||||
|
int(min_tiles / font.width),
|
||||||
|
min_tiles - length
|
||||||
|
))
|
||||||
|
length = min_tiles
|
||||||
|
image = QImage(length, font.height, QImage.Format_RGB32)
|
||||||
|
image.fill(font.palette[0])
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
self._painter.begin(image)
|
||||||
|
for glyph in line:
|
||||||
|
width = font.table[glyph]['width']
|
||||||
|
if pos + width >= max_tiles and max_tiles > 0:
|
||||||
|
break
|
||||||
|
self._painter.drawImage(pos, 0, font.index(font.table[glyph]['index'] - 1))
|
||||||
|
|
||||||
|
pos += width
|
||||||
|
self._painter.end()
|
||||||
|
|
||||||
|
lines.append((line, image, length, len(lines)))
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def generate_tilemap(self, font, lines):
|
||||||
|
tilemap = {}
|
||||||
|
raw_tiles = []
|
||||||
|
compressed_tiles = []
|
||||||
|
map_idx = {}
|
||||||
|
unique = total = 0
|
||||||
|
indexes = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
(text, image, length, lineno) = line
|
||||||
|
tile_idx = []
|
||||||
|
|
||||||
|
# number of tiles in this line
|
||||||
|
count = int(length / font.width)
|
||||||
|
|
||||||
|
column = 0
|
||||||
|
|
||||||
|
while count > 0:
|
||||||
|
tile = image.copy(column, 0, font.width, font.height)
|
||||||
|
if len(font.palette) > 1:
|
||||||
|
tile = tile.convertToFormat(QImage.Format_Indexed8, font.palette)
|
||||||
|
else:
|
||||||
|
tile = tile.convertToFormat(QImage.Format_Indexed8)
|
||||||
|
data = bytearray()
|
||||||
|
|
||||||
|
for y in range(tile.height()):
|
||||||
|
for x in range(tile.width()):
|
||||||
|
data.append(tile.pixelIndex(x, y))
|
||||||
|
|
||||||
|
data = bytes(data)
|
||||||
|
|
||||||
|
if data not in tilemap.keys():
|
||||||
|
tilemap[data] = tile
|
||||||
|
compressed_tiles.append(tile)
|
||||||
|
if self.output_format == 'atlas':
|
||||||
|
index = unique + self.tile_offset
|
||||||
|
upper_val = int(floor(index / 256))
|
||||||
|
lower_val = int(index % 256)
|
||||||
|
if upper_val > 0 or self.leading_zeroes is True:
|
||||||
|
map_idx[data] = "<${:02x}><${:02x}>".format(upper_val, lower_val)
|
||||||
|
else:
|
||||||
|
map_idx[data] = "<${:02x}>".format(lower_val)
|
||||||
|
elif self.output_format == 'thingy':
|
||||||
|
index = unique + self.tile_offset
|
||||||
|
upper_val = int(floor(index / 256))
|
||||||
|
lower_val = int(index % 256)
|
||||||
|
if upper_val > 0 or self.leading_zeroes is True:
|
||||||
|
map_idx[data] = "{:02x}{:02x}".format(upper_val, lower_val)
|
||||||
|
else:
|
||||||
|
map_idx[data] = "{:02x}".format(lower_val)
|
||||||
|
else:
|
||||||
|
if self.leading_zeroes:
|
||||||
|
map_idx[data] = '0x{:04x}'.format(unique + self.tile_offset)
|
||||||
|
else:
|
||||||
|
map_idx[data] = '0x{:02x}'.format(unique + self.tile_offset)
|
||||||
|
unique += 1
|
||||||
|
|
||||||
|
raw_tiles.append(tile)
|
||||||
|
tile_idx.append(map_idx[data])
|
||||||
|
total += 1
|
||||||
|
column += font.width
|
||||||
|
count -= 1
|
||||||
|
|
||||||
|
if self.output_format is None:
|
||||||
|
indexes.append((text, ' '.join(tile_idx)))
|
||||||
|
else:
|
||||||
|
indexes.append((text, ''.join(tile_idx)))
|
||||||
|
return compressed_tiles, raw_tiles, map_idx, indexes, total, unique
|
||||||
|
|
||||||
|
def render_tiles(self, font, tiles):
|
||||||
|
image = QImage(font.width * 16, ceil(len(tiles) / 16) * font.height, QImage.Format_RGB32)
|
||||||
|
image.fill(font.palette[0])
|
||||||
|
|
||||||
|
(row, column) = (0, 0)
|
||||||
|
|
||||||
|
self._painter.begin(image)
|
||||||
|
for tile in tiles:
|
||||||
|
self._painter.drawImage(column, row, tile)
|
||||||
|
|
||||||
|
if column < (font.width * 15):
|
||||||
|
column += font.width
|
||||||
|
else:
|
||||||
|
column = 0
|
||||||
|
row += font.height
|
||||||
|
self._painter.end()
|
||||||
|
|
||||||
|
if len(font.palette) > 1:
|
||||||
|
return image.convertToFormat(QImage.Format_Indexed8, font.palette)
|
||||||
|
else:
|
||||||
|
return image.convertToFormat(QImage.Format_Indexed8)
|
||||||
|
|
||||||
|
def render_tiles_to_file(self, font, tiles, filename):
|
||||||
|
self.render_tiles(font, tiles).save(filename, 'PNG')
|
||||||
|
|
||||||
|
__all__ = ['Script']
|
Loading…
Reference in New Issue