playscii/charset.py

229 lines
8.9 KiB
Python

import os.path
import string
import time
from PIL import Image
from texture import Texture
CHARSET_DIR = "charsets/"
CHARSET_FILE_EXTENSION = "char"
class CharacterSetLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
changed = None
for charset in self.app.charsets:
if charset.has_updated():
changed = charset.filename
# reload data and image even if only one changed
try:
success = charset.load_char_data()
if success:
self.app.log(
"CharacterSetLord: success reloading {}".format(
charset.filename
)
)
else:
self.app.log(
"CharacterSetLord: failed reloading {}".format(
charset.filename
),
True,
)
except:
self.app.log(
"CharacterSetLord: failed reloading {}".format(
charset.filename
),
True,
)
class CharacterSet:
transparent_color = (0, 0, 0)
def __init__(self, app, src_filename, log):
self.init_success = False
self.app = app
self.filename = self.app.find_filename_path(
src_filename, CHARSET_DIR, CHARSET_FILE_EXTENSION
)
if not self.filename:
self.app.log("Couldn't find character set data {}".format(self.filename))
return
self.name = os.path.basename(self.filename)
self.name = os.path.splitext(self.name)[0]
# image filename discovered by character data load process
self.image_filename = None
# remember last modified times for data and image files
self.last_data_change = os.path.getmtime(self.filename)
self.last_image_change = 0
# do most stuff in load_char_data so we can hot reload
if not self.load_char_data():
return
# report
if log and not self.app.game_mode:
self.app.log(
"loaded charmap '{}' from {}:".format(self.name, self.filename)
)
self.report()
self.init_success = True
def load_char_data(self):
"carries out majority of CharacterSet init, including loading image"
char_data_src = open(self.filename, encoding="utf-8").readlines()
# allow comments: discard any line in char data starting with //
# (make sure this doesn't muck up legit mapping data)
char_data = []
for line in char_data_src:
if not line.startswith("//"):
char_data.append(line)
# first line = image file
# hold off assigning to self.image_filename til we know it's valid
img_filename = self.app.find_filename_path(
char_data.pop(0).strip(), CHARSET_DIR, "png"
)
if not img_filename:
self.app.log(
"Couldn't find character set image {}".format(self.image_filename)
)
return False
self.image_filename = img_filename
# now that we know the image file's name, store its last modified time
self.last_image_change = os.path.getmtime(self.image_filename)
# second line = character set dimensions
second_line = char_data.pop(0).strip().split(",")
self.map_width, self.map_height = int(second_line[0]), int(second_line[1])
self.char_mapping = {}
index = 0
for line in char_data:
# strip newlines from mapping
for char in line.strip("\r\n"):
if char not in self.char_mapping:
self.char_mapping[char] = index
index += 1
if index >= self.map_width * self.map_height:
break
# if no lower case included, map upper to lower & vice versa
has_upper, has_lower = False, False
for line in char_data:
for char in line:
if char.isupper():
has_upper = True
elif char.islower():
has_lower = True
if has_upper and not has_lower:
for char in string.ascii_lowercase:
# set may not have all letters
if char.upper() not in self.char_mapping:
continue
self.char_mapping[char] = self.char_mapping[char.upper()]
elif has_lower and not has_upper:
for char in string.ascii_uppercase:
if char.lower() not in self.char_mapping:
continue
self.char_mapping[char] = self.char_mapping[char.lower()]
# last valid index a character can be
self.last_index = self.map_width * self.map_height
# load image
self.load_image_data()
self.set_char_dimensions()
# store base filename for easy comparisons with not-yet-loaded sets
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
return True
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert("RGBA")
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
# any pixel that is "transparent color" will be made fully transparent
# any pixel that isn't will be opaque + tinted FG color
for y in range(self.image_height):
for x in range(self.image_width):
# TODO: PIL pixel access shows up in profiler, use numpy array
# assignment instead
color = img.getpixel((x, y))
if color[:3] == self.transparent_color[:3]:
# MAYBE-TODO: does keeping non-alpha color improve sampling?
img.putpixel((x, y), (color[0], color[1], color[2], 0))
self.texture = Texture(img.tobytes(), self.image_width, self.image_height)
# flip image data back and save it for later, eg image conversion
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_data = img
def set_char_dimensions(self):
# store character dimensions and UV size
self.char_width = int(self.image_width / self.map_width)
self.char_height = int(self.image_height / self.map_height)
self.u_width = self.char_width / self.image_width
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(
" source texture {} is {} x {} pixels".format(
self.image_filename, self.image_width, self.image_height
)
)
self.app.log(
" char pixel width/height is {} x {}".format(
self.char_width, self.char_height
)
)
self.app.log(
" char map width/height is {} x {}".format(self.map_width, self.map_height)
)
self.app.log(" last character index: {}".format(self.last_index))
def has_updated(self):
"return True if source image file has changed since last check"
# tolerate bad filenames in data, don't check stamps on nonexistent ones
if (
not self.image_filename
or not os.path.exists(self.filename)
or not os.path.exists(self.image_filename)
):
return False
data_changed = os.path.getmtime(self.filename) > self.last_data_change
img_changed = os.path.getmtime(self.image_filename) > self.last_image_change
if data_changed:
self.last_data_change = time.time()
if img_changed:
self.last_image_change = time.time()
return data_changed or img_changed
def get_char_index(self, char):
return self.char_mapping.get(char, 0)
def get_solid_pixels_in_char(self, char_index):
"Returns # of solid pixels in character at given index"
tile_x = int(char_index % self.map_width)
tile_y = int(char_index / self.map_width)
x_start = self.char_width * tile_x
x_end = x_start + self.char_width
y_start = self.char_height * tile_y
y_end = y_start + self.char_height
pixels = 0
for x in range(x_start, x_end):
for y in range(y_start, y_end):
color = self.image_data.getpixel((x, y))
if color[3] > 0:
pixels += 1
return pixels