Apply ruff auto-fixes and formatting

This commit is contained in:
Jared Miller 2026-02-12 19:49:46 -05:00
parent 0bdd700350
commit 1e4d31121b
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
87 changed files with 8157 additions and 5443 deletions

528
art.py
View file

@ -1,5 +1,9 @@
import os.path, json, time, traceback
import random # import random only so art scripts don't have to
import json
import os.path
import random # noqa: F401 -- art scripts exec'd in this scope use it
import time
import traceback
import numpy as np
from edit_command import CommandStack, EntireArtCommand
@ -15,22 +19,22 @@ ELEM_STRIDE = 6
UV_STRIDE = 2 * 4
# starting document defaults
DEFAULT_CHARSET = 'c64_petscii'
DEFAULT_PALETTE = 'c64_original'
DEFAULT_CHARSET = "c64_petscii"
DEFAULT_PALETTE = "c64_original"
DEFAULT_WIDTH, DEFAULT_HEIGHT = 40, 25
DEFAULT_ART_FILENAME = 'new'
DEFAULT_ART_FILENAME = "new"
DEFAULT_FRAME_DELAY = 0.1
DEFAULT_LAYER_Z = 0
DEFAULT_LAYER_Z_OFFSET = 0.5
ART_DIR = 'art/'
ART_FILE_EXTENSION = 'psci'
ART_DIR = "art/"
ART_FILE_EXTENSION = "psci"
THUMBNAIL_CACHE_DIR = 'thumbnails/'
THUMBNAIL_CACHE_DIR = "thumbnails/"
ART_SCRIPT_DIR = 'artscripts/'
SCRIPT_FILE_EXTENSION = 'arsc'
ART_SCRIPT_DIR = "artscripts/"
SCRIPT_FILE_EXTENSION = "arsc"
# flip/rotate UV constants
UV_NORMAL = 0
@ -45,25 +49,25 @@ UV_FLIP90 = 6
UV_FLIP270 = 7
uv_names = {
UV_NORMAL: 'Normal',
UV_ROTATE90: 'Rotate 90',
UV_ROTATE180: 'Rotate 180',
UV_ROTATE270: 'Rotate 270',
UV_FLIPX: 'Flip X',
UV_FLIPY: 'Flip Y',
UV_FLIP90: 'Flipped90',
UV_FLIP270: 'Flipped270'
UV_NORMAL: "Normal",
UV_ROTATE90: "Rotate 90",
UV_ROTATE180: "Rotate 180",
UV_ROTATE270: "Rotate 270",
UV_FLIPX: "Flip X",
UV_FLIPY: "Flip Y",
UV_FLIP90: "Flipped90",
UV_FLIP270: "Flipped270",
}
uv_types = {
UV_NORMAL: (0, 0, 1, 0, 0, 1, 1, 1),
UV_ROTATE90: (0, 1, 0, 0, 1, 1, 1, 0),
UV_NORMAL: (0, 0, 1, 0, 0, 1, 1, 1),
UV_ROTATE90: (0, 1, 0, 0, 1, 1, 1, 0),
UV_ROTATE180: (1, 1, 0, 1, 1, 0, 0, 0),
UV_ROTATE270: (1, 0, 1, 1, 0, 0, 0, 1),
UV_FLIPX: (1, 0, 0, 0, 1, 1, 0, 1),
UV_FLIPY: (0, 1, 1, 1, 0, 0, 1, 0),
UV_FLIP90: (0, 0, 0, 1, 1, 0, 1, 1),
UV_FLIP270: (1, 1, 1, 0, 0, 1, 0, 0)
UV_FLIPX: (1, 0, 0, 0, 1, 1, 0, 1),
UV_FLIPY: (0, 1, 1, 1, 0, 0, 1, 0),
UV_FLIP90: (0, 0, 0, 1, 1, 0, 1, 1),
UV_FLIP270: (1, 1, 1, 0, 0, 1, 0, 0),
}
# reverse dict for easy (+ fast?) lookup in eg get_char_transform_at
@ -75,7 +79,7 @@ uv_types_reverse = {
uv_types[UV_FLIPX]: UV_FLIPX,
uv_types[UV_FLIPY]: UV_FLIPY,
uv_types[UV_FLIP90]: UV_FLIP90,
uv_types[UV_FLIP270]: UV_FLIP270
uv_types[UV_FLIP270]: UV_FLIP270,
}
@ -93,7 +97,8 @@ class Art:
- char/color tile values are expressed as indices into charset / palette
- all layers in an Art are the same dimensions
"""
quad_width,quad_height = 1.0, 1.0
quad_width, quad_height = 1.0, 1.0
"size of each tile in world space"
log_size_changes = False
recalc_quad_height = True
@ -102,8 +107,8 @@ class Art:
def __init__(self, filename, app, charset, palette, width, height):
"Creates a new, blank document with given parameters."
self.valid = False
if filename and not filename.endswith('.%s' % ART_FILE_EXTENSION):
filename += '.%s' % ART_FILE_EXTENSION
if filename and not filename.endswith(".%s" % ART_FILE_EXTENSION):
filename += ".%s" % ART_FILE_EXTENSION
self.filename = filename
self.app = app
# save "time loaded" for menu sorting
@ -113,7 +118,7 @@ class Art:
self.unsaved_changes = False
self.width, self.height = width, height
# selected char/fg/bg/xform
self.selected_char = self.charset.get_char_index('A') or 2
self.selected_char = self.charset.get_char_index("A") or 2
self.selected_fg_color = self.palette.lightest_index
self.selected_bg_color = self.palette.darkest_index
self.selected_xform = UV_NORMAL
@ -156,12 +161,12 @@ class Art:
self.valid = True
def log_init(self):
self.app.log('created new document:')
self.app.log(' character set: %s' % self.charset.name)
self.app.log(' palette: %s' % self.palette.name)
self.app.log(' width/height: %s x %s' % (self.width, self.height))
self.app.log(' frames: %s' % self.frames)
self.app.log(' layers: %s' % self.layers)
self.app.log("created new document:")
self.app.log(" character set: %s" % self.charset.name)
self.app.log(" palette: %s" % self.palette.name)
self.app.log(" width/height: %s x %s" % (self.width, self.height))
self.app.log(" frames: %s" % self.frames)
self.app.log(" layers: %s" % self.layers)
def init_layers(self):
self.layers = 1
@ -170,7 +175,7 @@ class Art:
# lists of layer Z values and names
self.layers_z = [DEFAULT_LAYER_Z]
self.layers_visibility = [True]
self.layer_names = ['Layer 1']
self.layer_names = ["Layer 1"]
def init_frames(self):
self.frames = 0
@ -214,7 +219,7 @@ class Art:
if self.app.ui and self is self.app.ui.active_art:
self.app.ui.set_active_frame(index)
if log:
self.app.log('Created new frame at index %s' % str(index))
self.app.log("Created new frame at index %s" % str(index))
def add_frame_to_end(self, delay=DEFAULT_FRAME_DELAY, log=True):
"Add a blank frame at the end of the current animation."
@ -223,7 +228,9 @@ class Art:
def duplicate_frame(self, src_frame_index, dest_frame_index=None, delay=None):
"Create a duplicate of given frame at given index."
# stick new frame at end if no destination index given
dest_frame_index = dest_frame_index if dest_frame_index is not None else self.frames
dest_frame_index = (
dest_frame_index if dest_frame_index is not None else self.frames
)
# copy source frame's delay if none given
delay = delay or self.frame_delays[src_frame_index]
self.frames += 1
@ -237,8 +244,10 @@ class Art:
self.mark_all_frames_changed()
# set new frame as active
if self is self.app.ui.active_art:
self.app.ui.set_active_frame(dest_frame_index-1)
self.app.log('Duplicated frame %s at frame %s' % (src_frame_index+1, dest_frame_index))
self.app.ui.set_active_frame(dest_frame_index - 1)
self.app.log(
"Duplicated frame %s at frame %s" % (src_frame_index + 1, dest_frame_index)
)
def delete_frame_at(self, index):
"Delete frame at given index."
@ -275,16 +284,18 @@ class Art:
# duplicate_layer increases self.layers by 1
self.duplicate_layer(index, z, name)
for frame in range(self.frames):
self.clear_frame_layer(frame, self.layers-1, 0)
self.clear_frame_layer(frame, self.layers - 1, 0)
# set new layer as active
if self is self.app.ui.active_art:
self.app.ui.set_active_layer(index+1)
self.app.ui.set_active_layer(index + 1)
def duplicate_layer(self, src_index, z=None, new_name=None):
"Duplicate layer with given index. Duplicate uses given Z and name."
def duplicate_layer_array(array):
src_data = np.array([array[src_index]])
return np.append(array, src_data, 0)
for frame in range(self.frames):
self.chars[frame] = duplicate_layer_array(self.chars[frame])
self.fg_colors[frame] = duplicate_layer_array(self.fg_colors[frame])
@ -295,7 +306,7 @@ class Art:
z = z if z is not None else self.layers_z[src_index]
self.layers_z.append(z)
self.layers_visibility.append(True)
new_name = new_name or 'Copy of %s' % self.layer_names[src_index]
new_name = new_name or "Copy of %s" % self.layer_names[src_index]
self.layer_names.append(new_name)
# rebuild geo with added verts for new layer
self.geo_changed = True
@ -304,7 +315,7 @@ class Art:
self.app.ui.set_active_layer(self.layers - 1)
# don't log new layers created on the fly in game mode
if not self.app.game_mode:
self.app.log('Added new layer %s' % new_name)
self.app.log("Added new layer %s" % new_name)
self.set_unsaved_changes(True)
def clear_frame_layer(self, frame, layer, bg_color=0, fg_color=None):
@ -348,11 +359,15 @@ class Art:
self.charset = new_charset
if self.recalc_quad_height:
self.quad_width = 1.0
self.quad_height = 1.0 * (self.charset.char_height / self.charset.char_width)
self.quad_height = 1.0 * (
self.charset.char_height / self.charset.char_width
)
self.set_unsaved_changes(True)
self.geo_changed = True
if log:
self.app.ui.message_line.post_line('Character set changed to %s' % self.charset.name)
self.app.ui.message_line.post_line(
"Character set changed to %s" % self.charset.name
)
def set_charset_by_name(self, new_charset_name):
charset = self.app.load_charset(new_charset_name)
@ -366,7 +381,9 @@ class Art:
self.palette = new_palette
self.set_unsaved_changes(True)
if log:
self.app.ui.message_line.post_line('Color palette changed to %s' % self.palette.name)
self.app.ui.message_line.post_line(
"Color palette changed to %s" % self.palette.name
)
def set_palette_by_name(self, new_palette_name):
palette = self.app.load_palette(new_palette_name)
@ -388,7 +405,7 @@ class Art:
def set_active_layer(self, new_layer):
"Set layer at given index for active editing in Art Mode."
self.active_layer = min(max(0, new_layer), self.layers-1)
self.active_layer = min(max(0, new_layer), self.layers - 1)
def crop(self, new_width, new_height, origin_x=0, origin_y=0):
x0, y0 = origin_x, origin_y
@ -396,8 +413,13 @@ class Art:
crop_x = new_width < self.width
crop_y = new_height < self.height
for frame in range(self.frames):
for array in [self.chars, self.fg_colors, self.bg_colors,
self.uv_mods, self.uv_maps]:
for array in [
self.chars,
self.fg_colors,
self.bg_colors,
self.uv_mods,
self.uv_maps,
]:
if crop_x:
array[frame] = array[frame].take(range(x0, x1), axis=2)
if crop_y:
@ -406,7 +428,8 @@ class Art:
def expand(self, new_width, new_height, bg_fill):
x_add = new_width - self.width
y_add = new_height - self.height
#print('%s expand: %sw + %s = %s, %sh + %s = %s' % (self.filename,
# print('%s expand: %sw + %s = %s, %sh + %s = %s' % (self.filename,
# self.width, x_add, new_width, self.height, y_add, new_height))
def expand_array(array, fill_value, stride):
# add columns (increasing width)
@ -424,6 +447,7 @@ class Art:
array = np.append(array, add, 1)
# can't modify passed array in-place
return array
for frame in range(self.frames):
self.chars[frame] = expand_array(self.chars[frame], 0, 4)
fg, bg = 0, 0
@ -435,7 +459,9 @@ class Art:
bg = self.app.ui.selected_bg_color
self.fg_colors[frame] = expand_array(self.fg_colors[frame], fg, 4)
self.bg_colors[frame] = expand_array(self.bg_colors[frame], bg, 4)
self.uv_mods[frame] = expand_array(self.uv_mods[frame], uv_types[UV_NORMAL], UV_STRIDE)
self.uv_mods[frame] = expand_array(
self.uv_mods[frame], uv_types[UV_NORMAL], UV_STRIDE
)
self.uv_maps[frame] = expand_array(self.uv_maps[frame], UV_NORMAL, 4)
def mark_frame_changed(self, frame):
@ -487,10 +513,10 @@ class Art:
top_y = tile_y * -self.quad_height
right_x = left_x + self.quad_width
bottom_y = top_y - self.quad_height
x0,y0 = left_x, top_y
x1,y1 = right_x, top_y
x2,y2 = left_x, bottom_y
x3,y3 = right_x, bottom_y
x0, y0 = left_x, top_y
x1, y1 = right_x, top_y
x2, y2 = left_x, bottom_y
x3, y3 = right_x, bottom_y
# Z of all layers is 0, layer Z set in shader
verts = [x0, y0, 0]
verts += [x1, y1, 0]
@ -498,9 +524,9 @@ class Art:
verts += [x3, y3, 0]
self.vert_array[layer][tile_y][tile_x] = verts
# vertex elements
elements = [vert_index, vert_index+1, vert_index+2]
elements += [vert_index+1, vert_index+2, vert_index+3]
self.elem_array[elem_index:elem_index+ELEM_STRIDE] = elements
elements = [vert_index, vert_index + 1, vert_index + 2]
elements += [vert_index + 1, vert_index + 2, vert_index + 3]
self.elem_array[elem_index : elem_index + ELEM_STRIDE] = elements
elem_index += ELEM_STRIDE
# 4 verts in a quad
vert_index += 4
@ -562,7 +588,8 @@ class Art:
Set (fg or bg) color index for given frame/layer/x,y tile.
Foreground or background specified with "fg" boolean.
"""
if color_index is None: return
if color_index is None:
return
# modulo to resolve any negative indices
if 0 < color_index >= len(self.palette.colors):
color_index %= len(self.palette.colors)
@ -602,8 +629,18 @@ class Art:
self.uv_maps[frame][layer][y][x] = transform
self.uv_changed_frames[frame] = True
def set_tile_at(self, frame, layer, x, y, char_index=None, fg=None, bg=None,
transform=None, set_all=False):
def set_tile_at(
self,
frame,
layer,
x,
y,
char_index=None,
fg=None,
bg=None,
transform=None,
set_all=False,
):
"""
Convenience function for setting all tile attributes (character index,
foreground and background color, and transofmr) at once.
@ -632,13 +669,25 @@ class Art:
for layer in range(self.layers):
for y in range(self.height):
for x in range(self.width):
self.set_char_transform_at(frame, layer, x, y, flip_dict[self.get_char_transform_at(frame, layer, x, y)])
self.set_char_transform_at(
frame,
layer,
x,
y,
flip_dict[self.get_char_transform_at(frame, layer, x, y)],
)
def flip_horizontal(self, frame, layer):
"Mirrors Art left-to-right."
command = EntireArtCommand(self)
command.save_tiles(before=True)
for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
for a in [
self.chars,
self.fg_colors,
self.bg_colors,
self.uv_mods,
self.uv_maps,
]:
a[frame][layer] = np.fliplr(a[frame][layer])
if self.app.ui.flip_affects_xforms:
flips = {
@ -649,7 +698,7 @@ class Art:
UV_ROTATE90: UV_FLIP90,
UV_FLIP90: UV_ROTATE90,
UV_ROTATE270: UV_FLIP270,
UV_FLIP270: UV_ROTATE270
UV_FLIP270: UV_ROTATE270,
}
self.flip_all_xforms(flips)
self.mark_frame_changed(frame)
@ -661,7 +710,13 @@ class Art:
"Flips Art upside down."
command = EntireArtCommand(self)
command.save_tiles(before=True)
for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
for a in [
self.chars,
self.fg_colors,
self.bg_colors,
self.uv_mods,
self.uv_maps,
]:
a[frame][layer] = np.flipud(a[frame][layer])
if self.app.ui.flip_affects_xforms:
flips = {
@ -673,7 +728,7 @@ class Art:
UV_ROTATE90: UV_FLIP270,
UV_FLIP270: UV_ROTATE90,
UV_ROTATE270: UV_FLIP90,
UV_FLIP90: UV_ROTATE270
UV_FLIP90: UV_ROTATE270,
}
self.flip_all_xforms(flips)
self.mark_frame_changed(frame)
@ -683,7 +738,13 @@ class Art:
def shift(self, frame, layer, amount_x, amount_y):
"Shift + wrap art on given frame and layer by given amount in X and Y."
for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
for a in [
self.chars,
self.fg_colors,
self.bg_colors,
self.uv_mods,
self.uv_maps,
]:
a[frame][layer] = np.roll(a[frame][layer], amount_x, 1)
a[frame][layer] = np.roll(a[frame][layer], amount_y, 0)
self.mark_frame_changed(frame)
@ -704,11 +765,13 @@ class Art:
self.selected_xform = self.app.ui.selected_xform
def changed_this_frame(self):
return self.geo_changed or \
True in self.char_changed_frames.values() or \
True in self.fg_changed_frames.values() or \
True in self.bg_changed_frames.values() or \
True in self.uv_changed_frames.values()
return (
self.geo_changed
or True in self.char_changed_frames.values()
or True in self.fg_changed_frames.values()
or True in self.bg_changed_frames.values()
or True in self.uv_changed_frames.values()
)
def update(self):
self.update_scripts()
@ -751,34 +814,40 @@ class Art:
# Art Mode documents dir, which we should assume does exist
filedir = os.path.dirname(self.filename)
if not os.path.exists(filedir):
#self.app.log('Tried to save to directory %s which does not exist!' % filedir, error=True)
new_path = self.app.documents_dir + ART_DIR + os.path.basename(self.filename)
# self.app.log('Tried to save to directory %s which does not exist!' % filedir, error=True)
new_path = (
self.app.documents_dir + ART_DIR + os.path.basename(self.filename)
)
self.set_filename(new_path)
start_time = time.time()
# cursor might be hovering, undo any preview changes
for edit in self.app.cursor.preview_edits:
edit.undo()
d = {'width': self.width, 'height': self.height,
'charset': self.charset.name, 'palette': self.palette.name,
'active_frame': self.active_frame,
'active_layer': self.active_layer,
'camera': (self.camera_x, self.camera_y, self.camera_z),
'selected_char': int(self.selected_char),
'selected_fg_color': int(self.selected_fg_color),
'selected_bg_color': int(self.selected_bg_color),
'selected_xform': int(self.selected_xform)
d = {
"width": self.width,
"height": self.height,
"charset": self.charset.name,
"palette": self.palette.name,
"active_frame": self.active_frame,
"active_layer": self.active_layer,
"camera": (self.camera_x, self.camera_y, self.camera_z),
"selected_char": int(self.selected_char),
"selected_fg_color": int(self.selected_fg_color),
"selected_bg_color": int(self.selected_bg_color),
"selected_xform": int(self.selected_xform),
}
# preferred character set and palette, default used if not found
# remember camera location
# frames and layers are dicts w/ lists of their data + a few properties
frames = []
for frame_index in range(self.frames):
frame = { 'delay': self.frame_delays[frame_index] }
frame = {"delay": self.frame_delays[frame_index]}
layers = []
for layer_index in range(self.layers):
layer = {'z': self.layers_z[layer_index],
'visible': int(self.layers_visibility[layer_index]),
'name': self.layer_names[layer_index]
layer = {
"z": self.layers_z[layer_index],
"visible": int(self.layers_visibility[layer_index]),
"name": self.layer_names[layer_index],
}
tiles = []
for y in range(self.height):
@ -787,28 +856,32 @@ class Art:
fg = int(self.fg_colors[frame_index][layer_index][y][x][0])
bg = int(self.bg_colors[frame_index][layer_index][y][x][0])
# use get method for transform, data's not simply an int
xform = int(self.get_char_transform_at(frame_index, layer_index, x, y))
tiles.append({'char': char, 'fg': fg, 'bg': bg, 'xform': xform})
layer['tiles'] = tiles
xform = int(
self.get_char_transform_at(frame_index, layer_index, x, y)
)
tiles.append({"char": char, "fg": fg, "bg": bg, "xform": xform})
layer["tiles"] = tiles
layers.append(layer)
frame['layers'] = layers
frame["layers"] = layers
frames.append(frame)
d['frames'] = frames
d["frames"] = frames
# MAYBE-TODO: below gives not-so-pretty-printing, find out way to control
# formatting for better output
json.dump(d, open(self.filename, 'w'), sort_keys=True, indent=1)
json.dump(d, open(self.filename, "w"), sort_keys=True, indent=1)
end_time = time.time()
self.set_unsaved_changes(False)
#self.app.log('saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time))
self.app.log('saved %s' % self.filename)
# self.app.log('saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time))
self.app.log("saved %s" % self.filename)
# remove old thumbnail
thumb_dir = self.app.cache_dir + THUMBNAIL_CACHE_DIR
if os.path.exists(self.filename):
old_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + '.png'
old_thumb_filename = (
thumb_dir + self.app.get_file_hash(self.filename) + ".png"
)
if os.path.exists(old_thumb_filename):
os.remove(old_thumb_filename)
# write thumbnail
new_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + '.png'
new_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + ".png"
write_thumbnail(self.app, self.filename, new_thumb_filename)
# thumbnail write process actually sets active frame! set it back
for r in self.renderables:
@ -824,34 +897,48 @@ class Art:
# - support multiple save+load code paths for different save versions
def get_flat_int_list(layer_array):
return list(map(int, layer_array.flatten()))[::4]
start_time = time.time()
d = {'width': self.width, 'height': self.height,
'charset': self.charset.name, 'palette': self.palette.name,
'active_frame': self.active_frame,
'active_layer': self.active_layer,
'camera': (self.camera_x, self.camera_y, self.camera_z)
d = {
"width": self.width,
"height": self.height,
"charset": self.charset.name,
"palette": self.palette.name,
"active_frame": self.active_frame,
"active_layer": self.active_layer,
"camera": (self.camera_x, self.camera_y, self.camera_z),
}
frames = []
for frame_index in range(self.frames):
frame = { 'delay': self.frame_delays[frame_index] }
frame = {"delay": self.frame_delays[frame_index]}
layers = []
for layer_index in range(self.layers):
layer = {'z': self.layers_z[layer_index],
'visible': int(self.layers_visibility[layer_index]),
'name': self.layer_names[layer_index]
layer = {
"z": self.layers_z[layer_index],
"visible": int(self.layers_visibility[layer_index]),
"name": self.layer_names[layer_index],
}
# compile lists-of-ints for chars, colors, xforms
layer['chars'] = get_flat_int_list(self.chars[frame_index][layer_index])
layer['fgs'] = get_flat_int_list(self.fg_colors[frame_index][layer_index])
layer['bgs'] = get_flat_int_list(self.bg_colors[frame_index][layer_index])
layer['xforms'] = get_flat_int_list(self.uv_maps[frame_index][layer_index])
layer["chars"] = get_flat_int_list(self.chars[frame_index][layer_index])
layer["fgs"] = get_flat_int_list(
self.fg_colors[frame_index][layer_index]
)
layer["bgs"] = get_flat_int_list(
self.bg_colors[frame_index][layer_index]
)
layer["xforms"] = get_flat_int_list(
self.uv_maps[frame_index][layer_index]
)
layers.append(layer)
frame['layers'] = layers
frame["layers"] = layers
frames.append(frame)
d['frames'] = frames
json.dump(d, open(self.filename + '2', 'w'), sort_keys=True, indent=None)
d["frames"] = frames
json.dump(d, open(self.filename + "2", "w"), sort_keys=True, indent=None)
end_time = time.time()
self.app.log('ALT saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time))
self.app.log(
"ALT saved %s to disk in %.5f seconds"
% (self.filename, end_time - start_time)
)
def set_unsaved_changes(self, new_status):
"Mark this Art as having unsaved changes in Art Mode."
@ -863,8 +950,8 @@ class Art:
def set_filename(self, new_filename):
"Change Art's filename to new given string."
# append extension if missing
if not new_filename.endswith('.' + ART_FILE_EXTENSION):
new_filename += '.' + ART_FILE_EXTENSION
if not new_filename.endswith("." + ART_FILE_EXTENSION):
new_filename += "." + ART_FILE_EXTENSION
# if no dir given, assume documents/art/ dir
if os.path.basename(new_filename) == new_filename:
new_dir = self.app.documents_dir
@ -898,15 +985,16 @@ class Art:
exec(open(script_filename).read())
# (assume script changed art)
self.unsaved_changes = True
logline = 'Executed %s' % script_filename
if log: self.app.log(logline)
logline = "Executed %s" % script_filename
if log:
self.app.log(logline)
error = False
except Exception as e:
except Exception:
error = True
logline = 'Error executing %s:' % script_filename
logline = "Error executing %s:" % script_filename
self.app.log(logline)
# skip first 3 lines of callstack before artscript exec
for line in traceback.format_exc().split('\n')[3:]:
for line in traceback.format_exc().split("\n")[3:]:
if line.strip():
self.app.log(line.rstrip())
# write "after" state of command and commit
@ -921,9 +1009,11 @@ class Art:
return script_filename and script_filename in self.scripts
def get_valid_script_filename(self, script_filename):
if not type(script_filename) is str: return None
return self.app.find_filename_path(script_filename, ART_SCRIPT_DIR,
SCRIPT_FILE_EXTENSION)
if type(script_filename) is not str:
return None
return self.app.find_filename_path(
script_filename, ART_SCRIPT_DIR, SCRIPT_FILE_EXTENSION
)
def run_script_every(self, script_filename, rate=0.1):
"Start a script running on this Art at a regular rate."
@ -931,7 +1021,7 @@ class Art:
if not script_filename:
return
if script_filename in self.scripts:
self.app.log('script %s is already running.' % script_filename)
self.app.log("script %s is already running." % script_filename)
return
# add to "scripts currently running" list
self.scripts.append(script_filename)
@ -946,7 +1036,7 @@ class Art:
script_filename = self.get_valid_script_filename(script_filename)
if not script_filename:
return
if not script_filename in self.scripts:
if script_filename not in self.scripts:
self.app.log("script %s exists but isn't running." % script_filename)
return
script_index = self.scripts.index(script_filename)
@ -965,15 +1055,16 @@ class Art:
# don't run on game art while paused
if self.app.game_mode and self.app.gw.paused:
return
for i,script in enumerate(self.scripts):
for i, script in enumerate(self.scripts):
if (self.app.get_elapsed_time() / 1000) > self.scripts_next_exec_time[i]:
# execute script directly; don't use formal safeguards of run_script
exec(open(script).read())
self.unsaved_changes = True
self.scripts_next_exec_time[i] += self.script_rates[i]
def clear_line(self, frame, layer, line_y, fg_color_index=None,
bg_color_index=None):
def clear_line(
self, frame, layer, line_y, fg_color_index=None, bg_color_index=None
):
"Clear characters on given horizontal line, to optional given colors."
# TODO: use numpy slicing to do this much more quickly!
for x in range(self.width):
@ -983,8 +1074,17 @@ class Art:
if bg_color_index:
self.set_color_at(frame, layer, x, line_y, bg_color_index, False)
def write_string(self, frame, layer, x, y, text, fg_color_index=None,
bg_color_index=None, right_justify=False):
def write_string(
self,
frame,
layer,
x,
y,
text,
fg_color_index=None,
bg_color_index=None,
right_justify=False,
):
"""
Write given string starting at given frame/layer/x,y tile, with
optional given colors, left-justified by default.
@ -998,18 +1098,30 @@ class Art:
else:
x_offset = 0
# never let string drawing go out of bounds
text = text[:self.width - (x+x_offset)]
text = text[: self.width - (x + x_offset)]
for char in text:
idx = self.charset.get_char_index(char)
self.set_char_index_at(frame, layer, x+x_offset, y, idx)
self.set_char_index_at(frame, layer, x + x_offset, y, idx)
if fg_color_index is not None:
self.set_color_at(frame, layer, x+x_offset, y, fg_color_index, True)
self.set_color_at(frame, layer, x + x_offset, y, fg_color_index, True)
if bg_color_index is not None:
self.set_color_at(frame, layer, x+x_offset, y, bg_color_index, False)
self.set_color_at(frame, layer, x + x_offset, y, bg_color_index, False)
x_offset += 1
def composite_to(self, src_frame, src_layer, src_x, src_y, width, height,
dest_art, dest_frame, dest_layer, dest_x, dest_y):
def composite_to(
self,
src_frame,
src_layer,
src_x,
src_y,
width,
height,
dest_art,
dest_frame,
dest_layer,
dest_x,
dest_y,
):
for y in range(src_y, src_y + height):
for x in range(src_x, src_x + width):
# never try to write out of bounds on dest art; let user be lazy
@ -1028,15 +1140,36 @@ class Art:
dy = dest_y + (y - src_y)
# transparent bg -> keep dest bg, else use entire src tile
if self.get_bg_color_index_at(src_frame, src_layer, x, y) == 0:
bg = dest_art.get_bg_color_index_at(dest_frame, dest_layer,
dx, dy)
dest_art.set_tile_at(dest_frame, dest_layer, dx, dy,
ch, fg, bg, xform)
bg = dest_art.get_bg_color_index_at(dest_frame, dest_layer, dx, dy)
dest_art.set_tile_at(dest_frame, dest_layer, dx, dy, ch, fg, bg, xform)
def composite_from(self, src_art, src_frame, src_layer, src_x, src_y,
width, height, dest_frame, dest_layer, dest_x, dest_y):
src_art.composite_to(src_frame, src_layer, src_x, src_y, width, height,
self, dest_frame, dest_layer, dest_x, dest_y)
def composite_from(
self,
src_art,
src_frame,
src_layer,
src_x,
src_y,
width,
height,
dest_frame,
dest_layer,
dest_x,
dest_y,
):
src_art.composite_to(
src_frame,
src_layer,
src_x,
src_y,
width,
height,
self,
dest_frame,
dest_layer,
dest_x,
dest_y,
)
def get_filtered_tiles(self, frame, layer, char_value, invert_filter=False):
"Return list of (x,y) tile coords that match (or don't) a char value."
@ -1044,8 +1177,9 @@ class Art:
for y in range(self.height):
for x in range(self.width):
char = self.get_char_index_at(frame, layer, x, y)
if (not invert_filter and char == char_value) or \
(invert_filter and char != char_value):
if (not invert_filter and char == char_value) or (
invert_filter and char != char_value
):
tiles.append((x, y))
return tiles
@ -1063,91 +1197,91 @@ class Art:
class ArtFromDisk(Art):
"Subclass of Art that loads from a file. Main difference is initialization."
def __init__(self, filename, app):
self.valid = False
try:
d = json.load(open(filename))
except:
return
width = d['width']
height = d['height']
charset = app.load_charset(d['charset'])
width = d["width"]
height = d["height"]
charset = app.load_charset(d["charset"])
if not charset:
app.log('Character set %s not found!' % d['charset'])
app.log("Character set %s not found!" % d["charset"])
return
palette = app.load_palette(d['palette'])
palette = app.load_palette(d["palette"])
if not palette:
app.log('Palette %s not found!' % d['palette'])
app.log("Palette %s not found!" % d["palette"])
return
# store loaded data for init_layers/frames
self.loaded_data = d
# base Art class initializes all vars, thereafter we just populate
Art.__init__(self, filename, app, charset, palette,
width, height)
Art.__init__(self, filename, app, charset, palette, width, height)
# still loading...
self.valid = False
if not self.app.override_saved_camera:
cam = d['camera']
cam = d["camera"]
self.camera_x, self.camera_y, self.camera_z = cam[0], cam[1], cam[2]
else:
self.update_saved_camera(self.app.camera)
# read saved tile attributes, which won't exist in pre-0.9.6 PSCI files
if 'selected_char' in d:
self.selected_char = d['selected_char']
if 'selected_fg_color' in d:
self.selected_fg_color = d['selected_fg_color']
if 'selected_bg_color' in d:
self.selected_bg_color = d['selected_bg_color']
if 'selected_xform' in d:
self.selected_xform = d['selected_xform']
if "selected_char" in d:
self.selected_char = d["selected_char"]
if "selected_fg_color" in d:
self.selected_fg_color = d["selected_fg_color"]
if "selected_bg_color" in d:
self.selected_bg_color = d["selected_bg_color"]
if "selected_xform" in d:
self.selected_xform = d["selected_xform"]
# update renderables with new data
self.update()
# signify to app that this file loaded successfully
self.valid = True
def log_init(self):
self.app.log('Loaded %s from disk:' % filename)
self.app.log(' character set: %s' % self.charset.name)
self.app.log(' palette: %s' % self.palette.name)
self.app.log(' width/height: %s x %s' % (self.width, self.height))
self.app.log(' frames: %s' % self.frames)
self.app.log(' layers: %s' % self.layers)
self.app.log("Loaded %s from disk:" % filename)
self.app.log(" character set: %s" % self.charset.name)
self.app.log(" palette: %s" % self.palette.name)
self.app.log(" width/height: %s x %s" % (self.width, self.height))
self.app.log(" frames: %s" % self.frames)
self.app.log(" layers: %s" % self.layers)
def init_layers(self):
frames = self.loaded_data['frames']
frames = self.loaded_data["frames"]
# number of layers should be same for all frames
self.layers = len(frames[0]['layers'])
self.layers = len(frames[0]["layers"])
self.layers_z, self.layers_visibility, self.layer_names = [], [], []
for i,layer in enumerate(frames[0]['layers']):
self.layers_z.append(layer['z'])
self.layers_visibility.append(bool(layer.get('visible', 1)))
for i, layer in enumerate(frames[0]["layers"]):
self.layers_z.append(layer["z"])
self.layers_visibility.append(bool(layer.get("visible", 1)))
layer_num = str(i + 1)
self.layer_names.append(layer.get('name', 'Layer %s' % layer_num))
active_layer = self.loaded_data.get('active_layer', 0)
self.layer_names.append(layer.get("name", "Layer %s" % layer_num))
active_layer = self.loaded_data.get("active_layer", 0)
self.set_active_layer(active_layer)
def init_frames(self):
frames = self.loaded_data['frames']
frames = self.loaded_data["frames"]
self.frames = len(frames)
self.active_frame = 0
self.frame_delays = []
# build tile data arrays from frame+layer lists
shape = (self.layers, self.height, self.width, 4)
for frame in frames:
self.frame_delays.append(frame['delay'])
self.frame_delays.append(frame["delay"])
chars = np.zeros(shape, dtype=np.float32)
uvs = self.new_uv_layers(self.layers)
uv_maps = np.zeros(shape, dtype=np.uint32)
fg_colors = chars.copy()
bg_colors = chars.copy()
for layer_index,layer in enumerate(frame['layers']):
for layer_index, layer in enumerate(frame["layers"]):
x, y = 0, 0
for tile in layer['tiles']:
chars[layer_index][y][x] = tile['char']
fg_colors[layer_index][y][x] = tile['fg']
bg_colors[layer_index][y][x] = tile['bg']
uvs[layer_index][y][x] = uv_types[tile.get('xform', UV_NORMAL)]
uv_maps[layer_index][y][x] = tile.get('xform', UV_NORMAL)
for tile in layer["tiles"]:
chars[layer_index][y][x] = tile["char"]
fg_colors[layer_index][y][x] = tile["fg"]
bg_colors[layer_index][y][x] = tile["bg"]
uvs[layer_index][y][x] = uv_types[tile.get("xform", UV_NORMAL)]
uv_maps[layer_index][y][x] = tile.get("xform", UV_NORMAL)
x += 1
if x >= self.width:
x = 0
@ -1158,7 +1292,7 @@ class ArtFromDisk(Art):
self.uv_mods.append(uvs)
self.uv_maps.append(uv_maps)
# set active frame properly
active_frame = self.loaded_data.get('active_frame', 0)
active_frame = self.loaded_data.get("active_frame", 0)
self.set_active_frame(active_frame)
def first_update(self):
@ -1171,12 +1305,14 @@ class ArtInstance(Art):
Deep copy / clone of a source Art that can hold unique changes and be
restored to its source.
"""
update_when_source_changes = True
"Set False if you want to manually update this Art."
def __init__(self, source):
self.source = source
# unique(?) filename
self.filename = '%s_Instance%i' % (source.filename, time.time())
self.filename = "%s_Instance%i" % (source.filename, time.time())
self.app = source.app
self.instances = None
self.char_changed_frames, self.uv_changed_frames = {}, {}
@ -1197,8 +1333,17 @@ class ArtInstance(Art):
def restore_from_source(self):
"Restore ArtInstance to its source Art's new values."
# copy common references/values
for prop in ['app', 'width', 'height', 'charset', 'palette',
'quad_width', 'quad_height', 'layers', 'frames']:
for prop in [
"app",
"width",
"height",
"charset",
"palette",
"quad_width",
"quad_height",
"layers",
"frames",
]:
setattr(self, prop, getattr(self.source, prop))
# copy lists
self.layers_z = self.source.layers_z[:]
@ -1225,6 +1370,7 @@ class ArtInstance(Art):
class TileIter:
"Iterator for iterating over all tiles in all layers and frames in an Art."
def __init__(self, art):
self.width, self.height = art.width, art.height
self.frames, self.layers = art.frames, art.layers

View file

@ -1,21 +1,20 @@
import traceback
from art import ART_DIR
class ArtExporter:
class ArtExporter:
"""
Class for exporting an Art into a non-Playscii format.
Export logic happens in run_export; exporter authors simply extend this
class, override run_export and the class properties below.
"""
format_name = 'ERROR - ArtExporter.format_name'
format_name = "ERROR - ArtExporter.format_name"
"User-visible name for this format, shown in export chooser."
format_description = "ERROR - ArtExporter.format_description"
"String (can be triple-quoted) describing format, shown in export chooser."
file_extension = ''
file_extension = ""
"Extension to give the exported file, sans dot."
options_dialog_class = None
"UIDialog subclass exposing export options to user."
@ -24,8 +23,10 @@ class ArtExporter:
self.app = app
self.art = self.app.ui.active_art
# add file extension to output filename if not present
if self.file_extension and not out_filename.endswith('.%s' % self.file_extension):
out_filename += '.%s' % self.file_extension
if self.file_extension and not out_filename.endswith(
".%s" % self.file_extension
):
out_filename += ".%s" % self.file_extension
# output filename in documents/art dir
if not out_filename.startswith(self.app.documents_dir + ART_DIR):
out_filename = self.app.documents_dir + ART_DIR + out_filename
@ -40,11 +41,14 @@ class ArtExporter:
if self.run_export(out_filename, options):
self.success = True
else:
line = '%s failed to export %s, see console for errors' % (self.__class__.__name__, out_filename)
line = "%s failed to export %s, see console for errors" % (
self.__class__.__name__,
out_filename,
)
self.app.log(line)
self.app.ui.message_line.post_line(line, hold_time=10, error=True)
except:
for line in traceback.format_exc().split('\n'):
for line in traceback.format_exc().split("\n"):
self.app.log(line)
# store last used export options for "Export last"
self.app.last_export_options = options

View file

@ -1,18 +1,18 @@
import os
import traceback
import os, traceback
from art import Art, ART_FILE_EXTENSION, DEFAULT_CHARSET, DEFAULT_PALETTE
from art import ART_FILE_EXTENSION, DEFAULT_CHARSET, DEFAULT_PALETTE
from ui_file_chooser_dialog import GenericImportChooserDialog
class ArtImporter:
class ArtImporter:
"""
Class for creating a new Art from data in non-Playscii format.
Import logic happens in run_import; importer authors simply extend this
class, override run_import and the class properties below.
"""
format_name = 'ERROR - ArtImporter.format_name'
format_name = "ERROR - ArtImporter.format_name"
"User-visible name for this format, shown in import chooser."
format_description = "ERROR - ArtImporter.format_description"
"String (can be triple-quoted) describing format, shown in import chooser."
@ -25,20 +25,27 @@ class ArtImporter:
"""
options_dialog_class = None
"UIDialog subclass exposing import options to user."
generic_error = '%s failed to import %s'
generic_error = "%s failed to import %s"
# if False (eg bitmap conversion), "Imported successfully" message
# won't show on successful creation
completes_instantly = True
def __init__(self, app, in_filename, options={}):
self.app = app
new_filename = '%s.%s' % (os.path.splitext(in_filename)[0],
ART_FILE_EXTENSION)
new_filename = "%s.%s" % (os.path.splitext(in_filename)[0], ART_FILE_EXTENSION)
self.art = self.app.new_art(new_filename)
# use charset and palette of existing art
charset = self.app.ui.active_art.charset if self.app.ui.active_art else self.app.load_charset(DEFAULT_CHARSET)
charset = (
self.app.ui.active_art.charset
if self.app.ui.active_art
else self.app.load_charset(DEFAULT_CHARSET)
)
self.art.set_charset(charset)
palette = self.app.ui.active_art.palette if self.app.ui.active_art else self.app.load_palette(DEFAULT_PALETTE)
palette = (
self.app.ui.active_art.palette
if self.app.ui.active_art
else self.app.load_palette(DEFAULT_PALETTE)
)
self.art.set_palette(palette)
self.app.set_new_art_for_edit(self.art)
self.art.clear_frame_layer(0, 0, 1)
@ -49,7 +56,7 @@ class ArtImporter:
if self.run_import(in_filename, options):
self.success = True
except:
for line in traceback.format_exc().split('\n'):
for line in traceback.format_exc().split("\n"):
self.app.log(line)
if not self.success:
line = self.generic_error % (self.__class__.__name__, in_filename)

View file

@ -1,25 +1,26 @@
import ctypes
from sdl2 import sdlmixer
class PlayingSound:
"represents a currently playing sound"
def __init__(self, filename, channel, game_object, looping=False):
self.filename = filename
self.channel = channel
self.go = game_object
self.looping = looping
class AudioLord:
class AudioLord:
sample_rate = 44100
def __init__(self, app):
self.app = app
# initialize audio
sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG|sdlmixer.MIX_INIT_MOD)
sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT,
2, 1024)
sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG | sdlmixer.MIX_INIT_MOD)
sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT, 2, 1024)
self.reset()
# sound callback
# retain handle to C callable even though we don't use it directly
@ -44,11 +45,11 @@ class AudioLord:
# {channel_number: PlayingSound object}
self.playing_channels = {}
# handle init case where self.musics doesn't exist yet
if hasattr(self, 'musics'):
if hasattr(self, "musics"):
for music in self.musics.values():
sdlmixer.Mix_FreeMusic(music)
self.musics = {}
if hasattr(self, 'sounds'):
if hasattr(self, "sounds"):
for sound in self.sounds.values():
sdlmixer.Mix_FreeChunk(sound)
self.sounds = {}
@ -56,12 +57,13 @@ class AudioLord:
def register_sound(self, sound_filename):
if sound_filename in self.sounds:
return self.sounds[sound_filename]
new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, 'utf-8'))
new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, "utf-8"))
self.sounds[sound_filename] = new_sound
return new_sound
def object_play_sound(self, game_object, sound_filename,
loops=0, allow_multiple=False):
def object_play_sound(
self, game_object, sound_filename, loops=0, allow_multiple=False
):
# TODO: volume param? sdlmixer.MIX_MAX_VOLUME if not specified
# bail if same object isn't allowed to play same sound multiple times
if not allow_multiple and sound_filename in self.playing_sounds:
@ -71,8 +73,9 @@ class AudioLord:
sound = self.register_sound(sound_filename)
channel = sdlmixer.Mix_PlayChannel(-1, sound, loops)
# add sound to dicts of playing sounds and channels
new_playing_sound = PlayingSound(sound_filename, channel, game_object,
loops == -1)
new_playing_sound = PlayingSound(
sound_filename, channel, game_object, loops == -1
)
if sound_filename in self.playing_sounds:
self.playing_sounds[sound_filename].append(new_playing_sound)
else:
@ -80,7 +83,7 @@ class AudioLord:
self.playing_channels[channel] = new_playing_sound
def object_stop_sound(self, game_object, sound_filename):
if not sound_filename in self.playing_sounds:
if sound_filename not in self.playing_sounds:
return
# stop all instances of this sound object might be playing
for sound in self.playing_sounds[sound_filename]:
@ -89,7 +92,7 @@ class AudioLord:
def object_stop_all_sounds(self, game_object):
sounds_to_stop = []
for sound_filename,sounds in self.playing_sounds.items():
for sound_filename, sounds in self.playing_sounds.items():
for sound in sounds:
if sound.go is game_object:
sounds_to_stop.append(sound_filename)
@ -102,7 +105,7 @@ class AudioLord:
def set_music(self, music_filename):
if music_filename in self.musics:
return
new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, 'utf-8'))
new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, "utf-8"))
self.musics[music_filename] = new_music
def start_music(self, music_filename, loops=-1):

View file

@ -1,14 +1,17 @@
import math
import numpy as np
import vector
def clamp(val, lowest, highest):
return min(highest, max(lowest, val))
class Camera:
class Camera:
# good starting values
start_x,start_y = 0,0
start_x, start_y = 0, 0
start_zoom = 2.5
x_tilt, y_tilt = 0, 0
# pan/zoom speed tuning
@ -28,10 +31,10 @@ class Camera:
min_velocity = 0.05
# map extents
# starting values only, bounds are generated according to art size
min_x,max_x = -10, 50
min_y,max_y = -50, 10
min_x, max_x = -10, 50
min_y, max_y = -50, 10
use_bounds = True
min_zoom,max_zoom = 1, 1000
min_zoom, max_zoom = 1, 1000
# matrices -> worldspace renderable vertex shader uniforms
fov = 90
near_z = 0.0001
@ -46,8 +49,8 @@ class Camera:
self.x, self.y = self.start_x, self.start_y
self.z = self.start_zoom
# store look vectors so world/screen space conversions can refer to it
self.look_x, self.look_y, self.look_z = None,None,None
self.vel_x, self.vel_y, self.vel_z = 0,0,0
self.look_x, self.look_y, self.look_z = None, None, None
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
self.mouse_panned, self.moved_this_frame = False, False
# GameObject to focus on
self.focus_object = None
@ -65,10 +68,12 @@ class Camera:
forward = (target - eye).normalize()
side = forward.cross(up).normalize()
upward = side.cross(forward)
m = [[side.x, upward.x, -forward.x, 0],
[side.y, upward.y, -forward.y, 0],
[side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]]
m = [
[side.x, upward.x, -forward.x, 0],
[side.y, upward.y, -forward.y, 0],
[side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1],
]
self.view_matrix = np.array(m, dtype=np.float32)
self.look_x, self.look_y, self.look_z = side, upward, forward
@ -77,10 +82,7 @@ class Camera:
ymul = 1 / math.tan(self.fov * math.pi / 360)
aspect = self.app.window_width / self.app.window_height
xmul = ymul / aspect
m = [[xmul, 0, 0, 0],
[ 0, ymul, 0, 0],
[ 0, 0, -1, -1],
[ 0, 0, zmul, 0]]
m = [[xmul, 0, 0, 0], [0, ymul, 0, 0], [0, 0, -1, -1], [0, 0, zmul, 0]]
return np.array(m, dtype=np.float32)
def get_ortho_matrix(self, width=None, height=None):
@ -95,10 +97,7 @@ class Camera:
wx = -(right + left) / (right - left)
wy = -(top + bottom) / (top - bottom)
wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z)
m = [[ x, 0, 0, 0],
[ 0, y, 0, 0],
[ 0, 0, z, 0],
[wx, wy, wz, 0]]
m = [[x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [wx, wy, wz, 0]]
return np.array(m, dtype=np.float32)
def pan(self, dx, dy, keyboard=False):
@ -177,12 +176,12 @@ class Camera:
x2 = x1 + art.width * art.quad_width
y2 = y1 - art.height * art.quad_height
right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z)
#print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
# print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
# add 1 tile of UI chars to top and bottom margins
top_margin = 1 - self.app.ui.menu_bar.art.quad_height
bot_margin = -1 + self.app.ui.status_bar.art.quad_height
return left >= -1 and top <= top_margin and \
right <= 1 and bot >= bot_margin
return left >= -1 and top <= top_margin and right <= 1 and bot >= bot_margin
# zoom out from minimum until all corners are visible
self.z = self.min_zoom
# recalc view matrix each move so projection stays correct
@ -199,9 +198,17 @@ class Camera:
art.camera_zoomed_extents = not override
if art.camera_zoomed_extents:
# restore cached position
self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z
self.x, self.y, self.z = (
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
)
else:
art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z = self.x, self.y, self.z
(
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
) = self.x, self.y, self.z
# center camera on art
self.x = (art.width * art.quad_width) / 2
self.y = -(art.height * art.quad_height) / 2
@ -218,7 +225,7 @@ class Camera:
self.z = z
def set_loc(self, x, y, z):
self.x, self.y, self.z = x, y, (z or self.z) # z optional
self.x, self.y, self.z = x, y, (z or self.z) # z optional
def set_loc_from_obj(self, game_object):
self.set_loc(game_object.x, game_object.y, game_object.z)
@ -244,8 +251,9 @@ class Camera:
def update(self):
# zoom-proportional pan scale is based on art
if self.app.ui.active_art:
speed_scale = clamp(self.get_current_zoom_pct(),
self.pan_min_pct, self.pan_max_pct)
speed_scale = clamp(
self.get_current_zoom_pct(), self.pan_min_pct, self.pan_max_pct
)
self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
else:
self.max_pan_speed = self.base_max_pan_speed
@ -256,7 +264,7 @@ class Camera:
# track towards target
# TODO: revisit this for better feel later
dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y
l = math.sqrt(dx ** 2 + dy ** 2)
l = math.sqrt(dx**2 + dy**2)
if l != 0 and l > 0.1:
il = 1 / l
dx *= il
@ -296,8 +304,13 @@ class Camera:
self.z = clamp(self.z, self.min_zoom, self.max_zoom)
# set view matrix from xyz
self.calc_view_matrix()
self.moved_this_frame = self.mouse_panned or self.x != self.last_x or self.y != self.last_y or self.z != self.last_z
self.moved_this_frame = (
self.mouse_panned
or self.x != self.last_x
or self.y != self.last_y
or self.z != self.last_z
)
self.mouse_panned = False
def log_loc(self):
self.app.log('camera x=%s, y=%s, z=%s' % (self.x, self.y, self.z))
self.app.log("camera x=%s, y=%s, z=%s" % (self.x, self.y, self.z))

View file

@ -1,14 +1,16 @@
import os.path, string, time
import os.path
import string
import time
from PIL import Image
from texture import Texture
CHARSET_DIR = 'charsets/'
CHARSET_FILE_EXTENSION = 'char'
CHARSET_DIR = "charsets/"
CHARSET_FILE_EXTENSION = "char"
class CharacterSetLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
@ -17,7 +19,10 @@ class CharacterSetLord:
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
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
@ -28,22 +33,29 @@ class CharacterSetLord:
try:
success = charset.load_char_data()
if success:
self.app.log('CharacterSetLord: success reloading %s' % charset.filename)
self.app.log(
"CharacterSetLord: success reloading %s" % charset.filename
)
else:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
self.app.log(
"CharacterSetLord: failed reloading %s" % charset.filename,
True,
)
except:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
self.app.log(
"CharacterSetLord: failed reloading %s" % 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)
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 %s" % self.filename)
return
@ -65,16 +77,18 @@ class CharacterSet:
def load_char_data(self):
"carries out majority of CharacterSet init, including loading image"
char_data_src = open(self.filename, encoding='utf-8').readlines()
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('//'):
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')
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 %s" % self.image_filename)
return False
@ -82,14 +96,14 @@ class CharacterSet:
# 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(',')
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 not char in self.char_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:
@ -105,12 +119,12 @@ class CharacterSet:
if has_upper and not has_lower:
for char in string.ascii_lowercase:
# set may not have all letters
if not char.upper() in self.char_mapping:
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 not char.lower() in self.char_mapping:
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
@ -125,7 +139,7 @@ class CharacterSet:
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
@ -152,16 +166,26 @@ class CharacterSet:
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(' source texture %s is %s x %s pixels' % (self.image_filename, self.image_width, self.image_height))
self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height))
self.app.log(' char map width/height is %s x %s' % (self.map_width, self.map_height))
self.app.log(' last character index: %s' % self.last_index)
self.app.log(
" source texture %s is %s x %s pixels"
% (self.image_filename, self.image_width, self.image_height)
)
self.app.log(
" char pixel width/height is %s x %s" % (self.char_width, self.char_height)
)
self.app.log(
" char map width/height is %s x %s" % (self.map_width, self.map_height)
)
self.app.log(" last character index: %s" % 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):
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

View file

@ -1,8 +1,11 @@
import math
from collections import namedtuple
from renderable import TileRenderable
from renderable_line import CircleCollisionRenderable, BoxCollisionRenderable, TileBoxCollisionRenderable
from renderable_line import (
BoxCollisionRenderable,
CircleCollisionRenderable,
TileBoxCollisionRenderable,
)
# collision shape types
CST_NONE = 0
@ -32,11 +35,11 @@ CTG_DYNAMIC = [CT_GENERIC_DYNAMIC, CT_PLAYER]
__pdoc__ = {}
# named tuples for collision structs that don't merit a class
Contact = namedtuple('Contact', ['overlap', 'timestamp'])
__pdoc__['Contact'] = "Represents a contact between two objects."
Contact = namedtuple("Contact", ["overlap", "timestamp"])
__pdoc__["Contact"] = "Represents a contact between two objects."
ShapeOverlap = namedtuple('ShapeOverlap', ['x', 'y', 'dist', 'area', 'other'])
__pdoc__['ShapeOverlap'] = "Represents a CollisionShape's overlap with another."
ShapeOverlap = namedtuple("ShapeOverlap", ["x", "y", "dist", "area", "other"])
__pdoc__["ShapeOverlap"] = "Represents a CollisionShape's overlap with another."
class CollisionShape:
@ -44,6 +47,7 @@ class CollisionShape:
Abstract class for a shape that can overlap and collide with other shapes.
Shapes are part of a Collideable which in turn is part of a GameObject.
"""
def resolve_overlaps_with_shapes(self, shapes):
"Resolve this shape's overlap(s) with given list of shapes."
overlaps = []
@ -57,7 +61,7 @@ class CollisionShape:
return
# resolve collisions in order of largest -> smallest overlap
overlaps.sort(key=lambda item: item.area, reverse=True)
for i,old_overlap in enumerate(overlaps):
for i, old_overlap in enumerate(overlaps):
# resolve first overlap without recalculating
overlap = self.get_overlap(old_overlap.other) if i > 0 else overlaps[0]
self.resolve_overlap(overlap)
@ -118,15 +122,26 @@ class CollisionShape:
else:
# skip if even bounds don't overlap
obj_left, obj_top, obj_right, obj_bottom = obj.get_edges()
if not boxes_overlap(shape_left, shape_top, shape_right, shape_bottom,
obj_left, obj_top, obj_right, obj_bottom):
if not boxes_overlap(
shape_left,
shape_top,
shape_right,
shape_bottom,
obj_left,
obj_top,
obj_right,
obj_bottom,
):
continue
overlapping_shapes += obj.collision.get_shapes_overlapping_box(shape_left, shape_top, shape_right, shape_bottom)
overlapping_shapes += obj.collision.get_shapes_overlapping_box(
shape_left, shape_top, shape_right, shape_bottom
)
return overlapping_shapes
class CircleCollisionShape(CollisionShape):
"CollisionShape using a circle area."
def __init__(self, loc_x, loc_y, radius, game_object):
self.x, self.y = loc_x, loc_y
self.radius = radius
@ -134,11 +149,16 @@ class CircleCollisionShape(CollisionShape):
def get_box(self):
"Return world coordinates of our bounds (left, top, right, bottom)"
return self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius
return (
self.x - self.radius,
self.y - self.radius,
self.x + self.radius,
self.y + self.radius,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius ** 2
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius**2
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this circle overlaps given line segment."
@ -147,20 +167,26 @@ class CircleCollisionShape(CollisionShape):
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = point_circle_penetration(self.x, self.y,
other.x, other.y,
self.radius + other.radius)
px, py, pdist1, pdist2 = point_circle_penetration(
self.x, self.y, other.x, other.y, self.radius + other.radius
)
elif type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(self.x, self.y,
other.x, other.y,
self.radius, other.halfwidth,
other.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
self.x,
self.y,
other.x,
other.y,
self.radius,
other.halfwidth,
other.halfheight,
)
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
class AABBCollisionShape(CollisionShape):
"CollisionShape using an axis-aligned bounding box area."
def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object):
self.x, self.y = loc_x, loc_y
self.halfwidth, self.halfheight = halfwidth, halfheight
@ -169,7 +195,12 @@ class AABBCollisionShape(CollisionShape):
self.tiles = []
def get_box(self):
return self.x - self.halfwidth, self.y - self.halfheight, self.x + self.halfwidth, self.y + self.halfheight
return (
self.x - self.halfwidth,
self.y - self.halfheight,
self.x + self.halfwidth,
self.y + self.halfheight,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
@ -183,15 +214,26 @@ class AABBCollisionShape(CollisionShape):
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = box_penetration(self.x, self.y,
other.x, other.y,
self.halfwidth, self.halfheight,
other.halfwidth, other.halfheight)
px, py, pdist1, pdist2 = box_penetration(
self.x,
self.y,
other.x,
other.y,
self.halfwidth,
self.halfheight,
other.halfwidth,
other.halfheight,
)
elif type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(other.x, other.y,
self.x, self.y,
other.radius, self.halfwidth,
self.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
other.x,
other.y,
self.x,
self.y,
other.radius,
self.halfwidth,
self.halfheight,
)
# reverse result if we're shape B
px, py = -px, -py
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
@ -200,8 +242,10 @@ class AABBCollisionShape(CollisionShape):
class Collideable:
"Collision component for GameObjects. Contains a list of shapes."
use_art_offset = False
"use game object's art_off_pct values"
def __init__(self, obj):
"Create new Collideable for given GameObject."
self.go = obj
@ -249,12 +293,11 @@ class Collideable:
self.renderables = [CircleCollisionRenderable(shape)]
def _create_box(self):
x = self.go.x # + self.go.col_offset_x
y = self.go.y # + self.go.col_offset_y
shape = self.cl._add_box_shape(x, y,
self.go.col_width / 2,
self.go.col_height / 2,
self.go)
x = self.go.x # + self.go.col_offset_x
y = self.go.y # + self.go.col_offset_y
shape = self.cl._add_box_shape(
x, y, self.go.col_width / 2, self.go.col_height / 2, self.go
)
self.shapes = [shape]
self.renderables = [BoxCollisionRenderable(shape)]
@ -262,19 +305,28 @@ class Collideable:
"Create AABB shapes for a CST_TILE object"
# generate fewer, larger boxes!
frame = self.go.renderable.frame
if not self.go.col_layer_name in self.go.art.layer_names:
self.go.app.dev_log("%s: Couldn't find collision layer with name '%s'" % (self.go.name, self.go.col_layer_name))
if self.go.col_layer_name not in self.go.art.layer_names:
self.go.app.dev_log(
"%s: Couldn't find collision layer with name '%s'"
% (self.go.name, self.go.col_layer_name)
)
return
layer = self.go.art.layer_names.index(self.go.col_layer_name)
# tile is available if it's not empty and not already covered by a shape
def tile_available(tile_x, tile_y):
return self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0 and not (tile_x, tile_y) in self.tile_shapes
return (
self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0
and (tile_x, tile_y) not in self.tile_shapes
)
def tile_range_available(start_x, end_x, start_y, end_y):
for y in range(start_y, end_y + 1):
for x in range(start_x, end_x + 1):
if not tile_available(x, y):
return False
return True
for y in range(self.go.art.height):
for x in range(self.go.art.width):
if not tile_available(x, y):
@ -286,7 +338,9 @@ class Collideable:
end_x += 1
# then fill top to bottom
end_y = y
while end_y < self.go.art.height - 1 and tile_range_available(x, end_x, y, end_y + 1):
while end_y < self.go.art.height - 1 and tile_range_available(
x, end_x, y, end_y + 1
):
end_y += 1
# compute origin and halfsizes of box covering tile range
wx1, wy1 = self.go.get_tile_loc(x, y, tile_center=True)
@ -299,8 +353,7 @@ class Collideable:
halfheight = (end_y - y) * self.go.art.quad_height
halfheight /= 2
halfheight += self.go.art.quad_height / 2
shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight,
self.go)
shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight, self.go)
# fill in cell(s) in our tile collision dict,
# write list of tiles shape covers to shape.tiles
for tile_y in range(y, end_y + 1):
@ -322,9 +375,9 @@ class Collideable:
"Return a list of our shapes that overlap given box."
shapes = []
tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom)
for (x, y) in tiles:
for x, y in tiles:
shape = self.tile_shapes.get((x, y), None)
if shape and not shape in shapes:
if shape and shape not in shapes:
shapes.append(shape)
return shapes
@ -373,11 +426,13 @@ class CollisionLord:
Collision manager object, tracks Collideables, detects overlaps and
resolves collisions.
"""
iterations = 7
"""
Number of times to resolve collisions per update. Lower at own risk;
multi-object collisions require multiple iterations to settle correctly.
"""
def __init__(self, world):
self.world = world
self.ticks = 0
@ -386,9 +441,10 @@ class CollisionLord:
self.reset()
def report(self):
print('%s: %s dynamic shapes, %s static shapes' % (self,
len(self.dynamic_shapes),
len(self.static_shapes)))
print(
"%s: %s dynamic shapes, %s static shapes"
% (self, len(self.dynamic_shapes), len(self.static_shapes))
)
def reset(self):
self.dynamic_shapes, self.static_shapes = [], []
@ -437,19 +493,25 @@ class CollisionLord:
# collision handling
def point_in_box(x, y, box_left, box_top, box_right, box_bottom):
"Return True if given point lies within box with given corners."
return box_left <= x <= box_right and box_bottom <= y <= box_top
def boxes_overlap(left_a, top_a, right_a, bottom_a,
left_b, top_b, right_b, bottom_b):
def boxes_overlap(left_a, top_a, right_a, bottom_a, left_b, top_b, right_b, bottom_b):
"Return True if given boxes A and B overlap."
for (x, y) in ((left_a, top_a), (right_a, top_a),
(right_a, bottom_a), (left_a, bottom_a)):
for x, y in (
(left_a, top_a),
(right_a, top_a),
(right_a, bottom_a),
(left_a, bottom_a),
):
if left_b <= x <= right_b and bottom_b <= y <= top_b:
return True
return False
def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4):
"Return True if given lines intersect."
denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
@ -465,6 +527,7 @@ def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4):
ub = numer2 / denom
return ua >= 0 and ua <= 1 and ub >= 0 and ub <= 1
def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
"Return point on given line that's closest to given point."
wx, wy = point_x - x1, point_y - y1
@ -473,7 +536,7 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
if proj <= 0:
# line point 1 is closest
return x1, y1
vsq = dir_x ** 2 + dir_y ** 2
vsq = dir_x**2 + dir_y**2
if proj >= vsq:
# line point 2 is closest
return x2, y2
@ -481,25 +544,32 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
# closest point is between 1 and 2
return x1 + (proj / vsq) * dir_x, y1 + (proj / vsq) * dir_y
def circle_overlaps_line(circle_x, circle_y, radius, x1, y1, x2, y2):
"Return True if given circle overlaps given line."
# get closest point on line to circle center
closest_x, closest_y = line_point_closest_to_point(circle_x, circle_y,
x1, y1, x2, y2)
closest_x, closest_y = line_point_closest_to_point(
circle_x, circle_y, x1, y1, x2, y2
)
dist_x, dist_y = closest_x - circle_x, closest_y - circle_y
return dist_x ** 2 + dist_y ** 2 <= radius ** 2
return dist_x**2 + dist_y**2 <= radius**2
def box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2):
"Return True if given box overlaps given line."
# TODO: determine if this is less efficient than slab method below
if point_in_box(x1, y1, left, top, right, bottom) and \
point_in_box(x2, y2, left, top, right, bottom):
if point_in_box(x1, y1, left, top, right, bottom) and point_in_box(
x2, y2, left, top, right, bottom
):
return True
# check left/top/right/bottoms edges
return lines_intersect(left, top, left, bottom, x1, y1, x2, y2) or \
lines_intersect(left, top, right, top, x1, y1, x2, y2) or \
lines_intersect(right, top, right, bottom, x1, y1, x2, y2) or \
lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2)
return (
lines_intersect(left, top, left, bottom, x1, y1, x2, y2)
or lines_intersect(left, top, right, top, x1, y1, x2, y2)
or lines_intersect(right, top, right, bottom, x1, y1, x2, y2)
or lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2)
)
def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
"Return True if given box overlaps given ray."
@ -519,16 +589,18 @@ def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
tmax = min(tmax, max(ty1, ty2))
return tmax >= tmin
def point_circle_penetration(point_x, point_y, circle_x, circle_y, radius):
"Return normalized penetration x, y, and distance for given circles."
dx, dy = circle_x - point_x, circle_y - point_y
pdist = math.sqrt(dx ** 2 + dy ** 2)
pdist = math.sqrt(dx**2 + dy**2)
# point is center of circle, arbitrarily project out in +X
if pdist == 0:
return 1, 0, -radius, -radius
# TODO: calculate other axis of intersection for area?
return dx / pdist, dy / pdist, pdist - radius, pdist - radius
def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
"Return penetration vector and magnitude for given boxes."
left_a, right_a = ax - ahw, ax + ahw
@ -553,22 +625,32 @@ def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
elif dy < 0:
return 0, -1, -py, -px
def circle_box_penetration(circle_x, circle_y, box_x, box_y, circle_radius,
box_hw, box_hh):
def circle_box_penetration(
circle_x, circle_y, box_x, box_y, circle_radius, box_hw, box_hh
):
"Return penetration vector and magnitude for given circle and box."
box_left, box_right = box_x - box_hw, box_x + box_hw
box_top, box_bottom = box_y + box_hh, box_y - box_hh
# if circle center inside box, use box-on-box penetration vector + distance
if point_in_box(circle_x, circle_y, box_left, box_top, box_right, box_bottom):
return box_penetration(circle_x, circle_y, box_x, box_y,
circle_radius, circle_radius, box_hw, box_hh)
return box_penetration(
circle_x,
circle_y,
box_x,
box_y,
circle_radius,
circle_radius,
box_hw,
box_hh,
)
# find point on AABB edges closest to center of circle
# clamp = min(highest, max(lowest, val))
px = min(box_right, max(box_left, circle_x))
py = min(box_top, max(box_bottom, circle_y))
closest_x = circle_x - px
closest_y = circle_y - py
d = math.sqrt(closest_x ** 2 + closest_y ** 2)
d = math.sqrt(closest_x**2 + closest_y**2)
pdist = circle_radius - d
if d == 0:
return

172
cursor.py
View file

@ -1,4 +1,6 @@
import math, ctypes
import ctypes
import math
import numpy as np
from OpenGL import GL
@ -24,38 +26,29 @@ OUTSIDE_EDGE_SIZE = 0.2
THICKNESS = 0.1
corner_verts = [
0, 0, # A/0
OUTSIDE_EDGE_SIZE, 0, # B/1
OUTSIDE_EDGE_SIZE, -THICKNESS, # C/2
THICKNESS, -THICKNESS, # D/3
THICKNESS, -OUTSIDE_EDGE_SIZE, # E/4
0, -OUTSIDE_EDGE_SIZE # F/5
0,
0, # A/0
OUTSIDE_EDGE_SIZE,
0, # B/1
OUTSIDE_EDGE_SIZE,
-THICKNESS, # C/2
THICKNESS,
-THICKNESS, # D/3
THICKNESS,
-OUTSIDE_EDGE_SIZE, # E/4
0,
-OUTSIDE_EDGE_SIZE, # F/5
]
# vert indices for the above
corner_elems = [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 5, 4
]
corner_elems = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 5, 4]
# X/Y flip transforms to make all 4 corners
# (top left, top right, bottom left, bottom right)
corner_transforms = [
( 1, 1),
(-1, 1),
( 1, -1),
(-1, -1)
]
corner_transforms = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
# offsets to translate the 4 corners by
corner_offsets = [
(0, 0),
(1, 0),
(0, -1),
(1, -1)
]
corner_offsets = [(0, 0), (1, 0), (0, -1), (1, -1)]
BASE_COLOR = (0.8, 0.8, 0.8, 1)
@ -63,10 +56,10 @@ BASE_COLOR = (0.8, 0.8, 0.8, 1)
# because a static vertex list wouldn't be able to adjust to different
# character set aspect ratios.
class Cursor:
vert_shader_source = 'cursor_v.glsl'
frag_shader_source = 'cursor_f.glsl'
class Cursor:
vert_shader_source = "cursor_v.glsl"
frag_shader_source = "cursor_f.glsl"
alpha = 1
icon_scale_factor = 4
logg = False
@ -92,29 +85,40 @@ class Cursor:
self.elem_array = np.array(corner_elems, dtype=np.uint32)
self.vert_count = int(len(self.elem_array))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
# shader, attributes
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# vert positions
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, 2,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.color_uniform = self.shader.get_uniform_location('baseColor')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.xform_uniform = self.shader.get_uniform_location('vertTransform')
self.offset_uniform = self.shader.get_uniform_location('vertOffset')
self.alpha_uniform = self.shader.get_uniform_location('baseAlpha')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.color_uniform = self.shader.get_uniform_location("baseColor")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.xform_uniform = self.shader.get_uniform_location("vertTransform")
self.offset_uniform = self.shader.get_uniform_location("vertOffset")
self.alpha_uniform = self.shader.get_uniform_location("baseAlpha")
# finish
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
@ -136,7 +140,10 @@ class Cursor:
self.moved = True
self.app.keyboard_editing = True
if self.logg:
self.app.log('Cursor: %s,%s,%s scale %.2f,%.2f' % (self.x, self.y, self.z, self.scale_x, self.scale_y))
self.app.log(
"Cursor: %s,%s,%s scale %.2f,%.2f"
% (self.x, self.y, self.z, self.scale_x, self.scale_y)
)
def set_scale(self, new_scale):
self.scale_x = self.scale_y = new_scale
@ -178,7 +185,7 @@ class Cursor:
x0, y0 = self.x, -self.y
x1, y1 = self.last_x, -self.last_y
tiles = vector.get_tiles_along_line(x0, y0, x1, y1)
print('drag from %s,%s to %s,%s:' % (x0, y0, x1, y1))
print("drag from %s,%s to %s,%s:" % (x0, y0, x1, y1))
print(tiles)
return tiles
@ -209,7 +216,10 @@ class Cursor:
self.preview_edits = []
def start_paint(self):
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
if self.app.ui.selected_tool is self.app.ui.grab_tool:
self.app.ui.grab_tool.grab()
@ -219,11 +229,14 @@ class Cursor:
self.current_command.add_command_tiles(self.preview_edits)
self.preview_edits = []
self.app.ui.active_art.set_unsaved_changes(True)
#print(self.app.ui.active_art.command_stack)
# print(self.app.ui.active_art.command_stack)
def finish_paint(self):
"invoked by mouse button up and undo"
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
# push current command group onto undo stack
if not self.current_command:
@ -234,17 +247,19 @@ class Cursor:
# tools like rotate produce a different change each time, so update again
if self.app.ui.selected_tool.update_preview_after_paint:
self.update_cursor_preview()
#print(self.app.ui.active_art.command_stack)
# print(self.app.ui.active_art.command_stack)
def moved_this_frame(self):
return self.moved or \
int(self.last_x) != int(self.x) or \
int(self.last_y) != int(self.y)
return (
self.moved
or int(self.last_x) != int(self.x)
or int(self.last_y) != int(self.y)
)
def reposition_from_mouse(self):
self.x, self.y, _ = vector.screen_to_world(self.app,
self.app.mouse_x,
self.app.mouse_y)
self.x, self.y, _ = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
def snap_to_tile(self):
w, h = self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height
@ -267,10 +282,12 @@ class Cursor:
self.last_x, self.last_y = self.x, self.y
# pulse alpha and scale
self.alpha = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)
#self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
# self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
mouse_moved = self.app.mouse_dx != 0 or self.app.mouse_dy != 0
# update cursor from mouse if: mouse moved, camera moved w/o keyboard
if mouse_moved or (not self.app.keyboard_editing and self.app.camera.moved_this_frame):
if mouse_moved or (
not self.app.keyboard_editing and self.app.camera.moved_this_frame
):
# don't let mouse move cursor if text tool input is happening
if not self.app.ui.text_tool.input_active:
self.reposition_from_mouse()
@ -311,22 +328,36 @@ class Cursor:
def render(self):
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix)
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix)
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix
)
GL.glUniform3f(self.position_uniform, self.x, self.y, self.z)
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform4fv(self.color_uniform, 1, self.color)
GL.glUniform2f(self.quad_size_uniform, self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height)
GL.glUniform2f(
self.quad_size_uniform,
self.app.ui.active_art.quad_width,
self.app.ui.active_art.quad_height,
)
GL.glUniform1f(self.alpha_uniform, self.alpha)
# VAO vs non-VAO paths
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
attrib = self.shader.get_attrib_location # for brevity
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(attrib('vertPosition'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0,
ctypes.c_void_p(0))
GL.glEnableVertexAttribArray(attrib('vertPosition'))
GL.glVertexAttribPointer(
attrib("vertPosition"),
2,
GL.GL_FLOAT,
GL.GL_FALSE,
0,
ctypes.c_void_p(0),
)
GL.glEnableVertexAttribArray(attrib("vertPosition"))
# bind elem array instead of passing it to glDrawElements - latter
# sends pyopengl a new array, which is deprecated and breaks on Mac.
# thanks Erin Congden!
@ -335,12 +366,13 @@ class Cursor:
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
# draw 4 corners
for i in range(4):
tx,ty = corner_transforms[i][0], corner_transforms[i][1]
ox,oy = corner_offsets[i][0], corner_offsets[i][1]
tx, ty = corner_transforms[i][0], corner_transforms[i][1]
ox, oy = corner_offsets[i][0], corner_offsets[i][1]
GL.glUniform2f(self.xform_uniform, tx, ty)
GL.glUniform2f(self.offset_uniform, ox, oy)
GL.glDrawElements(GL.GL_TRIANGLES, self.vert_count,
GL.GL_UNSIGNED_INT, None)
GL.glDrawElements(
GL.GL_TRIANGLES, self.vert_count, GL.GL_UNSIGNED_INT, None
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:

View file

@ -1,7 +1,4 @@
import time
class EditCommand:
"undo/redo-able representation of an art edit (eg paint, erase) operation"
def __init__(self, art):
@ -25,25 +22,27 @@ class EditCommand:
def __str__(self):
# get unique-ish ID from memory address
addr = self.__repr__()
addr = addr[addr.find('0'):-1]
s = 'EditCommand_%s: %s tiles, time %s' % (addr, self.get_number_of_commands(),
self.finish_time)
addr = addr[addr.find("0") : -1]
s = "EditCommand_%s: %s tiles, time %s" % (
addr,
self.get_number_of_commands(),
self.finish_time,
)
return s
def add_command_tiles(self, new_command_tiles):
for ct in new_command_tiles:
# create new tables for frames/layers/columns if not present
if not ct.frame in self.tile_commands:
if ct.frame not in self.tile_commands:
self.tile_commands[ct.frame] = {}
if not ct.layer in self.tile_commands[ct.frame]:
if ct.layer not in self.tile_commands[ct.frame]:
self.tile_commands[ct.frame][ct.layer] = {}
if not ct.y in self.tile_commands[ct.frame][ct.layer]:
if ct.y not in self.tile_commands[ct.frame][ct.layer]:
self.tile_commands[ct.frame][ct.layer][ct.y] = {}
# preserve "before" state of any command we overwrite
if ct.x in self.tile_commands[ct.frame][ct.layer][ct.y]:
old_ct = self.tile_commands[ct.frame][ct.layer][ct.y][ct.x]
ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg,
old_ct.b_xform)
ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg, old_ct.b_xform)
self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] = ct
def undo_commands_for_tile(self, frame, layer, x, y):
@ -51,8 +50,10 @@ class EditCommand:
if len(self.tile_commands) == 0:
return
# tile might not have undo commands, eg text entry beyond start region
if not y in self.tile_commands[frame][layer] or \
not x in self.tile_commands[frame][layer][y]:
if (
y not in self.tile_commands[frame][layer]
or x not in self.tile_commands[frame][layer][y]
):
return
self.tile_commands[frame][layer][y][x].undo()
@ -72,14 +73,13 @@ class EditCommand:
class EntireArtCommand:
"""
undo/redo-able representation of a whole-art operation, eg:
resize/crop, run art script, add/remove layer, etc
"""
# art arrays to grab
array_types = ['chars', 'fg_colors', 'bg_colors', 'uv_mods']
array_types = ["chars", "fg_colors", "bg_colors", "uv_mods"]
def __init__(self, art, origin_x=0, origin_y=0):
self.art = art
@ -91,11 +91,11 @@ class EntireArtCommand:
def save_tiles(self, before=True):
# save copies of tile data lists
prefix = 'b' if before else 'a'
prefix = "b" if before else "a"
for atype in self.array_types:
# save list as eg "b_chars" for "character data before operation"
src_data = getattr(self.art, atype)
var_name = '%s_%s' % (prefix, atype)
var_name = "%s_%s" % (prefix, atype)
# deep copy each frame's data, else before == after
new_data = []
for frame in src_data:
@ -114,7 +114,7 @@ class EntireArtCommand:
x, y = self.before_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'b_' + atype)
new_data = getattr(self, "b_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
# Art.resize will set geo_changed and mark all frames changed
@ -126,7 +126,7 @@ class EntireArtCommand:
x, y = self.after_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'a_' + atype)
new_data = getattr(self, "a_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
self.art.app.ui.adjust_for_art_resize(self.art)
@ -134,7 +134,6 @@ class EntireArtCommand:
class EditCommandTile:
def __init__(self, art):
self.art = art
self.creation_time = self.art.app.get_elapsed_time()
@ -146,24 +145,38 @@ class EditCommandTile:
self.a_char = self.a_fg = self.a_bg = self.a_xform = None
def __str__(self):
s = 'F%s L%s %s,%s @ %.2f: ' % (self.frame, self.layer, str(self.x).rjust(2, '0'), str(self.y).rjust(2, '0'), self.creation_time)
s += 'c%s f%s b%s x%s -> ' % (self.b_char, self.b_fg, self.b_bg, self.b_xform)
s += 'c%s f%s b%s x%s' % (self.a_char, self.a_fg, self.a_bg, self.a_xform)
s = "F%s L%s %s,%s @ %.2f: " % (
self.frame,
self.layer,
str(self.x).rjust(2, "0"),
str(self.y).rjust(2, "0"),
self.creation_time,
)
s += "c%s f%s b%s x%s -> " % (self.b_char, self.b_fg, self.b_bg, self.b_xform)
s += "c%s f%s b%s x%s" % (self.a_char, self.a_fg, self.a_bg, self.a_xform)
return s
def __eq__(self, value):
return self.frame == value.frame and self.layer == value.layer and \
self.x == value.x and self.y == value.y and \
self.b_char == value.b_char and self.b_fg == value.b_fg and \
self.b_bg == value.b_bg and self.b_xform == value.b_xform and \
self.a_char == value.a_char and self.a_fg == value.a_fg and \
self.a_bg == value.a_bg and self.a_xform == value.a_xform
return (
self.frame == value.frame
and self.layer == value.layer
and self.x == value.x
and self.y == value.y
and self.b_char == value.b_char
and self.b_fg == value.b_fg
and self.b_bg == value.b_bg
and self.b_xform == value.b_xform
and self.a_char == value.a_char
and self.a_fg == value.a_fg
and self.a_bg == value.a_bg
and self.a_xform == value.a_xform
)
def copy(self):
"returns a deep copy of this tile command"
new_ect = EditCommandTile(self.art)
# TODO: old or new timestamp? does it matter?
#new_ect.creation_time = self.art.app.get_elapsed_time()
# new_ect.creation_time = self.art.app.get_elapsed_time()
new_ect.creation_time = self.creation_time
# copy all properties
new_ect.frame, new_ect.layer = self.frame, self.layer
@ -187,7 +200,12 @@ class EditCommandTile:
self.a_fg, self.a_bg = fg, bg
def is_null(self):
return self.a_char == self.b_char and self.a_fg == self.b_fg and self.a_bg == self.b_bg and self.a_xform == self.b_xform
return (
self.a_char == self.b_char
and self.a_fg == self.b_fg
and self.a_bg == self.b_bg
and self.a_xform == self.b_xform
)
def undo(self):
# tile's frame or layer may have been deleted
@ -196,31 +214,58 @@ class EditCommandTile:
if self.x >= self.art.width or self.y >= self.art.height:
return
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.b_char, self.b_fg, self.b_bg, self.b_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.b_char,
self.b_fg,
self.b_bg,
self.b_xform,
set_all,
)
def apply(self):
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.a_char, self.a_fg, self.a_bg, self.a_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.a_char,
self.a_fg,
self.a_bg,
self.a_xform,
set_all,
)
class CommandStack:
def __init__(self, art):
self.art = art
self.undo_commands, self.redo_commands = [], []
def __str__(self):
s = 'stack for %s:\n' % self.art.filename
s += '===\nundo:\n'
s = "stack for %s:\n" % self.art.filename
s += "===\nundo:\n"
for cmd in self.undo_commands:
s += str(cmd) + '\n'
s += '\n===\nredo:\n'
s += str(cmd) + "\n"
s += "\n===\nredo:\n"
for cmd in self.redo_commands:
s += str(cmd) + '\n'
s += str(cmd) + "\n"
return s
def commit_commands(self, new_commands):

View file

@ -1,17 +1,17 @@
from art_import import ArtImporter
DEFAULT_FG, DEFAULT_BG = 7, 0
WIDTH = 80
MAX_LINES = 250
class ANSImporter(ArtImporter):
format_name = 'ANSI'
format_name = "ANSI"
format_description = """
Classic scene format using ANSI standard codes.
Assumes 80 columns, DOS character set and EGA palette.
"""
allowed_file_extensions = ['ans', 'txt']
allowed_file_extensions = ["ans", "txt"]
def get_sequence(self, data):
"returns a list of ints from given data ending in a letter"
@ -26,24 +26,24 @@ Assumes 80 columns, DOS character set and EGA palette.
def get_commands_from_sequence(self, seq):
"returns command type & commands (separated by semicolon) from sequence"
cmds = []
new_cmd = ''
new_cmd = ""
for k in seq[:-1]:
if k != 59:
new_cmd += chr(k)
else:
cmds.append(new_cmd)
new_cmd = ''
new_cmd = ""
# include last command
cmds.append(new_cmd)
return chr(seq[-1]), cmds
def run_import(self, in_filename, options={}):
self.set_art_charset('dos')
self.set_art_palette('ansi')
self.set_art_charset("dos")
self.set_art_palette("ansi")
# resize to arbitrary height, crop once we know final line count
self.resize(WIDTH, MAX_LINES)
self.art.clear_frame_layer(0, 0, DEFAULT_BG + 1)
data = open(in_filename, 'rb').read()
data = open(in_filename, "rb").read()
x, y = 0, 0
# cursor save/restore codes position
saved_x, saved_y = 0, 0
@ -57,18 +57,19 @@ Assumes 80 columns, DOS character set and EGA palette.
if x >= WIDTH:
x = 0
y += 1
if y > max_y: max_y = y
if y > max_y:
max_y = y
# how much we will advance through bytes for next iteration
increment = 1
# command sequence
if data[i] == 27 and data[i+1] == 91:
if data[i] == 27 and data[i + 1] == 91:
increment += 1
# grab full length of sequence
seq = self.get_sequence(data[i+2:])
seq = self.get_sequence(data[i + 2 :])
# split sequence into individual commands
cmd_type, cmds = self.get_commands_from_sequence(seq)
# display control
if cmd_type == 'm':
if cmd_type == "m":
# empty command = reset
if len(cmds) == 0:
fg, bg = DEFAULT_FG, DEFAULT_BG
@ -96,30 +97,33 @@ Assumes 80 columns, DOS character set and EGA palette.
# change fg color
elif 30 <= code <= 37:
fg = code - 30
if fg_bright: fg += 8
if fg_bright:
fg += 8
# change bg color
elif 40 <= code <= 47:
bg = code - 40
if bg_bright: bg += 8
#else: print('unhandled display code %s' % code)
if bg_bright:
bg += 8
# else: print('unhandled display code %s' % code)
# cursor up/down/forward/back
elif cmd_type == 'A':
elif cmd_type == "A":
y -= int(cmds[0]) if cmds[0] else 1
elif cmd_type == 'B':
elif cmd_type == "B":
y += int(cmds[0]) if cmds[0] else 1
if y > max_y: max_y = y
elif cmd_type == 'C':
if y > max_y:
max_y = y
elif cmd_type == "C":
x += int(cmds[0]) if cmds[0] else 1
elif cmd_type == 'D':
elif cmd_type == "D":
x -= int(cmds[0]) if cmds[0] else 1
# break
elif ord(cmd_type) == 26:
break
# set line wrap (ignore for now)
elif cmd_type == 'h':
elif cmd_type == "h":
pass
# move cursor to Y,X
elif cmd_type == 'H' or cmd_type == 'f':
elif cmd_type == "H" or cmd_type == "f":
if len(cmds) == 0 or len(cmds[0]) == 0:
new_y = 0
else:
@ -129,9 +133,10 @@ Assumes 80 columns, DOS character set and EGA palette.
else:
new_x = int(cmds[1]) - 1
x, y = new_x, new_y
if y > max_y: max_y = y
if y > max_y:
max_y = y
# clear line/screen
elif cmd_type == 'J':
elif cmd_type == "J":
cmd = int(cmds[0]) if cmds else 0
# 0: clear from cursor to end of screen
if cmd == 0:
@ -146,24 +151,26 @@ Assumes 80 columns, DOS character set and EGA palette.
x, y = 0, 0
self.art.clear_frame_layer(0, 0, DEFAULT_BG + 1)
# save cursor position
elif cmd_type == 's':
elif cmd_type == "s":
saved_x, saved_y = x, y
# restore cursor position
elif cmd_type == 'u':
elif cmd_type == "u":
x, y = saved_x, saved_y
#else: print('unhandled escape code %s' % cmd_type)
# else: print('unhandled escape code %s' % cmd_type)
increment += len(seq)
# CR + LF
elif data[i] == 13 and data[i+1] == 10:
elif data[i] == 13 and data[i + 1] == 10:
increment += 1
x = 0
y += 1
if y > max_y: max_y = y
if y > max_y:
max_y = y
# LF
elif data[i] == 10:
x = 0
y += 1
if y > max_y: max_y = y
if y > max_y:
max_y = y
# indent
elif data[i] == 9:
x += 8

View file

@ -1,26 +1,26 @@
from art_import import ArtImporter
# import as white on black for ease of edit + export
DEFAULT_FG, DEFAULT_BG =113, 1
DEFAULT_FG, DEFAULT_BG = 113, 1
# most ATAs are 40 columns, but some are a couple chars longer and a few are 80!
WIDTH, HEIGHT = 80, 40
class ATAImporter(ArtImporter):
format_name = 'ATASCII'
format_name = "ATASCII"
format_description = """
ATARI 8-bit computer version of ASCII.
Imports with ATASCII character set and Atari palette.
"""
allowed_file_extensions = ['ata']
allowed_file_extensions = ["ata"]
def run_import(self, in_filename, options={}):
self.set_art_charset('atari')
self.set_art_palette('atari')
self.set_art_charset("atari")
self.set_art_palette("atari")
self.resize(WIDTH, HEIGHT)
self.art.clear_frame_layer(0, 0, DEFAULT_BG)
# iterate over the bytes
data = open(in_filename, 'rb').read()
data = open(in_filename, "rb").read()
i = 0
x, y = 0, 0
while i < len(data):

View file

@ -1,24 +1,24 @@
# bitmap image conversion predates the import/export system so it's a bit weird.
# conversion happens over time, so it merely kicks off the process.
import os
from PIL import Image
from ui_file_chooser_dialog import ImageFileChooserDialog
from ui_dialog import UIDialog, Field
from ui_art_dialog import ImportOptionsDialog
from image_convert import ImageConverter
from art import DEFAULT_CHARSET, DEFAULT_HEIGHT, DEFAULT_PALETTE, DEFAULT_WIDTH
from art_import import ArtImporter
from image_convert import ImageConverter
from palette import PaletteFromFile
from art import DEFAULT_CHARSET, DEFAULT_PALETTE, DEFAULT_WIDTH, DEFAULT_HEIGHT
from ui_art_dialog import ImportOptionsDialog
from ui_dialog import Field, UIDialog
from ui_file_chooser_dialog import ImageFileChooserDialog
# custom chooser showing image previews, shares parent w/ "palette from image"
class ConvertImageChooserDialog(ImageFileChooserDialog):
title = 'Convert image'
confirm_caption = 'Choose'
class ConvertImageChooserDialog(ImageFileChooserDialog):
title = "Convert image"
confirm_caption = "Choose"
def confirm_pressed(self):
filename = self.field_texts[0]
@ -30,28 +30,24 @@ class ConvertImageChooserDialog(ImageFileChooserDialog):
dialog_class = self.ui.app.importer.options_dialog_class
# tell the dialog which image we chose, store its size
w, h = Image.open(filename).size
options = {
'filename': filename,
'image_width': w,
'image_height': h
}
options = {"filename": filename, "image_width": w, "image_height": h}
self.ui.open_dialog(dialog_class, options)
# custom dialog box providing convert options
class ConvertImageOptionsDialog(ImportOptionsDialog):
title = 'Convert bitmap image options'
field0_label = 'Color palette:'
field1_label = 'Current palette (%s)'
field2_label = 'From source image; # of colors:'
field3_label = ' '
field5_label = 'Converted art size:'
field6_label = 'Best fit to current size (%s)'
field7_label = '%% of source image: (%s)'
field8_label = ' '
field10_label = 'Smooth (bicubic) scale source image'
class ConvertImageOptionsDialog(ImportOptionsDialog):
title = "Convert bitmap image options"
field0_label = "Color palette:"
field1_label = "Current palette (%s)"
field2_label = "From source image; # of colors:"
field3_label = " "
field5_label = "Converted art size:"
field6_label = "Best fit to current size (%s)"
field7_label = "%% of source image: (%s)"
field8_label = " "
field10_label = "Smooth (bicubic) scale source image"
radio_groups = [(1, 2), (6, 7)]
field_width = UIDialog.default_short_field_width
# to get the layout we want, we must specify 0 padding lines and
@ -62,17 +58,17 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
Field(label=field1_label, type=bool, width=0, oneline=True),
Field(label=field2_label, type=bool, width=0, oneline=True),
Field(label=field3_label, type=int, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field5_label, type=None, width=0, oneline=True),
Field(label=field6_label, type=bool, width=0, oneline=True),
Field(label=field7_label, type=bool, width=0, oneline=True),
Field(label=field8_label, type=float, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field10_label, type=bool, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
invalid_color_error = 'Palettes must be between 2 and 256 colors.'
invalid_scale_error = 'Scale must be greater than 0.0'
invalid_color_error = "Palettes must be between 2 and 256 colors."
invalid_scale_error = "Scale must be greater than 0.0"
# redraw dynamic labels
always_redraw_labels = True
@ -81,38 +77,42 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
return UIDialog.true_field_text
elif field_number == 3:
# # of colors from source image
return '64'
return "64"
elif field_number == 6:
return UIDialog.true_field_text
elif field_number == 8:
# % of source image size
return '50.0'
return "50.0"
elif field_number == 10:
return ' '
return ''
return " "
return ""
def get_field_label(self, field_index):
label = self.fields[field_index].label
# custom label replacements to show palette, possible convert sizes
if field_index == 1:
label %= self.ui.active_art.palette.name if self.ui.active_art else DEFAULT_PALETTE
label %= (
self.ui.active_art.palette.name
if self.ui.active_art
else DEFAULT_PALETTE
)
elif field_index == 6:
# can't assume any art is open, use defaults if needed
w = self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH
h = self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT
label %= '%s x %s' % (w, h)
label %= "%s x %s" % (w, h)
elif field_index == 7:
# scale # might not be valid
valid,_ = self.is_input_valid()
valid, _ = self.is_input_valid()
if not valid:
return label % '???'
label %= '%s x %s' % self.get_tile_scale()
return label % "???"
label %= "%s x %s" % self.get_tile_scale()
return label
def get_tile_scale(self):
"returns scale in tiles of image dimensions"
# filename won't be set just after dialog is created
if not hasattr(self, 'filename'):
if not hasattr(self, "filename"):
return 0, 0
scale = float(self.field_texts[8]) / 100
# can't assume any art is open, use defaults if needed
@ -130,48 +130,62 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
def is_input_valid(self):
# colors: int between 2 and 256
try: int(self.field_texts[3])
except: return False, self.invalid_color_error
try:
int(self.field_texts[3])
except:
return False, self.invalid_color_error
colors = int(self.field_texts[3])
if colors < 2 or colors > 256:
if colors < 2 or colors > 256:
return False, self.invalid_color_error
# % scale: >0 float
try: float(self.field_texts[8])
except: return False, self.invalid_scale_error
try:
float(self.field_texts[8])
except:
return False, self.invalid_scale_error
if float(self.field_texts[8]) <= 0:
return False, self.invalid_scale_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
self.dismiss()
# compile options for importer
options = {}
# create new palette from image?
if self.field_texts[1].strip():
options['palette'] = self.ui.active_art.palette.name if self.ui.active_art else DEFAULT_PALETTE
options["palette"] = (
self.ui.active_art.palette.name
if self.ui.active_art
else DEFAULT_PALETTE
)
else:
# create new palette
palette_filename = os.path.basename(self.filename)
colors = int(self.field_texts[3])
new_pal = PaletteFromFile(self.ui.app, self.filename,
palette_filename, colors)
new_pal = PaletteFromFile(
self.ui.app, self.filename, palette_filename, colors
)
# palette now loaded and saved to disk
options['palette'] = new_pal.name
options["palette"] = new_pal.name
# rescale art?
if self.field_texts[6].strip():
options['art_width'] = self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH
options['art_height'] = self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT
options["art_width"] = (
self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH
)
options["art_height"] = (
self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT
)
else:
# art dimensions = scale% of image dimensions, in tiles
options['art_width'], options['art_height'] = self.get_tile_scale()
options['bicubic_scale'] = bool(self.field_texts[10].strip())
options["art_width"], options["art_height"] = self.get_tile_scale()
options["bicubic_scale"] = bool(self.field_texts[10].strip())
ImportOptionsDialog.do_import(self.ui.app, self.filename, options)
class BitmapImageImporter(ArtImporter):
format_name = 'Bitmap image'
format_name = "Bitmap image"
format_description = """
Bitmap image in PNG, JPEG, or BMP format.
"""
@ -181,11 +195,11 @@ Bitmap image in PNG, JPEG, or BMP format.
def run_import(self, in_filename, options={}):
# modify self.app.ui.active_art based on options
palette = self.app.load_palette(options['palette'])
palette = self.app.load_palette(options["palette"])
self.art.set_palette(palette)
width, height = options['art_width'], options['art_height']
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options["bicubic_scale"]
# let ImageConverter do the actual heavy lifting
ic = ImageConverter(self.app, in_filename, self.art, bicubic_scale)
# early failures: file no longer exists, PIL fails to load and convert image

View file

@ -1,14 +1,17 @@
import numpy as np
from formats.in_bitmap import (
BitmapImageImporter,
ConvertImageChooserDialog,
ConvertImageOptionsDialog,
)
from image_convert import ImageConverter
from ui_dialog import UIDialog, Field, SkipFieldType
from formats.in_bitmap import BitmapImageImporter, ConvertImageChooserDialog, ConvertImageOptionsDialog
from ui_dialog import Field, SkipFieldType, UIDialog
class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog):
# simplified version of parent options dialog, reusing as much as possible
title = 'Convert 2-color bitmap image options'
title = "Convert 2-color bitmap image options"
field5_label = ConvertImageOptionsDialog.field5_label
field6_label = ConvertImageOptionsDialog.field6_label
field7_label = ConvertImageOptionsDialog.field7_label
@ -16,18 +19,18 @@ class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog):
field10_label = ConvertImageOptionsDialog.field10_label
field_width = ConvertImageOptionsDialog.field_width
fields = [
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label=field5_label, type=None, width=0, oneline=True),
Field(label=field6_label, type=bool, width=0, oneline=True),
Field(label=field7_label, type=bool, width=0, oneline=True),
Field(label=field8_label, type=float, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field10_label, type=bool, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
def __init__(self, ui, options):
@ -37,18 +40,17 @@ class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog):
def get_initial_field_text(self, field_number):
# alternate defaults - use 1:1 scaling
if field_number == 6:
return ' '
return " "
elif field_number == 7:
return UIDialog.true_field_text
elif field_number == 8:
# % of source image size - alternate default
return '100.0'
return "100.0"
else:
return ConvertImageOptionsDialog.get_initial_field_text(self, field_number)
class TwoColorImageConverter(ImageConverter):
def get_color_combos_for_block(self, src_block):
colors, counts = np.unique(src_block, False, False, return_counts=True)
if len(colors) > 0:
@ -60,7 +62,7 @@ class TwoColorImageConverter(ImageConverter):
class TwoColorBitmapImageImporter(BitmapImageImporter):
format_name = '2-color bitmap image'
format_name = "2-color bitmap image"
format_description = """
Variation on bitmap image conversion that forces
a black and white (1-bit) palette, and doesn't use
@ -72,12 +74,12 @@ fg/bg color swaps. Suitable for plaintext export.
def run_import(self, in_filename, options={}):
# force palette to 1-bit black and white
palette = self.app.load_palette('bw')
palette = self.app.load_palette("bw")
self.art.set_palette(palette)
width, height = options['art_width'], options['art_height']
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options["bicubic_scale"]
ic = TwoColorImageConverter(self.app, in_filename, self.art, bicubic_scale)
# early failures: file no longer exists, PIL fails to load and convert image
if not ic.init_success:

View file

@ -1,22 +1,22 @@
# "convert folder of images to animation"
# heavy lifting still done by ImageConverter, this mainly coordinates
# conversion of multiple frames
import os, time
import os
import time
import image_convert
import formats.in_bitmap as bm
import image_convert
class ImageSequenceConverter:
def __init__(self, app, image_filenames, art, bicubic_scale):
self.init_success = False
self.app = app
self.start_time = time.time()
self.image_filenames = image_filenames
# App.update_window_title uses image_filename for titlebar
self.image_filename = ''
self.image_filename = ""
# common name of sequence
self.image_name = os.path.splitext(self.image_filename)[0]
self.art = art
@ -36,10 +36,9 @@ class ImageSequenceConverter:
# next frame
self.art.set_active_frame(self.art.active_frame + 1)
try:
self.current_frame_converter = image_convert.ImageConverter(self.app,
self.image_filenames[0],
self.art,
self.bicubic_scale, self)
self.current_frame_converter = image_convert.ImageConverter(
self.app, self.image_filenames[0], self.art, self.bicubic_scale, self
)
except:
self.fail()
return
@ -51,7 +50,7 @@ class ImageSequenceConverter:
self.app.update_window_title()
def fail(self):
self.app.log('Bad frame %s' % self.image_filenames[0], error=True)
self.app.log("Bad frame %s" % self.image_filenames[0], error=True)
self.finish(True)
def update(self):
@ -64,50 +63,54 @@ class ImageSequenceConverter:
def finish(self, cancelled=False):
time_taken = time.time() - self.start_time
(verb, error) = ('cancelled', True) if cancelled else ('finished', False)
self.app.log('Conversion of image sequence %s %s after %.3f seconds' % (self.image_name, verb, time_taken), error)
(verb, error) = ("cancelled", True) if cancelled else ("finished", False)
self.app.log(
"Conversion of image sequence %s %s after %.3f seconds"
% (self.image_name, verb, time_taken),
error,
)
self.app.converter = None
self.app.update_window_title()
class ConvertImageSequenceChooserDialog(bm.ConvertImageChooserDialog):
title = 'Convert folder'
confirm_caption = 'Choose First Image'
title = "Convert folder"
confirm_caption = "Choose First Image"
class BitmapImageSequenceImporter(bm.BitmapImageImporter):
format_name = 'Bitmap image folder'
format_name = "Bitmap image folder"
format_description = """
Converts a folder of Bitmap images (PNG, JPEG, or BMP)
into an animation. Dimensions will be based on first
image chosen.
"""
file_chooser_dialog_class = ConvertImageSequenceChooserDialog
#options_dialog_class = bm.ConvertImageOptionsDialog
# options_dialog_class = bm.ConvertImageOptionsDialog
def run_import(self, in_filename, options={}):
palette = self.app.load_palette(options['palette'])
palette = self.app.load_palette(options["palette"])
self.art.set_palette(palette)
width, height = options['art_width'], options['art_height']
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options["bicubic_scale"]
# get dir listing with full pathname
in_dir = os.path.dirname(in_filename)
in_files = ['%s/%s' % (in_dir, f) for f in os.listdir(in_dir)]
in_files = ["%s/%s" % (in_dir, f) for f in os.listdir(in_dir)]
in_files.sort()
# assume numeric sequence starts from chosen file
in_files = in_files[in_files.index(in_filename):]
in_files = in_files[in_files.index(in_filename) :]
# remove files from end of list if they don't end in a number
while not os.path.splitext(in_files[-1])[0][-1].isdecimal() and \
len(in_files) > 0:
while (
not os.path.splitext(in_files[-1])[0][-1].isdecimal() and len(in_files) > 0
):
in_files.pop()
# add frames to art as needed
while self.art.frames < len(in_files):
self.art.add_frame_to_end(log=False)
self.art.set_active_frame(0)
# create converter
isc = ImageSequenceConverter(self.app, in_files, self.art,
bicubic_scale)
isc = ImageSequenceConverter(self.app, in_files, self.art, bicubic_scale)
# bail on early failure
if not isc.init_success:
return False

View file

@ -1,45 +1,44 @@
from art_import import ArtImporter
from ui_dialog import UIDialog, Field
from ui_art_dialog import ImportOptionsDialog
from ui_dialog import Field, UIDialog
class EDSCIIImportOptionsDialog(ImportOptionsDialog):
title = 'Import EDSCII (legacy format) art'
field0_label = 'Width override (leave 0 to guess):'
title = "Import EDSCII (legacy format) art"
field0_label = "Width override (leave 0 to guess):"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=False)
]
invalid_width_error = 'Invalid width override.'
fields = [Field(label=field0_label, type=int, width=field_width, oneline=False)]
invalid_width_error = "Invalid width override."
def get_initial_field_text(self, field_number):
if field_number == 0:
return '0'
return ''
return "0"
return ""
def is_input_valid(self):
# valid widths: any >=0 int
try: int(self.field_texts[0])
except: return False, self.invalid_width_error
try:
int(self.field_texts[0])
except:
return False, self.invalid_width_error
if int(self.field_texts[0]) < 0:
return False, self.invalid_width_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
width = int(self.field_texts[0])
width = width if width > 0 else None
options = {'width_override':width}
options = {"width_override": width}
self.dismiss()
# self.filename is set in our importer's file_chooser_dialog_class
ImportOptionsDialog.do_import(self.ui.app, self.filename, options)
class EDSCIIImporter(ArtImporter):
format_name = 'EDSCII'
format_name = "EDSCII"
format_description = """
Binary format for EDSCII, Playscii's predecessor.
Assumes single frame, single layer document.
@ -48,29 +47,31 @@ Current character set and palette will be used.
options_dialog_class = EDSCIIImportOptionsDialog
def run_import(self, in_filename, options={}):
data = open(in_filename, 'rb').read()
data = open(in_filename, "rb").read()
# document width = find longest stretch before a \n
longest_line = 0
for line in data.splitlines():
if len(line) > longest_line:
longest_line = len(line)
# user can override assumed document width, needed for a few files
width = options.get('width_override', None) or int(longest_line / 3)
width = options.get("width_override", None) or int(longest_line / 3)
# derive height from width
# 2-byte line breaks might produce non-int result, cast erases this
height = int(len(data) / width / 3)
self.art.resize(width, height)
# populate char/color arrays by scanning width-long chunks of file
def chunks(l, n):
for i in range(0, len(l), n):
yield l[i:i+n]
yield l[i : i + n]
# 3 bytes per tile, +1 for line ending
# BUT: files saved in windows may have 2 byte line breaks, try to detect
lb_length = 1
lines = chunks(data, (width * 3) + lb_length)
for line in lines:
if line[-2] == ord('\r') and line[-1] == ord('\n'):
#self.app.log('EDSCIIImporter: windows-style line breaks detected')
if line[-2] == ord("\r") and line[-1] == ord("\n"):
# self.app.log('EDSCIIImporter: windows-style line breaks detected')
lb_length = 2
break
# recreate generator after first use
@ -81,8 +82,8 @@ Current character set and palette will be used.
while index < len(line) - lb_length:
char = line[index]
# +1 to color indices; playscii color index 0 = transparent
fg = line[index+1] + 1
bg = line[index+2] + 1
fg = line[index + 1] + 1
bg = line[index + 2] + 1
self.art.set_tile_at(0, 0, x, y, char, fg, bg)
index += 3
x += 1

View file

@ -1,8 +1,8 @@
from art_import import ArtImporter
class EndDoomImporter(ArtImporter):
format_name = 'ENDOOM'
format_name = "ENDOOM"
format_description = """
ENDOOM lump file format for Doom engine games.
80x25 DOS ASCII with EGA palette.
@ -18,17 +18,17 @@ Background colors can only be EGA colors 0-8.
second byte = color:
bits 0-3 = fg color, bits 4-6 = bg color, bit 7 = blink
"""
self.set_art_charset('dos')
self.set_art_palette('ega')
self.set_art_charset("dos")
self.set_art_palette("ega")
self.art.resize(80, 25)
data = open(in_filename, 'rb').read(4000)
data = open(in_filename, "rb").read(4000)
x, y = 0, 0
for i,byte in enumerate(data):
for i, byte in enumerate(data):
if i % 2 != 0:
continue
color_byte = data[i+1]
color_byte = data[i + 1]
bits = bin(color_byte)[2:]
bits = bits.rjust(7, '0')
bits = bits.rjust(7, "0")
bg_bits = bits[:3]
fg_bits = bits[3:]
offset = 1

View file

@ -1,9 +1,8 @@
from art_import import ArtImporter
class TextImporter(ArtImporter):
format_name = 'Plain text'
class TextImporter(ArtImporter):
format_name = "Plain text"
format_description = """
ASCII art in ordinary text format.
Assumes single frame, single layer document.

View file

@ -1,33 +1,33 @@
from art_export import ArtExporter
WIDTH = 80
ENCODING = 'cp1252' # old default
ENCODING = 'us-ascii' # DEBUG
ENCODING = 'latin_1' # DEBUG - seems to handle >128 chars ok?
ENCODING = "cp1252" # old default
ENCODING = "us-ascii" # DEBUG
ENCODING = "latin_1" # DEBUG - seems to handle >128 chars ok?
class ANSExporter(ArtExporter):
format_name = 'ANSI'
format_name = "ANSI"
format_description = """
Classic scene format using ANSI standard codes.
Assumes 80 columns, DOS character set and EGA palette.
Exports active layer of active frame.
"""
file_extension = 'ans'
file_extension = "ans"
def get_display_command(self, fg, bg):
"return a display command sequence string for given colors"
# reset colors on every tile
s = chr(27) + chr(91) + '0;'
s = chr(27) + chr(91) + "0;"
if fg >= 8:
s += '1;'
s += "1;"
fg -= 8
if bg >= 8:
s += '5;'
s += "5;"
bg -= 8
s += '%s;' % (fg + 30)
s += '%s' % (bg + 40)
s += 'm'
s += "%s;" % (fg + 30)
s += "%s" % (bg + 40)
s += "m"
return s
def write(self, data):
@ -35,7 +35,7 @@ Exports active layer of active frame.
def run_export(self, out_filename, options):
# binary file; encoding into ANSI bytes happens just before write
self.outfile = open(out_filename, 'wb')
self.outfile = open(out_filename, "wb")
layer = self.art.active_layer
frame = self.art.active_frame
for y in range(self.art.height):
@ -57,6 +57,6 @@ Exports active layer of active frame.
# special (top row) chars won't display in terminal anyway
self.write(chr(0))
# carriage return + line feed
self.outfile.write(b'\r\n')
self.outfile.write(b"\r\n")
self.outfile.close()
return True

View file

@ -1,19 +1,19 @@
from art_export import ArtExporter
from art import TileIter
from art_export import ArtExporter
class ANSExporter(ArtExporter):
format_name = 'ATASCII'
format_name = "ATASCII"
format_description = """
ATARI 8-bit computer version of ASCII.
Assumes ATASCII character set and Atari palette.
Any tile with non-black background will be considered inverted.
"""
file_extension = 'ata'
file_extension = "ata"
def run_export(self, out_filename, options):
# binary file; encoding into ANSI bytes happens just before write
self.outfile = open(out_filename, 'wb')
self.outfile = open(out_filename, "wb")
for frame, layer, x, y in TileIter(self.art):
# only read from layer 0 of frame 0
if layer > 0 or frame > 0:

View file

@ -1,20 +1,21 @@
from art_export import ArtExporter
WIDTH, HEIGHT = 80, 25
class EndDoomExporter(ArtExporter):
format_name = 'ENDOOM'
format_name = "ENDOOM"
format_description = """
ENDOOM lump file format for Doom engine games.
80x25 DOS ASCII with EGA palette.
Background colors can only be EGA colors 0-8.
"""
def run_export(self, out_filename, options):
if self.art.width < WIDTH or self.art.height < HEIGHT:
self.app.log("ENDOOM export: Art isn't big enough!")
return False
outfile = open(out_filename, 'wb')
outfile = open(out_filename, "wb")
for y in range(HEIGHT):
for x in range(WIDTH):
char, fg, bg, xform = self.art.get_tile_at(0, 0, x, y)
@ -26,11 +27,11 @@ Background colors can only be EGA colors 0-8.
bg = max(0, bg)
char_byte = bytes([char])
outfile.write(char_byte)
fg_bits = bin(fg)[2:].rjust(4, '0')
fg_bits = bin(fg)[2:].rjust(4, "0")
# BG color can't be above 8
bg %= 8
bg_bits = bin(bg)[2:].rjust(3, '0')
color_bits = '0' + bg_bits + fg_bits
bg_bits = bin(bg)[2:].rjust(3, "0")
color_bits = "0" + bg_bits + fg_bits
color_byte = int(color_bits, 2)
color_byte = bytes([color_byte])
outfile.write(color_byte)

View file

@ -1,14 +1,15 @@
from art_export import ArtExporter
from image_export import export_animation
class GIFExporter(ArtExporter):
format_name = 'Animated GIF image'
format_name = "Animated GIF image"
format_description = """
Animated GIF of all frames in current document, with
transparency and proper frame timings.
"""
file_extension = 'gif'
file_extension = "gif"
def run_export(self, out_filename, options):
# heavy lifting done by image_export module
export_animation(self.app, self.app.ui.active_art, out_filename)

View file

@ -1,67 +1,70 @@
from art_export import ArtExporter
from image_export import export_still_image
from ui_dialog import UIDialog, Field
from ui_art_dialog import ExportOptionsDialog
from ui_dialog import Field, UIDialog
DEFAULT_SCALE = 4
DEFAULT_CRT = True
class PNGExportOptionsDialog(ExportOptionsDialog):
title = 'PNG image export options'
field0_label = 'Scale factor (%s pixels)'
field1_label = 'CRT filter'
title = "PNG image export options"
field0_label = "Scale factor (%s pixels)"
field1_label = "CRT filter"
fields = [
Field(label=field0_label, type=int, width=6, oneline=False),
Field(label=field1_label, type=bool, width=0, oneline=True)
Field(label=field1_label, type=bool, width=0, oneline=True),
]
# redraw dynamic labels
always_redraw_labels = True
invalid_scale_error = 'Scale must be greater than 0'
invalid_scale_error = "Scale must be greater than 0"
def get_initial_field_text(self, field_number):
if field_number == 0:
return str(DEFAULT_SCALE)
elif field_number == 1:
return [' ', UIDialog.true_field_text][DEFAULT_CRT]
return [" ", UIDialog.true_field_text][DEFAULT_CRT]
def get_field_label(self, field_index):
label = self.fields[field_index].label
if field_index == 0:
valid,_ = self.is_input_valid()
valid, _ = self.is_input_valid()
if not valid:
label %= '???'
label %= "???"
else:
# calculate exported image size
art = self.ui.active_art
scale = int(self.field_texts[0])
width = art.charset.char_width * art.width * scale
height = art.charset.char_height * art.height * scale
label %= '%s x %s' % (width, height)
label %= "%s x %s" % (width, height)
return label
def is_input_valid(self):
# scale factor: >0 int
try: int(self.field_texts[0])
except: return False, self.invalid_scale_error
try:
int(self.field_texts[0])
except:
return False, self.invalid_scale_error
if int(self.field_texts[0]) <= 0:
return False, self.invalid_scale_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
self.dismiss()
# compile options for exporter
options = {
'scale': int(self.field_texts[0]),
'crt': bool(self.field_texts[1].strip())
"scale": int(self.field_texts[0]),
"crt": bool(self.field_texts[1].strip()),
}
ExportOptionsDialog.do_export(self.ui.app, self.filename, options)
class PNGExporter(ArtExporter):
format_name = 'PNG image'
format_name = "PNG image"
format_description = """
PNG format (lossless compression) still image of current frame.
Can be exported with or without CRT filter effect.
@ -70,12 +73,15 @@ exported image will be 8-bit with same palette as this Art.
Otherwise it will be 32-bit with alpha transparency.
If CRT filter is enabled, image will always be 32-bit.
"""
file_extension = 'png'
file_extension = "png"
options_dialog_class = PNGExportOptionsDialog
def run_export(self, out_filename, options):
# heavy lifting done by image_export module
return export_still_image(self.app, self.app.ui.active_art,
out_filename,
crt=options.get('crt', DEFAULT_CRT),
scale=options.get('scale', DEFAULT_SCALE))
return export_still_image(
self.app,
self.app.ui.active_art,
out_filename,
crt=options.get("crt", DEFAULT_CRT),
scale=options.get("scale", DEFAULT_SCALE),
)

View file

@ -1,20 +1,20 @@
import os
from art_export import ArtExporter
from image_export import export_still_image
from ui_dialog import UIDialog, Field
from ui_art_dialog import ExportOptionsDialog
from renderable import LAYER_VIS_FULL, LAYER_VIS_NONE
from ui_art_dialog import ExportOptionsDialog
from ui_dialog import Field, UIDialog
FILE_EXTENSION = 'png'
FILE_EXTENSION = "png"
DEFAULT_SCALE = 1
DEFAULT_CRT = False
def get_full_filename(in_filename, frame, layer_name,
use_frame, use_layer,
forbidden_chars):
def get_full_filename(
in_filename, frame, layer_name, use_frame, use_layer, forbidden_chars
):
"Returns properly mutated filename for given frame/layer data"
# strip out path and extension from filename as we mutate it
dirname = os.path.dirname(in_filename)
@ -22,62 +22,63 @@ def get_full_filename(in_filename, frame, layer_name,
base_filename = os.path.splitext(base_filename)[0]
fn = base_filename
if use_frame:
fn += '_%s' % (str(frame).rjust(4, '0'))
fn += "_%s" % (str(frame).rjust(4, "0"))
if use_layer:
fn += '_%s' % layer_name
fn += "_%s" % layer_name
# strip unfriendly chars from output filename
for forbidden_char in ['\\', '/', '*', ':']:
fn = fn.replace(forbidden_char, '')
for forbidden_char in ["\\", "/", "*", ":"]:
fn = fn.replace(forbidden_char, "")
# add path and extension for final mutated filename
return '%s/%s.%s' % (dirname, fn, FILE_EXTENSION)
return "%s/%s.%s" % (dirname, fn, FILE_EXTENSION)
class PNGSetExportOptionsDialog(ExportOptionsDialog):
title = 'PNG set export options'
tile_width = 60 # extra width for filename preview
field0_label = 'Scale factor (%s pixels)'
field1_label = 'CRT filter'
field2_label = 'Export frames'
field3_label = 'Export layers'
field4_label = 'First filename (in set of %s):'
field5_label = ' %s'
title = "PNG set export options"
tile_width = 60 # extra width for filename preview
field0_label = "Scale factor (%s pixels)"
field1_label = "CRT filter"
field2_label = "Export frames"
field3_label = "Export layers"
field4_label = "First filename (in set of %s):"
field5_label = " %s"
fields = [
Field(label=field0_label, type=int, width=6, oneline=False),
Field(label=field1_label, type=bool, width=0, oneline=True),
Field(label=field2_label, type=bool, width=0, oneline=True),
Field(label=field3_label, type=bool, width=0, oneline=True),
Field(label=field4_label, type=None, width=0, oneline=True),
Field(label=field5_label, type=None, width=0, oneline=True)
Field(label=field5_label, type=None, width=0, oneline=True),
]
# redraw dynamic labels
always_redraw_labels = True
invalid_scale_error = 'Scale must be greater than 0'
invalid_scale_error = "Scale must be greater than 0"
def get_initial_field_text(self, field_number):
art = self.ui.active_art
if field_number == 0:
return str(DEFAULT_SCALE)
elif field_number == 1:
return [' ', UIDialog.true_field_text][DEFAULT_CRT]
return [" ", UIDialog.true_field_text][DEFAULT_CRT]
elif field_number == 2:
# default false if only one frame
return [' ', UIDialog.true_field_text][art.frames > 1]
return [" ", UIDialog.true_field_text][art.frames > 1]
elif field_number == 3:
# default false if only one layer
return [' ', UIDialog.true_field_text][art.layers > 1]
return [" ", UIDialog.true_field_text][art.layers > 1]
def get_field_label(self, field_index):
label = self.fields[field_index].label
if field_index == 0:
valid,_ = self.is_input_valid()
valid, _ = self.is_input_valid()
if not valid:
label %= '???'
label %= "???"
else:
# calculate exported image size
art = self.ui.active_art
scale = int(self.field_texts[0])
width = art.charset.char_width * art.width * scale
height = art.charset.char_height * art.height * scale
label %= '%s x %s' % (width, height)
label %= "%s x %s" % (width, height)
# show how many images exported set will be
elif field_index == 4:
export_frames = bool(self.field_texts[2].strip())
@ -90,43 +91,51 @@ class PNGSetExportOptionsDialog(ExportOptionsDialog):
elif export_layers:
label %= str(art.layers)
else:
label %= '1'
label %= "1"
# preview frame + layer filename mutations based on current settings
elif field_index == 5:
export_frames = bool(self.field_texts[2].strip())
export_layers = bool(self.field_texts[3].strip())
art = self.ui.active_art
fn = get_full_filename(self.filename, 0, art.layer_names[0],
export_frames, export_layers,
self.ui.app.forbidden_filename_chars)
fn = get_full_filename(
self.filename,
0,
art.layer_names[0],
export_frames,
export_layers,
self.ui.app.forbidden_filename_chars,
)
fn = os.path.basename(fn)
label %= fn
return label
def is_input_valid(self):
# scale factor: >0 int
try: int(self.field_texts[0])
except: return False, self.invalid_scale_error
try:
int(self.field_texts[0])
except:
return False, self.invalid_scale_error
if int(self.field_texts[0]) <= 0:
return False, self.invalid_scale_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
self.dismiss()
# compile options for importer
options = {
'scale': int(self.field_texts[0]),
'crt': bool(self.field_texts[1].strip()),
'frames': bool(self.field_texts[2].strip()),
'layers': bool(self.field_texts[3].strip())
"scale": int(self.field_texts[0]),
"crt": bool(self.field_texts[1].strip()),
"frames": bool(self.field_texts[2].strip()),
"layers": bool(self.field_texts[3].strip()),
}
ExportOptionsDialog.do_export(self.ui.app, self.filename, options)
class PNGSetExporter(ArtExporter):
format_name = 'PNG image set'
format_name = "PNG image set"
format_description = """
PNG image set for each frame and/or layer.
"""
@ -134,8 +143,8 @@ PNG image set for each frame and/or layer.
options_dialog_class = PNGSetExportOptionsDialog
def run_export(self, out_filename, options):
export_frames = options['frames']
export_layers = options['layers']
export_frames = options["frames"]
export_layers = options["layers"]
art = self.app.ui.active_art
# remember user's active frame/layer/viz settings so we
# can set em back when done
@ -145,7 +154,9 @@ PNG image set for each frame and/or layer.
start_layer_viz = self.app.inactive_layer_visibility
self.app.onion_frames_visible = False
# if multi-player, only show active layer
self.app.inactive_layer_visibility = LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL
self.app.inactive_layer_visibility = (
LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL
)
success = True
for frame in range(art.frames):
# if exporting layers but not frames, only export active frame
@ -154,13 +165,21 @@ PNG image set for each frame and/or layer.
art.set_active_frame(frame)
for layer in range(art.layers):
art.set_active_layer(layer)
full_filename = get_full_filename(out_filename, frame,
art.layer_names[layer],
export_frames, export_layers,
self.app.forbidden_filename_chars)
if not export_still_image(self.app, art, full_filename,
crt=options.get('crt', DEFAULT_CRT),
scale=options.get('scale', DEFAULT_SCALE)):
full_filename = get_full_filename(
out_filename,
frame,
art.layer_names[layer],
export_frames,
export_layers,
self.app.forbidden_filename_chars,
)
if not export_still_image(
self.app,
art,
full_filename,
crt=options.get("crt", DEFAULT_CRT),
scale=options.get("scale", DEFAULT_SCALE),
):
success = False
# put everything back how user left it
art.set_active_frame(start_frame)

View file

@ -1,29 +1,31 @@
from art_export import ArtExporter
class TextExporter(ArtExporter):
format_name = 'Plain text'
format_name = "Plain text"
format_description = """
ASCII art in ordinary text format.
Assumes single frame, single layer document.
Current character set will be used; make sure it supports
any extended characters you want translated.
"""
file_extension = 'txt'
file_extension = "txt"
def run_export(self, out_filename, options):
# utf-8 is safest encoding to use here, but non-default on Windows
outfile = open(out_filename, 'w', encoding='utf-8')
outfile = open(out_filename, "w", encoding="utf-8")
for y in range(self.art.height):
for x in range(self.art.width):
char = self.art.get_char_index_at(0, 0, x, y)
found_char = False
for k,v in self.art.charset.char_mapping.items():
for k, v in self.art.charset.char_mapping.items():
if v == char:
found_char = True
outfile.write(k)
break
# if char not found, just write a blank space
if not found_char:
outfile.write(' ')
outfile.write('\n')
outfile.write(" ")
outfile.write("\n")
outfile.close()
return True

View file

@ -3,16 +3,18 @@ from OpenGL import GL
class Framebuffer:
start_crt_enabled = False
disable_crt = False
clear_color = (0, 0, 0, 1)
# declared as an option here in case people want to sub their own via CFG
crt_fragment_shader_filename = 'framebuffer_f_crt.glsl'
crt_fragment_shader_filename = "framebuffer_f_crt.glsl"
def __init__(self, app, width=None, height=None):
self.app = app
self.width, self.height = width or self.app.window_width, height or self.app.window_height
self.width, self.height = (
width or self.app.window_width,
height or self.app.window_height,
)
# bind vao before compiling shaders
if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1)
@ -20,27 +22,34 @@ class Framebuffer:
self.vbo = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo)
fb_verts = np.array([-1, -1, 1, -1, -1, 1, 1, 1], dtype=np.float32)
GL.glBufferData(GL.GL_ARRAY_BUFFER, fb_verts.nbytes, fb_verts,
GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER, fb_verts.nbytes, fb_verts, GL.GL_STATIC_DRAW
)
# texture, depth buffer, framebuffer
self.texture = GL.glGenTextures(1)
self.depth_buffer = GL.glGenRenderbuffers(1)
self.framebuffer = GL.glGenFramebuffers(1)
self.setup_texture_and_buffers()
# shaders
self.plain_shader = self.app.sl.new_shader('framebuffer_v.glsl', 'framebuffer_f.glsl')
self.plain_shader = self.app.sl.new_shader(
"framebuffer_v.glsl", "framebuffer_f.glsl"
)
if not self.disable_crt:
self.crt_shader = self.app.sl.new_shader('framebuffer_v.glsl', self.crt_fragment_shader_filename)
self.crt_shader = self.app.sl.new_shader(
"framebuffer_v.glsl", self.crt_fragment_shader_filename
)
self.crt = self.get_crt_enabled()
# shader uniforms and attributes
self.plain_tex_uniform = self.plain_shader.get_uniform_location('fbo_texture')
self.plain_attrib = self.plain_shader.get_attrib_location('v_coord')
self.plain_tex_uniform = self.plain_shader.get_uniform_location("fbo_texture")
self.plain_attrib = self.plain_shader.get_attrib_location("v_coord")
GL.glEnableVertexAttribArray(self.plain_attrib)
GL.glVertexAttribPointer(self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
GL.glVertexAttribPointer(
self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None
)
if not self.disable_crt:
self.crt_tex_uniform = self.crt_shader.get_uniform_location('fbo_texture')
self.crt_time_uniform = self.crt_shader.get_uniform_location('elapsed_time')
self.crt_res_uniform = self.crt_shader.get_uniform_location('resolution')
self.crt_tex_uniform = self.crt_shader.get_uniform_location("fbo_texture")
self.crt_time_uniform = self.crt_shader.get_uniform_location("elapsed_time")
self.crt_res_uniform = self.crt_shader.get_uniform_location("resolution")
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
@ -50,26 +59,40 @@ class Framebuffer:
def setup_texture_and_buffers(self):
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture)
GL.glTexParameterf(GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
GL.glTexParameterf(GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
GL.glTexParameterf(GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameterf(GL.GL_TEXTURE_2D,
GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA,
self.width, self.height, 0,
GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
GL.GL_RGBA,
self.width,
self.height,
0,
GL.GL_RGBA,
GL.GL_UNSIGNED_BYTE,
None,
)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self.depth_buffer)
GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16,
self.width, self.height)
GL.glRenderbufferStorage(
GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, self.width, self.height
)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, 0)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.framebuffer)
GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0,
GL.GL_TEXTURE_2D, self.texture, 0)
GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT,
GL.GL_RENDERBUFFER, self.depth_buffer)
GL.glFramebufferTexture2D(
GL.GL_FRAMEBUFFER,
GL.GL_COLOR_ATTACHMENT0,
GL.GL_TEXTURE_2D,
self.texture,
0,
)
GL.glFramebufferRenderbuffer(
GL.GL_FRAMEBUFFER,
GL.GL_DEPTH_ATTACHMENT,
GL.GL_RENDERBUFFER,
self.depth_buffer,
)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0)
def resize(self, new_width, new_height):
@ -104,7 +127,9 @@ class Framebuffer:
GL.glBindVertexArray(self.vao)
else:
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo)
GL.glVertexAttribPointer(self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
GL.glVertexAttribPointer(
self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None
)
GL.glEnableVertexAttribArray(self.plain_attrib)
GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4)
if self.app.use_vao:
@ -114,9 +139,13 @@ class Framebuffer:
class ExportFramebuffer(Framebuffer):
clear_color = (0, 0, 0, 0)
def get_crt_enabled(self): return True
def get_crt_enabled(self):
return True
class ExportFramebufferNoCRT(Framebuffer):
clear_color = (0, 0, 0, 0)
def get_crt_enabled(self): return False
def get_crt_enabled(self):
return False

View file

@ -1,10 +1,9 @@
from art import Art
from renderable import TileRenderable
class GameHUDArt(Art):
#recalc_quad_height = False
# recalc_quad_height = False
log_creation = False
quad_width = 0.1
@ -13,12 +12,12 @@ class GameHUDRenderable(TileRenderable):
def get_projection_matrix(self):
# much like UIRenderable, use UI's matrices to render in screen space
return self.app.ui.view_matrix
def get_view_matrix(self):
return self.app.ui.view_matrix
class GameHUD:
"stub HUD, subclass and put your own stuff here"
def __init__(self, world):

View file

@ -1,14 +1,22 @@
import os, math, random
from collections import namedtuple
import math
import os
import random
import vector
from art import Art, ArtInstance
from art import ArtInstance
from collision import (
CST_AABB,
CST_CIRCLE,
CST_NONE,
CST_TILE,
CT_NONE,
CTG_DYNAMIC,
Collideable,
Contact,
point_in_box,
)
from renderable import GameObjectRenderable
from renderable_line import OriginIndicatorRenderable, BoundsIndicatorRenderable
from collision import Contact, Collideable, CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CTG_STATIC, CTG_DYNAMIC, point_in_box
from renderable_line import BoundsIndicatorRenderable, OriginIndicatorRenderable
# facings
GOF_LEFT = 0
@ -20,23 +28,18 @@ GOF_FRONT = 2
GOF_BACK = 3
"Object is facing back"
FACINGS = {
GOF_LEFT: 'left',
GOF_RIGHT: 'right',
GOF_FRONT: 'front',
GOF_BACK: 'back'
}
FACINGS = {GOF_LEFT: "left", GOF_RIGHT: "right", GOF_FRONT: "front", GOF_BACK: "back"}
"Dict mapping GOF_* facing enum values to strings"
FACING_DIRS = {
GOF_LEFT: (-1, 0),
GOF_RIGHT: (1, 0),
GOF_FRONT: (0, -1),
GOF_BACK: (0, 1)
GOF_BACK: (0, 1),
}
"Dict mapping GOF_* facing enum values to (x,y) orientations"
DEFAULT_STATE = 'stand'
DEFAULT_STATE = "stand"
# timer slots
TIMER_PRE_UPDATE = 0
@ -44,7 +47,7 @@ TIMER_UPDATE = 1
TIMER_POST_UPDATE = 2
__pdoc__ = {}
__pdoc__['GameObject.x'] = "Object's location in 3D space."
__pdoc__["GameObject.x"] = "Object's location in 3D space."
class GameObject:
@ -57,7 +60,8 @@ class GameObject:
See game_util_object module for some generic subclasses for things like
a player, spawners, triggers, attachments etc.
"""
art_src = 'game_object_default'
art_src = "game_object_default"
"""
If specified, this art file will be loaded from disk and used as object's
default appearance. If object has states/facings, this is the "base"
@ -84,7 +88,7 @@ class GameObject:
art_charset, art_palette = None, None
y_sort = False
"If True, object will sort according to its Y position a la Zelda LttP"
lifespan = 0.
lifespan = 0.0
"If >0, object will self-destroy after this many seconds"
kill_distance_from_origin = 1000
"""
@ -103,11 +107,11 @@ class GameObject:
# 2 = each step is half object's size
# N = each step is 1/N object's size
"""
move_accel_x = move_accel_y = 200.
move_accel_x = move_accel_y = 200.0
"Acceleration per update from player movement"
ground_friction = 10.0
air_friction = 25.0
mass = 1.
mass = 1.0
"Mass: negative number = infinitely dense"
bounciness = 0.25
"Bounciness aka restitution, % of velocity reflected on bounce"
@ -117,7 +121,7 @@ class GameObject:
log_load = False
log_spawn = False
visible = True
alpha = 1.
alpha = 1.0
locked = False
"If True, location is protected from edit mode drags, can't click to select"
show_origin = False
@ -127,15 +131,15 @@ class GameObject:
"Collision shape: tile, circle, AABB - see the CST_* enum values"
collision_type = CT_NONE
"Type of collision (static, dynamic)"
col_layer_name = 'collision'
col_layer_name = "collision"
"Collision layer name for CST_TILE objects"
draw_col_layer = False
"If True, collision layer will draw normally"
col_offset_x, col_offset_y = 0., 0.
col_offset_x, col_offset_y = 0.0, 0.0
"Collision circle/box offset from origin"
col_radius = 1.
col_radius = 1.0
"Collision circle size, if CST_CIRCLE"
col_width, col_height = 1., 1.
col_width, col_height = 1.0, 1.0
"Collision AABB size, if CST_AABB"
art_off_pct_x, art_off_pct_y = 0.5, 0.5
"""
@ -144,21 +148,47 @@ class GameObject:
"""
should_save = True
"If True, write this object to state save files"
serialized = ['name', 'x', 'y', 'z', 'art_src', 'visible', 'locked', 'y_sort',
'art_off_pct_x', 'art_off_pct_y', 'alpha', 'state', 'facing',
'animating', 'scale_x', 'scale_y']
serialized = [
"name",
"x",
"y",
"z",
"art_src",
"visible",
"locked",
"y_sort",
"art_off_pct_x",
"art_off_pct_y",
"alpha",
"state",
"facing",
"animating",
"scale_x",
"scale_y",
]
"List of members to serialize (no weak refs!)"
editable = ['show_collision', 'col_radius', 'col_width', 'col_height',
'mass', 'bounciness', 'stop_velocity']
editable = [
"show_collision",
"col_radius",
"col_width",
"col_height",
"mass",
"bounciness",
"stop_velocity",
]
"""
Members that don't need to be serialized, but should be exposed to
object edit UI
"""
set_methods = {'art_src': 'set_art_src', 'alpha': '_set_alpha',
'scale_x': '_set_scale_x', 'scale_y': '_set_scale_y',
'name': '_rename', 'col_radius': '_set_col_radius',
'col_width': '_set_col_width',
'col_height': '_set_col_height'
set_methods = {
"art_src": "set_art_src",
"alpha": "_set_alpha",
"scale_x": "_set_scale_x",
"scale_y": "_set_scale_y",
"name": "_rename",
"col_radius": "_set_col_radius",
"col_width": "_set_col_width",
"col_height": "_set_col_height",
}
"If setting a given member should run some logic, specify the method here"
selectable = True
@ -190,13 +220,14 @@ class GameObject:
"If True, handle mouse click/wheel events passed in from world / input handler"
consume_mouse_events = False
"If True, prevent any other mouse click/wheel events from being processed"
def __init__(self, world, obj_data=None):
"""
Create new GameObject in world, from serialized data if provided.
"""
self.x, self.y, self.z = 0., 0., 0.
self.x, self.y, self.z = 0.0, 0.0, 0.0
"Object's location in 3D space."
self.scale_x, self.scale_y, self.scale_z = 1., 1., 1.
self.scale_x, self.scale_y, self.scale_z = 1.0, 1.0, 1.0
"Object's scale in 3D space."
self.rooms = {}
"Dict of rooms we're in - if empty, object appears in all rooms"
@ -209,9 +240,11 @@ class GameObject:
# properties that need non-None defaults should be declared above
if obj_data:
for v in self.serialized:
if not v in obj_data:
if v not in obj_data:
if self.log_load:
self.app.dev_log("Serialized property '%s' not found for %s" % (v, self.name))
self.app.dev_log(
"Serialized property '%s' not found for %s" % (v, self.name)
)
continue
# if value is in data and serialized list but undeclared, do so
if not hasattr(self, v):
@ -253,10 +286,14 @@ class GameObject:
"Dict of all Arts this object can reference, eg for states"
# if art_src not specified, create a new art according to dimensions
if self.generate_art:
self.art_src = '%s_art' % self.name
self.art = self.app.new_art(self.art_src, self.art_width,
self.art_height, self.art_charset,
self.art_palette)
self.art_src = "%s_art" % self.name
self.art = self.app.new_art(
self.art_src,
self.art_width,
self.art_height,
self.art_charset,
self.art_palette,
)
# generated art will likely be only entry in this dict,
# but make sure it's there (eg generated art for Characters)
self.arts[self.art_src] = self.art
@ -278,7 +315,7 @@ class GameObject:
self.bounds_renderable = BoundsIndicatorRenderable(self.app, self)
"1px LineRenderable showing object's bounding box"
for art in self.arts.values():
if not art in self.world.art_loaded:
if art not in self.world.art_loaded:
self.world.art_loaded.append(art)
self.orig_collision_type = self.collision_type
"Remember last collision type for enable/disable - don't set manually!"
@ -286,7 +323,7 @@ class GameObject:
self.world.new_objects[self.name] = self
self.attachments = []
if self.attachment_classes:
for atch_name,atch_class_name in self.attachment_classes.items():
for atch_name, atch_class_name in self.attachment_classes.items():
atch_class = self.world.classes[atch_class_name]
attachment = atch_class(self.world)
self.attachments.append(attachment)
@ -305,12 +342,15 @@ class GameObject:
if self.animating and self.art.frames > 0:
self.start_animating()
if self.log_spawn:
self.app.log('Spawned %s with Art %s' % (self.name, os.path.basename(self.art.filename)))
self.app.log(
"Spawned %s with Art %s"
% (self.name, os.path.basename(self.art.filename))
)
def get_unique_name(self):
"Generate and return a somewhat human-readable unique name for object"
name = str(self)
return '%s_%s' % (type(self).__name__, name[name.rfind('x')+1:-1])
return "%s_%s" % (type(self).__name__, name[name.rfind("x") + 1 : -1])
def _rename(self, new_name):
# pass thru to world, this method exists for edit set method
@ -336,13 +376,13 @@ class GameObject:
if self.facing_changes_art:
# load each facing for each state
for facing in FACINGS.values():
art_name = '%s_%s_%s' % (self.art_src, state, facing)
art_name = "%s_%s_%s" % (self.art_src, state, facing)
art = self.app.load_art(art_name, False)
if art:
self.arts[art_name] = art
else:
# load each state
art_name = '%s_%s' % (self.art_src, state)
art_name = "%s_%s" % (self.art_src, state)
art = self.app.load_art(art_name, False)
if art:
self.arts[art_name] = art
@ -370,7 +410,7 @@ class GameObject:
"Return distance from center of this object to given point."
dx = self.x - point_x
dy = self.y - point_y
return math.sqrt(dx ** 2 + dy ** 2)
return math.sqrt(dx**2 + dy**2)
def normal_to_object(self, other):
"Return tuple normal pointing in direction of given object."
@ -406,8 +446,7 @@ class GameObject:
# use sound_name as filename if it's not in our filenames dict
sound_filename = self.sound_filenames.get(sound_name, sound_name)
sound_filename = self.world.sounds_dir + sound_filename
self.world.app.al.object_play_sound(self, sound_filename,
loops, allow_multiple)
self.world.app.al.object_play_sound(self, sound_filename, loops, allow_multiple)
def stop_sound(self, sound_name):
"Stop playing given sound."
@ -444,9 +483,9 @@ class GameObject:
def stopped_colliding(self, other):
"Run when object stops colliding with another object."
if not other.name in self.collision.contacts:
if other.name not in self.collision.contacts:
# TODO: understand why this spams when player has a MazePickup
#self.world.app.log("%s stopped colliding with %s but wasn't in its contacts!" % (self.name, other.name))
# self.world.app.log("%s stopped colliding with %s but wasn't in its contacts!" % (self.name, other.name))
return
# called from check_finished_contacts
self.collision.contacts.pop(other.name)
@ -460,8 +499,10 @@ class GameObject:
total_vel = self.vel_x + self.vel_y + other.vel_x + other.vel_y
# negative mass = infinite
total_mass = max(0, self.mass) + max(0, other.mass)
if other.name not in self.collision.contacts or \
self.name not in other.collision.contacts:
if (
other.name not in self.collision.contacts
or self.name not in other.collision.contacts
):
return
# redistribute velocity based on mass we're colliding with
if self.is_dynamic() and self.mass >= 0:
@ -489,7 +530,7 @@ class GameObject:
finished = []
# keep separate list of names of objects no longer present
destroyed = []
for obj_name,contact in self.collision.contacts.items():
for obj_name, contact in self.collision.contacts.items():
if contact.timestamp < self.world.cl.ticks:
# object might have been destroyed
obj = self.world.objects.get(obj_name, None)
@ -532,7 +573,7 @@ class GameObject:
def are_bounds_overlapping(self, other):
"Return True if we overlap with other object's Art's bounds"
left, top, right, bottom = self.get_edges()
for x,y in [(left, top), (right, top), (right, bottom), (left, bottom)]:
for x, y in [(left, top), (right, top), (right, bottom), (left, bottom)]:
if other.is_point_inside(x, y):
return True
return False
@ -546,7 +587,9 @@ class GameObject:
y = math.ceil(-y)
return x, y
def get_tiles_overlapping_box(self, box_left, box_top, box_right, box_bottom, log=False):
def get_tiles_overlapping_box(
self, box_left, box_top, box_right, box_bottom, log=False
):
"Returns x,y coords for each tile overlapping given box"
if self.collision_shape_type != CST_TILE:
return []
@ -573,8 +616,7 @@ class GameObject:
"""
started = other.name not in self.collision.contacts
# create or update contact info: (overlap, timestamp)
self.collision.contacts[other.name] = Contact(overlap,
self.world.cl.ticks)
self.collision.contacts[other.name] = Contact(overlap, self.world.cl.ticks)
can_collide = self.can_collide_with(other)
if not can_collide and started:
self.started_overlapping(other)
@ -622,25 +664,25 @@ class GameObject:
"Return Art (and 'flip X' bool) that best represents current state"
# use current state if none specified
state = self.state if state is None else state
art_state_name = '%s_%s' % (self.art_src, self.state)
art_state_name = "%s_%s" % (self.art_src, self.state)
# simple case: no facing, just state
if not self.facing_changes_art:
# return art for current state, use default if not available
if art_state_name in self.arts:
return self.arts[art_state_name], False
else:
default_name = '%s_%s' % (self.art_src, self.state or DEFAULT_STATE)
#assert(default_name in self.arts
default_name = "%s_%s" % (self.art_src, self.state or DEFAULT_STATE)
# assert(default_name in self.arts
# don't assert - if base+state name available, use that
if default_name in self.arts:
return self.arts[default_name], False
else:
#self.app.log('%s: Art with name %s not available, using %s' % (self.name, default_name, self.art_src))
# self.app.log('%s: Art with name %s not available, using %s' % (self.name, default_name, self.art_src))
return self.arts[self.art_src], False
# more complex case: art determined by both state and facing
facing_suffix = FACINGS[self.facing]
# first see if anim exists for this exact state, skip subsequent logic
exact_name = '%s_%s' % (art_state_name, facing_suffix)
exact_name = "%s_%s" % (art_state_name, facing_suffix)
if exact_name in self.arts:
return self.arts[exact_name], False
# see what anims are available and try to choose best for facing
@ -651,18 +693,18 @@ class GameObject:
break
# if NO anims for current state, fall back to default
if not has_state:
default_name = '%s_%s' % (self.art_src, DEFAULT_STATE)
default_name = "%s_%s" % (self.art_src, DEFAULT_STATE)
art_state_name = default_name
front_name = '%s_%s' % (art_state_name, FACINGS[GOF_FRONT])
left_name = '%s_%s' % (art_state_name, FACINGS[GOF_LEFT])
right_name = '%s_%s' % (art_state_name, FACINGS[GOF_RIGHT])
back_name = '%s_%s' % (art_state_name, FACINGS[GOF_BACK])
front_name = "%s_%s" % (art_state_name, FACINGS[GOF_FRONT])
left_name = "%s_%s" % (art_state_name, FACINGS[GOF_LEFT])
right_name = "%s_%s" % (art_state_name, FACINGS[GOF_RIGHT])
back_name = "%s_%s" % (art_state_name, FACINGS[GOF_BACK])
has_front = front_name in self.arts
has_left = left_name in self.arts
has_right = right_name in self.arts
has_sides = has_left or has_right
# throw an error if nothing basic is available
#assert(has_front or has_sides)
# assert(has_front or has_sides)
if not has_front and not has_sides:
return self.arts[self.art_src], False
# if left/right opposite available, flip it
@ -782,10 +824,10 @@ class GameObject:
self.move_y += dir_y
def is_on_ground(self):
'''
"""
Return True if object is "on the ground". Subclasses define custom
logic here.
'''
"""
return True
def get_friction(self):
@ -814,7 +856,7 @@ class GameObject:
force_z += grav_z * self.mass
# friction / drag
friction = self.get_friction()
speed = math.sqrt(vel_x ** 2 + vel_y ** 2 + vel_z ** 2)
speed = math.sqrt(vel_x**2 + vel_y**2 + vel_z**2)
force_x -= friction * self.mass * vel_x
force_y -= friction * self.mass * vel_y
force_z -= friction * self.mass * vel_z
@ -831,7 +873,9 @@ class GameObject:
Apply current acceleration / velocity to position using Verlet
integration with half-step velocity estimation.
"""
accel_x, accel_y, accel_z = self.get_acceleration(self.vel_x, self.vel_y, self.vel_z)
accel_x, accel_y, accel_z = self.get_acceleration(
self.vel_x, self.vel_y, self.vel_z
)
timestep = self.world.app.timestep / 1000
hsvel_x = self.vel_x + 0.5 * timestep * accel_x
hsvel_y = self.vel_y + 0.5 * timestep * accel_y
@ -843,11 +887,17 @@ class GameObject:
self.vel_x = hsvel_x + 0.5 * timestep * accel_x
self.vel_y = hsvel_y + 0.5 * timestep * accel_y
self.vel_z = hsvel_z + 0.5 * timestep * accel_z
self.vel_x, self.vel_y, self.vel_z = vector.cut_xyz(self.vel_x, self.vel_y, self.vel_z, self.stop_velocity)
self.vel_x, self.vel_y, self.vel_z = vector.cut_xyz(
self.vel_x, self.vel_y, self.vel_z, self.stop_velocity
)
def moved_this_frame(self):
"Return True if object changed locations this frame."
delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2)
delta = math.sqrt(
abs(self.last_x - self.x) ** 2
+ abs(self.last_y - self.y) ** 2
+ abs(self.last_z - self.z) ** 2
)
return delta > self.stop_velocity
def warped_recently(self):
@ -905,8 +955,15 @@ class GameObject:
"""
pass
def set_timer_function(self, timer_name, timer_function, delay_min,
delay_max=0, repeats=-1, slot=TIMER_PRE_UPDATE):
def set_timer_function(
self,
timer_name,
timer_function,
delay_min,
delay_max=0,
repeats=-1,
slot=TIMER_PRE_UPDATE,
):
"""
Run given function in X seconds or every X seconds Y times.
If max is given, next execution will be between min and max time.
@ -914,29 +971,42 @@ class GameObject:
"Slot" determines whether function will run in pre_update, update, or
post_update.
"""
timer = GameObjectTimerFunction(self, timer_name, timer_function,
delay_min, delay_max, repeats, slot)
timer = GameObjectTimerFunction(
self, timer_name, timer_function, delay_min, delay_max, repeats, slot
)
# add to slot-appropriate dict
d = [self.timer_functions_pre_update, self.timer_functions_update,
self.timer_functions_post_update][slot]
d = [
self.timer_functions_pre_update,
self.timer_functions_update,
self.timer_functions_post_update,
][slot]
d[timer_name] = timer
def stop_timer_function(self, timer_name):
"Stop currently running timer function with given name."
timer = self.timer_functions_pre_update.get(timer_name, None) or \
self.timer_functions_update.get(timer_name, None) or \
self.timer_functions_post_update.get(timer_name, None)
timer = (
self.timer_functions_pre_update.get(timer_name, None)
or self.timer_functions_update.get(timer_name, None)
or self.timer_functions_post_update.get(timer_name, None)
)
if not timer:
self.app.log('Timer named %s not found on object %s' % (timer_name,
self.name))
d = [self.timer_functions_pre_update, self.timer_functions_update,
self.timer_functions_post_update][timer.slot]
self.app.log(
"Timer named %s not found on object %s" % (timer_name, self.name)
)
d = [
self.timer_functions_pre_update,
self.timer_functions_update,
self.timer_functions_post_update,
][timer.slot]
d.pop(timer_name)
def update_state(self):
"Update object state based on current context, eg movement."
if self.state_changes_art and self.stand_if_not_moving and \
not self.moved_this_frame():
if (
self.state_changes_art
and self.stand_if_not_moving
and not self.moved_this_frame()
):
self.state = DEFAULT_STATE
def update_facing(self):
@ -952,7 +1022,7 @@ class GameObject:
def update_state_sounds(self):
"Stop and play looping sounds appropriate to current/recent states."
for state,sound in self.looping_state_sounds.items():
for state, sound in self.looping_state_sounds.items():
if self.is_entering_state(state):
self.play_sound(sound, loops=-1)
elif self.is_exiting_state(state):
@ -992,7 +1062,7 @@ class GameObject:
"""
final_x, final_y = self.x, self.y
dx, dy = self.x - self.last_x, self.y - self.last_y
total_move_dist = math.sqrt(dx ** 2 + dy ** 2)
total_move_dist = math.sqrt(dx**2 + dy**2)
if total_move_dist == 0:
return
# get movement normal
@ -1003,7 +1073,7 @@ class GameObject:
elif self.collision_shape_type == CST_AABB:
# get size in axis object is moving in
step_x, step_y = self.col_width * dir_x, self.col_height * dir_y
step_dist = math.sqrt(step_x ** 2 + step_y ** 2)
step_dist = math.sqrt(step_x**2 + step_y**2)
step_dist /= self.fast_move_steps
# if object isn't moving fast enough, don't step
if total_move_dist <= step_dist:
@ -1034,7 +1104,7 @@ class GameObject:
if 0 < self.destroy_time <= self.world.get_elapsed_time():
self.destroy()
# don't apply physics to selected objects being dragged
if self.physics_move and not self.name in self.world.drag_objects:
if self.physics_move and self.name not in self.world.drag_objects:
self.apply_move()
if self.fast_move_steps > 0:
self.fast_move()
@ -1044,9 +1114,14 @@ class GameObject:
self.update_facing()
# update collision shape before CollisionLord resolves any collisions
self.collision.update()
if abs(self.x) > self.kill_distance_from_origin or \
abs(self.y) > self.kill_distance_from_origin:
self.app.log('%s reached %s from origin, destroying.' % (self.name, self.kill_distance_from_origin))
if (
abs(self.x) > self.kill_distance_from_origin
or abs(self.y) > self.kill_distance_from_origin
):
self.app.log(
"%s reached %s from origin, destroying."
% (self.name, self.kill_distance_from_origin)
)
self.destroy()
def update_renderables(self):
@ -1057,8 +1132,11 @@ class GameObject:
# even if debug viz are off, update once on init to set correct state
if self.show_origin or self in self.world.selected_objects:
self.origin_renderable.update()
if self.show_bounds or self in self.world.selected_objects or \
(self is self.world.hovered_focus_object and self.selectable):
if (
self.show_bounds
or self in self.world.selected_objects
or (self is self.world.hovered_focus_object and self.selectable)
):
self.bounds_renderable.update()
if self.show_collision and self.is_dynamic():
self.collision.update_renderables()
@ -1086,7 +1164,9 @@ class GameObject:
def is_in_current_room(self):
"Return True if this object is in the world's currently active Room."
return len(self.rooms) == 0 or (self.world.current_room and self.world.current_room.name in self.rooms)
return len(self.rooms) == 0 or (
self.world.current_room and self.world.current_room.name in self.rooms
)
def room_entered(self, room, old_room):
"Run when a room we're in is entered."
@ -1103,14 +1183,17 @@ class GameObject:
return
if self.show_origin or self in self.world.selected_objects:
self.origin_renderable.render()
if self.show_bounds or self in self.world.selected_objects or \
(self.selectable and self is self.world.hovered_focus_object):
if (
self.show_bounds
or self in self.world.selected_objects
or (self.selectable and self is self.world.hovered_focus_object)
):
self.bounds_renderable.render()
if self.show_collision and self.collision_type != CT_NONE:
self.collision.render()
def render(self, layer, z_override=None):
#print('GameObject %s layer %s has Z %s' % (self.art.filename, layer, self.art.layers_z[layer]))
# print('GameObject %s layer %s has Z %s' % (self.art.filename, layer, self.art.layers_z[layer]))
self.renderable.render(layer, z_override)
def get_dict(self):
@ -1120,7 +1203,7 @@ class GameObject:
this object's "serialized" list are stored. Direct object references
are not safe to serialize, use only primitive types like strings.
"""
d = { 'class_name': type(self).__name__ }
d = {"class_name": type(self).__name__}
# serialize whatever other vars are declared in self.serialized
for prop_name in self.serialized:
if hasattr(self, prop_name):
@ -1145,8 +1228,10 @@ class GameObject:
if self in self.world.selected_objects:
self.world.selected_objects.remove(self)
if self.spawner:
if hasattr(self.spawner, 'spawned_objects') and \
self in self.spawner.spawned_objects:
if (
hasattr(self.spawner, "spawned_objects")
and self in self.spawner.spawned_objects
):
self.spawner.spawned_objects.remove(self)
self.origin_renderable.destroy()
self.bounds_renderable.destroy()
@ -1162,6 +1247,7 @@ class GameObjectTimerFunction:
Object that manages a function's execution schedule for a GameObject.
Use GameObject.set_timer_function to create these.
"""
def __init__(self, go, name, function, delay_min, delay_max, repeats, slot):
self.go = go
"GameObject using this timer"

View file

@ -1,27 +1,35 @@
from game_object import GameObject
class GameRoom:
"""
A collection of GameObjects within a GameWorld. Can be used to limit scope
of object updates, collisions, etc.
"""
camera_marker_name = ''
camera_marker_name = ""
"If set, camera will move to marker with this name when room entered"
camera_follow_player = False
"If True, camera will follow player while in this room"
left_edge_warp_dest_name, right_edge_warp_dest_name = '', ''
left_edge_warp_dest_name, right_edge_warp_dest_name = "", ""
"If set, warp to room OR marker with this name when edge crossed"
top_edge_warp_dest_name, bottom_edge_warp_dest_name = '', ''
warp_edge_bounds_obj_name = ''
"Object whose art's bounds should be used as our \"edges\" for above"
serialized = ['name', 'camera_marker_name', 'left_edge_warp_dest_name',
'right_edge_warp_dest_name', 'top_edge_warp_dest_name',
'bottom_edge_warp_dest_name', 'warp_edge_bounds_obj_name',
'camera_follow_player']
top_edge_warp_dest_name, bottom_edge_warp_dest_name = "", ""
warp_edge_bounds_obj_name = ""
'Object whose art\'s bounds should be used as our "edges" for above'
serialized = [
"name",
"camera_marker_name",
"left_edge_warp_dest_name",
"right_edge_warp_dest_name",
"top_edge_warp_dest_name",
"bottom_edge_warp_dest_name",
"warp_edge_bounds_obj_name",
"camera_follow_player",
]
"List of string names of members to serialize for this Room class."
log_changes = False
"Log changes to and from this room"
def __init__(self, world, name, room_data=None):
self.world = world
self.name = name
@ -34,8 +42,10 @@ class GameRoom:
# TODO: this is copy-pasted from GameObject, find a way to unify
# TODO: GameWorld.set_data_for that takes instance, serialized list, data dict
for v in self.serialized:
if not v in room_data:
self.world.app.dev_log("Serialized property '%s' not found for room %s" % (v, self.name))
if v not in room_data:
self.world.app.dev_log(
"Serialized property '%s' not found for room %s" % (v, self.name)
)
continue
if not hasattr(self, v):
setattr(self, v, None)
@ -47,7 +57,7 @@ class GameRoom:
else:
setattr(self, v, room_data[v])
# find objects by name and add them
for obj_name in room_data.get('objects', []):
for obj_name in room_data.get("objects", []):
self.add_object_by_name(obj_name)
def pre_first_update(self):
@ -58,7 +68,8 @@ class GameRoom:
# no warping if we don't know our bounds
if not self.edge_obj:
return
edge_dest_name_suffix = '_name'
edge_dest_name_suffix = "_name"
def set_edge_dest(dest_property):
# property name to destination name
dest_name = getattr(self, dest_property)
@ -66,14 +77,19 @@ class GameRoom:
dest_room = self.world.rooms.get(dest_name, None)
dest_obj = self.world.objects.get(dest_name, None)
# derive member name from serialized property name
member_name = dest_property.replace(edge_dest_name_suffix, '')
member_name = dest_property.replace(edge_dest_name_suffix, "")
setattr(self, member_name, dest_room or dest_obj or None)
for pname in ['left_edge_warp_dest_name', 'right_edge_warp_dest_name',
'top_edge_warp_dest_name', 'bottom_edge_warp_dest_name']:
for pname in [
"left_edge_warp_dest_name",
"right_edge_warp_dest_name",
"top_edge_warp_dest_name",
"bottom_edge_warp_dest_name",
]:
set_edge_dest(pname)
def set_camera_marker_name(self, marker_name):
if not marker_name in self.world.objects:
if marker_name not in self.world.objects:
self.world.app.log("Couldn't find camera marker with name %s" % marker_name)
return
self.camera_marker_name = marker_name
@ -81,7 +97,7 @@ class GameRoom:
self.use_camera_marker()
def use_camera_marker(self):
if not self.camera_marker_name in self.world.objects:
if self.camera_marker_name not in self.world.objects:
return
cam_mark = self.world.objects[self.camera_marker_name]
self.world.camera.set_loc_from_obj(cam_mark)
@ -135,16 +151,20 @@ class GameRoom:
if obj.name in self.objects:
self.objects.pop(obj.name)
else:
self.world.app.log("GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name))
self.world.app.log(
"GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name)
)
if self.name in obj.rooms:
obj.rooms.pop(self.name)
else:
self.world.app.log("GameObject %s not found in GameRoom %s" % (obj.name, self.name))
self.world.app.log(
"GameObject %s not found in GameRoom %s" % (obj.name, self.name)
)
def get_dict(self):
"Return a dict that GameWorld.save_to_file can dump to JSON"
object_names = list(self.objects.keys())
d = {'class_name': type(self).__name__, 'objects': object_names}
d = {"class_name": type(self).__name__, "objects": object_names}
# serialize whatever other vars are declared in self.serialized
for prop_name in self.serialized:
if hasattr(self, prop_name):
@ -155,7 +175,12 @@ class GameRoom:
# bail if no bounds or edge warp destinations set
if not self.edge_obj:
return
if not self.left_edge_warp_dest and not self.right_edge_warp_dest and not self.top_edge_warp_dest and not self.bottom_edge_warp_dest:
if (
not self.left_edge_warp_dest
and not self.right_edge_warp_dest
and not self.top_edge_warp_dest
and not self.bottom_edge_warp_dest
):
return
if game_object.warped_recently():
return

View file

@ -1,21 +1,31 @@
import os.path
import random
import os.path, random
from collision import (
CST_AABB,
CST_CIRCLE,
CST_TILE,
CT_GENERIC_DYNAMIC,
CT_GENERIC_STATIC,
CT_NONE,
CT_PLAYER,
)
from game_object import FACING_DIRS, GameObject
from game_object import GameObject, FACING_DIRS
from collision import CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CT_GENERIC_STATIC, CT_GENERIC_DYNAMIC, CT_PLAYER, CTG_STATIC, CTG_DYNAMIC
class GameObjectAttachment(GameObject):
"GameObject that doesn't think about anything, just renders"
collision_type = CT_NONE
should_save = False
selectable = False
exclude_from_class_list = True
physics_move = False
offset_x, offset_y, offset_z = 0., 0., 0.
offset_x, offset_y, offset_z = 0.0, 0.0, 0.0
"Offset from parent object's origin"
fixed_z = False
"If True, Z will not be locked to GO we're attached to"
editable = GameObject.editable + ['offset_x', 'offset_y', 'offset_z']
editable = GameObject.editable + ["offset_x", "offset_y", "offset_z"]
def attach_to(self, game_object):
"Attach this object to given object."
@ -36,46 +46,56 @@ class GameObjectAttachment(GameObject):
class BlobShadow(GameObjectAttachment):
"Generic blob shadow attachment class"
art_src = 'blob_shadow'
art_src = "blob_shadow"
alpha = 0.5
class StaticTileBG(GameObject):
"Generic static world object with tile-based collision"
collision_shape_type = CST_TILE
collision_type = CT_GENERIC_STATIC
physics_move = False
class StaticTileObject(GameObject):
collision_shape_type = CST_TILE
collision_type = CT_GENERIC_STATIC
physics_move = False
y_sort = True
class StaticBoxObject(GameObject):
"Generic static world object with AABB-based (rectangle) collision"
collision_shape_type = CST_AABB
collision_type = CT_GENERIC_STATIC
physics_move = False
class DynamicBoxObject(GameObject):
collision_shape_type = CST_AABB
collision_type = CT_GENERIC_DYNAMIC
y_sort = True
class Pickup(GameObject):
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
y_sort = True
attachment_classes = { 'shadow': 'BlobShadow' }
attachment_classes = {"shadow": "BlobShadow"}
class Projectile(GameObject):
"Generic projectile class"
fast_move_steps = 1
collision_type = CT_GENERIC_DYNAMIC
collision_shape_type = CST_CIRCLE
move_accel_x = move_accel_y = 400.
noncolliding_classes = ['Projectile']
lifespan = 10.
move_accel_x = move_accel_y = 400.0
noncolliding_classes = ["Projectile"]
lifespan = 10.0
"Projectiles should be transient, limited max life"
should_save = False
@ -93,17 +113,19 @@ class Projectile(GameObject):
self.move(self.fire_dir_x, self.fire_dir_y)
GameObject.update(self)
class Character(GameObject):
"Generic character class"
state_changes_art = True
stand_if_not_moving = True
move_state = 'walk'
move_state = "walk"
"Move state name - added to valid_states in init so subclasses recognized"
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
def __init__(self, world, obj_data=None):
if not self.move_state in self.valid_states:
if self.move_state not in self.valid_states:
self.valid_states.append(self.move_state)
GameObject.__init__(self, world, obj_data)
# assume that character should start idling, if its art animates
@ -115,13 +137,20 @@ class Character(GameObject):
if self.state_changes_art and abs(self.vel_x) > 0.1 or abs(self.vel_y) > 0.1:
self.state = self.move_state
class Player(Character):
"Generic player class"
log_move = False
collision_type = CT_PLAYER
editable = Character.editable + ['move_accel_x', 'move_accel_y',
'ground_friction', 'air_friction',
'bounciness', 'stop_velocity']
editable = Character.editable + [
"move_accel_x",
"move_accel_y",
"ground_friction",
"air_friction",
"bounciness",
"stop_velocity",
]
def pre_first_update(self):
if self.world.player is None:
@ -139,9 +168,8 @@ class Player(Character):
class TopDownPlayer(Player):
y_sort = True
attachment_classes = { 'shadow': 'BlobShadow' }
attachment_classes = {"shadow": "BlobShadow"}
facing_changes_art = True
def get_facing_dir(self):
@ -150,20 +178,37 @@ class TopDownPlayer(Player):
class WorldPropertiesObject(GameObject):
"Special magic singleton object that stores and sets GameWorld properties"
art_src = 'world_properties_object'
art_src = "world_properties_object"
visible = deleteable = selectable = False
locked = True
physics_move = False
exclude_from_object_list = True
exclude_from_class_list = True
world_props = ['game_title', 'gravity_x', 'gravity_y', 'gravity_z',
'hud_class_name', 'globals_object_class_name',
'camera_x', 'camera_y', 'camera_z',
'bg_color_r', 'bg_color_g', 'bg_color_b', 'bg_color_a',
'player_camera_lock', 'object_grid_snap', 'draw_hud',
'collision_enabled', 'show_collision_all', 'show_bounds_all',
'show_origin_all', 'show_all_rooms',
'room_camera_changes_enabled', 'draw_debug_objects'
world_props = [
"game_title",
"gravity_x",
"gravity_y",
"gravity_z",
"hud_class_name",
"globals_object_class_name",
"camera_x",
"camera_y",
"camera_z",
"bg_color_r",
"bg_color_g",
"bg_color_b",
"bg_color_a",
"player_camera_lock",
"object_grid_snap",
"draw_hud",
"collision_enabled",
"show_collision_all",
"show_bounds_all",
"show_origin_all",
"show_all_rooms",
"room_camera_changes_enabled",
"draw_debug_objects",
]
"""
Properties we serialize on behalf of GameWorld
@ -172,6 +217,7 @@ class WorldPropertiesObject(GameObject):
serialized = world_props
editable = []
"All visible properties are serialized, not editable"
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
world_class = type(world)
@ -188,26 +234,31 @@ class WorldPropertiesObject(GameObject):
# set explicitly as float, for camera & bg color
setattr(self, v, 0.0)
# special handling of bg color (a list)
self.world.bg_color = [self.bg_color_r, self.bg_color_g, self.bg_color_b, self.bg_color_a]
self.world.bg_color = [
self.bg_color_r,
self.bg_color_g,
self.bg_color_b,
self.bg_color_a,
]
self.world.camera.set_loc(self.camera_x, self.camera_y, self.camera_z)
# TODO: figure out why collision_enabled seems to default False!
def set_object_property(self, prop_name, new_value):
setattr(self, prop_name, new_value)
# special handling for some values, eg bg color and camera
if prop_name.startswith('bg_color_'):
component = {'r': 0, 'g': 1, 'b': 2, 'a': 3}[prop_name[-1]]
if prop_name.startswith("bg_color_"):
component = {"r": 0, "g": 1, "b": 2, "a": 3}[prop_name[-1]]
self.world.bg_color[component] = float(new_value)
elif prop_name.startswith('camera_') and len(prop_name) == len('camera_x'):
elif prop_name.startswith("camera_") and len(prop_name) == len("camera_x"):
setattr(self.world.camera, prop_name[-1], new_value)
# some properties have unique set methods in GW
elif prop_name == 'show_collision_all':
elif prop_name == "show_collision_all":
self.world.toggle_all_collision_viz()
elif prop_name == 'show_bounds_all':
elif prop_name == "show_bounds_all":
self.world.toggle_all_bounds_viz()
elif prop_name == 'show_origin_all':
elif prop_name == "show_origin_all":
self.world.toggle_all_origin_viz()
elif prop_name == 'player_camera_lock':
elif prop_name == "player_camera_lock":
self.world.toggle_player_camera_lock()
# normal properties you can just set: set em
elif hasattr(self.world, prop_name):
@ -225,6 +276,7 @@ class WorldGlobalsObject(GameObject):
Subclass can be specified in WorldPropertiesObject.
NOTE: this object is spawned from scratch every load, it's never serialized!
"""
should_save = False
visible = deleteable = selectable = False
locked = True
@ -237,8 +289,9 @@ class WorldGlobalsObject(GameObject):
class LocationMarker(GameObject):
"Very simple GameObject that marks an XYZ location for eg camera points"
art_src = 'loc_marker'
serialized = ['name', 'x', 'y', 'z', 'visible', 'locked']
art_src = "loc_marker"
serialized = ["name", "x", "y", "z", "visible", "locked"]
editable = []
alpha = 0.5
physics_move = False
@ -250,21 +303,24 @@ class StaticTileTrigger(GameObject):
Generic static trigger with tile-based collision.
Overlaps but doesn't collide.
"""
is_debug = True
collision_shape_type = CST_TILE
collision_type = CT_GENERIC_STATIC
noncolliding_classes = ['GameObject']
noncolliding_classes = ["GameObject"]
physics_move = False
serialized = ['name', 'x', 'y', 'z', 'art_src', 'visible', 'locked']
serialized = ["name", "x", "y", "z", "art_src", "visible", "locked"]
def started_overlapping(self, other):
#self.app.log('Trigger overlapped with %s' % other.name)
# self.app.log('Trigger overlapped with %s' % other.name)
pass
class WarpTrigger(StaticTileTrigger):
"Trigger that warps object to a room/marker when they touch it."
is_debug = True
art_src = 'trigger_default'
art_src = "trigger_default"
alpha = 0.5
destination_marker_name = None
"If set, warp to this location marker"
@ -272,15 +328,20 @@ class WarpTrigger(StaticTileTrigger):
"If set, make this room the world's current"
use_marker_room = True
"If True, change to destination marker's room"
warp_class_names = ['Player']
warp_class_names = ["Player"]
"List of class names to warp on contact with us."
serialized = StaticTileTrigger.serialized + ['destination_room_name',
'destination_marker_name',
'use_marker_room']
serialized = StaticTileTrigger.serialized + [
"destination_room_name",
"destination_marker_name",
"use_marker_room",
]
def __init__(self, world, obj_data=None):
StaticTileTrigger.__init__(self, world, obj_data)
self.warp_classes = [self.world.get_class_by_name(class_name) for class_name in self.warp_class_names]
self.warp_classes = [
self.world.get_class_by_name(class_name)
for class_name in self.warp_class_names
]
def started_overlapping(self, other):
if other.warped_recently():
@ -307,25 +368,34 @@ class WarpTrigger(StaticTileTrigger):
elif self.destination_marker_name:
marker = self.world.objects.get(self.destination_marker_name, None)
if not marker:
self.app.log('Warp destination object %s not found' % self.destination_marker_name)
self.app.log(
"Warp destination object %s not found"
% self.destination_marker_name
)
return
other.set_loc(marker.x, marker.y, marker.z)
# warp to marker's room if specified, pick a random one if multiple
if self.use_marker_room and len(marker.rooms) == 1:
room = random.choice(list(marker.rooms.values()))
# warn if both room and marker are set but they conflict
if self.destination_room_name and \
room.name != self.destination_room_name:
self.app.log("Marker %s's room differs from destination room %s" % (marker.name, self.destination_room_name))
if (
self.destination_room_name
and room.name != self.destination_room_name
):
self.app.log(
"Marker %s's room differs from destination room %s"
% (marker.name, self.destination_room_name)
)
self.world.change_room(room.name)
other.last_warp_update = self.world.updates
class ObjectSpawner(LocationMarker):
"Simple object that spawns an object when triggered"
is_debug = True
spawn_class_name = None
spawn_obj_name = ''
spawn_obj_name = ""
spawn_random_in_bounds = False
"If True, spawn somewhere in this object's bounds, else spawn at location"
spawn_obj_data = {}
@ -336,8 +406,11 @@ class ObjectSpawner(LocationMarker):
"Set False for any subclass that triggers in some other way"
destroy_on_room_exit = True
"if True, spawned object will be destroyed when player leaves its room"
serialized = LocationMarker.serialized + ['spawn_class_name', 'spawn_obj_name',
'times_to_fire', 'destroy_on_room_exit'
serialized = LocationMarker.serialized + [
"spawn_class_name",
"spawn_obj_name",
"times_to_fire",
"destroy_on_room_exit",
]
def __init__(self, world, obj_data=None):
@ -403,26 +476,35 @@ class ObjectSpawner(LocationMarker):
class SoundBlaster(LocationMarker):
"Simple object that plays sound when triggered"
is_debug = True
sound_name = ''
sound_name = ""
"String name of sound to play, minus any extension"
can_play = True
"If False, won't play sound when triggered"
play_on_room_enter = True
loops = -1
"Number of times to loop, if -1 loop indefinitely"
serialized = LocationMarker.serialized + ['sound_name', 'can_play',
'play_on_room_enter']
serialized = LocationMarker.serialized + [
"sound_name",
"can_play",
"play_on_room_enter",
]
def __init__(self, world, obj_data=None):
LocationMarker.__init__(self, world, obj_data)
# find file, try common extensions
for ext in ['', '.ogg', '.wav']:
for ext in ["", ".ogg", ".wav"]:
filename = self.sound_name + ext
if self.world.sounds_dir and os.path.exists(self.world.sounds_dir + filename):
if self.world.sounds_dir and os.path.exists(
self.world.sounds_dir + filename
):
self.sound_filenames[self.sound_name] = filename
return
self.world.app.log("Couldn't find sound file %s for SoundBlaster %s" % (self.sound_name, self.name))
self.world.app.log(
"Couldn't find sound file %s for SoundBlaster %s"
% (self.sound_name, self.name)
)
def room_entered(self, room, old_room):
self.play_sound(self.sound_name, self.loops)

View file

@ -1,22 +1,31 @@
import os, sys, math, time, importlib, json, traceback
import importlib
import json
import math
import os
import sys
import time
import traceback
from collections import namedtuple
import sdl2
import game_object, game_util_objects, game_hud, game_room
import collision, vector
from camera import Camera
from grid import GameGrid
import collision
import game_hud
import game_object
import game_room
import game_util_objects
import vector
from art import ART_DIR
from camera import Camera
from charset import CHARSET_DIR
from grid import GameGrid
from palette import PALETTE_DIR
TOP_GAME_DIR = 'games/'
DEFAULT_STATE_FILENAME = 'start'
STATE_FILE_EXTENSION = 'gs'
GAME_SCRIPTS_DIR = 'scripts/'
SOUNDS_DIR = 'sounds/'
TOP_GAME_DIR = "games/"
DEFAULT_STATE_FILENAME = "start"
STATE_FILE_EXTENSION = "gs"
GAME_SCRIPTS_DIR = "scripts/"
SOUNDS_DIR = "sounds/"
# generic starter script with a GO and Player subclass
STARTER_SCRIPT = """
@ -43,37 +52,39 @@ class MyGameObject(GameObject):
# Quickie class to debug render order
RenderItem = namedtuple('RenderItem', ['obj', 'layer', 'sort_value'])
RenderItem = namedtuple("RenderItem", ["obj", "layer", "sort_value"])
class GameCamera(Camera):
pan_friction = 0.2
use_bounds = False
class GameWorld:
"""
Holds global state for Game Mode. Spawns, manages, and renders GameObjects.
Properties serialized via WorldPropertiesObject.
Global state can be controlled via a WorldGlobalsObject.
"""
game_title = 'Untitled Game'
game_title = "Untitled Game"
"Title for game, shown in window titlebar when not editing"
gravity_x, gravity_y, gravity_z = 0., 0., 0.
gravity_x, gravity_y, gravity_z = 0.0, 0.0, 0.0
"Gravity applied to all objects who are affected by gravity."
bg_color = [0., 0., 0., 1.]
bg_color = [0.0, 0.0, 0.0, 1.0]
"OpenGL wiewport color to render behind everything else, ie the void."
hud_class_name = 'GameHUD'
hud_class_name = "GameHUD"
"String name of HUD class to use"
properties_object_class_name = 'WorldPropertiesObject'
globals_object_class_name = 'WorldGlobalsObject'
properties_object_class_name = "WorldPropertiesObject"
globals_object_class_name = "WorldGlobalsObject"
"String name of WorldGlobalsObject class to use."
player_camera_lock = True
"If True, camera will be locked to player's location."
object_grid_snap = True
# editable properties
# TODO:
#update_when_unfocused = False
#"If True, game sim will update even when window doesn't have input focus"
# update_when_unfocused = False
# "If True, game sim will update even when window doesn't have input focus"
draw_hud = True
allow_pause = True
"If False, user cannot pause game sim"
@ -91,10 +102,12 @@ class GameWorld:
"If True, snap camera to new room's associated camera marker."
list_only_current_room_objects = False
"If True, list UI will only show objects in current room."
builtin_module_names = ['game_object', 'game_util_objects', 'game_hud',
'game_room']
builtin_base_classes = (game_object.GameObject, game_hud.GameHUD,
game_room.GameRoom)
builtin_module_names = ["game_object", "game_util_objects", "game_hud", "game_room"]
builtin_base_classes = (
game_object.GameObject,
game_hud.GameHUD,
game_room.GameRoom,
)
def __init__(self, app):
self.app = app
@ -122,9 +135,12 @@ class GameWorld:
self._pause_time = 0
self.updates = 0
"Number of updates this we have performed."
self.modules = {'game_object': game_object,
'game_util_objects': game_util_objects,
'game_hud': game_hud, 'game_room': game_room}
self.modules = {
"game_object": game_object,
"game_util_objects": game_util_objects,
"game_hud": game_hud,
"game_room": game_room,
}
self.classname_to_spawn = None
self.objects = {}
"Dict of objects by name:object"
@ -167,9 +183,12 @@ class GameWorld:
if len(objects) == 0:
return None
# don't bother cycling if only one object found
if len(objects) == 1 and objects[0].selectable and \
not objects[0] in self.selected_objects:
return objects[0]
if (
len(objects) == 1
and objects[0].selectable
and objects[0] not in self.selected_objects
):
return objects[0]
# cycle through objects at point til an unselected one is found
for obj in objects:
if not obj.selectable:
@ -194,15 +213,14 @@ class GameWorld:
return objects
def select_click(self):
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
# remember last place we clicked
self.last_mouse_click_x, self.last_mouse_click_y = x, y
if self.classname_to_spawn:
new_obj = self.spawn_object_of_class(self.classname_to_spawn, x, y)
if self.current_room:
self.current_room.add_object(new_obj)
self.app.ui.message_line.post_line('Spawned %s' % new_obj.name)
self.app.ui.message_line.post_line("Spawned %s" % new_obj.name)
return
objects = self.get_objects_at(x, y)
next_obj = self.pick_next_object_at(x, y)
@ -237,13 +255,15 @@ class GameWorld:
self.select_click()
# else pass clicks to any objects under mouse
else:
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
# 'locked" only relevant to edit mode, ignore it if in play mode
objects = self.get_objects_at(x, y, allow_locked=True)
for obj in objects:
if obj.handle_mouse_events and \
(not obj.locked or not self.app.can_edit):
if obj.handle_mouse_events and (
not obj.locked or not self.app.can_edit
):
obj.clicked(button, x, y)
if obj.consume_mouse_events:
break
@ -260,8 +280,7 @@ class GameWorld:
# if we're clicking to spawn something, don't drag/select
if self.classname_to_spawn:
return
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
# remember selected objects now, they might be deselected but still
# need to have their collision turned back on.
selected_objects = self.selected_objects[:]
@ -270,7 +289,7 @@ class GameWorld:
# except one mouse is over.
dx = self.last_mouse_click_x - x
dy = self.last_mouse_click_y - y
if math.sqrt(dx ** 2 + dy ** 2) < 1.5:
if math.sqrt(dx**2 + dy**2) < 1.5:
for obj in self.get_objects_at(x, y):
if obj in self.selected_objects:
self.deselect_all()
@ -289,8 +308,9 @@ class GameWorld:
if button == sdl2.SDL_BUTTON_LEFT:
self.select_unclick()
else:
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
objects = self.get_objects_at(x, y)
for obj in objects:
if obj.handle_mouse_events:
@ -298,8 +318,7 @@ class GameWorld:
def check_hovers(self):
"Update objects on their mouse hover status"
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
new_hovers = self.get_objects_at(x, y)
# if this object will be selected on left click; draw bounds & label
if self.app.ui.is_game_edit_ui_visible():
@ -308,21 +327,19 @@ class GameWorld:
# if in play mode, notify objects who have begun to be hovered
else:
for obj in new_hovers:
if obj.handle_mouse_events and not obj in self.hovered_objects:
if obj.handle_mouse_events and obj not in self.hovered_objects:
obj.hovered(x, y)
# check for objects un-hovered by this move
for obj in self.hovered_objects:
if obj.handle_mouse_events and not obj in new_hovers:
if obj.handle_mouse_events and obj not in new_hovers:
obj.unhovered(x, y)
self.hovered_objects = new_hovers
def mouse_wheeled(self, wheel_y):
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
objects = self.get_objects_at(x, y, allow_locked=True)
for obj in objects:
if obj.handle_mouse_events and \
(not obj.locked or not self.app.can_edit):
if obj.handle_mouse_events and (not obj.locked or not self.app.can_edit):
obj.mouse_wheeled(wheel_y)
if obj.consume_mouse_events:
break
@ -331,8 +348,11 @@ class GameWorld:
if self.app.ui.active_dialog:
return
# bail if mouse didn't move (in world space - include camera) last input
if self.app.mouse_dx == 0 and self.app.mouse_dy == 0 and \
not self.camera.moved_this_frame:
if (
self.app.mouse_dx == 0
and self.app.mouse_dy == 0
and not self.camera.moved_this_frame
):
return
# if last onclick was a UI element, don't drag
if self.last_click_on_ui:
@ -345,9 +365,8 @@ class GameWorld:
if dx == 0 and dy == 0:
return
# set dragged objects to mouse + offset from mouse when drag started
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x,
self.app.mouse_y)
for obj_name,offset in self.drag_objects.items():
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
for obj_name, offset in self.drag_objects.items():
obj = self.objects[obj_name]
if obj.locked:
continue
@ -369,7 +388,7 @@ class GameWorld:
"Add given object to our list of selected objects."
if not self.app.can_edit:
return
if obj and (obj.selectable or force) and not obj in self.selected_objects:
if obj and (obj.selectable or force) and obj not in self.selected_objects:
self.selected_objects.append(obj)
self.app.ui.object_selection_changed()
@ -387,9 +406,9 @@ class GameWorld:
def create_new_game(self, new_game_dir, new_game_title):
"Create appropriate dirs and files for a new game, return success."
self.unload_game()
new_dir = self.app.documents_dir + TOP_GAME_DIR + new_game_dir + '/'
new_dir = self.app.documents_dir + TOP_GAME_DIR + new_game_dir + "/"
if os.path.exists(new_dir):
self.app.log('Game dir %s already exists!' % new_game_dir)
self.app.log("Game dir %s already exists!" % new_game_dir)
return False
os.mkdir(new_dir)
os.mkdir(new_dir + ART_DIR)
@ -398,12 +417,12 @@ class GameWorld:
os.mkdir(new_dir + CHARSET_DIR)
os.mkdir(new_dir + PALETTE_DIR)
# create a generic starter script with a GO and Player subclass
f = open(new_dir + GAME_SCRIPTS_DIR + new_game_dir + '.py', 'w')
f = open(new_dir + GAME_SCRIPTS_DIR + new_game_dir + ".py", "w")
f.write(STARTER_SCRIPT)
f.close()
# load game
self.set_game_dir(new_game_dir)
self.properties = self.spawn_object_of_class('WorldPropertiesObject')
self.properties = self.spawn_object_of_class("WorldPropertiesObject")
self.objects.update(self.new_objects)
self.new_objects = {}
# HACK: set some property defaults, no idea why they don't take :[
@ -493,9 +512,9 @@ class GameWorld:
continue
self.game_dir = d
self.game_name = dir_name
if not d.endswith('/'):
self.game_dir += '/'
self.app.log('Game data folder is now %s' % self.game_dir)
if not d.endswith("/"):
self.game_dir += "/"
self.app.log("Game data folder is now %s" % self.game_dir)
# set sounds dir before loading state; some obj inits depend on it
self.sounds_dir = self.game_dir + SOUNDS_DIR
if reset:
@ -515,14 +534,16 @@ class GameWorld:
GameWorld's dicts.
"""
modules_to_remove = []
games_dir_prefix = TOP_GAME_DIR.replace('/', '')
this_game_dir_prefix = '%s.%s' % (games_dir_prefix, self.game_name)
games_dir_prefix = TOP_GAME_DIR.replace("/", "")
this_game_dir_prefix = "%s.%s" % (games_dir_prefix, self.game_name)
for module_name in sys.modules:
# remove any module that isn't for this game or part of its path
if module_name != games_dir_prefix and \
module_name != this_game_dir_prefix and \
module_name.startswith(games_dir_prefix) and \
not module_name.startswith(this_game_dir_prefix + '.'):
if (
module_name != games_dir_prefix
and module_name != this_game_dir_prefix
and module_name.startswith(games_dir_prefix)
and not module_name.startswith(this_game_dir_prefix + ".")
):
modules_to_remove.append(module_name)
for module_name in modules_to_remove:
sys.modules.pop(module_name)
@ -534,16 +555,18 @@ class GameWorld:
# build list of module files
modules_list = self.builtin_module_names[:]
# create appropriately-formatted python import path
module_path_prefix = '%s.%s.%s.' % (TOP_GAME_DIR.replace('/', ''),
self.game_name,
GAME_SCRIPTS_DIR.replace('/', ''))
module_path_prefix = "%s.%s.%s." % (
TOP_GAME_DIR.replace("/", ""),
self.game_name,
GAME_SCRIPTS_DIR.replace("/", ""),
)
for filename in os.listdir(self.game_dir + GAME_SCRIPTS_DIR):
# exclude emacs temp files and special world start script
if not filename.endswith('.py'):
if not filename.endswith(".py"):
continue
if filename.startswith('.#'):
if filename.startswith(".#"):
continue
new_module_name = module_path_prefix + filename.replace('.py', '')
new_module_name = module_path_prefix + filename.replace(".py", "")
modules_list.append(new_module_name)
return modules_list
@ -553,7 +576,7 @@ class GameWorld:
refers to when finding classes to spawn.
"""
# on first load, documents dir may not be in import path
if not self.app.documents_dir in sys.path:
if self.app.documents_dir not in sys.path:
sys.path += [self.app.documents_dir]
# clean modules dict before (re)loading anything
self._remove_non_current_game_modules()
@ -564,8 +587,10 @@ class GameWorld:
for module_name in self._get_game_modules_list():
try:
# always reload built in modules
if module_name in self.builtin_module_names or \
module_name in old_modules:
if (
module_name in self.builtin_module_names
or module_name in old_modules
):
m = importlib.reload(old_modules[module_name])
else:
m = importlib.import_module(module_name)
@ -578,7 +603,7 @@ class GameWorld:
if not self.allow_pause:
return
self.paused = not self.paused
s = 'Game %spaused.' % ['un', ''][self.paused]
s = "Game %spaused." % ["un", ""][self.paused]
self.app.ui.message_line.post_line(s)
def get_elapsed_time(self):
@ -609,7 +634,7 @@ class GameWorld:
def handle_input(self, event, shift_pressed, alt_pressed, ctrl_pressed):
# pass event's key to any objects that want to handle it
if not event.type in [sdl2.SDL_KEYDOWN, sdl2.SDL_KEYUP]:
if event.type not in [sdl2.SDL_KEYDOWN, sdl2.SDL_KEYUP]:
return
key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode()
key = key.lower()
@ -622,11 +647,15 @@ class GameWorld:
self.try_object_method(obj, obj.handle_key_up, args)
# TODO: handle_ functions for other types of input
def get_colliders_at_point(self, point_x, point_y,
include_object_names=[],
include_class_names=[],
exclude_object_names=[],
exclude_class_names=[]):
def get_colliders_at_point(
self,
point_x,
point_y,
include_object_names=[],
include_class_names=[],
exclude_object_names=[],
exclude_class_names=[],
):
"""
Return lists of colliding objects and shapes at given point that pass
given filters.
@ -645,28 +674,34 @@ class GameWorld:
check_objects = []
if whitelist_objects or whitelist_classes:
# list of class names -> list of classes
include_classes = [self.get_class_by_name(class_name) for class_name in include_class_names]
include_classes = [
self.get_class_by_name(class_name) for class_name in include_class_names
]
# only given objects of given classes
if whitelist_objects and whitelist_classes:
for obj_name in include_object_names:
obj = self.objects[obj_name]
for c in include_classes:
if isinstance(obj, c) and not obj in check_objects:
if isinstance(obj, c) and obj not in check_objects:
check_objects.append(obj)
# only given objects of any class
elif whitelist_objects and not whitelist_classes:
check_objects += [self.objects[obj_name] for obj_name in include_object_names]
check_objects += [
self.objects[obj_name] for obj_name in include_object_names
]
# all colliders of given classes
elif whitelist_classes:
for obj in colliders:
for c in include_classes:
if isinstance(obj, c) and not obj in check_objects:
if isinstance(obj, c) and obj not in check_objects:
check_objects.append(obj)
else:
check_objects = colliders[:]
check_objects_unfiltered = check_objects[:]
if blacklist_objects or blacklist_classes:
exclude_classes = [self.get_class_by_name(class_name) for class_name in exclude_class_names]
exclude_classes = [
self.get_class_by_name(class_name) for class_name in exclude_class_names
]
for obj in check_objects_unfiltered:
if obj.name in exclude_object_names:
check_objects.remove(obj)
@ -707,19 +742,23 @@ class GameWorld:
def try_object_method(self, obj, method, args=()):
"Try to run given object's given method, printing error if encountered."
#print('running %s.%s' % (obj.name, method.__name__))
# print('running %s.%s' % (obj.name, method.__name__))
try:
method(*args)
if method.__name__ == 'update':
if method.__name__ == "update":
obj.last_update_failed = False
except Exception as e:
if method.__name__ == 'update' and obj.last_update_failed:
except Exception:
if method.__name__ == "update" and obj.last_update_failed:
return
obj.last_update_failed = True
for line in traceback.format_exc().split('\n'):
if line and not 'try_object_method' in line and line.strip() != 'method()':
for line in traceback.format_exc().split("\n"):
if (
line
and "try_object_method" not in line
and line.strip() != "method()"
):
self.app.log(line.rstrip())
s = 'Error in %s.%s! See console.' % (obj.name, method.__name__)
s = "Error in %s.%s! See console." % (obj.name, method.__name__)
self.app.ui.message_line.post_line(s, 10, True)
def pre_update(self):
@ -733,7 +772,9 @@ class GameWorld:
self.try_object_method(obj, obj.pre_first_update)
obj.pre_first_update_run = True
# only run pre_update if not paused
elif not self.paused and (obj.is_in_current_room() or obj.update_if_outside_room):
elif not self.paused and (
obj.is_in_current_room() or obj.update_if_outside_room
):
# update timers
# (copy timers list in case a timer removes itself from object)
for timer in list(obj.timer_functions_pre_update.values())[:]:
@ -807,8 +848,7 @@ class GameWorld:
in_room = self.current_room is None or obj.is_in_current_room()
hide_debug = obj.is_debug and not self.draw_debug_objects
# respect object's "should render at all" flag
if obj.visible and not hide_debug and \
(self.show_all_rooms or in_room):
if obj.visible and not hide_debug and (self.show_all_rooms or in_room):
visible_objects.append(obj)
#
# process non "Y sort" objects first
@ -820,15 +860,17 @@ class GameWorld:
if obj.y_sort:
y_objects.append(obj)
continue
for i,z in enumerate(obj.art.layers_z):
for i, z in enumerate(obj.art.layers_z):
# ignore invisible layers
if not obj.art.layers_visibility[i]:
continue
# only draw collision layer if show collision is set, OR if
# "draw collision layer" is set
if obj.collision_shape_type == collision.CST_TILE and \
obj.col_layer_name == obj.art.layer_names[i] and \
not obj.draw_col_layer:
if (
obj.collision_shape_type == collision.CST_TILE
and obj.col_layer_name == obj.art.layer_names[i]
and not obj.draw_col_layer
):
if obj.show_collision:
item = RenderItem(obj, i, z + obj.z)
collision_items.append(item)
@ -843,11 +885,13 @@ class GameWorld:
# draw layers of each Y-sorted object in Z order
for obj in y_objects:
items = []
for i,z in enumerate(obj.art.layers_z):
for i, z in enumerate(obj.art.layers_z):
if not obj.art.layers_visibility[i]:
continue
if obj.collision_shape_type == collision.CST_TILE and \
obj.col_layer_name == obj.art.layer_names[i]:
if (
obj.collision_shape_type == collision.CST_TILE
and obj.col_layer_name == obj.art.layer_names[i]
):
if obj.show_collision:
item = RenderItem(obj, i, 0)
collision_items.append(item)
@ -884,26 +928,24 @@ class GameWorld:
for obj in self.objects.values():
if obj.should_save:
objects.append(obj.get_dict())
d = {'objects': objects}
d = {"objects": objects}
# save rooms if any exist
if len(self.rooms) > 0:
rooms = [room.get_dict() for room in self.rooms.values()]
d['rooms'] = rooms
d["rooms"] = rooms
if self.current_room:
d['current_room'] = self.current_room.name
if filename and filename != '':
d["current_room"] = self.current_room.name
if filename and filename != "":
if not filename.endswith(STATE_FILE_EXTENSION):
filename += '.' + STATE_FILE_EXTENSION
filename = '%s%s' % (self.game_dir, filename)
filename += "." + STATE_FILE_EXTENSION
filename = "%s%s" % (self.game_dir, filename)
else:
# state filename example:
# games/mytestgame2/1431116386.gs
timestamp = int(time.time())
filename = '%s%s.%s' % (self.game_dir, timestamp,
STATE_FILE_EXTENSION)
json.dump(d, open(filename, 'w'),
sort_keys=True, indent=1)
self.app.log('Saved game state %s to disk.' % filename)
filename = "%s%s.%s" % (self.game_dir, timestamp, STATE_FILE_EXTENSION)
json.dump(d, open(filename, "w"), sort_keys=True, indent=1)
self.app.log("Saved game state %s to disk." % filename)
self.app.update_window_title()
def _get_all_loaded_classes(self):
@ -912,13 +954,17 @@ class GameWorld:
"""
classes = {}
for module in self.modules.values():
for k,v in module.__dict__.items():
for k, v in module.__dict__.items():
# skip anything that's not a game class
if not type(v) is type:
if type(v) is not type:
continue
base_classes = (game_object.GameObject, game_hud.GameHUD, game_room.GameRoom)
base_classes = (
game_object.GameObject,
game_hud.GameHUD,
game_room.GameRoom,
)
# TODO: find out why above works but below doesn't!! O___O
#base_classes = self.builtin_base_classes
# base_classes = self.builtin_base_classes
if issubclass(v, base_classes):
classes[k] = v
return classes
@ -936,7 +982,7 @@ class GameWorld:
obj_class = obj.__class__.__name__
spawned = self.spawn_object_of_class(obj_class, x, y)
if spawned:
self.app.log('%s reset to class defaults' % obj.name)
self.app.log("%s reset to class defaults" % obj.name)
if obj is self.player:
self.player = spawned
obj.destroy()
@ -948,21 +994,21 @@ class GameWorld:
new_objects.append(self.duplicate_object(obj))
# report on objects created
if len(new_objects) == 1:
self.app.log('%s created from %s' % (new_objects[0].name, obj.name))
self.app.log("%s created from %s" % (new_objects[0].name, obj.name))
elif len(new_objects) > 1:
self.app.log('%s new objects created' % len(new_objects))
self.app.log("%s new objects created" % len(new_objects))
def duplicate_object(self, obj):
"Create a duplicate of given object."
d = obj.get_dict()
# offset new object's location
x, y = d['x'], d['y']
x, y = d["x"], d["y"]
x += obj.renderable.width
y -= obj.renderable.height
d['x'], d['y'] = x, y
d["x"], d["y"] = x, y
# new object needs a unique name, use a temp one until object exists
# for real and we can give it a proper, more-likely-to-be-unique one
d['name'] = obj.name + ' TEMP COPY NAME'
d["name"] = obj.name + " TEMP COPY NAME"
new_obj = self.spawn_object_from_data(d)
# give object a non-duplicate name
self.rename_object(new_obj, new_obj.get_unique_name())
@ -977,8 +1023,10 @@ class GameWorld:
"Give specified object a new name. Doesn't accept already-in-use names."
self.objects.update(self.new_objects)
for other_obj in self.objects.values():
if not other_obj is self and other_obj.name == new_name:
self.app.ui.message_line.post_line("Can't rename %s to %s, name already in use" % (obj.name, new_name))
if other_obj is not self and other_obj.name == new_name:
self.app.ui.message_line.post_line(
"Can't rename %s to %s, name already in use" % (obj.name, new_name)
)
return
self.objects.pop(obj.name)
old_name = obj.name
@ -991,13 +1039,13 @@ class GameWorld:
def spawn_object_of_class(self, class_name, x=None, y=None):
"Spawn a new object of given class name at given location."
if not class_name in self.classes:
if class_name not in self.classes:
# no need for log here, import_all prints exception cause
#self.app.log("Couldn't find class %s" % class_name)
# self.app.log("Couldn't find class %s" % class_name)
return
d = {'class_name': class_name}
d = {"class_name": class_name}
if x is not None and y is not None:
d['x'], d['y'] = x, y
d["x"], d["y"] = x, y
new_obj = self.spawn_object_from_data(d)
self.app.ui.edit_list_panel.items_changed()
return new_obj
@ -1005,20 +1053,20 @@ class GameWorld:
def spawn_object_from_data(self, object_data):
"Spawn a new object with properties populated from given data dict."
# load module and class
class_name = object_data.get('class_name', None)
if not class_name or not class_name in self.classes:
class_name = object_data.get("class_name", None)
if not class_name or class_name not in self.classes:
# no need for log here, import_all prints exception cause
#self.app.log("Couldn't parse class %s" % class_name)
# self.app.log("Couldn't parse class %s" % class_name)
return
obj_class = self.classes[class_name]
# pass in object data
new_object = obj_class(self, object_data)
return new_object
def add_room(self, new_room_name, new_room_classname='GameRoom'):
def add_room(self, new_room_name, new_room_classname="GameRoom"):
"Add a new Room with given name of (optional) given class."
if new_room_name in self.rooms:
self.log('Room called %s already exists!' % new_room_name)
self.log("Room called %s already exists!" % new_room_name)
return
new_room_class = self.classes[new_room_classname]
new_room = new_room_class(self, new_room_name)
@ -1026,7 +1074,7 @@ class GameWorld:
def remove_room(self, room_name):
"Delete Room with given name."
if not room_name in self.rooms:
if room_name not in self.rooms:
return
room = self.rooms.pop(room_name)
if room is self.current_room:
@ -1035,7 +1083,7 @@ class GameWorld:
def change_room(self, new_room_name):
"Set world's current active room to Room with given name."
if not new_room_name in self.rooms:
if new_room_name not in self.rooms:
self.app.log("Couldn't change to missing room %s" % new_room_name)
return
old_room = self.current_room
@ -1062,7 +1110,7 @@ class GameWorld:
if not os.path.exists(filename):
filename = self.game_dir + filename
if not filename.endswith(STATE_FILE_EXTENSION):
filename += '.' + STATE_FILE_EXTENSION
filename += "." + STATE_FILE_EXTENSION
self.app.enter_game_mode()
self.unload_game()
# tell list panel to reset, its contents might get jostled
@ -1072,14 +1120,14 @@ class GameWorld:
self.classes = self._get_all_loaded_classes()
try:
d = json.load(open(filename))
#self.app.log('Loading game state %s...' % filename)
# self.app.log('Loading game state %s...' % filename)
except:
self.app.log("Couldn't load game state from %s" % filename)
#self.app.log(sys.exc_info())
# self.app.log(sys.exc_info())
return
errors = False
# spawn objects
for obj_data in d['objects']:
for obj_data in d["objects"]:
obj = self.spawn_object_from_data(obj_data)
if not obj:
errors = True
@ -1089,44 +1137,46 @@ class GameWorld:
self.properties = obj
break
if not self.properties:
self.properties = self.spawn_object_of_class(self.properties_object_class_name, 0, 0)
self.properties = self.spawn_object_of_class(
self.properties_object_class_name, 0, 0
)
# spawn a WorldGlobalStateObject
self.globals = self.spawn_object_of_class(self.globals_object_class_name, 0, 0)
# just for first update, merge new objects list into objects list
self.objects.update(self.new_objects)
# create rooms
for room_data in d.get('rooms', []):
for room_data in d.get("rooms", []):
# get room class
room_class_name = room_data.get('class_name', None)
room_class_name = room_data.get("class_name", None)
room_class = self.classes.get(room_class_name, game_room.GameRoom)
room = room_class(self, room_data['name'], room_data)
room = room_class(self, room_data["name"], room_data)
self.rooms[room.name] = room
start_room = self.rooms.get(d.get('current_room', None), None)
start_room = self.rooms.get(d.get("current_room", None), None)
if start_room:
self.change_room(start_room.name)
# spawn hud
hud_class = self.classes[d.get('hud_class', self.hud_class_name)]
hud_class = self.classes[d.get("hud_class", self.hud_class_name)]
self.hud = hud_class(self)
self.hud_class_name = hud_class.__name__
if not errors and self.app.init_success:
self.app.log('Loaded game state from %s' % filename)
self.app.log("Loaded game state from %s" % filename)
self.last_state_loaded = filename
self.set_for_all_objects('show_collision', self.show_collision_all)
self.set_for_all_objects('show_bounds', self.show_bounds_all)
self.set_for_all_objects('show_origin', self.show_origin_all)
self.set_for_all_objects("show_collision", self.show_collision_all)
self.set_for_all_objects("show_bounds", self.show_bounds_all)
self.set_for_all_objects("show_origin", self.show_origin_all)
self.app.update_window_title()
self.app.ui.edit_list_panel.items_changed()
#self.report()
# self.report()
def report(self):
"Print (not log) information about current world state."
print('--------------\n%s report:' % self)
print("--------------\n%s report:" % self)
obj_arts, obj_rends, obj_dbg_rends, obj_cols, obj_col_rends = 0, 0, 0, 0, 0
attachments = 0
# create merged dict of existing and just-spawned objects
all_objects = self.objects.copy()
all_objects.update(self.new_objects)
print('%s objects:' % len(all_objects))
print("%s objects:" % len(all_objects))
for obj in all_objects.values():
obj_arts += len(obj.arts)
if obj.renderable is not None:
@ -1139,34 +1189,46 @@ class GameWorld:
obj_cols += 1
obj_col_rends += len(obj.collision.renderables)
attachments += len(obj.attachments)
print("""
print(
"""
%s arts in objects, %s arts loaded,
%s HUD arts, %s HUD renderables,
%s renderables, %s debug renderables,
%s collideables, %s collideable viz renderables,
%s attachments""" % (obj_arts, len(self.art_loaded), len(self.hud.arts),
len(self.hud.renderables),
obj_rends, obj_dbg_rends,
obj_cols, obj_col_rends, attachments))
%s attachments"""
% (
obj_arts,
len(self.art_loaded),
len(self.hud.arts),
len(self.hud.renderables),
obj_rends,
obj_dbg_rends,
obj_cols,
obj_col_rends,
attachments,
)
)
self.cl.report()
print('%s charsets loaded, %s palettes' % (len(self.app.charsets),
len(self.app.palettes)))
print('%s arts loaded for edit' % len(self.app.art_loaded_for_edit))
print(
"%s charsets loaded, %s palettes"
% (len(self.app.charsets), len(self.app.palettes))
)
print("%s arts loaded for edit" % len(self.app.art_loaded_for_edit))
def toggle_all_origin_viz(self):
"Toggle visibility of XYZ markers for all object origins."
self.show_origin_all = not self.show_origin_all
self.set_for_all_objects('show_origin', self.show_origin_all)
self.set_for_all_objects("show_origin", self.show_origin_all)
def toggle_all_bounds_viz(self):
"Toggle visibility of boxes for all object bounds."
self.show_bounds_all = not self.show_bounds_all
self.set_for_all_objects('show_bounds', self.show_bounds_all)
self.set_for_all_objects("show_bounds", self.show_bounds_all)
def toggle_all_collision_viz(self):
"Toggle visibility of debug lines for all object Collideables."
self.show_collision_all = not self.show_collision_all
self.set_for_all_objects('show_collision', self.show_collision_all)
self.set_for_all_objects("show_collision", self.show_collision_all)
def destroy(self):
self.unload_game()

View file

@ -1,4 +1,3 @@
from game_object import GameObject
# initial work: 2019-02-17 and 18
@ -31,24 +30,45 @@ DIR_NORTH = (0, -1)
DIR_SOUTH = (0, 1)
DIR_EAST = (1, 0)
DIR_WEST = (-1, 0)
LEFT_TURN_DIRS = { DIR_NORTH: DIR_WEST, DIR_WEST: DIR_SOUTH,
DIR_SOUTH: DIR_EAST, DIR_EAST: DIR_NORTH }
RIGHT_TURN_DIRS = { DIR_NORTH: DIR_EAST, DIR_EAST: DIR_SOUTH,
DIR_SOUTH: DIR_WEST, DIR_WEST: DIR_NORTH }
DIR_NAMES = { DIR_NORTH: 'north', DIR_SOUTH: 'south',
DIR_EAST: 'east', DIR_WEST: 'west' }
OPPOSITE_DIRS = { DIR_NORTH: DIR_SOUTH, DIR_SOUTH: DIR_NORTH,
DIR_EAST: DIR_WEST, DIR_WEST: DIR_EAST }
LEFT_TURN_DIRS = {
DIR_NORTH: DIR_WEST,
DIR_WEST: DIR_SOUTH,
DIR_SOUTH: DIR_EAST,
DIR_EAST: DIR_NORTH,
}
RIGHT_TURN_DIRS = {
DIR_NORTH: DIR_EAST,
DIR_EAST: DIR_SOUTH,
DIR_SOUTH: DIR_WEST,
DIR_WEST: DIR_NORTH,
}
DIR_NAMES = {DIR_NORTH: "north", DIR_SOUTH: "south", DIR_EAST: "east", DIR_WEST: "west"}
OPPOSITE_DIRS = {
DIR_NORTH: DIR_SOUTH,
DIR_SOUTH: DIR_NORTH,
DIR_EAST: DIR_WEST,
DIR_WEST: DIR_EAST,
}
class CompositeTester(GameObject):
# slightly confusing terms here, our "source" will be loaded at runtime
art_src = 'comptest_dest'
art_src = "comptest_dest"
use_art_instance = True
def pre_first_update(self):
# load composite source art
comp_src_art = self.app.load_art('comptest_src', False)
self.art.composite_from(comp_src_art, 0, 0, 0, 0,
comp_src_art.width, comp_src_art.height,
0, 0, 3, 2)
comp_src_art = self.app.load_art("comptest_src", False)
self.art.composite_from(
comp_src_art,
0,
0,
0,
0,
comp_src_art.width,
comp_src_art.height,
0,
0,
3,
2,
)

View file

@ -1,10 +1,7 @@
import vector
from game_object import GameObject
from renderable_line import DebugLineRenderable
# stuff for troubleshooting "get tiles intersecting line" etc
@ -14,6 +11,7 @@ class DebugMarker(GameObject):
generate_art = True
should_save = False
alpha = 0.5
def pre_first_update(self):
# red X with yellow background
self.art.set_tile_at(0, 0, 0, 0, 24, 3, 8)
@ -26,8 +24,8 @@ class LineTester(GameObject):
generate_art = True
def pre_first_update(self):
self.mark_a = self.world.spawn_object_of_class('DebugMarker', -3, 33)
self.mark_b = self.world.spawn_object_of_class('DebugMarker', -10, 40)
self.mark_a = self.world.spawn_object_of_class("DebugMarker", -3, 33)
self.mark_b = self.world.spawn_object_of_class("DebugMarker", -10, 40)
self.z = -0.01
self.world.grid.visible = True
self.line = DebugLineRenderable(self.app, self.art)
@ -37,14 +35,14 @@ class LineTester(GameObject):
def update(self):
GameObject.update(self)
# debug line
self.line.set_lines([(self.mark_a.x, self.mark_a.y, 0.0),
(self.mark_b.x, self.mark_b.y, 0.0)])
self.line.set_lines(
[(self.mark_a.x, self.mark_a.y, 0.0), (self.mark_b.x, self.mark_b.y, 0.0)]
)
# paint tiles under line
self.art.clear_frame_layer(0, 0, 7)
line_func = vector.get_tiles_along_line
line_func = vector.get_tiles_along_integer_line
tiles = line_func(self.mark_a.x, self.mark_a.y,
self.mark_b.x, self.mark_b.y)
tiles = line_func(self.mark_a.x, self.mark_a.y, self.mark_b.x, self.mark_b.y)
for tile in tiles:
x, y = self.get_tile_at_point(tile[0], tile[1])
char, fg = 1, 6
@ -53,5 +51,5 @@ class LineTester(GameObject):
def render(self, layer, z_override=None):
GameObject.render(self, layer, z_override)
# TODO not sure why this is necessary, pre_first_update should run before first render(), right? blech
if hasattr(self, 'line') and self.line:
if hasattr(self, "line") and self.line:
self.line.render()

View file

@ -1,27 +1,29 @@
from game_object import GameObject
from game_util_objects import Player
from games.crawler.scripts.crawler import DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST, LEFT_TURN_DIRS, RIGHT_TURN_DIRS, DIR_NAMES, OPPOSITE_DIRS
from games.crawler.scripts.crawler import (
DIR_EAST,
DIR_NAMES,
DIR_NORTH,
DIR_SOUTH,
DIR_WEST,
LEFT_TURN_DIRS,
OPPOSITE_DIRS,
RIGHT_TURN_DIRS,
)
class CrawlPlayer(Player):
should_save = False # we are spawned by maze
should_save = False # we are spawned by maze
generate_art = True
art_width, art_height = 1, 1
art_charset, art_palette = 'jpetscii', 'c64_pepto'
art_charset, art_palette = "jpetscii", "c64_pepto"
art_off_pct_x, art_off_pct_y = 0, 0
# bespoke grid-based movement method
physics_move = False
handle_key_events = True
view_range_tiles = 8
fg_color = 8 # yellow
dir_chars = { DIR_NORTH: 147,
DIR_SOUTH: 163,
DIR_EAST: 181,
DIR_WEST: 180
}
fg_color = 8 # yellow
dir_chars = {DIR_NORTH: 147, DIR_SOUTH: 163, DIR_EAST: 181, DIR_WEST: 180}
def pre_first_update(self):
Player.pre_first_update(self)
@ -32,14 +34,14 @@ class CrawlPlayer(Player):
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
# turning?
if key == 'left':
if key == "left":
self.direction = LEFT_TURN_DIRS[self.direction]
elif key == 'right':
elif key == "right":
self.direction = RIGHT_TURN_DIRS[self.direction]
# moving?
elif key == 'up' or key == 'down':
elif key == "up" or key == "down":
x, y = self.maze.get_tile_at_point(self.x, self.y)
if key == 'up':
if key == "up":
new_x = x + self.direction[0]
new_y = y + self.direction[1]
else:
@ -48,7 +50,11 @@ class CrawlPlayer(Player):
# is move valid?
if self.maze.is_tile_solid(new_x, new_y):
# TEMP negative feedback
dir_name = DIR_NAMES[self.direction] if key == 'up' else DIR_NAMES[OPPOSITE_DIRS[self.direction]]
dir_name = (
DIR_NAMES[self.direction]
if key == "up"
else DIR_NAMES[OPPOSITE_DIRS[self.direction]]
)
self.app.log("can't go %s!" % dir_name)
else:
self.x, self.y = self.maze.x + new_x, self.maze.y - new_y

View file

@ -1,36 +1,41 @@
from game_object import GameObject
from vector import get_tiles_along_integer_line
from art import TileIter
from random import randint # DEBUG
from games.crawler.scripts.crawler import DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST, LEFT_TURN_DIRS, RIGHT_TURN_DIRS, DIR_NAMES, OPPOSITE_DIRS
from game_object import GameObject
from games.crawler.scripts.crawler import (
DIR_EAST,
DIR_NORTH,
DIR_SOUTH,
DIR_WEST,
)
from vector import get_tiles_along_integer_line
class CrawlTopDownView(GameObject):
art_src = 'maze2'
art_src = "maze2"
art_off_pct_x, art_off_pct_y = 0, 0
# we will be modifying this view at runtime so don't write on the source art
use_art_instance = True
# first character we find with this index will be where we spawn player
playerstart_char_index = 147
# undiscovered = player has never seen this tile
undiscovered_color_index = 1 # black
undiscovered_color_index = 1 # black
# discovered = player has seen this tile but isn't currently looking at it
discovered_color_index = 12 # dark grey
discovered_color_index = 12 # dark grey
def pre_first_update(self):
# scan art for spot to spawn player
player_x, player_y = -1, -1
for frame, layer, x, y in TileIter(self.art):
if self.art.get_char_index_at(frame, layer, x, y) == self.playerstart_char_index:
if (
self.art.get_char_index_at(frame, layer, x, y)
== self.playerstart_char_index
):
player_x, player_y = self.x + x, self.y - y
# clear the tile at this spot in our art
self.art.set_char_index_at(frame, layer, x, y, 0)
break
self.world.player = self.world.spawn_object_of_class('CrawlPlayer', player_x, player_y)
self.world.player = self.world.spawn_object_of_class(
"CrawlPlayer", player_x, player_y
)
# give player a ref to us
self.world.player.maze = self
# make a copy of original layer to color for visibility, hide original
@ -41,7 +46,7 @@ class CrawlTopDownView(GameObject):
continue
# set all tiles undiscovered
self.art.set_color_at(0, layer, x, y, self.undiscovered_color_index)
self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why?
self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why?
# keep a list of tiles player can see
self.player_visible_tiles = []
@ -72,7 +77,7 @@ class CrawlTopDownView(GameObject):
# scan back of frustum tile by tile left to right,
# checking each tile hit
scan_distance = 0
scan_length = tile_range * 2 + 1 # TODO make sure this is correct
scan_length = tile_range * 2 + 1 # TODO make sure this is correct
while scan_distance < scan_length:
scan_x = scan_start_x + (scan_dir_x * scan_distance)
scan_y = scan_start_y + (scan_dir_y * scan_distance)
@ -80,11 +85,15 @@ class CrawlTopDownView(GameObject):
for tile in hit_tiles:
tile_x, tile_y = tile[0], tile[1]
# skip out-of-bounds tiles
if 0 > tile_x or tile_x >= self.art.width or \
0 > tile_y or tile_y >= self.art.height:
if (
tile_x < 0
or tile_x >= self.art.width
or tile_y < 0
or tile_y >= self.art.height
):
continue
# whether this tile is solid or not, we have seen it
if not tile in tiles:
if tile not in tiles:
tiles.append((tile_x, tile_y))
if not see_thru_walls and self.is_tile_solid(*tile):
break
@ -99,25 +108,27 @@ class CrawlTopDownView(GameObject):
previously_visible_tiles = self.player_visible_tiles[:]
p = self.world.player
px, py = self.get_tile_at_point(p.x, p.y)
self.player_visible_tiles = self.get_visible_tiles(px, py,
*p.direction,
p.view_range_tiles,
see_thru_walls=False)
#print(self.player_visible_tiles)
self.player_visible_tiles = self.get_visible_tiles(
px, py, *p.direction, p.view_range_tiles, see_thru_walls=False
)
# print(self.player_visible_tiles)
# color currently visible tiles
for tile in self.player_visible_tiles:
#print(tile)
if 0 > tile[0] or tile[0] >= self.art.width or \
0 > tile[1] or tile[1] >= self.art.height:
# print(tile)
if (
tile[0] < 0
or tile[0] >= self.art.width
or tile[1] < 0
or tile[1] >= self.art.height
):
continue
if self.is_tile_solid(*tile):
orig_color = self.art.get_fg_color_index_at(0, 0, *tile)
self.art.set_color_at(0, 1, *tile, orig_color)
else:
#self.art.set_color_at(0, 1, *tile, randint(2, 14)) # DEBUG
# self.art.set_color_at(0, 1, *tile, randint(2, 14)) # DEBUG
pass
# color "previously seen" tiles
for tile in previously_visible_tiles:
if not tile in self.player_visible_tiles and \
self.is_tile_solid(*tile):
if tile not in self.player_visible_tiles and self.is_tile_solid(*tile):
self.art.set_color_at(0, 1, *tile, self.discovered_color_index)

View file

@ -1,31 +1,32 @@
import math
from game_util_objects import DynamicBoxObject, Pickup, StaticTileObject, TopDownPlayer
from game_util_objects import TopDownPlayer, StaticTileBG, StaticTileObject, DynamicBoxObject, Pickup
from collision import CST_AABB
class CronoPlayer(TopDownPlayer):
art_src = 'crono'
art_src = "crono"
col_radius = 1.5
# AABB testing
#collision_shape_type = CST_AABB
#col_offset_x, col_offset_y = 0, 1.25
# collision_shape_type = CST_AABB
# col_offset_x, col_offset_y = 0, 1.25
col_width = 3
col_height = 3
art_off_pct_y = 0.9
class Chest(DynamicBoxObject):
art_src = 'chest'
art_src = "chest"
col_width, col_height = 6, 4
col_offset_y = -0.5
class Urn(Pickup):
art_src = 'urn'
art_src = "urn"
col_radius = 2
art_off_pct_y = 0.85
class Bed(StaticTileObject):
art_src = 'bed'
art_src = "bed"
art_off_pct_x, art_off_pct_y = 0.5, 1

View file

@ -1,4 +1,3 @@
# PETSCII Fireplace for Playscii
# https://jp.itch.io/petscii-fireplace
@ -10,11 +9,12 @@ expensive compared to many old demoscene fire tricks. But it's easy to think abo
and tune, which was the right call for a one-day exercise :]
"""
import os, webbrowser
from random import random, randint, choice
import os
import webbrowser
from random import choice, randint
from game_object import GameObject
from art import TileIter
from game_object import GameObject
#
# some tuning knobs
@ -28,29 +28,28 @@ SPAWN_MARGIN_X = 8
# each particle's character "decays" towards 0 in random jumps
CHAR_DECAY_RATE_MAX = 16
# music is just an OGG file, modders feel free to provide your own in sounds/
MUSIC_FILENAME = 'music.ogg'
MUSIC_URL = 'http://brotherandroid.com'
MUSIC_FILENAME = "music.ogg"
MUSIC_URL = "http://brotherandroid.com"
# random ranges for time in seconds til next message pops up
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX = 300, 600
MESSAGES = [
'Happy Holidays',
'Merry Christmas',
'Happy New Year',
'Happy Hanukkah',
'Happy Kwanzaa',
'Feliz Navidad',
'Joyeux Noel'
"Happy Holidays",
"Merry Christmas",
"Happy New Year",
"Happy Hanukkah",
"Happy Kwanzaa",
"Feliz Navidad",
"Joyeux Noel",
]
class Fireplace(GameObject):
"The main game object, manages particles, handles input, draws the fire."
generate_art = True
art_charset = 'c64_petscii'
art_width, art_height = 54, 30 # approximately 16x9 aspect
art_palette = 'fireplace'
art_charset = "c64_petscii"
art_width, art_height = 54, 30 # approximately 16x9 aspect
art_palette = "fireplace"
handle_key_events = True
def pre_first_update(self):
@ -75,12 +74,12 @@ class Fireplace(GameObject):
self.help_screen.z = 1
self.help_screen.set_scale(0.75, 0.75, 1)
# start with help screen up, uncomment to hide on start
#self.help_screen.visible = False
# self.help_screen.visible = False
# don't bother creating credit screen if no music present
self.credit_screen = None
self.music_exists = False
if os.path.exists(self.world.sounds_dir + MUSIC_FILENAME):
self.app.log('%s found in %s' % (MUSIC_FILENAME, self.world.sounds_dir))
self.app.log("%s found in %s" % (MUSIC_FILENAME, self.world.sounds_dir))
self.world.play_music(MUSIC_FILENAME)
self.music_paused = False
self.music_exists = True
@ -89,7 +88,7 @@ class Fireplace(GameObject):
self.credit_screen.z = 1.1
self.credit_screen.set_scale(0.75, 0.75, 1)
else:
self.app.log('No %s found in %s' % (MUSIC_FILENAME, self.world.sounds_dir))
self.app.log("No %s found in %s" % (MUSIC_FILENAME, self.world.sounds_dir))
self.set_new_message_time()
def update(self):
@ -145,9 +144,11 @@ class Fireplace(GameObject):
self.art.set_tile_at(frame, layer, x, y, ch, fg - 1, bg - 1)
# draw particles
# (looks nicer if we don't clear between frames, actually)
#self.art.clear_frame_layer(0, 0)
# self.art.clear_frame_layer(0, 0)
for p in self.particles:
self.art.set_tile_at(0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg)
self.art.set_tile_at(
0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg
)
# spawn new particles to maintain target count
while len(self.particles) < self.target_particles:
p = FireParticle(self)
@ -155,7 +156,9 @@ class Fireplace(GameObject):
GameObject.update(self)
def set_new_message_time(self):
self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX)
self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX
)
def post_new_message(self):
msg_text = choice(MESSAGES)
@ -168,41 +171,44 @@ class Fireplace(GameObject):
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
# in many Playscii games all input goes through the Player object;
# here input is handled by this object.
if key == 'escape' and not self.world.app.can_edit:
if key == "escape" and not self.world.app.can_edit:
self.world.app.should_quit = True
elif key == 'h':
elif key == "h":
self.help_screen.visible = not self.help_screen.visible
if self.credit_screen:
self.credit_screen.visible = not self.credit_screen.visible
elif key == 'm' and self.music_exists:
elif key == "m" and self.music_exists:
if self.music_paused:
self.world.resume_music()
self.music_paused = False
else:
self.world.pause_music()
self.music_paused = True
elif key == 'c':
elif key == "c":
if not self.app.fb.disable_crt:
self.app.fb.toggle_crt()
elif key == '=' or key == '+':
elif key == "=" or key == "+":
self.target_particles += 10
self.art.write_string(0, 0, 0, 0, 'Embers: %s' % self.target_particles, 15, 1)
elif key == '-':
self.art.write_string(
0, 0, 0, 0, "Embers: %s" % self.target_particles, 15, 1
)
elif key == "-":
if self.target_particles <= 10:
return
self.target_particles -= 10
self.art.write_string(0, 0, 0, 0, 'Embers: %s' % self.target_particles, 15, 1)
self.art.write_string(
0, 0, 0, 0, "Embers: %s" % self.target_particles, 15, 1
)
class FireParticle:
"Simulated particle, spawned and ticked and rendered by a Fireplace object."
def __init__(self, fp):
# pick char and color here; Fireplace should just run sim
self.y = fp.art.height
# spawn at random point along bottom edge, within margin
self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X)
self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X)
# char here is not character index but density, which decays;
# fp.weighted_chars is used to look up actual index
self.char = randint(100, fp.art.charset.last_index - 1)
@ -228,8 +234,8 @@ class FireParticle:
self.bg -= randint(0, 1)
# don't bother with range checks on colors;
# if random embers "flare up" that's cool
#self.fg = max(0, self.fg)
#self.bg = max(0, self.bg)
# self.fg = max(0, self.fg)
# self.bg = max(0, self.bg)
def merge(self, other):
# merge (sum w/ other) colors & chars (ie when particles overlap)
@ -239,15 +245,14 @@ class FireParticle:
class HelpScreen(GameObject):
art_src = 'help'
art_src = "help"
alpha = 0.7
class CreditScreen(GameObject):
"Separate object for the clickable area of the help screen."
art_src = 'credit'
art_src = "credit"
alpha = 0.7
handle_mouse_events = True

View file

@ -1,4 +1,3 @@
from random import choice
from art import TileIter
@ -21,8 +20,8 @@ GS_LOST = 2
class Board(GameObject):
generate_art = True
art_width, art_height = BOARD_WIDTH, BOARD_HEIGHT
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
handle_key_events = True
def __init__(self, world, obj_data):
@ -43,25 +42,25 @@ class Board(GameObject):
def get_adjacent_tiles(self, x, y):
tiles = []
if x > 0:
tiles.append((x-1, y))
tiles.append((x - 1, y))
if x < BOARD_WIDTH - 1:
tiles.append((x+1, y))
tiles.append((x + 1, y))
if y > 0:
tiles.append((x, y-1))
tiles.append((x, y - 1))
if y < BOARD_HEIGHT - 1:
tiles.append((x, y+1))
tiles.append((x, y + 1))
return tiles
def flood_with_color(self, flood_color):
# set captured tiles to new color
for tile_x,tile_y in self.captured_tiles:
for tile_x, tile_y in self.captured_tiles:
self.art.set_color_at(0, 0, tile_x, tile_y, flood_color, False)
# capture like-colored tiles adjacent to captured tiles
for frame, layer, x, y in TileIter(self.art):
if not (x, y) in self.captured_tiles:
if (x, y) not in self.captured_tiles:
continue
adjacents = self.get_adjacent_tiles(x, y)
for adj_x,adj_y in adjacents:
for adj_x, adj_y in adjacents:
adj_color = self.art.get_bg_color_index_at(frame, layer, adj_x, adj_y)
if adj_color == flood_color:
self.captured_tiles.append((adj_x, adj_y))
@ -80,8 +79,8 @@ class Board(GameObject):
self.reset()
return
# get list of valid keys from length of tile_colors
valid_keys = ['%s' % str(i + 1) for i in range(len(TILE_COLORS))]
if not key in valid_keys:
valid_keys = ["%s" % str(i + 1) for i in range(len(TILE_COLORS))]
if key not in valid_keys:
return
key = int(key) - 1
self.color_picked(key)
@ -90,8 +89,8 @@ class Board(GameObject):
class ColorBar(GameObject):
generate_art = True
art_width, art_height = len(TILE_COLORS), 1
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
def __init__(self, world, obj_data):
GameObject.__init__(self, world, obj_data)
@ -103,18 +102,18 @@ class ColorBar(GameObject):
class TurnsBar(GameObject):
text = 'turns: %s'
text = "turns: %s"
generate_art = True
art_width, art_height = len(text) + 3, 1
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
def __init__(self, world, obj_data):
GameObject.__init__(self, world, obj_data)
self.board = None
def pre_first_update(self):
self.board = self.world.get_all_objects_of_type('Board')[0]
self.board = self.world.get_all_objects_of_type("Board")[0]
def draw_text(self):
if not self.board:
@ -122,9 +121,9 @@ class TurnsBar(GameObject):
self.art.clear_frame_layer(0, 0)
new_text = self.text % self.board.turns
if self.board.game_state == GS_WON:
new_text = 'won!!'
new_text = "won!!"
elif self.board.game_state == GS_LOST:
new_text = 'lost :('
new_text = "lost :("
color = TILE_COLORS[self.board.turns % len(TILE_COLORS)]
self.art.write_string(0, 0, 0, 0, new_text, color, 0)

View file

@ -1,14 +1,14 @@
from game_hud import GameHUD, GameHUDRenderable
class MazeHUD(GameHUD):
class MazeHUD(GameHUD):
message_color = 4
def __init__(self, world):
GameHUD.__init__(self, world)
self.msg_art = self.world.app.new_art('mazehud_msg', 42, 1,
'jpetscii', 'c64_original')
self.msg_art = self.world.app.new_art(
"mazehud_msg", 42, 1, "jpetscii", "c64_original"
)
self.msg = GameHUDRenderable(self.world.app, self.msg_art)
self.arts = [self.msg_art]
self.renderables = [self.msg]
@ -17,9 +17,9 @@ class MazeHUD(GameHUD):
aspect = self.world.app.window_height / self.world.app.window_width
self.msg.scale_x = 0.075 * aspect
self.msg.scale_y = 0.05
self.current_msg = ''
self.current_msg = ""
self.msg_art.clear_frame_layer(0, 0, 0, self.message_color)
self.post_msg('Welcome to MAZE, the amazing example game!')
self.post_msg("Welcome to MAZE, the amazing example game!")
def post_msg(self, msg_text):
self.current_msg = msg_text

View file

@ -1,21 +1,29 @@
import math, random
import math
import random
from art import TileIter
from collision import (
CST_CIRCLE,
CST_TILE,
CT_GENERIC_DYNAMIC,
CT_GENERIC_STATIC,
CT_NONE,
)
from game_object import GameObject
from game_util_objects import Player, StaticTileBG
from collision import CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CT_GENERIC_STATIC, CT_GENERIC_DYNAMIC, CT_PLAYER, CTG_STATIC, CTG_DYNAMIC
class MazeBG(StaticTileBG):
z = -0.1
class MazeNPC(GameObject):
art_src = 'npc'
art_src = "npc"
use_art_instance = True
col_radius = 0.5
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_STATIC
bark = 'Well hello there!'
bark = "Well hello there!"
def started_colliding(self, other):
if not isinstance(other, Player):
@ -30,17 +38,18 @@ class MazeNPC(GameObject):
for art in self.arts.values():
art.set_all_non_transparent_colors(random_color)
class MazeBaker(MazeNPC):
bark = 'Sorry, all outta bread today!'
bark = "Sorry, all outta bread today!"
class MazeCritter(MazeNPC):
"dynamically-spawned NPC that wobbles around"
collision_type = CT_GENERIC_DYNAMIC
should_save = False
move_rate = 0.25
bark = 'wheee!'
bark = "wheee!"
def update(self):
# skitter around randomly
@ -52,14 +61,13 @@ class MazeCritter(MazeNPC):
class MazePickup(GameObject):
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
col_radius = 0.5
hold_offset_y = 1.2
consume_on_use = True
sound_filenames = {'pickup': 'pickup.ogg'}
sound_filenames = {"pickup": "pickup.ogg"}
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
@ -80,13 +88,13 @@ class MazePickup(GameObject):
def picked_up(self, new_holder):
self.holder = new_holder
self.world.hud.post_msg('got %s!' % self.display_name)
self.world.hud.post_msg("got %s!" % self.display_name)
self.disable_collision()
self.play_sound('pickup')
self.play_sound("pickup")
def used(self, user):
if 'used' in self.sound_filenames:
self.play_sound('used')
if "used" in self.sound_filenames:
self.play_sound("used")
if self.consume_on_use:
self.destroy()
@ -103,32 +111,32 @@ class MazePickup(GameObject):
# if held, shadow holder
self.x = self.holder.x
# bob slightly above holder's head
bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10
bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10
self.y = self.holder.y + self.hold_offset_y + bob_y
self.z = self.holder.z
class MazeKey(MazePickup):
art_src = 'key'
display_name = 'a gold key'
used_message = 'unlocked!'
art_src = "key"
display_name = "a gold key"
used_message = "unlocked!"
class MazeAx(MazePickup):
art_src = 'ax'
display_name = 'an ax'
art_src = "ax"
display_name = "an ax"
consume_on_use = False
used_message = 'chop!'
used_message = "chop!"
# TODO: see if there's a way to add to MazePickup's sound dict here :/
sound_filenames = {'pickup': 'pickup.ogg',
'used': 'break.ogg'}
sound_filenames = {"pickup": "pickup.ogg", "used": "break.ogg"}
class MazePortalKey(MazePickup):
art_src = 'artifact'
display_name = 'the Artifact of Zendor'
used_message = '!!??!?!!?!?!?!!'
art_src = "artifact"
display_name = "the Artifact of Zendor"
used_message = "!!??!?!!?!?!?!!"
consume_on_use = False
sound_filenames = {'pickup': 'artifact.ogg',
'used': 'portal.ogg'}
sound_filenames = {"pickup": "artifact.ogg", "used": "portal.ogg"}
def update(self):
MazePickup.update(self)
@ -146,7 +154,7 @@ class MazePortalKey(MazePickup):
class MazeLock(StaticTileBG):
art_src = 'lock'
art_src = "lock"
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
col_radius = 0.5
@ -159,7 +167,7 @@ class MazeLock(StaticTileBG):
if other.held_object and type(other.held_object) is self.key_type:
self.unlocked(other)
else:
self.world.hud.post_msg('blocked - need %s!' % self.key_type.display_name)
self.world.hud.post_msg("blocked - need %s!" % self.key_type.display_name)
def unlocked(self, other):
self.disable_collision()
@ -168,13 +176,12 @@ class MazeLock(StaticTileBG):
class MazeBlockage(MazeLock):
art_src = 'debris'
art_src = "debris"
key_type = MazeAx
class MazePortalGate(MazeLock):
art_src = 'portalgate'
art_src = "portalgate"
key_type = MazePortalKey
collision_shape_type = CST_TILE
collision_type = CT_GENERIC_STATIC
@ -182,8 +189,8 @@ class MazePortalGate(MazeLock):
def update(self):
MazeLock.update(self)
if self.collision_type == CT_NONE:
if not self.art.is_script_running('evap'):
self.art.run_script_every('evap')
if not self.art.is_script_running("evap"):
self.art.run_script_every("evap")
return
# cycle non-black colors
BLACK = 1
@ -207,7 +214,8 @@ class MazePortalGate(MazeLock):
class MazePortal(GameObject):
art_src = 'portal'
art_src = "portal"
def update(self):
GameObject.update(self)
if self.app.updates % 2 != 0:
@ -215,16 +223,16 @@ class MazePortal(GameObject):
ramps = {11: 10, 10: 3, 3: 11}
for frame, layer, x, y in TileIter(self.art):
ch, fg, bg, xform = self.art.get_tile_at(frame, layer, x, y)
fg = ramps.get(fg, None)
fg = ramps.get(fg)
self.art.set_tile_at(frame, layer, x, y, ch, fg, bg, xform)
class MazeStandingNPC(GameObject):
art_src = 'npc'
art_src = "npc"
col_radius = 0.5
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
bark = 'Well hello there!'
bark = "Well hello there!"
def started_colliding(self, other):
if not isinstance(other, Player):

View file

@ -1,14 +1,15 @@
import math
from game_util_objects import Player, BlobShadow
from game_util_objects import BlobShadow, Player
from games.maze.scripts.rooms import OutsideRoom
class PlayerBlobShadow(BlobShadow):
z = 0
fixed_z = True
scale_x = scale_y = 0.5
offset_y = -0.5
def pre_first_update(self):
BlobShadow.pre_first_update(self)
# TODO: figure out why class default scale isn't taking?
@ -16,12 +17,12 @@ class PlayerBlobShadow(BlobShadow):
class MazePlayer(Player):
art_src = 'player'
move_state = 'stand'
art_src = "player"
move_state = "stand"
col_radius = 0.5
# TODO: setting this to 2 fixes tunneling, but shouldn't slow down the player!
fast_move_steps = 2
attachment_classes = { 'shadow': 'PlayerBlobShadow' }
attachment_classes = {"shadow": "PlayerBlobShadow"}
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)

View file

@ -1,18 +1,15 @@
from game_room import GameRoom
class MazeRoom(GameRoom):
def exited(self, new_room):
GameRoom.exited(self, new_room)
# clear message line when exiting
if self.world.hud:
self.world.hud.post_msg('')
self.world.hud.post_msg("")
class OutsideRoom(MazeRoom):
camera_follow_player = True
def entered(self, old_room):

View file

@ -1,20 +1,19 @@
import math
import random
import math, random
from game_util_objects import Character, Player, StaticTileBG, WarpTrigger
from game_object import GameObject
from game_util_objects import StaticTileBG, Player, Character, WarpTrigger
from collision import CST_AABB
class PlatformWorld(StaticTileBG):
draw_col_layer = True
class PlatformPlayer(Player):
class PlatformPlayer(Player):
# from http://www.piratehearts.com/blog/2010/08/30/40/:
# JumpSpeed = sqrt(2.0f * Gravity * JumpHeight);
art_src = 'player'
#collision_shape_type = CST_AABB
art_src = "player"
# collision_shape_type = CST_AABB
col_width = 2
col_height = 3
handle_key_events = True
@ -25,8 +24,8 @@ class PlatformPlayer(Player):
ground_friction = 20
air_friction = 15
max_jump_press_time = 0.15
editable = Player.editable + ['max_jump_press_time']
jump_key = 'x'
editable = Player.editable + ["max_jump_press_time"]
jump_key = "x"
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)
@ -61,15 +60,23 @@ class PlatformPlayer(Player):
return False
def update_state(self):
self.state = 'stand' if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0) else 'walk'
self.state = (
"stand"
if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0)
else "walk"
)
def moved_this_frame(self):
delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2)
delta = math.sqrt(
abs(self.last_x - self.x) ** 2
+ abs(self.last_y - self.y) ** 2
+ abs(self.last_z - self.z) ** 2
)
return delta > self.stop_velocity
def is_on_ground(self):
# works for now: just check for -Y contact with first world object
ground = self.world.get_first_object_of_type('PlatformWorld')
ground = self.world.get_first_object_of_type("PlatformWorld")
contact = self.collision.contacts.get(ground.name, None)
if not contact:
return False
@ -85,18 +92,20 @@ class PlatformPlayer(Player):
if on_ground and self.jump_time > 0:
self.jump_time = 0
# poll jump key for variable length jump
if self.world.app.il.is_key_pressed(self.jump_key) and \
(self.started_jump or not on_ground):
if self.world.app.il.is_key_pressed(self.jump_key) and (
self.started_jump or not on_ground
):
self.jump()
self.started_jump = False
Player.update(self)
# wobble as we walk a la ELC2
if self.state == 'walk' and on_ground:
if self.state == "walk" and on_ground:
self.y += math.sin(self.world.app.updates) / 5
class PlatformMonster(Character):
art_src = 'monster'
move_state = 'stand'
art_src = "monster"
move_state = "stand"
animating = True
fast_move_steps = 2
move_accel_x = 100
@ -105,7 +114,7 @@ class PlatformMonster(Character):
def pre_first_update(self):
# pick random starting direction
self.move_dir_x = random.choice([-1, 1])
self.set_timer_function('hit_wall', self.check_wall_hits, 0.2)
self.set_timer_function("hit_wall", self.check_wall_hits, 0.2)
def is_affected_by_gravity(self):
return True
@ -123,13 +132,15 @@ class PlatformMonster(Character):
x = self.x - self.col_radius - margin
y = self.y
# DEBUG see trace destination
#lines = [(self.x, self.y, 0), (x, y, 0)]
#self.app.debug_line_renderable.set_lines(lines)
hits, shapes = self.world.get_colliders_at_point(x, y,
#include_object_names=[],
include_class_names=['PlatformWorld',
'PlatformMonster'],
exclude_object_names=[self.name])
# lines = [(self.x, self.y, 0), (x, y, 0)]
# self.app.debug_line_renderable.set_lines(lines)
hits, shapes = self.world.get_colliders_at_point(
x,
y,
# include_object_names=[],
include_class_names=["PlatformWorld", "PlatformMonster"],
exclude_object_names=[self.name],
)
if len(hits) > 0:
self.move_dir_x = -self.move_dir_x
@ -139,4 +150,4 @@ class PlatformMonster(Character):
class PlatformWarpTrigger(WarpTrigger):
warp_class_names = ['Player', 'PlatformMonster']
warp_class_names = ["Player", "PlatformMonster"]

View file

@ -1,16 +1,17 @@
import math, random
import math
import random
from game_object import GameObject
from game_util_objects import Player, Character, Projectile, StaticTileBG, ObjectSpawner
from game_util_objects import Character, ObjectSpawner, Player, Projectile, StaticTileBG
class ShmupPlayer(Player):
state_changes_art = False
move_state = 'stand'
art_src = 'player'
move_state = "stand"
art_src = "player"
handle_key_events = True
invincible = False # DEBUG
serialized = Player.serialized + ['invincible']
invincible = False # DEBUG
serialized = Player.serialized + ["invincible"]
respawn_delay = 3
# refire delay, else holding X chokes game
fire_delay = 0.15
@ -24,11 +25,11 @@ class ShmupPlayer(Player):
self.start_x, self.start_y = self.x, self.y
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == 'x' and self.state == 'dead':
if key == "x" and self.state == "dead":
# respawn after short delay
time = self.world.get_elapsed_time() / 1000
if time >= self.last_death_time + self.respawn_delay:
self.state = 'stand'
self.state = "stand"
self.set_loc(self.start_x, self.start_y)
self.visible = True
@ -37,18 +38,18 @@ class ShmupPlayer(Player):
pass
def die(self, killer):
if self.invincible or self.state == 'dead':
if self.invincible or self.state == "dead":
return
boom = Boom(self.world)
boom.set_loc(self.x, self.y)
self.state = 'dead'
self.state = "dead"
self.last_death_time = self.world.get_elapsed_time() / 1000
self.visible = False
def update(self):
Player.update(self)
# poll fire key directly for continuous fire (with refire delay)
if self.state != 'dead' and self.world.app.il.is_key_pressed('x'):
if self.state != "dead" and self.world.app.il.is_key_pressed("x"):
time = self.world.get_elapsed_time() / 1000
if time >= self.last_fire_time + self.fire_delay:
proj = ShmupPlayerProjectile(self.world)
@ -58,12 +59,15 @@ class ShmupPlayer(Player):
class PlayerBlocker(StaticTileBG):
"keeps player from advancing too far upfield"
art_src = 'blockline_horiz'
noncolliding_classes = ['Projectile', 'ShmupEnemy']
art_src = "blockline_horiz"
noncolliding_classes = ["Projectile", "ShmupEnemy"]
class EnemySpawner(ObjectSpawner):
"sits at top of screen and spawns enemies"
art_src = 'spawn_area'
art_src = "spawn_area"
spawn_random_in_bounds = True
trigger_on_room_enter = False
@ -73,22 +77,25 @@ class EnemySpawner(ObjectSpawner):
self.target_enemy_count = 1
def can_spawn(self):
player = self.world.get_first_object_of_type('ShmupPlayer')
player = self.world.get_first_object_of_type("ShmupPlayer")
# only spawn if player has fired, there's room, and it's time
return player and player.state != 'dead' and \
player.last_fire_time > 0 and \
len(self.spawned_objects) < self.target_enemy_count and \
self.world.get_elapsed_time() >= self.next_spawn_time
return (
player
and player.state != "dead"
and player.last_fire_time > 0
and len(self.spawned_objects) < self.target_enemy_count
and self.world.get_elapsed_time() >= self.next_spawn_time
)
def get_spawn_class_name(self):
roll = random.random()
# pick random enemy type to spawn
if roll > 0.8:
return 'Enemy1'
return "Enemy1"
elif roll > 0.6:
return 'Enemy2'
return "Enemy2"
else:
return 'Asteroid'
return "Asteroid"
def update(self):
StaticTileBG.update(self)
@ -106,19 +113,23 @@ class EnemySpawner(ObjectSpawner):
next_delay = random.random() * 3
self.next_spawn_time = self.world.get_elapsed_time() + next_delay * 1000
class EnemyDeleter(StaticTileBG):
"deletes enemies once they hit a certain point on screen"
art_src = 'blockline_horiz'
art_src = "blockline_horiz"
def started_colliding(self, other):
if isinstance(other, ShmupEnemy):
other.destroy()
class ShmupEnemy(Character):
state_changes_art = False
move_state = 'stand'
move_state = "stand"
should_save = False
invincible = False # DEBUG
serialized = Character.serialized + ['invincible']
invincible = False # DEBUG
serialized = Character.serialized + ["invincible"]
def started_colliding(self, other):
if isinstance(other, ShmupPlayer):
@ -133,8 +144,9 @@ class ShmupEnemy(Character):
self.move(0, -1)
Character.update(self)
class Enemy1(ShmupEnemy):
art_src = 'enemy1'
art_src = "enemy1"
move_accel_y = 100
def update(self):
@ -146,8 +158,9 @@ class Enemy1(ShmupEnemy):
self.fire_proj()
ShmupEnemy.update(self)
class Enemy2(ShmupEnemy):
art_src = 'enemy2'
art_src = "enemy2"
animating = True
move_accel_y = 50
@ -171,16 +184,20 @@ class Enemy2(ShmupEnemy):
self.fire_proj()
ShmupEnemy.update(self)
class Asteroid(ShmupEnemy):
"totally inert, just moves slowly down the screen"
art_src = 'asteroid'
art_src = "asteroid"
move_accel_y = 200
class ShmupPlayerProjectile(Projectile):
animating = True
art_src = 'player_proj'
art_src = "player_proj"
use_art_instance = True
noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'Player']
noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "Player"]
def started_colliding(self, other):
if isinstance(other, ShmupEnemy) and not other.invincible:
boom = Boom(self.world)
@ -189,35 +206,39 @@ class ShmupPlayerProjectile(Projectile):
other.destroy()
self.destroy()
class ShmupEnemyProjectile(Projectile):
animating = True
art_src = 'enemy_proj'
art_src = "enemy_proj"
use_art_instance = True
noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'ShmupEnemy']
noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "ShmupEnemy"]
def started_colliding(self, other):
if isinstance(other, ShmupPlayer) and other.state != 'dead':
if isinstance(other, ShmupPlayer) and other.state != "dead":
other.die(self)
self.destroy()
class Boom(GameObject):
art_src = 'boom'
art_src = "boom"
animating = True
use_art_instance = True
should_save = False
z = 0.5
scale_x, scale_y = 3, 3
lifespan = 0.5
def get_acceleration(self, vel_x, vel_y, vel_z):
return 0, 0, -100
class Starfield(GameObject):
class Starfield(GameObject):
"scrolling background with stars generated on-the-fly - no PSCI file!"
generate_art = True
art_width, art_height = 30, 41
art_charset = 'jpetscii'
alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties
art_charset = "jpetscii"
alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties
# indices of star characters
star_chars = [201]

View file

@ -1,14 +1,12 @@
import random
import time
import time, random
from art import ART_DIR, UV_FLIPX, UV_FLIPY, UV_ROTATE180
from game_object import GameObject
from art import UV_FLIPX, UV_FLIPY, UV_ROTATE180, ART_DIR
from renderable import TileRenderable
from games.wildflowers.scripts.ramps import PALETTE_RAMPS
from games.wildflowers.scripts.petal import Petal
from games.wildflowers.scripts.frond import Frond
from games.wildflowers.scripts.petal import Petal
from games.wildflowers.scripts.ramps import PALETTE_RAMPS
from renderable import TileRenderable
# TODO: random size range?
# (should also change camera zoom, probably frond/petal counts)
@ -16,7 +14,6 @@ FLOWER_WIDTH, FLOWER_HEIGHT = 16, 16
class FlowerObject(GameObject):
generate_art = True
should_save = False
physics_move = False
@ -42,7 +39,7 @@ class FlowerObject(GameObject):
# set random seed based on date, a different flower each day
t = time.localtime()
year, month, day = t.tm_year, t.tm_mon, t.tm_mday
weekday = t.tm_wday # 0 = monday
weekday = t.tm_wday # 0 = monday
date = year * 10000 + month * 100 + day
if self.seed_includes_time:
date += t.tm_hour * 0.01 + t.tm_min * 0.0001 + t.tm_sec * 0.000001
@ -54,21 +51,23 @@ class FlowerObject(GameObject):
# pick a random dark BG color (will be quantized to palette)
r, g, b = random.random() / 10, random.random() / 10, random.random() / 10
# set up art with character set, size, and a random (supported) palette
self.art.set_charset_by_name('jpetscii')
self.art.set_charset_by_name("jpetscii")
palette = random.choice(list(PALETTE_RAMPS.keys()))
self.art.set_palette_by_name(palette)
# quantize bg color and set it for art and world
self.bg_index = self.art.palette.get_closest_color_index(int(r * 255), int(g * 255), int(b * 255))
self.bg_index = self.art.palette.get_closest_color_index(
int(r * 255), int(g * 255), int(b * 255)
)
bg_color = self.art.palette.colors[self.bg_index]
self.world.bg_color[0] = bg_color[0] / 255.0
self.world.bg_color[1] = bg_color[1] / 255.0
self.world.bg_color[2] = bg_color[2] / 255.0
self.world.bg_color[3] = 1.0 # set here or alpha is zero?
self.world.bg_color[3] = 1.0 # set here or alpha is zero?
self.art.resize(self.art_width, self.art_height)
self.app.ui.adjust_for_art_resize(self) # grid etc
self.app.ui.adjust_for_art_resize(self) # grid etc
self.art.clear_frame_layer(0, 0, bg_color=self.bg_index)
# petals on a layer underneath fronds?
#self.art.add_layer(z=-0.001, name='petals')
# self.art.add_layer(z=-0.001, name='petals')
self.finished_growing = False
# some flowers can be more petal-centric or frond-centric,
# but keep a certain minimum complexity
@ -78,24 +77,31 @@ class FlowerObject(GameObject):
petal_count = random.randint(self.min_petals, self.max_petals)
frond_count = random.randint(self.min_fronds, self.max_fronds)
self.petals = []
#petal_count = 5 # DEBUG
# petal_count = 5 # DEBUG
for i in range(petal_count):
self.petals.append(Petal(self, i))
# sort petals by radius largest to smallest,
# so big ones don't totally stomp smaller ones
self.petals.sort(key=lambda item: item.goal_radius, reverse=True)
self.fronds = []
#frond_count = 0 # DEBUG
# frond_count = 0 # DEBUG
for i in range(frond_count):
self.fronds.append(Frond(self, i))
# track # of growth updates we've had
self.grows = 0
# create an art document we can add frames to and later export
self.export_filename = '%s%swildflower_%s' % (self.app.documents_dir, ART_DIR, self.seed)
self.exportable_art = self.app.new_art(self.export_filename,
self.art_width, self.art_height,
self.art.charset.name,
self.art.palette.name)
self.export_filename = "%s%swildflower_%s" % (
self.app.documents_dir,
ART_DIR,
self.seed,
)
self.exportable_art = self.app.new_art(
self.export_filename,
self.art_width,
self.art_height,
self.art.charset.name,
self.art.palette.name,
)
# re-set art's filename to be in documents dir rather than game dir :/
self.exportable_art.set_filename(self.export_filename)
# image export process needs a renderable
@ -111,7 +117,7 @@ class FlowerObject(GameObject):
def update_growth(self):
if self.debug_log:
print('update growth:')
print("update growth:")
grew = False
for p in self.petals:
if not p.finished_growing:
@ -136,7 +142,7 @@ class FlowerObject(GameObject):
self.finished_growing = True
self.exportable_art.set_active_frame(self.exportable_art.frames - 1)
if self.debug_log:
print('flower finished')
print("flower finished")
def paint_mirrored(self, layer, x, y, char, fg, bg=None):
# only paint if in top left quadrant
@ -148,12 +154,11 @@ class FlowerObject(GameObject):
top_right = (self.art_width - 1 - x, y)
bottom_left = (x, self.art_height - 1 - y)
bottom_right = (self.art_width - 1 - x, self.art_height - 1 - y)
self.art.set_tile_at(0, layer, *top_right,
char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left,
char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(0, layer, *bottom_right,
char, fg, bg, transform=UV_ROTATE180)
self.art.set_tile_at(0, layer, *top_right, char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left, char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(
0, layer, *bottom_right, char, fg, bg, transform=UV_ROTATE180
)
def copy_new_frame(self):
# add new frame to art for export

View file

@ -1,35 +1,38 @@
import random
from games.wildflowers.scripts.ramps import RampIterator
# growth direction consts
NONE = (0, 0)
LEFT = (-1, 0)
LEFT_UP = (-1, -1)
UP = (0, -1)
RIGHT_UP = (1, -1)
RIGHT = (1, 0)
NONE = (0, 0)
LEFT = (-1, 0)
LEFT_UP = (-1, -1)
UP = (0, -1)
RIGHT_UP = (1, -1)
RIGHT = (1, 0)
RIGHT_DOWN = (1, 1)
DOWN = (0, 1)
LEFT_DOWN = (-1, 1)
DOWN = (0, 1)
LEFT_DOWN = (-1, 1)
DIRS = [LEFT, LEFT_UP, UP, RIGHT_UP, RIGHT, RIGHT_DOWN, DOWN, LEFT_DOWN]
FROND_CHARS = [
# thick and skinny \
151, 166,
151,
166,
# thick and skinny /
150, 167,
150,
167,
# thick and skinny X
183, 182,
183,
182,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Frond:
min_life, max_life = 3, 16
random_char_chance = 0.5
mutate_char_chance = 0.2
@ -42,10 +45,13 @@ class Frond:
self.index = index
self.finished_growing = False
# choose growth function
self.growth_functions = [self.grow_straight_line, self.grow_curl,
self.grow_wander_outward]
self.growth_functions = [
self.grow_straight_line,
self.grow_curl,
self.grow_wander_outward,
]
self.get_grow_dir = random.choice(self.growth_functions)
#self.get_grow_dir = self.grow_curl # DEBUG
# self.get_grow_dir = self.grow_curl # DEBUG
# for straight line growers, set a consistent direction
if self.get_grow_dir == self.grow_straight_line:
self.grow_line = random.choice(DIRS)
@ -75,16 +81,22 @@ class Frond:
if self.life <= 0 or self.color == self.ramp.end:
self.finished_growing = True
if self.debug:
print(' frond %i finished.' % self.index)
print(" frond %i finished." % self.index)
return painted
if self.debug:
print(' frond %i at (%i, %i) using %s' % (self.index, self.x, self.y, self.get_grow_dir.__name__))
print(
" frond %i at (%i, %i) using %s"
% (self.index, self.x, self.y, self.get_grow_dir.__name__)
)
# if we're out of bounds, simply don't paint;
# we might go back in bounds next grow
if 0 <= self.x < self.flower.art_width - 1 and \
0 <= self.y < self.flower.art_height - 1:
self.flower.paint_mirrored(self.layer, self.x, self.y,
self.char, self.color)
if (
0 <= self.x < self.flower.art_width - 1
and 0 <= self.y < self.flower.art_height - 1
):
self.flower.paint_mirrored(
self.layer, self.x, self.y, self.char, self.color
)
painted = True
self.growth_history.append((self.x, self.y))
self.life -= 1

View file

@ -1,27 +1,35 @@
import random, math
import math
import random
from games.wildflowers.scripts.ramps import RampIterator
PETAL_CHARS = [
# solid block
255,
# shaded boxes
254, 253,
254,
253,
# solid circle
122,
# curved corner lines, NW NE SE SW
105, 107, 139, 137,
105,
107,
139,
137,
# mostly-solid curved corners, NW NE SE SW
144, 146, 178, 176,
144,
146,
178,
176,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Petal:
min_radius = 3
mutate_char_chance = 0.2
# layer all petals should paint on
@ -36,8 +44,12 @@ class Petal:
max_radius = int(self.flower.art_width / 2)
self.goal_radius = random.randint(self.min_radius, max_radius)
self.radius = 0
ring_styles = [self.get_ring_tiles_box, self.get_ring_tiles_wings,
self.get_ring_tiles_diamond, self.get_ring_tiles_circle]
ring_styles = [
self.get_ring_tiles_box,
self.get_ring_tiles_wings,
self.get_ring_tiles_diamond,
self.get_ring_tiles_circle,
]
self.get_ring_tiles = random.choice(ring_styles)
# pick a starting point near center
w, h = self.flower.art_width, self.flower.art_height
@ -55,7 +67,16 @@ class Petal:
self.finished_growing = True
return
if self.debug:
print(' petal %i at (%i, %i) at radius %i using %s' % (self.index, self.x, self.y, self.radius, self.get_ring_tiles.__name__))
print(
" petal %i at (%i, %i) at radius %i using %s"
% (
self.index,
self.x,
self.y,
self.radius,
self.get_ring_tiles.__name__,
)
)
self.paint_ring()
# grow and change
self.radius += 1
@ -70,11 +91,12 @@ class Petal:
x = self.x - t[0]
y = self.y - t[1]
# don't paint out of bounds
if 0 <= x < self.flower.art_width - 1 and \
0 <= y < self.flower.art_height - 1:
self.flower.paint_mirrored(self.layer, x, y,
self.char, self.color)
#print('%s, %s' % (x, y))
if (
0 <= x < self.flower.art_width - 1
and 0 <= y < self.flower.art_height - 1
):
self.flower.paint_mirrored(self.layer, x, y, self.char, self.color)
# print('%s, %s' % (x, y))
def get_ring_tiles_box(self):
tiles = []
@ -103,7 +125,6 @@ class Petal:
tiles.append((x, y))
return tiles
def get_ring_tiles_diamond(self):
tiles = []
for y in range(self.radius, -1, -1):
@ -120,6 +141,6 @@ class Petal:
angle += math.radians(90.0 / resolution)
x = round(math.cos(angle) * self.radius)
y = round(math.sin(angle) * self.radius)
if not (x, y) in tiles:
if (x, y) not in tiles:
tiles.append((x, y))
return tiles

View file

@ -1,53 +1,52 @@
import random
# wildflowers palette ramp definitions
PALETTE_RAMPS = {
# palette name : list of its ramps
'dpaint': [
"dpaint": [
# ramp tuple: (start index, length, stride)
# generally, lighter / more vivid to darker
(17, 16, 1), # white to black
(33, 16, 1), # red to black
(49, 8, 1), # white to red
(57, 8, 1), # light orange to dark orange
(49, 8, 1), # white to red
(57, 8, 1), # light orange to dark orange
(65, 16, 1), # light yellow to ~black
(81, 8, 1), # light green to green
(81, 8, 1), # light green to green
(89, 24, 1), # white to green to ~black
(113, 16, 1), # light cyan to ~black
(113, 16, 1), # light cyan to ~black
(129, 8, 1), # light blue to blue
(137, 24, 1), # white to blue to ~black
(161, 16, 1), # light purple to ~black
(177, 16, 1), # light magenta to ~black
(193, 24, 1), # pale flesh to ~black
(225, 22, 1) # ROYGBV rainbow
(137, 24, 1), # white to blue to ~black
(161, 16, 1), # light purple to ~black
(177, 16, 1), # light magenta to ~black
(193, 24, 1), # pale flesh to ~black
(225, 22, 1), # ROYGBV rainbow
],
'doom': [
"doom": [
(17, 27, 1), # very light pink to dark red
(44, 20, 1), # pale flesh to brown
(69, 26, 1), # white to very dark grey
(95, 14, 1), # bright green to ~black
(109, 12, 1), # light tan to dark tan
(109, 12, 1), # light tan to dark tan
(126, 4, 1), # olive drab
(130, 7, 1), # light gold to gold brown
(137, 18, 1), # white to dark red
(155, 14, 1), # white to dark blue
(169, 11, 1), # white to orange
(137, 18, 1), # white to dark red
(155, 14, 1), # white to dark blue
(169, 11, 1), # white to orange
(180, 7, 1), # white to yellow
(187, 4, 1), # orange to burnt orange
(193, 7, 1), # dark blue to black
(201, 5, 1) # light magenta to dark purple
(201, 5, 1), # light magenta to dark purple
],
'quake': [
"quake": [
(16, 15, -1), # white to black
(32, 16, -1), # mustard to black
(48, 16, -1), # lavender to black
(63, 15, -1), # olive to black
(79, 16, -1), # red to black
(92, 13, -1), # orange to ~black
(108, 16, -1), # yellow to orange to ~black
(124, 16, -1), # pale flesh to ~black
(108, 16, -1), # yellow to orange to ~black
(124, 16, -1), # pale flesh to ~black
(125, 16, 1), # light purple to ~black
(141, 13, 1), # purpleish pink to ~black
(154, 15, 1), # light tan to ~black
@ -57,48 +56,47 @@ PALETTE_RAMPS = {
(233, 4, -1), # yellow to brown
(236, 3, -1), # light blue to blue
(240, 4, -1), # red to dark red
(243, 3, -1) # white to yellow
(243, 3, -1), # white to yellow
],
'heretic': [
"heretic": [
(35, 35, -1), # white to black
(51, 16, -1), # light grey to dark grey
(65, 14, -1), # white to dark violent-grey
(94, 29, -1), # white to dark brown
(110, 16, -1), # light tan to brown
(136, 26, -1), # light yellow to dark golden brown
(110, 16, -1), # light tan to brown
(136, 26, -1), # light yellow to dark golden brown
(144, 8, -1), # yellow to orange
(160, 16, -1), # red to dark red
(160, 16, -1), # red to dark red
(168, 8, -1), # white to pink
(176, 8, -1), # light magenta to dark magenta
(184, 8, -1), # white to purple
(208, 24, -1), # white to cyan to dark blue
(224, 16, -1), # light green to dark green
(240, 16, -1), # olive to dark olive
(247, 7, -1) # red to yellow
(208, 24, -1), # white to cyan to dark blue
(224, 16, -1), # light green to dark green
(240, 16, -1), # olive to dark olive
(247, 7, -1), # red to yellow
],
"atari": [
(113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown
(116, 8, -16), # peach to burnt orange
(117, 8, -16), # pink to red
(118, 8, -16), # magenta to dark magenta
(119, 8, -16), # purple to dark purple
(120, 8, -16), # violet to dark violet
(121, 8, -16), # light blue to dark blue
(122, 8, -16), # light cobalt to dark cobalt
(123, 8, -16), # light teal to dark teal
(124, 8, -16), # light sea green to dark sea green
(125, 8, -16), # light green to dark green
(126, 8, -16), # yellow green to dark yellow green
(127, 8, -16), # pale yellow to dark olive
(128, 8, -16), # gold to golden brown
],
'atari': [
(113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown
(116, 8, -16), # peach to burnt orange
(117, 8, -16), # pink to red
(118, 8, -16), # magenta to dark magenta
(119, 8, -16), # purple to dark purple
(120, 8, -16), # violet to dark violet
(121, 8, -16), # light blue to dark blue
(122, 8, -16), # light cobalt to dark cobalt
(123, 8, -16), # light teal to dark teal
(124, 8, -16), # light sea green to dark sea green
(125, 8, -16), # light green to dark green
(126, 8, -16), # yellow green to dark yellow green
(127, 8, -16), # pale yellow to dark olive
(128, 8, -16) # gold to golden brown
]
}
class RampIterator:
def __init__(self, flower):
ramp_def = random.choice(PALETTE_RAMPS[flower.art.palette.name])
self.start, self.length, self.stride = ramp_def

View file

@ -1,8 +1,5 @@
from game_util_objects import WorldGlobalsObject, GameObject
from image_export import export_animation, export_still_image
from games.wildflowers.scripts.flower import FlowerObject
from game_util_objects import GameObject, WorldGlobalsObject
from image_export import export_still_image
"""
overall approach:
@ -22,7 +19,6 @@ character ramps based on direction changes, visual density, something else?
class FlowerGlobals(WorldGlobalsObject):
# if True, generate a 4x4 grid instead of just one
test_gen = False
handle_key_events = True
@ -31,23 +27,23 @@ class FlowerGlobals(WorldGlobalsObject):
WorldGlobalsObject.__init__(self, world, obj_data)
def pre_first_update(self):
#self.app.can_edit = False
# self.app.can_edit = False
self.app.ui.set_game_edit_ui_visibility(False)
self.app.ui.message_line.post_line('')
self.app.ui.message_line.post_line("")
if self.test_gen:
for x in range(4):
for y in range(4):
flower = self.world.spawn_object_of_class('FlowerObject')
flower = self.world.spawn_object_of_class("FlowerObject")
flower.set_loc(x * flower.art.width, y * flower.art.height)
self.world.camera.set_loc(25, 25, 35)
else:
flower = self.world.spawn_object_of_class('FlowerObject')
flower = self.world.spawn_object_of_class("FlowerObject")
self.world.camera.set_loc(0, 0, 10)
self.flower = flower
self.world.spawn_object_of_class('SeedDisplay')
self.world.spawn_object_of_class("SeedDisplay")
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key != 'e':
if key != "e":
return
if not self.flower:
return
@ -57,27 +53,30 @@ class FlowerGlobals(WorldGlobalsObject):
self.flower.exportable_art.frame_delays[-1] = 6.0
self.flower.exportable_art.save_to_file()
# TODO: investigate why opening for edit puts art mode in a bad state
#self.app.load_art_for_edit(self.flower.exportable_art.filename)
# self.app.load_art_for_edit(self.flower.exportable_art.filename)
# save to .gif - TODO investigate problem with frame deltas not clearing
#export_animation(self.app, self.flower.exportable_art,
# export_animation(self.app, self.flower.exportable_art,
# self.flower.export_filename + '.gif',
# bg_color=self.world.bg_color, loop=False)
# export to .png - works
export_still_image(self.app, self.flower.exportable_art,
self.flower.export_filename + '.png',
crt=self.app.fb.crt, scale=4,
bg_color=self.world.bg_color)
self.app.log('Exported %s.png' % self.flower.export_filename)
export_still_image(
self.app,
self.flower.exportable_art,
self.flower.export_filename + ".png",
crt=self.app.fb.crt,
scale=4,
bg_color=self.world.bg_color,
)
self.app.log("Exported %s.png" % self.flower.export_filename)
class SeedDisplay(GameObject):
generate_art = True
art_width, art_height = 30, 1
art_charset = 'ui'
art_palette = 'c64_original'
art_charset = "ui"
art_palette = "c64_original"
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)

14
grid.py
View file

@ -8,8 +8,8 @@ AXIS_COLOR = (0.8, 0.8, 0.8, 0.5)
BASE_COLOR = (0.5, 0.5, 0.5, 0.25)
EXTENTS_COLOR = (0, 0, 0, 1)
class Grid(LineRenderable):
class Grid(LineRenderable):
visible = True
draw_axes = False
@ -28,7 +28,7 @@ class Grid(LineRenderable):
index = 4
# axes - Y and X
if self.draw_axes:
v += [(w/2, -h), (w/2, 0), (0, -h/2), (w, -h/2)]
v += [(w / 2, -h), (w / 2, 0), (0, -h / 2), (w, -h / 2)]
e += [4, 5, 6, 7]
color = AXIS_COLOR
c += color * 4
@ -37,15 +37,15 @@ class Grid(LineRenderable):
color = BASE_COLOR
for x in range(1, w):
# skip middle line
if not self.draw_axes or x != w/2:
if not self.draw_axes or x != w / 2:
v += [(x, -h), (x, 0)]
e += [index, index+1]
e += [index, index + 1]
c += color * 2
index += 2
for y in range(1, h):
if not self.draw_axes or y != h/2:
if not self.draw_axes or y != h / 2:
v += [(0, -y), (w, -y)]
e += [index, index+1]
e += [index, index + 1]
c += color * 2
index += 2
self.vert_array = np.array(v, dtype=np.float32)
@ -74,7 +74,6 @@ class Grid(LineRenderable):
class ArtGrid(Grid):
def reset_loc(self):
self.x, self.y = 0, 0
self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer]
@ -88,7 +87,6 @@ class ArtGrid(Grid):
class GameGrid(Grid):
draw_axes = True
base_size = 800

View file

@ -1,11 +1,12 @@
import math
import os.path
import time
import math, os.path, time
import numpy as np
from PIL import Image
from PIL import Image, ImageChops, ImageStat
from lab_color import lab_color_diff, rgb_to_lab
from renderable_sprite import SpriteRenderable
from lab_color import rgb_to_lab, lab_color_diff
"""
notes / future research
@ -23,15 +24,17 @@ https://www.youtube.com/watch?v=L6CkYou6hYU
- downsample each block bilinearly, divide each into 4x4 cells, then compare them with similarly bilinearly-downsampled char blocks
"""
class ImageConverter:
class ImageConverter:
tiles_per_tick = 1
lab_color_comparison = True
# delay in seconds before beginning to convert tiles.
# lets eg UI catch up to BitmapImageImporter changes to Art.
start_delay = 1.0
def __init__(self, app, image_filename, art, bicubic_scale=False, sequence_converter=None):
def __init__(
self, app, image_filename, art, bicubic_scale=False, sequence_converter=None
):
self.init_success = False
image_filename = app.find_filename_path(image_filename)
if not image_filename or not os.path.exists(image_filename):
@ -46,7 +49,7 @@ class ImageConverter:
# if an ImageSequenceConverter created us, keep a handle to it
self.sequence_converter = sequence_converter
try:
self.src_img = Image.open(self.image_filename).convert('RGB')
self.src_img = Image.open(self.image_filename).convert("RGB")
except:
return
# if we're part of a sequence, app doesn't need handle directly to us
@ -70,21 +73,24 @@ class ImageConverter:
self.src_array = np.reshape(self.src_array, (src_h, src_w))
# convert charmap to 1-bit color for fast value swaps during
# block comparison
self.char_img = self.art.charset.image_data.copy().convert('RGB')
bw_pal_img = Image.new('P', (1, 1))
self.char_img = self.art.charset.image_data.copy().convert("RGB")
bw_pal_img = Image.new("P", (1, 1))
bw_pal = [0, 0, 0, 255, 255, 255]
while len(bw_pal) < 256 * 3:
bw_pal.append(0)
bw_pal_img.putpalette(tuple(bw_pal))
self.char_img = self.char_img.quantize(palette=bw_pal_img)
self.char_array = np.fromstring(self.char_img.tobytes(), dtype=np.uint8)
self.char_array = np.reshape(self.char_array, (self.art.charset.image_height, self.art.charset.image_width))
self.char_array = np.reshape(
self.char_array,
(self.art.charset.image_height, self.art.charset.image_width),
)
# create, size and position image preview
preview_img = self.src_img.copy()
# remove transparency if source image is a GIF to avoid a PIL crash :[
# TODO: https://github.com/python-pillow/Pillow/issues/1377
if 'transparency' in preview_img.info:
preview_img.info.pop('transparency')
if "transparency" in preview_img.info:
preview_img.info.pop("transparency")
self.preview_sprite = SpriteRenderable(self.app, None, preview_img)
# preview image scale takes into account character aspect
self.preview_sprite.scale_x = w / (self.char_w / self.art.quad_width)
@ -113,10 +119,14 @@ class ImageConverter:
unique_colors = len(colors)
color_diffs = np.zeros((unique_colors, unique_colors), dtype=np.float32)
# option: L*a*b color space conversion for greater accuracy
get_color_diff = self.get_lab_color_diff if self.lab_color_comparison else self.get_rgb_color_diff
#get_color_diff = self.get_nonlinear_rgb_color_diff
for i,color in enumerate(colors):
for j,other_color in enumerate(colors):
get_color_diff = (
self.get_lab_color_diff
if self.lab_color_comparison
else self.get_rgb_color_diff
)
# get_color_diff = self.get_nonlinear_rgb_color_diff
for i, color in enumerate(colors):
for j, other_color in enumerate(colors):
color_diffs[i][j] = get_color_diff(color, other_color)
return color_diffs
@ -138,7 +148,9 @@ class ImageConverter:
r = color1[0] - color2[0]
g = color1[1] - color2[1]
b = color1[2] - color2[2]
return math.sqrt((((512+rmean)*r*r)>>8) + 4*g*g + (((767-rmean)*b*b)>>8))
return math.sqrt(
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8)
)
def update(self):
if time.time() < self.start_time + self.start_delay:
@ -152,9 +164,16 @@ class ImageConverter:
# but transparency isn't properly supported yet
fg = self.art.palette.darkest_index if fg == 0 else fg
bg = self.art.palette.darkest_index if bg == 0 else bg
self.art.set_tile_at(self.art.active_frame, self.art.active_layer,
self.x, self.y, char, fg, bg)
#print('set block %s,%s to ch %s fg %s bg %s' % (self.x, self.y, char, fg, bg))
self.art.set_tile_at(
self.art.active_frame,
self.art.active_layer,
self.x,
self.y,
char,
fg,
bg,
)
# print('set block %s,%s to ch %s fg %s bg %s' % (self.x, self.y, char, fg, bg))
self.x += 1
if self.x >= self.art.width:
self.x = 0
@ -175,12 +194,12 @@ class ImageConverter:
return colors, []
# sort by most to least used colors
color_counts = []
for i,color in enumerate(colors):
for i, color in enumerate(colors):
color_counts += [(color, counts[i])]
color_counts.sort(key=lambda item: item[1], reverse=True)
combos = []
for color1,count1 in color_counts:
for color2,count2 in color_counts:
for color1, count1 in color_counts:
for color2, count2 in color_counts:
if color1 == color2:
continue
# fg/bg color swap SHOULD be allowed
@ -202,14 +221,14 @@ class ImageConverter:
best_char = 0
best_diff = 9999999999999
best_fg, best_bg = 0, 0
for bg,fg in combos:
for bg, fg in combos:
# reset char index before each run through charset
char_index = 0
char_array = self.char_array.copy()
# replace 1-bit color of char image with fg and bg colors
char_array[char_array == 0] = bg
char_array[char_array == 1] = fg
for (x0, y0, x1, y1) in self.char_blocks:
for x0, y0, x1, y1 in self.char_blocks:
char_block = char_array[y0:y1, x0:x1]
# using array of difference values w/ fancy numpy indexing,
# sum() it
@ -222,31 +241,34 @@ class ImageConverter:
best_diff = diff
best_char = char_index
best_fg, best_bg = fg, bg
#print('%s is new best char index, diff %s:' % (char_index, diff))
# print('%s is new best char index, diff %s:' % (char_index, diff))
char_index += 1
# return best (least different to source block) char/fg/bg found
#print('%s is best char index, diff %s:' % (best_char, best_diff))
# print('%s is best char index, diff %s:' % (best_char, best_diff))
return (best_char, best_fg, best_bg)
def print_block(self, block, fg, bg):
"prints ASCII representation of a block with . and # as white and black"
w, h = block.shape
s = ''
s = ""
for y in range(h):
for x in range(w):
if block[y][x] == fg:
s += '#'
s += "#"
else:
s += '.'
s += '\n'
s += "."
s += "\n"
print(s)
def finish(self, cancelled=False):
self.finished = True
if not self.sequence_converter:
time_taken = time.time() - self.start_time
verb = 'cancelled' if cancelled else 'finished'
self.app.log('Conversion of image %s %s after %.3f seconds' % (self.image_filename, verb, time_taken))
verb = "cancelled" if cancelled else "finished"
self.app.log(
"Conversion of image %s %s after %.3f seconds"
% (self.image_filename, verb, time_taken)
)
self.app.converter = None
self.preview_sprite = None
self.app.update_window_title()

View file

@ -1,9 +1,9 @@
import os
from OpenGL import GL
from PIL import Image, ImageChops, GifImagePlugin
from PIL import GifImagePlugin, Image, ImageChops
from framebuffer import ExportFramebuffer, ExportFramebufferNoCRT
def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, 0)):
"returns a PIL image of given frame of given art, None on failure"
post_fb_class = ExportFramebuffer if allow_crt else ExportFramebufferNoCRT
@ -13,8 +13,14 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0,
w, h = int(w * scale), int(h * scale)
# error out if over max texture size
if w > app.max_texture_size or h > app.max_texture_size:
app.log("ERROR: Image output size (%s x %s) exceeds your hardware's max supported texture size (%s x %s)!" % (w, h, app.max_texture_size, app.max_texture_size), error=True)
app.log(' Please export at a smaller scale or chop up your artwork :[', error=True)
app.log(
"ERROR: Image output size (%s x %s) exceeds your hardware's max supported texture size (%s x %s)!"
% (w, h, app.max_texture_size, app.max_texture_size),
error=True,
)
app.log(
" Please export at a smaller scale or chop up your artwork :[", error=True
)
return None
# create CRT framebuffer
post_fb = post_fb_class(app, w, h)
@ -24,8 +30,12 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0,
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, render_buffer)
GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_RGBA8, w, h)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, export_fb)
GL.glFramebufferRenderbuffer(GL.GL_DRAW_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0,
GL.GL_RENDERBUFFER, render_buffer)
GL.glFramebufferRenderbuffer(
GL.GL_DRAW_FRAMEBUFFER,
GL.GL_COLOR_ATTACHMENT0,
GL.GL_RENDERBUFFER,
render_buffer,
)
GL.glViewport(0, 0, w, h)
# do render
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, post_fb.framebuffer)
@ -38,8 +48,9 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0,
post_fb.render()
GL.glReadBuffer(GL.GL_COLOR_ATTACHMENT0)
# read pixels from it
pixels = GL.glReadPixels(0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE,
outputType=None)
pixels = GL.glReadPixels(
0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, outputType=None
)
# cleanup / deinit of GL stuff
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0)
GL.glViewport(0, 0, app.window_width, app.window_height)
@ -48,10 +59,11 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0,
post_fb.destroy()
# GL pixel data as numpy array -> bytes for PIL image export
pixel_bytes = pixels.flatten().tobytes()
src_img = Image.frombytes(mode='RGBA', size=(w, h), data=pixel_bytes)
src_img = Image.frombytes(mode="RGBA", size=(w, h), data=pixel_bytes)
src_img = src_img.transpose(Image.FLIP_TOP_BOTTOM)
return src_img
def export_animation(app, art, out_filename, bg_color=None, loop=True):
# get list of rendered frame images
frames = []
@ -60,33 +72,38 @@ def export_animation(app, art, out_filename, bg_color=None, loop=True):
# if bg color is specified, this isn't art mode; play along
if bg_color is not None:
f_transp = bg_color
art.palette.colors[0] = (round(bg_color[0] * 255),
round(bg_color[1] * 255),
round(bg_color[2] * 255),
255)
art.palette.colors[0] = (
round(bg_color[0] * 255),
round(bg_color[1] * 255),
round(bg_color[2] * 255),
255,
)
else:
# GL wants floats
f_transp = (i_transp[0]/255, i_transp[1]/255, i_transp[2]/255, 1.)
f_transp = (i_transp[0] / 255, i_transp[1] / 255, i_transp[2] / 255, 1.0)
for frame in range(art.frames):
frame_img = get_frame_image(app, art, frame, allow_crt=False,
scale=1, bg_color=f_transp)
frame_img = get_frame_image(
app, art, frame, allow_crt=False, scale=1, bg_color=f_transp
)
if bg_color is not None:
# if bg color is specified, assume no transparency
frame_img = art.palette.get_palettized_image(frame_img, force_no_transparency=True)
frame_img = art.palette.get_palettized_image(
frame_img, force_no_transparency=True
)
else:
frame_img = art.palette.get_palettized_image(frame_img, i_transp[:3])
frames.append(frame_img)
# compile frames into animated GIF with proper frame delays
# technique thanks to:
# https://github.com/python-pillow/Pillow/blob/master/Scripts/gifmaker.py
output_img = open(out_filename, 'wb')
for i,img in enumerate(frames):
output_img = open(out_filename, "wb")
for i, img in enumerate(frames):
delay = art.frame_delays[i] * 1000
if i == 0:
data = GifImagePlugin.getheader(img)[0]
# PIL only wants to write GIF87a for some reason...
# welcome to 1989 B]
data[0] = data[0].replace(b'7', b'9')
data[0] = data[0].replace(b"7", b"9")
# TODO: loop doesn't work?
if bg_color is not None:
# if bg color is specified, assume no transparency
@ -95,23 +112,24 @@ def export_animation(app, art, out_filename, bg_color=None, loop=True):
else:
data += GifImagePlugin.getdata(img, duration=delay)
else:
data += GifImagePlugin.getdata(img, duration=delay,
transparency=0, loop=0)
data += GifImagePlugin.getdata(
img, duration=delay, transparency=0, loop=0
)
for b in data:
output_img.write(b)
continue
delta = ImageChops.subtract_modulo(img, frames[i-1])
delta = ImageChops.subtract_modulo(img, frames[i - 1])
# Image.getbbox() rather unhelpfully returns None if no delta
dw, dh = delta.size
bbox = delta.getbbox() or (0, 0, dw, dh)
for b in GifImagePlugin.getdata(img.crop(bbox), offset=bbox[:2],
duration=delay, transparency=0,
loop=0):
for b in GifImagePlugin.getdata(
img.crop(bbox), offset=bbox[:2], duration=delay, transparency=0, loop=0
):
output_img.write(b)
output_img.write(b';')
output_img.write(b";")
output_img.close()
output_format = 'Animated GIF'
#app.log('%s exported (%s)' % (out_filename, output_format))
output_format = "Animated GIF"
# app.log('%s exported (%s)' % (out_filename, output_format))
def export_still_image(app, art, out_filename, crt=True, scale=1, bg_color=None):
@ -124,20 +142,20 @@ def export_still_image(app, art, out_filename, crt=True, scale=1, bg_color=None)
src_img = get_frame_image(app, art, art.active_frame, crt, scale, bg_color)
if not src_img:
return False
src_img.save(out_filename, 'PNG')
output_format = '32-bit w/ alpha'
src_img.save(out_filename, "PNG")
output_format = "32-bit w/ alpha"
else:
# else convert to current palette.
# as with aniGIF export, use arbitrary color for transparency
i_transp = art.palette.get_random_non_palette_color()
f_transp = (i_transp[0]/255, i_transp[1]/255, i_transp[2]/255, 1.)
f_transp = (i_transp[0] / 255, i_transp[1] / 255, i_transp[2] / 255, 1.0)
src_img = get_frame_image(app, art, art.active_frame, False, scale, f_transp)
if not src_img:
return False
output_img = art.palette.get_palettized_image(src_img, i_transp[:3])
output_img.save(out_filename, 'PNG', transparency=0)
output_format = '8-bit palettized w/ transparency'
#app.log('%s exported (%s)' % (out_filename, output_format))
output_img.save(out_filename, "PNG", transparency=0)
output_format = "8-bit palettized w/ transparency"
# app.log('%s exported (%s)' % (out_filename, output_format))
return True
@ -153,6 +171,6 @@ def write_thumbnail(app, art_filename, thumb_filename):
art.renderables.append(renderable)
img = get_frame_image(app, art, 0, allow_crt=False)
if img:
img.save(thumb_filename, 'PNG')
img.save(thumb_filename, "PNG")
if renderable:
renderable.destroy()

View file

@ -1,26 +1,74 @@
import ctypes, os, platform
import sdl2
import ctypes
import os
import platform
from sys import exit
from ui import SCALE_INCREMENT, OIS_WIDTH, OIS_HEIGHT, OIS_FILL
from renderable import LAYER_VIS_FULL, LAYER_VIS_DIM, LAYER_VIS_NONE
from ui_art_dialog import NewArtDialog, SaveAsDialog, QuitUnsavedChangesDialog, CloseUnsavedChangesDialog, RevertChangesDialog, ResizeArtDialog, AddFrameDialog, DuplicateFrameDialog, FrameDelayDialog, FrameDelayAllDialog, FrameIndexDialog, AddLayerDialog, DuplicateLayerDialog, SetLayerNameDialog, SetLayerZDialog, PaletteFromFileDialog, ImportFileDialog, ExportFileDialog, SetCameraZoomDialog, ExportOptionsDialog, OverlayImageOpacityDialog
from ui_game_dialog import NewGameDirDialog, LoadGameStateDialog, SaveGameStateDialog, AddRoomDialog, SetRoomCamDialog, SetRoomEdgeWarpsDialog, SetRoomBoundsObjDialog, RenameRoomDialog
from ui_info_dialog import PagedInfoDialog
from ui_file_chooser_dialog import ArtChooserDialog, CharSetChooserDialog, PaletteChooserDialog, PaletteFromImageChooserDialog, RunArtScriptDialog, OverlayImageFileChooserDialog
from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, LO_SET_ROOM_CAMERA
from collision import CT_NONE
from art import ART_DIR, ART_FILE_EXTENSION
from key_shifts import NUMLOCK_ON_MAP, NUMLOCK_OFF_MAP
import sdl2
BINDS_FILENAME = 'binds.cfg'
BINDS_TEMPLATE_FILENAME = 'binds.cfg.default'
from art import ART_DIR, ART_FILE_EXTENSION
from collision import CT_NONE
from key_shifts import NUMLOCK_OFF_MAP, NUMLOCK_ON_MAP
from renderable import LAYER_VIS_DIM, LAYER_VIS_FULL, LAYER_VIS_NONE
from ui import OIS_FILL, OIS_HEIGHT, OIS_WIDTH, SCALE_INCREMENT
from ui_art_dialog import (
AddFrameDialog,
AddLayerDialog,
CloseUnsavedChangesDialog,
DuplicateFrameDialog,
DuplicateLayerDialog,
ExportFileDialog,
ExportOptionsDialog,
FrameDelayAllDialog,
FrameDelayDialog,
FrameIndexDialog,
ImportFileDialog,
NewArtDialog,
OverlayImageOpacityDialog,
QuitUnsavedChangesDialog,
ResizeArtDialog,
RevertChangesDialog,
SaveAsDialog,
SetCameraZoomDialog,
SetLayerNameDialog,
SetLayerZDialog,
)
from ui_file_chooser_dialog import (
ArtChooserDialog,
CharSetChooserDialog,
OverlayImageFileChooserDialog,
PaletteChooserDialog,
PaletteFromImageChooserDialog,
RunArtScriptDialog,
)
from ui_game_dialog import (
AddRoomDialog,
NewGameDirDialog,
RenameRoomDialog,
SaveGameStateDialog,
SetRoomBoundsObjDialog,
SetRoomCamDialog,
SetRoomEdgeWarpsDialog,
)
from ui_list_operations import (
LO_LOAD_STATE,
LO_OPEN_GAME_DIR,
LO_SELECT_OBJECTS,
LO_SET_OBJECT_ROOMS,
LO_SET_ROOM,
LO_SET_ROOM_CAMERA,
LO_SET_ROOM_EDGE_OBJ,
LO_SET_ROOM_EDGE_WARP,
LO_SET_ROOM_OBJECTS,
LO_SET_SPAWN_CLASS,
)
BINDS_FILENAME = "binds.cfg"
BINDS_TEMPLATE_FILENAME = "binds.cfg.default"
class InputLord:
"sets up key binds and handles input"
wheel_zoom_amount = 3.0
keyboard_zoom_amount = 1.0
@ -36,19 +84,21 @@ class InputLord:
# TODO: better solution is find any binds in template but not binds.cfg
# and add em
binds_filename = self.app.config_dir + BINDS_FILENAME
binds_outdated = not os.path.exists(binds_filename) or os.path.getmtime(binds_filename) < os.path.getmtime(BINDS_TEMPLATE_FILENAME)
binds_outdated = not os.path.exists(binds_filename) or os.path.getmtime(
binds_filename
) < os.path.getmtime(BINDS_TEMPLATE_FILENAME)
if not binds_outdated and os.path.exists(binds_filename):
exec(open(binds_filename).read())
self.app.log('Loaded key binds from %s' % binds_filename)
self.app.log("Loaded key binds from %s" % binds_filename)
else:
default_data = open(BINDS_TEMPLATE_FILENAME).readlines()[1:]
new_binds = open(binds_filename, 'w')
new_binds = open(binds_filename, "w")
new_binds.writelines(default_data)
new_binds.close()
self.app.log('Created new key binds file %s' % binds_filename)
exec(''.join(default_data))
self.app.log("Created new key binds file %s" % binds_filename)
exec("".join(default_data))
if not self.edit_bind_src:
self.app.log('No bind data found, Is binds.cfg.default present?')
self.app.log("No bind data found, Is binds.cfg.default present?")
exit()
# associate key + mod combos with methods
self.edit_binds = {}
@ -59,9 +109,9 @@ class InputLord:
# bind data could be a single item (string) or a list/tuple
bind_data = self.edit_bind_src[bind_string]
if type(bind_data) is str:
bind_fnames = ['BIND_%s' % bind_data]
bind_fnames = ["BIND_%s" % bind_data]
else:
bind_fnames = ['BIND_%s' % s for s in bind_data]
bind_fnames = ["BIND_%s" % s for s in bind_data]
bind_functions = []
for bind_fname in bind_fnames:
if not hasattr(self, bind_fname):
@ -72,19 +122,24 @@ class InputLord:
# TODO: use kewl SDL2 gamepad system
js_init = sdl2.SDL_InitSubSystem(sdl2.SDL_INIT_JOYSTICK)
if js_init != 0:
self.app.log("SDL2: Couldn't initialize joystick subsystem, code %s" % js_init)
self.app.log(
"SDL2: Couldn't initialize joystick subsystem, code %s" % js_init
)
return
sticks = sdl2.SDL_NumJoysticks()
#self.app.log('%s gamepads found' % sticks)
# self.app.log('%s gamepads found' % sticks)
self.gamepad = None
self.gamepad_left_x, self.gamepad_left_y = 0, 0
# for now, just grab first pad
if sticks > 0:
pad = sdl2.SDL_JoystickOpen(0)
pad_name = sdl2.SDL_JoystickName(pad).decode('utf-8')
pad_name = sdl2.SDL_JoystickName(pad).decode("utf-8")
pad_axes = sdl2.SDL_JoystickNumAxes(pad)
pad_buttons = sdl2.SDL_JoystickNumButtons(pad)
self.app.log('Gamepad found: %s with %s axes, %s buttons' % (pad_name, pad_axes, pad_buttons))
self.app.log(
"Gamepad found: %s with %s axes, %s buttons"
% (pad_name, pad_axes, pad_buttons)
)
self.gamepad = pad
# before main loop begins, set initial mouse position -
# SDL_GetMouseState returns 0,0 if the mouse hasn't yet moved
@ -107,11 +162,11 @@ class InputLord:
ctrl = False
key = None
for i in in_string.split():
if i.lower() == 'shift':
if i.lower() == "shift":
shift = True
elif i.lower() == 'alt':
elif i.lower() == "alt":
alt = True
elif i.lower() == 'ctrl':
elif i.lower() == "ctrl":
ctrl = True
else:
key = i
@ -138,7 +193,7 @@ class InputLord:
for bind in self.edit_bind_src:
if command_function == self.edit_bind_src[bind]:
return bind
return ''
return ""
def get_menu_items_for_command_function(self, function):
# search both menus for items; command checks
@ -146,10 +201,10 @@ class InputLord:
items = []
for button in buttons:
# skip eg playscii button
if not hasattr(button, 'menu_data'):
if not hasattr(button, "menu_data"):
continue
for item in button.menu_data.items:
if function.__name__ == 'BIND_%s' % item.command:
if function.__name__ == "BIND_%s" % item.command:
items.append(item)
return items
@ -208,7 +263,9 @@ class InputLord:
ms = sdl2.SDL_GetModState()
self.capslock_on = bool(ms & sdl2.KMOD_CAPS)
# macOS: treat command as interchangeable with control, is this kosher?
if platform.system() == 'Darwin' and (ks[sdl2.SDL_SCANCODE_LGUI] or ks[sdl2.SDL_SCANCODE_RGUI]):
if platform.system() == "Darwin" and (
ks[sdl2.SDL_SCANCODE_LGUI] or ks[sdl2.SDL_SCANCODE_RGUI]
):
self.ctrl_pressed = True
if app.capslock_is_ctrl and ks[sdl2.SDL_SCANCODE_CAPSLOCK]:
self.ctrl_pressed = True
@ -216,8 +273,14 @@ class InputLord:
mods = self.shift_pressed, self.alt_pressed, self.ctrl_pressed
# get controller state
if self.gamepad:
self.gamepad_left_x = sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTX) / 32768
self.gamepad_left_y = sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTY) / -32768
self.gamepad_left_x = (
sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTX)
/ 32768
)
self.gamepad_left_y = (
sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTY)
/ -32768
)
for event in sdl2.ext.get_events():
if event.type == sdl2.SDL_QUIT:
app.should_quit = True
@ -239,13 +302,19 @@ class InputLord:
if self.ui.console.visible:
self.ui.console.handle_input(keysym, *mods)
# same with dialog box
elif self.ui.active_dialog and self.ui.active_dialog is self.ui.keyboard_focus_element:
elif (
self.ui.active_dialog
and self.ui.active_dialog is self.ui.keyboard_focus_element
):
self.ui.active_dialog.handle_input(keysym, *mods)
# bail, process no further input
#sdl2.SDL_PumpEvents()
#return
# sdl2.SDL_PumpEvents()
# return
# handle text input if text tool is active
elif self.ui.selected_tool is self.ui.text_tool and self.ui.text_tool.input_active:
elif (
self.ui.selected_tool is self.ui.text_tool
and self.ui.text_tool.input_active
):
self.ui.text_tool.handle_keyboard_input(keysym, *mods)
# see if there's a function for this bind and run it
else:
@ -272,7 +341,11 @@ class InputLord:
# keyup shouldn't have any special meaning in a dialog
pass
elif self.BIND_game_grab in flist:
if self.app.game_mode and not self.ui.active_dialog and self.app.gw.player:
if (
self.app.game_mode
and not self.ui.active_dialog
and self.app.gw.player
):
self.app.gw.player.button_unpressed(0)
return
elif self.BIND_toggle_picker in flist:
@ -281,7 +354,10 @@ class InputLord:
self.ui.popup.hide()
elif self.BIND_select_or_paint in flist:
app.keyboard_editing = True
if not self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active:
if (
self.ui.selected_tool is not self.ui.text_tool
and not self.ui.text_tool.input_active
):
self.app.cursor.finish_paint()
#
# mouse events aren't handled by bind table for now
@ -292,8 +368,9 @@ class InputLord:
if self.app.can_edit:
if event.wheel.y > 0:
# only zoom in should track towards cursor
app.camera.zoom(-self.wheel_zoom_amount,
towards_cursor=True)
app.camera.zoom(
-self.wheel_zoom_amount, towards_cursor=True
)
elif event.wheel.y < 0:
app.camera.zoom(self.wheel_zoom_amount)
else:
@ -308,14 +385,26 @@ class InputLord:
self.app.gw.unclicked(event.button.button)
# LMB up: finish paint for most tools, end select drag
if event.button.button == sdl2.SDL_BUTTON_LEFT:
if self.ui.selected_tool is self.ui.select_tool and self.ui.select_tool.selection_in_progress:
self.ui.select_tool.finish_select(self.shift_pressed, self.ctrl_pressed)
elif not self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active:
if (
self.ui.selected_tool is self.ui.select_tool
and self.ui.select_tool.selection_in_progress
):
self.ui.select_tool.finish_select(
self.shift_pressed, self.ctrl_pressed
)
elif (
self.ui.selected_tool is not self.ui.text_tool
and not self.ui.text_tool.input_active
):
app.cursor.finish_paint()
elif event.type == sdl2.SDL_MOUSEBUTTONDOWN:
ui_clicked = self.ui.clicked(event.button.button)
# don't register edit commands if a menu is up
if ui_clicked or self.ui.menu_bar.active_menu_name or self.ui.active_dialog:
if (
ui_clicked
or self.ui.menu_bar.active_menu_name
or self.ui.active_dialog
):
sdl2.SDL_PumpEvents()
if self.app.game_mode:
self.app.gw.last_click_on_ui = True
@ -330,16 +419,21 @@ class InputLord:
return
elif self.ui.selected_tool is self.ui.text_tool:
# text tool: only start entry if click is outside popup
if not self.ui.text_tool.input_active and \
not self.ui.popup in self.ui.hovered_elements:
if (
not self.ui.text_tool.input_active
and self.ui.popup not in self.ui.hovered_elements
):
self.ui.text_tool.start_entry()
elif self.ui.selected_tool is self.ui.select_tool:
# select tool: accept clicks if they're outside the popup
if not self.ui.select_tool.selection_in_progress and \
(not self.ui.keyboard_focus_element or \
(self.ui.keyboard_focus_element is self.ui.popup and \
not self.ui.popup in self.ui.hovered_elements)):
self.ui.select_tool.start_select()
if not self.ui.select_tool.selection_in_progress and (
not self.ui.keyboard_focus_element
or (
self.ui.keyboard_focus_element is self.ui.popup
and self.ui.popup not in self.ui.hovered_elements
)
):
self.ui.select_tool.start_select()
else:
app.cursor.start_paint()
elif event.button.button == sdl2.SDL_BUTTON_RIGHT:
@ -349,20 +443,49 @@ class InputLord:
if self.ui.active_dialog:
sdl2.SDL_PumpEvents()
return
# directly query keys we don't want affected by OS key repeat delay
# TODO: these are hard-coded for the moment, think of a good way
# to expose this functionality to the key bind system
def pressing_up(ks):
return ks[sdl2.SDL_SCANCODE_W] or ks[sdl2.SDL_SCANCODE_UP] or ks[sdl2.SDL_SCANCODE_KP_8]
return (
ks[sdl2.SDL_SCANCODE_W]
or ks[sdl2.SDL_SCANCODE_UP]
or ks[sdl2.SDL_SCANCODE_KP_8]
)
def pressing_down(ks):
return ks[sdl2.SDL_SCANCODE_S] or ks[sdl2.SDL_SCANCODE_DOWN] or ks[sdl2.SDL_SCANCODE_KP_2]
return (
ks[sdl2.SDL_SCANCODE_S]
or ks[sdl2.SDL_SCANCODE_DOWN]
or ks[sdl2.SDL_SCANCODE_KP_2]
)
def pressing_left(ks):
return ks[sdl2.SDL_SCANCODE_A] or ks[sdl2.SDL_SCANCODE_LEFT] or ks[sdl2.SDL_SCANCODE_KP_4]
return (
ks[sdl2.SDL_SCANCODE_A]
or ks[sdl2.SDL_SCANCODE_LEFT]
or ks[sdl2.SDL_SCANCODE_KP_4]
)
def pressing_right(ks):
return ks[sdl2.SDL_SCANCODE_D] or ks[sdl2.SDL_SCANCODE_RIGHT] or ks[sdl2.SDL_SCANCODE_KP_6]
return (
ks[sdl2.SDL_SCANCODE_D]
or ks[sdl2.SDL_SCANCODE_RIGHT]
or ks[sdl2.SDL_SCANCODE_KP_6]
)
# prevent camera move if: console is up, text input is active, editing
# is not allowed
if self.shift_pressed and not self.alt_pressed and not self.ctrl_pressed and not self.ui.console.visible and not self.ui.text_tool.input_active and self.app.can_edit and self.ui.keyboard_focus_element is None:
if (
self.shift_pressed
and not self.alt_pressed
and not self.ctrl_pressed
and not self.ui.console.visible
and not self.ui.text_tool.input_active
and self.app.can_edit
and self.ui.keyboard_focus_element is None
):
if pressing_up(ks):
app.camera.pan(0, 1, True)
if pressing_down(ks):
@ -372,14 +495,24 @@ class InputLord:
if pressing_right(ks):
app.camera.pan(1, 0, True)
if ks[sdl2.SDL_SCANCODE_X]:
app.camera.zoom(-self.keyboard_zoom_amount, keyboard=True,
towards_cursor=True)
app.camera.zoom(
-self.keyboard_zoom_amount, keyboard=True, towards_cursor=True
)
if ks[sdl2.SDL_SCANCODE_Z]:
app.camera.zoom(self.keyboard_zoom_amount, keyboard=True)
if self.app.can_edit and app.middle_mouse and (app.mouse_dx != 0 or app.mouse_dy != 0):
if (
self.app.can_edit
and app.middle_mouse
and (app.mouse_dx != 0 or app.mouse_dy != 0)
):
app.camera.mouse_pan(app.mouse_dx, app.mouse_dy)
# game mode: arrow keys and left gamepad stick move player
if self.app.game_mode and not self.ui.console.visible and not self.ui.active_dialog and self.ui.keyboard_focus_element is None:
if (
self.app.game_mode
and not self.ui.console.visible
and not self.ui.active_dialog
and self.ui.keyboard_focus_element is None
):
if pressing_up(ks):
# shift = move selected
if self.shift_pressed and self.app.can_edit:
@ -409,7 +542,7 @@ class InputLord:
def is_key_pressed(self, key):
"returns True if given key is pressed"
key = bytes(key, encoding='utf-8')
key = bytes(key, encoding="utf-8")
scancode = sdl2.keyboard.SDL_GetScancodeFromName(key)
return sdl2.SDL_GetKeyboardState(None)[scancode]
@ -443,8 +576,9 @@ class InputLord:
out_filename = self.ui.active_art.filename
out_filename = os.path.basename(out_filename)
out_filename = os.path.splitext(out_filename)[0]
ExportOptionsDialog.do_export(self.ui.app, out_filename,
self.ui.app.last_export_options)
ExportOptionsDialog.do_export(
self.ui.app, out_filename, self.ui.app.last_export_options
)
else:
self.ui.open_dialog(ExportFileDialog)
@ -469,22 +603,22 @@ class InputLord:
self.ui.menu_bar.refresh_active_menu()
def BIND_cycle_char_forward(self):
self.ui.select_char(self.ui.selected_char+1)
self.ui.select_char(self.ui.selected_char + 1)
def BIND_cycle_char_backward(self):
self.ui.select_char(self.ui.selected_char-1)
self.ui.select_char(self.ui.selected_char - 1)
def BIND_cycle_fg_forward(self):
self.ui.select_fg(self.ui.selected_fg_color+1)
self.ui.select_fg(self.ui.selected_fg_color + 1)
def BIND_cycle_fg_backward(self):
self.ui.select_fg(self.ui.selected_fg_color-1)
self.ui.select_fg(self.ui.selected_fg_color - 1)
def BIND_cycle_bg_forward(self):
self.ui.select_bg(self.ui.selected_bg_color+1)
self.ui.select_bg(self.ui.selected_bg_color + 1)
def BIND_cycle_bg_backward(self):
self.ui.select_bg(self.ui.selected_bg_color-1)
self.ui.select_bg(self.ui.selected_bg_color - 1)
def BIND_cycle_xform_forward(self):
self.ui.cycle_selected_xform()
@ -657,7 +791,7 @@ class InputLord:
self.app.gw.save_last_state()
elif self.ui.active_art:
# if new document, ask for a name
default_name = ART_DIR + 'new.' + ART_FILE_EXTENSION
default_name = ART_DIR + "new." + ART_FILE_EXTENSION
if self.ui.active_art.filename == default_name:
self.ui.open_dialog(SaveAsDialog)
else:
@ -742,10 +876,10 @@ class InputLord:
def BIND_toggle_camera_tilt(self):
if self.app.camera.y_tilt == 2:
self.app.camera.y_tilt = 0
self.ui.message_line.post_line('Camera tilt disengaged.')
self.ui.message_line.post_line("Camera tilt disengaged.")
else:
self.app.camera.y_tilt = 2
self.ui.message_line.post_line('Camera tilt engaged.')
self.ui.message_line.post_line("Camera tilt engaged.")
self.ui.menu_bar.refresh_active_menu()
def BIND_select_overlay_image(self):
@ -760,9 +894,9 @@ class InputLord:
def BIND_set_overlay_image_scaling(self):
if self.app.overlay_scale_type == OIS_WIDTH:
self.app.overlay_scale_type = OIS_HEIGHT
self.app.overlay_scale_type = OIS_HEIGHT
elif self.app.overlay_scale_type == OIS_HEIGHT:
self.app.overlay_scale_type = OIS_FILL
self.app.overlay_scale_type = OIS_FILL
elif self.app.overlay_scale_type == OIS_FILL:
self.app.overlay_scale_type = OIS_WIDTH
self.ui.size_and_position_overlay_image()
@ -791,7 +925,10 @@ class InputLord:
return
if not self.ui.active_art:
return
elif self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active:
elif (
self.ui.selected_tool is self.ui.text_tool
and not self.ui.text_tool.input_active
):
self.ui.text_tool.start_entry()
elif self.ui.selected_tool is self.ui.select_tool:
if self.ui.select_tool.selection_in_progress:
@ -806,10 +943,10 @@ class InputLord:
self.app.screenshot()
def BIND_run_test_mutate(self):
if self.ui.active_art.is_script_running('conway'):
self.ui.active_art.stop_script('conway')
if self.ui.active_art.is_script_running("conway"):
self.ui.active_art.stop_script("conway")
else:
self.ui.active_art.run_script_every('conway', 0.05)
self.ui.active_art.run_script_every("conway", 0.05)
def BIND_arrow_up(self):
if self.ui.keyboard_focus_element:
@ -841,60 +978,60 @@ class InputLord:
return
if self.ui.active_art.layers == 1:
return
message_text = 'Non-active layers: '
message_text = "Non-active layers: "
if self.app.inactive_layer_visibility == LAYER_VIS_FULL:
self.app.inactive_layer_visibility = LAYER_VIS_DIM
message_text += 'dim'
message_text += "dim"
elif self.app.inactive_layer_visibility == LAYER_VIS_DIM:
self.app.inactive_layer_visibility = LAYER_VIS_NONE
message_text += 'invisible'
message_text += "invisible"
else:
self.app.inactive_layer_visibility = LAYER_VIS_FULL
message_text += 'visible'
message_text += "visible"
self.ui.message_line.post_line(message_text)
self.ui.menu_bar.refresh_active_menu()
def BIND_open_file_menu(self):
self.ui.menu_bar.open_menu_by_name('file')
self.ui.menu_bar.open_menu_by_name("file")
def BIND_open_edit_menu(self):
self.ui.menu_bar.open_menu_by_name('edit')
self.ui.menu_bar.open_menu_by_name("edit")
def BIND_open_tool_menu(self):
self.ui.menu_bar.open_menu_by_name('tool')
self.ui.menu_bar.open_menu_by_name("tool")
def BIND_open_view_menu(self):
self.ui.menu_bar.open_menu_by_name('view')
self.ui.menu_bar.open_menu_by_name("view")
def BIND_open_art_menu(self):
self.ui.menu_bar.open_menu_by_name('art')
self.ui.menu_bar.open_menu_by_name("art")
def BIND_open_frame_menu(self):
if self.app.game_mode:
self.ui.menu_bar.open_menu_by_name('room')
self.ui.menu_bar.open_menu_by_name("room")
else:
self.ui.menu_bar.open_menu_by_name('frame')
self.ui.menu_bar.open_menu_by_name("frame")
def BIND_open_layer_menu(self):
self.ui.menu_bar.open_menu_by_name('layer')
self.ui.menu_bar.open_menu_by_name("layer")
def BIND_open_char_color_menu(self):
self.ui.menu_bar.open_menu_by_name('char_color')
self.ui.menu_bar.open_menu_by_name("char_color")
def BIND_open_help_menu(self):
self.ui.menu_bar.open_menu_by_name('help')
self.ui.menu_bar.open_menu_by_name("help")
def BIND_open_game_menu(self):
self.ui.menu_bar.open_menu_by_name('game')
self.ui.menu_bar.open_menu_by_name("game")
def BIND_open_state_menu(self):
self.ui.menu_bar.open_menu_by_name('state')
self.ui.menu_bar.open_menu_by_name("state")
def BIND_open_world_menu(self):
self.ui.menu_bar.open_menu_by_name('world')
self.ui.menu_bar.open_menu_by_name("world")
def BIND_open_object_menu(self):
self.ui.menu_bar.open_menu_by_name('object')
self.ui.menu_bar.open_menu_by_name("object")
def BIND_new_art(self):
self.ui.open_dialog(NewArtDialog)
@ -943,12 +1080,14 @@ class InputLord:
self.ui.open_dialog(ResizeArtDialog)
def BIND_art_flip_horizontal(self):
self.ui.active_art.flip_horizontal(self.ui.active_art.active_frame,
self.ui.active_art.active_layer)
self.ui.active_art.flip_horizontal(
self.ui.active_art.active_frame, self.ui.active_art.active_layer
)
def BIND_art_flip_vertical(self):
self.ui.active_art.flip_vertical(self.ui.active_art.active_frame,
self.ui.active_art.active_layer)
self.ui.active_art.flip_vertical(
self.ui.active_art.active_frame, self.ui.active_art.active_layer
)
def BIND_art_toggle_flip_affects_xforms(self):
self.ui.flip_affects_xforms = not self.ui.flip_affects_xforms
@ -1085,10 +1224,10 @@ class InputLord:
for obj in self.app.gw.selected_objects:
if obj.orig_collision_type and obj.collision_type == CT_NONE:
obj.enable_collision()
self.ui.message_line.post_line('Collision enabled for %s' % obj.name)
self.ui.message_line.post_line("Collision enabled for %s" % obj.name)
elif obj.collision_type != CT_NONE:
obj.disable_collision()
self.ui.message_line.post_line('Collision disabled for %s' % obj.name)
self.ui.message_line.post_line("Collision disabled for %s" % obj.name)
def BIND_toggle_game_edit_ui(self):
self.ui.toggle_game_edit_ui()
@ -1097,7 +1236,12 @@ class InputLord:
# game mode binds
#
def accept_normal_game_input(self):
return self.app.game_mode and self.app.gw.player and not self.ui.active_dialog and not self.ui.pulldown.visible
return (
self.app.game_mode
and self.app.gw.player
and not self.ui.active_dialog
and not self.ui.pulldown.visible
)
# TODO: generalize these two somehow
def BIND_game_frob(self):
@ -1153,7 +1297,9 @@ class InputLord:
self.ui.menu_bar.refresh_active_menu()
def BIND_toggle_room_camera_changes(self):
self.app.gw.properties.set_object_property('room_camera_changes_enabled', not self.app.gw.room_camera_changes_enabled)
self.app.gw.properties.set_object_property(
"room_camera_changes_enabled", not self.app.gw.room_camera_changes_enabled
)
self.ui.menu_bar.refresh_active_menu()
def BIND_set_room_camera_marker(self):
@ -1199,7 +1345,9 @@ class InputLord:
self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM_EDGE_OBJ)
def BIND_toggle_list_only_room_objects(self):
self.app.gw.list_only_current_room_objects = not self.app.gw.list_only_current_room_objects
self.app.gw.list_only_current_room_objects = (
not self.app.gw.list_only_current_room_objects
)
self.ui.menu_bar.refresh_active_menu()
def BIND_rename_current_room(self):
@ -1208,5 +1356,7 @@ class InputLord:
def BIND_toggle_debug_objects(self):
if not self.app.gw.properties:
return
self.app.gw.properties.set_object_property('draw_debug_objects', not self.app.gw.draw_debug_objects)
self.app.gw.properties.set_object_property(
"draw_debug_objects", not self.app.gw.draw_debug_objects
)
self.ui.menu_bar.refresh_active_menu()

View file

@ -5,9 +5,27 @@ import sdl2
# MAYBE-TODO: find out if this breaks for non-US english KB layouts
SHIFT_MAP = {
'1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*',
'9': '(', '0': ')', '-': '_', '=': '+', '`': '~', '[': '{', ']': '}', '\\': '|',
';': ':', "'": '"', ',': '<', '.': '>', '/': '?'
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
"`": "~",
"[": "{",
"]": "}",
"\\": "|",
";": ":",
"'": '"',
",": "<",
".": ">",
"/": "?",
}
NUMLOCK_ON_MAP = {
@ -26,7 +44,7 @@ NUMLOCK_ON_MAP = {
sdl2.SDLK_KP_PLUS: sdl2.SDLK_PLUS,
sdl2.SDLK_KP_MINUS: sdl2.SDLK_MINUS,
sdl2.SDLK_KP_PERIOD: sdl2.SDLK_PERIOD,
sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN
sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN,
}
NUMLOCK_OFF_MAP = {
@ -40,5 +58,5 @@ NUMLOCK_OFF_MAP = {
sdl2.SDLK_KP_8: sdl2.SDLK_UP,
sdl2.SDLK_KP_9: sdl2.SDLK_PAGEUP,
sdl2.SDLK_KP_PERIOD: sdl2.SDLK_DELETE,
sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN
sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN,
}

View file

@ -3,20 +3,21 @@
import math
def rgb_to_xyz(r, g, b):
r /= 255.0
g /= 255.0
b /= 255.0
if r > 0.04045:
r = ((r + 0.055) / 1.055)**2.4
r = ((r + 0.055) / 1.055) ** 2.4
else:
r /= 12.92
if g > 0.04045:
g = ((g + 0.055) / 1.055)**2.4
g = ((g + 0.055) / 1.055) ** 2.4
else:
g /= 12.92
if b > 0.04045:
b = ((b + 0.055) / 1.055)**2.4
b = ((b + 0.055) / 1.055) ** 2.4
else:
b /= 12.92
r *= 100
@ -28,21 +29,22 @@ def rgb_to_xyz(r, g, b):
z = r * 0.0193 + g * 0.1192 + b * 0.9505
return x, y, z
def xyz_to_lab(x, y, z):
# observer: 2deg, illuminant: D65
x /= 95.047
y /= 100.0
z /= 108.883
if x > 0.008856:
x = x**(1.0/3)
x = x ** (1.0 / 3)
else:
x = (7.787 * x) + (16.0 / 116)
if y > 0.008856:
y = y**(1.0/3)
y = y ** (1.0 / 3)
else:
y = (7.787 * y) + (16.0 / 116)
if z > 0.008856:
z = z**(1.0/3)
z = z ** (1.0 / 3)
else:
z = (7.787 * z) + (16.0 / 116)
l = (116 * y) - 16
@ -50,13 +52,15 @@ def xyz_to_lab(x, y, z):
b = 200 * (y - z)
return l, a, b
def rgb_to_lab(r, g, b):
x, y, z = rgb_to_xyz(r, g, b)
return xyz_to_lab(x, y, z)
def lab_color_diff(l1, a1, b1, l2, a2, b2):
"quick n' dirty CIE 1976 color delta"
dl = (l1 - l2)**2
da = (a1 - a2)**2
db = (b1 - b2)**2
dl = (l1 - l2) ** 2
da = (a1 - a2) ** 2
db = (b1 - b2) ** 2
return math.sqrt(dl + da + db)

View file

@ -1,16 +1,19 @@
import os.path, math, time
import math
import os.path
import time
from random import randint
from PIL import Image
from lab_color import lab_color_diff, rgb_to_lab
from texture import Texture
from lab_color import rgb_to_lab, lab_color_diff
PALETTE_DIR = 'palettes/'
PALETTE_EXTENSIONS = ['png', 'gif', 'bmp']
PALETTE_DIR = "palettes/"
PALETTE_EXTENSIONS = ["png", "gif", "bmp"]
MAX_COLORS = 1024
class PaletteLord:
class PaletteLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
@ -19,7 +22,10 @@ class PaletteLord:
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
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
@ -28,18 +34,20 @@ class PaletteLord:
changed = palette.filename
try:
palette.load_image()
self.app.log('PaletteLord: success reloading %s' % palette.filename)
self.app.log("PaletteLord: success reloading %s" % palette.filename)
except:
self.app.log('PaletteLord: failed reloading %s' % palette.filename, True)
self.app.log(
"PaletteLord: failed reloading %s" % palette.filename, True
)
class Palette:
def __init__(self, app, src_filename, log):
self.init_success = False
self.app = app
self.filename = self.app.find_filename_path(src_filename, PALETTE_DIR,
PALETTE_EXTENSIONS)
self.filename = self.app.find_filename_path(
src_filename, PALETTE_DIR, PALETTE_EXTENSIONS
)
if self.filename is None:
self.app.log("Couldn't find palette image %s" % src_filename)
return
@ -50,15 +58,15 @@ class Palette:
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
if log and not self.app.game_mode:
self.app.log("loaded palette '%s' from %s:" % (self.name, self.filename))
self.app.log(' unique colors found: %s' % int(len(self.colors)-1))
self.app.log(' darkest color index: %s' % self.darkest_index)
self.app.log(' lightest color index: %s' % self.lightest_index)
self.app.log(" unique colors found: %s" % int(len(self.colors) - 1))
self.app.log(" darkest color index: %s" % self.darkest_index)
self.app.log(" lightest color index: %s" % self.lightest_index)
self.init_success = True
def load_image(self):
"loads palette data from the given bitmap image"
src_img = Image.open(self.filename)
src_img = src_img.convert('RGBA')
src_img = src_img.convert("RGBA")
width, height = src_img.size
# store texture for chooser preview etc
self.src_texture = Texture(src_img.tobytes(), width, height)
@ -75,10 +83,10 @@ class Palette:
if len(self.colors) >= MAX_COLORS:
break
color = src_img.getpixel((x, y))
if not color in self.colors:
if color not in self.colors:
self.colors.append(color)
# is this lightest/darkest unique color so far? save index
luminosity = color[0]*0.21 + color[1]*0.72 + color[2]*0.07
luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07
if luminosity < darkest:
darkest = luminosity
self.darkest_index = len(self.colors) - 1
@ -86,13 +94,13 @@ class Palette:
lightest = luminosity
self.lightest_index = len(self.colors) - 1
# create new 1D image with unique colors
img = Image.new('RGBA', (MAX_COLORS, 1), (0, 0, 0, 0))
img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0))
x = 0
for color in self.colors:
img.putpixel((x, 0), color)
x += 1
# debug: save out generated palette texture
#img.save('palette.png')
# img.save('palette.png')
self.texture = Texture(img.tobytes(), MAX_COLORS, 1)
def has_updated(self):
@ -106,7 +114,7 @@ class Palette:
width = min(16, len(self.colors) - 1)
height = math.floor((len(self.colors) - 1) / width)
# new PIL image, blank (0 alpha) pixels
img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
# set each pixel from color list (minus first, transparent color)
color_index = 1
for y in range(height):
@ -122,10 +130,11 @@ class Palette:
block_size = 8
# scale up
width, height = img.size
img = img.resize((width * block_size, height * block_size),
resample=Image.NEAREST)
img = img.resize(
(width * block_size, height * block_size), resample=Image.NEAREST
)
# write to file
img_filename = self.app.documents_dir + PALETTE_DIR + self.name + '.png'
img_filename = self.app.documents_dir + PALETTE_DIR + self.name + ".png"
img.save(img_filename)
def all_colors_opaque(self):
@ -137,23 +146,26 @@ class Palette:
def get_random_non_palette_color(self):
"returns random color not in this palette, eg for 8-bit transparency"
def rand_byte():
return randint(0, 255)
# assume full alpha
r, g, b, a = rand_byte(), rand_byte(), rand_byte(), 255
while (r, g, b, a) in self.colors:
r, g, b = rand_byte(), rand_byte(), rand_byte()
return r, g, b, a
def get_palettized_image(self, src_img, transparent_color=(0, 0, 0),
force_no_transparency=False):
def get_palettized_image(
self, src_img, transparent_color=(0, 0, 0), force_no_transparency=False
):
"returns a copy of source image quantized to this palette"
pal_img = Image.new('P', (1, 1))
pal_img = Image.new("P", (1, 1))
# source must be in RGB (no alpha) format
out_img = src_img.convert('RGB')
out_img = src_img.convert("RGB")
# Image.putpalette needs a flat tuple :/
colors = []
for i,color in enumerate(self.colors):
for i, color in enumerate(self.colors):
# ignore alpha for palettized image output
for channel in color[:-1]:
colors.append(channel)
@ -165,12 +177,11 @@ class Palette:
for i in range(3):
colors.append(0)
# palette for PIL must be exactly 256 colors
colors = colors[:256*3]
colors = colors[: 256 * 3]
pal_img.putpalette(tuple(colors))
return out_img.quantize(palette=pal_img)
def are_colors_similar(self, color_index_a, palette_b, color_index_b,
tolerance=50):
def are_colors_similar(self, color_index_a, palette_b, color_index_b, tolerance=50):
"""
returns True if color index A is similar to color index B from
another palette.
@ -186,14 +197,14 @@ class Palette:
"returns index of closest color in this palette to given color (kinda slow?)"
closest_diff = 99999999999
closest_diff_index = -1
for i,color in enumerate(self.colors):
for i, color in enumerate(self.colors):
l1, a1, b1 = rgb_to_lab(r, g, b)
l2, a2, b2 = rgb_to_lab(*color[:3])
diff = lab_color_diff(l1, a1, b1, l2, a2, b2)
if diff < closest_diff:
closest_diff = diff
closest_diff_index = i
#print('%s is closest to input color %s' % (self.colors[closest_diff_index], (r, g, b)))
# print('%s is closest to input color %s' % (self.colors[closest_diff_index], (r, g, b)))
return closest_diff_index
def get_random_color_index(self):
@ -202,14 +213,13 @@ class Palette:
class PaletteFromList(Palette):
"palette created from list of 3/4-tuple base-255 colors instead of image"
def __init__(self, app, src_color_list, log):
self.init_success = False
self.app = app
# generate a unique non-user-facing palette name
name = 'PaletteFromList_%s' % time.time()
name = "PaletteFromList_%s" % time.time()
self.filename = self.name = self.base_filename = name
colors = []
for color in src_color_list:
@ -222,7 +232,7 @@ class PaletteFromList(Palette):
lightest = 0
darkest = 255 * 3 + 1
for color in self.colors:
luminosity = color[0]*0.21 + color[1]*0.72 + color[2]*0.07
luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07
if luminosity < darkest:
darkest = luminosity
self.darkest_index = len(self.colors) - 1
@ -230,7 +240,7 @@ class PaletteFromList(Palette):
lightest = luminosity
self.lightest_index = len(self.colors) - 1
# create texture
img = Image.new('RGBA', (MAX_COLORS, 1), (0, 0, 0, 0))
img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0))
x = 0
for color in self.colors:
img.putpixel((x, 0), color)
@ -238,9 +248,9 @@ class PaletteFromList(Palette):
self.texture = Texture(img.tobytes(), MAX_COLORS, 1)
if log and not self.app.game_mode:
self.app.log("generated new palette '%s'" % (self.name))
self.app.log(' unique colors: %s' % int(len(self.colors)-1))
self.app.log(' darkest color index: %s' % self.darkest_index)
self.app.log(' lightest color index: %s' % self.lightest_index)
self.app.log(" unique colors: %s" % int(len(self.colors) - 1))
self.app.log(" darkest color index: %s" % self.darkest_index)
self.app.log(" lightest color index: %s" % self.lightest_index)
def has_updated(self):
"No bitmap source for this type of palette, so no hot-reload"
@ -248,7 +258,6 @@ class PaletteFromList(Palette):
class PaletteFromFile(Palette):
def __init__(self, app, src_filename, palette_filename, colors=MAX_COLORS):
self.init_success = False
src_filename = app.find_filename_path(src_filename)
@ -258,8 +267,10 @@ class PaletteFromFile(Palette):
# dither source image, re-save it, use that as the source for a palette
src_img = Image.open(src_filename)
# method:
src_img = src_img.convert('P', None, Image.FLOYDSTEINBERG, Image.ADAPTIVE, colors)
src_img = src_img.convert('RGBA')
src_img = src_img.convert(
"P", None, Image.FLOYDSTEINBERG, Image.ADAPTIVE, colors
)
src_img = src_img.convert("RGBA")
# write converted source image with new filename
# snip path & extension if it has em
palette_filename = os.path.basename(palette_filename)
@ -267,13 +278,15 @@ class PaletteFromFile(Palette):
# get most appropriate path for palette image
palette_path = app.get_dirnames(PALETTE_DIR, False)[0]
# if new filename exists, add a number to avoid overwriting
if os.path.exists(palette_path + palette_filename + '.png'):
if os.path.exists(palette_path + palette_filename + ".png"):
i = 0
while os.path.exists('%s%s%s.png' % (palette_path, palette_filename, str(i))):
while os.path.exists(
"%s%s%s.png" % (palette_path, palette_filename, str(i))
):
i += 1
palette_filename += str(i)
# (re-)add path and PNG extension
palette_filename = palette_path + palette_filename + '.png'
palette_filename = palette_path + palette_filename + ".png"
src_img.save(palette_filename)
# create the actual palette and export it as an image
Palette.__init__(self, app, palette_filename, True)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,9 @@
import os, math, ctypes
import ctypes
import math
import numpy as np
from OpenGL import GL
from art import VERT_LENGTH
from palette import MAX_COLORS
@ -16,17 +19,18 @@ class TileRenderable:
rectangular OpenGL triangle-pairs. Animation frames are uploaded into our
buffers from source Art's numpy arrays.
"""
vert_shader_source = 'renderable_v.glsl'
vert_shader_source = "renderable_v.glsl"
"vertex shader: includes view projection matrix, XYZ camera uniforms."
frag_shader_source = 'renderable_f.glsl'
frag_shader_source = "renderable_f.glsl"
"Pixel shader: handles FG/BG colors."
log_create_destroy = False
log_animation = False
log_buffer_updates = False
grain_strength = 0.
alpha = 1.
grain_strength = 0.0
alpha = 1.0
"Alpha (0 to 1) for entire Renderable."
bg_alpha = 1.
bg_alpha = 1.0
"Alpha (0 to 1) *only* for tile background colors."
default_move_rate = 1
use_art_offset = True
@ -69,65 +73,125 @@ class TileRenderable:
if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1)
GL.glBindVertexArray(self.vao)
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.charset_width_uniform = self.shader.get_uniform_location('charMapWidth')
self.charset_height_uniform = self.shader.get_uniform_location('charMapHeight')
self.char_uv_width_uniform = self.shader.get_uniform_location('charUVWidth')
self.char_uv_height_uniform = self.shader.get_uniform_location('charUVHeight')
self.charset_tex_uniform = self.shader.get_uniform_location('charset')
self.palette_tex_uniform = self.shader.get_uniform_location('palette')
self.grain_tex_uniform = self.shader.get_uniform_location('grain')
self.palette_width_uniform = self.shader.get_uniform_location('palTextureWidth')
self.grain_strength_uniform = self.shader.get_uniform_location('grainStrength')
self.alpha_uniform = self.shader.get_uniform_location('alpha')
self.brightness_uniform = self.shader.get_uniform_location('brightness')
self.bg_alpha_uniform = self.shader.get_uniform_location('bgColorAlpha')
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.charset_width_uniform = self.shader.get_uniform_location("charMapWidth")
self.charset_height_uniform = self.shader.get_uniform_location("charMapHeight")
self.char_uv_width_uniform = self.shader.get_uniform_location("charUVWidth")
self.char_uv_height_uniform = self.shader.get_uniform_location("charUVHeight")
self.charset_tex_uniform = self.shader.get_uniform_location("charset")
self.palette_tex_uniform = self.shader.get_uniform_location("palette")
self.grain_tex_uniform = self.shader.get_uniform_location("grain")
self.palette_width_uniform = self.shader.get_uniform_location("palTextureWidth")
self.grain_strength_uniform = self.shader.get_uniform_location("grainStrength")
self.alpha_uniform = self.shader.get_uniform_location("alpha")
self.brightness_uniform = self.shader.get_uniform_location("brightness")
self.bg_alpha_uniform = self.shader.get_uniform_location("bgColorAlpha")
self.create_buffers()
# finish
if self.app.use_vao:
GL.glBindVertexArray(0)
if self.log_create_destroy:
self.app.log('created: %s' % self)
self.app.log("created: %s" % self)
def __str__(self):
"for debug purposes, return a concise unique name"
for i,r in enumerate(self.art.renderables):
for i, r in enumerate(self.art.renderables):
if r is self:
break
return '%s %s %s' % (self.art.get_simple_name(), self.__class__.__name__, i)
return "%s %s %s" % (self.art.get_simple_name(), self.__class__.__name__, i)
def create_buffers(self):
# vertex positions and elements
# determine vertex count needed for render
self.vert_count = int(len(self.art.elem_array))
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
self.update_buffer(self.vert_buffer, self.art.vert_array,
GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, 'vertPosition', VERT_LENGTH)
self.update_buffer(self.elem_buffer, self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None)
self.update_buffer(
self.vert_buffer,
self.art.vert_array,
GL.GL_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_FLOAT,
"vertPosition",
VERT_LENGTH,
)
self.update_buffer(
self.elem_buffer,
self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_UNSIGNED_INT,
None,
None,
)
# tile data buffers
# use GL_DYNAMIC_DRAW given they change every time a char/color changes
self.char_buffer, self.uv_buffer = GL.glGenBuffers(2)
# character indices (which become vertex UVs)
self.update_buffer(self.char_buffer, self.art.chars[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'charIndex', 1)
self.update_buffer(
self.char_buffer,
self.art.chars[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"charIndex",
1,
)
# UV "mods" - modify UV derived from character index
self.update_buffer(self.uv_buffer, self.art.uv_mods[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'uvMod', 2)
self.update_buffer(
self.uv_buffer,
self.art.uv_mods[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"uvMod",
2,
)
self.fg_buffer, self.bg_buffer = GL.glGenBuffers(2)
# foreground/background color indices (which become rgba colors)
self.update_buffer(self.fg_buffer, self.art.fg_colors[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'fgColorIndex', 1)
self.update_buffer(self.bg_buffer, self.art.bg_colors[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'bgColorIndex', 1)
self.update_buffer(
self.fg_buffer,
self.art.fg_colors[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"fgColorIndex",
1,
)
self.update_buffer(
self.bg_buffer,
self.art.bg_colors[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"bgColorIndex",
1,
)
def update_geo_buffers(self):
self.update_buffer(self.vert_buffer, self.art.vert_array, GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, None, None)
self.update_buffer(self.elem_buffer, self.art.elem_array, GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None)
self.update_buffer(
self.vert_buffer,
self.art.vert_array,
GL.GL_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_FLOAT,
None,
None,
)
self.update_buffer(
self.elem_buffer,
self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_UNSIGNED_INT,
None,
None,
)
# total vertex count probably changed
self.vert_count = int(len(self.art.elem_array))
@ -143,21 +207,47 @@ class TileRenderable:
if update_bg:
updates[self.bg_buffer] = self.art.bg_colors
for update in updates:
self.update_buffer(update, updates[update][self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT, None, None)
self.update_buffer(
update,
updates[update][self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
None,
None,
)
def update_buffer(self, buffer_index, array, target, buffer_type, data_type,
attrib_name, attrib_size):
def update_buffer(
self,
buffer_index,
array,
target,
buffer_type,
data_type,
attrib_name,
attrib_size,
):
if self.log_buffer_updates:
self.app.log('update_buffer: %s, %s, %s, %s, %s, %s, %s' % (buffer_index, array, target, buffer_type, data_type, attrib_name, attrib_size))
self.app.log(
"update_buffer: %s, %s, %s, %s, %s, %s, %s"
% (
buffer_index,
array,
target,
buffer_type,
data_type,
attrib_name,
attrib_size,
)
)
GL.glBindBuffer(target, buffer_index)
GL.glBufferData(target, array.nbytes, array, buffer_type)
if attrib_name:
attrib = self.shader.get_attrib_location(attrib_name)
GL.glEnableVertexAttribArray(attrib)
GL.glVertexAttribPointer(attrib, attrib_size, data_type,
GL.GL_FALSE, 0, ctypes.c_void_p(0))
GL.glVertexAttribPointer(
attrib, attrib_size, data_type, GL.GL_FALSE, 0, ctypes.c_void_p(0)
)
# unbind each buffer before binding next
GL.glBindBuffer(target, 0)
@ -177,7 +267,9 @@ class TileRenderable:
self.frame = new_frame_index % self.art.frames
self.update_tile_buffers(True, True, True, True)
if self.log_animation:
self.app.log('%s animating from frames %s to %s' % (self, old_frame, self.frame))
self.app.log(
"%s animating from frames %s to %s" % (self, old_frame, self.frame)
)
def start_animating(self):
"Start animation playback."
@ -202,7 +294,7 @@ class TileRenderable:
self.frame %= self.art.frames
self.update_geo_buffers()
self.update_tile_buffers(True, True, True, True)
#print('%s now uses Art %s' % (self, self.art.filename))
# print('%s now uses Art %s' % (self, self.art.filename))
def reset_size(self):
self.width = self.art.width * self.art.quad_width * abs(self.scale_x)
@ -220,14 +312,16 @@ class TileRenderable:
dx = x - self.x
dy = y - self.y
dz = z - self.z
dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
dist = math.sqrt(dx**2 + dy**2 + dz**2)
self.move_rate = dist / frames
else:
self.move_rate = self.default_move_rate
self.ui_moving = True
self.goal_x, self.goal_y, self.goal_z = x, y, z
if self.log_animation:
self.app.log('%s will move to %s,%s' % (self.art.filename, self.goal_x, self.goal_y))
self.app.log(
"%s will move to %s,%s" % (self.art.filename, self.goal_x, self.goal_y)
)
def snap_to(self, x, y, z):
self.x, self.y, self.z = x, y, z
@ -257,7 +351,7 @@ class TileRenderable:
dx = self.goal_x - self.x
dy = self.goal_y - self.y
dz = self.goal_z - self.z
dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
dist = math.sqrt(dx**2 + dy**2 + dz**2)
# close enough?
if dist <= self.move_rate:
self.x = self.goal_x
@ -273,7 +367,7 @@ class TileRenderable:
self.x += self.move_rate * dir_x
self.y += self.move_rate * dir_y
self.z += self.move_rate * dir_z
#self.app.log('%s moved to %s,%s' % (self, self.x, self.y))
# self.app.log('%s moved to %s,%s' % (self, self.x, self.y))
def update(self):
if self.go:
@ -301,11 +395,21 @@ class TileRenderable:
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(6, [self.vert_buffer, self.elem_buffer, self.char_buffer, self.uv_buffer, self.fg_buffer, self.bg_buffer])
GL.glDeleteBuffers(
6,
[
self.vert_buffer,
self.elem_buffer,
self.char_buffer,
self.uv_buffer,
self.fg_buffer,
self.bg_buffer,
],
)
if self.art and self in self.art.renderables:
self.art.renderables.remove(self)
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log("destroyed: %s" % self)
def get_projection_matrix(self):
"""
@ -374,10 +478,12 @@ class TileRenderable:
GL.glUniform1f(self.palette_width_uniform, MAX_COLORS)
GL.glUniform1f(self.grain_strength_uniform, self.grain_strength)
# camera uniforms
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE,
self.get_projection_matrix())
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE,
self.get_view_matrix())
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()
)
# TODO: determine if cost of setting all above uniforms for each
# Renderable is significant enough to warrant opti where they're set once
GL.glUniform1f(self.bg_alpha_uniform, self.bg_alpha)
@ -387,29 +493,39 @@ class TileRenderable:
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
attrib = self.shader.get_attrib_location # for brevity
vp = ctypes.c_void_p(0)
# bind each buffer and set its attrib:
# verts
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(attrib('vertPosition'), VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('vertPosition'))
GL.glVertexAttribPointer(
attrib("vertPosition"), VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(attrib("vertPosition"))
# chars
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.char_buffer)
GL.glVertexAttribPointer(attrib('charIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('charIndex'))
GL.glVertexAttribPointer(
attrib("charIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(attrib("charIndex"))
# uvs
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.uv_buffer)
GL.glVertexAttribPointer(attrib('uvMod'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('uvMod'))
GL.glVertexAttribPointer(
attrib("uvMod"), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(attrib("uvMod"))
# fg colors
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.fg_buffer)
GL.glVertexAttribPointer(attrib('fgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('fgColorIndex'))
GL.glVertexAttribPointer(
attrib("fgColorIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(attrib("fgColorIndex"))
# bg colors
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.bg_buffer)
GL.glVertexAttribPointer(attrib('bgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('bgColorIndex'))
GL.glVertexAttribPointer(
attrib("bgColorIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(attrib("bgColorIndex"))
# finally, bind element buffer
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glEnable(GL.GL_BLEND)
@ -430,8 +546,14 @@ class TileRenderable:
layer_start = i * layer_size
layer_end = layer_start + layer_size
# for active art, dim all but active layer based on UI setting
if not self.app.game_mode and self.art is self.app.ui.active_art and i != self.art.active_layer:
GL.glUniform1f(self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility)
if (
not self.app.game_mode
and self.art is self.app.ui.active_art
and i != self.art.active_layer
):
GL.glUniform1f(
self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility
)
else:
GL.glUniform1f(self.alpha_uniform, self.alpha)
# use position offset instead of baked-in Z for layers - this
@ -442,8 +564,12 @@ class TileRenderable:
z += self.art.layers_z[i]
z = z_override if z_override else z
GL.glUniform3f(self.position_uniform, x, y, z)
GL.glDrawElements(GL.GL_TRIANGLES, layer_size, GL.GL_UNSIGNED_INT,
ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint)))
GL.glDrawElements(
GL.GL_TRIANGLES,
layer_size,
GL.GL_UNSIGNED_INT,
ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint)),
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
@ -452,7 +578,6 @@ class TileRenderable:
class OnionTileRenderable(TileRenderable):
"TileRenderable subclass used for onion skin display in Art Mode animation."
# never animate
@ -464,7 +589,6 @@ class OnionTileRenderable(TileRenderable):
class GameObjectRenderable(TileRenderable):
"""
TileRenderable subclass used by GameObjects. Almost no custom logic for now.
"""

View file

@ -1,15 +1,20 @@
import math, time, ctypes, platform
import ctypes
import math
import platform
import time
import numpy as np
from OpenGL import GL
from renderable import TileRenderable
class LineRenderable():
class LineRenderable:
"Renderable comprised of GL_LINES"
vert_shader_source = 'lines_v.glsl'
vert_shader_source_3d = 'lines_3d_v.glsl'
frag_shader_source = 'lines_f.glsl'
vert_shader_source = "lines_v.glsl"
vert_shader_source_3d = "lines_3d_v.glsl"
frag_shader_source = "lines_f.glsl"
log_create_destroy = False
line_width = 1
# items in vert array: 2 for XY-only renderables, 3 for ones that include Z
@ -22,7 +27,7 @@ class LineRenderable():
self.app = app
# we may be attached to a game object
self.go = game_object
self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.unique_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
self.quad_size_ref = quad_size_ref
self.x, self.y, self.z = 0, 0, 0
self.scale_x, self.scale_y = 1, 1
@ -36,44 +41,60 @@ class LineRenderable():
GL.glBindVertexArray(self.vao)
if self.vert_items == 3:
self.vert_shader_source = self.vert_shader_source_3d
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.color_uniform = self.shader.get_uniform_location('objectColor')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.color_uniform = self.shader.get_uniform_location("objectColor")
# vert buffers
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, self.vert_items,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, self.vert_items, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
# vert colors
self.color_buffer = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
self.color_attrib = self.shader.get_attrib_location('vertColor')
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
self.color_attrib = self.shader.get_attrib_location("vertColor")
GL.glEnableVertexAttribArray(self.color_attrib)
GL.glVertexAttribPointer(self.color_attrib, 4,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
if self.log_create_destroy:
self.app.log('created: %s' % self)
self.app.log("created: %s" % self)
def __str__(self):
"for debug purposes, return a unique name"
@ -101,18 +122,30 @@ class LineRenderable():
def rebind_buffers(self):
# resend verts
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
# resend color
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
def get_projection_matrix(self):
@ -145,14 +178,18 @@ class LineRenderable():
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(3, [self.vert_buffer, self.elem_buffer, self.color_buffer])
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log("destroyed: %s" % self)
def render(self):
if not self.visible:
return
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix())
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix())
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()
)
GL.glUniform3f(self.position_uniform, *self.get_loc())
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform2f(self.quad_size_uniform, *self.get_quad_size())
@ -165,22 +202,28 @@ class LineRenderable():
# attribs:
# pos
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(self.pos_attrib, self.vert_items,
GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0))
GL.glVertexAttribPointer(
self.pos_attrib,
self.vert_items,
GL.GL_FLOAT,
GL.GL_FALSE,
0,
ctypes.c_void_p(0),
)
GL.glEnableVertexAttribArray(self.pos_attrib)
# color
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glVertexAttribPointer(self.color_attrib, 4,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glEnableVertexAttribArray(self.color_attrib)
# bind elem array - see similar behavior in Cursor.render
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glEnable(GL.GL_BLEND)
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
if platform.system() != 'Darwin':
if platform.system() != "Darwin":
GL.glLineWidth(self.get_line_width())
GL.glDrawElements(GL.GL_LINES, self.vert_count,
GL.GL_UNSIGNED_INT, None)
GL.glDrawElements(GL.GL_LINES, self.vert_count, GL.GL_UNSIGNED_INT, None)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
@ -191,6 +234,7 @@ class LineRenderable():
# common data/code used by various boxes
BOX_VERTS = [(0, 0), (1, 0), (1, -1), (0, -1)]
def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)):
verts = np.array(vert_list or BOX_VERTS, dtype=np.float32)
elems = np.array([0, 1, 1, 2, 2, 3, 3, 0], dtype=np.uint32)
@ -199,8 +243,8 @@ def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)):
class UIRenderableX(LineRenderable):
"Red X used to denote transparent color in various places"
color = (1, 0, 0, 1)
line_width = 2
@ -211,7 +255,6 @@ class UIRenderableX(LineRenderable):
class SwatchSelectionBoxRenderable(LineRenderable):
"used for UI selection boxes etc"
color = (0.5, 0.5, 0.5, 1)
@ -220,13 +263,15 @@ class SwatchSelectionBoxRenderable(LineRenderable):
def __init__(self, app, quad_size_ref):
LineRenderable.__init__(self, app, quad_size_ref)
# track tile X and Y for cursor movement
self.tile_x, self.tile_y = 0,0
self.tile_x, self.tile_y = 0, 0
def get_color(self):
return self.color
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class ToolSelectionBoxRenderable(LineRenderable):
@ -241,6 +286,7 @@ class ToolSelectionBoxRenderable(LineRenderable):
class WorldLineRenderable(LineRenderable):
"any LineRenderable that draws in world, ie in 3D perspective"
def get_projection_matrix(self):
return self.app.camera.projection_matrix
@ -249,7 +295,6 @@ class WorldLineRenderable(LineRenderable):
class DebugLineRenderable(WorldLineRenderable):
"""
renderable for drawing debug lines in the world.
use set_lines and add_lines to replace and add to, respectively, the list
@ -268,8 +313,9 @@ class DebugLineRenderable(WorldLineRenderable):
for i in range(1, len(new_verts)):
elements += [i - 1, i]
self.elem_array = np.array(elements, dtype=np.uint32)
self.color_array = np.array(new_colors or self.color * len(new_verts),
dtype=np.float32)
self.color_array = np.array(
new_colors or self.color * len(new_verts), dtype=np.float32
)
self.rebind_buffers()
def set_color(self, new_color):
@ -289,7 +335,7 @@ class DebugLineRenderable(WorldLineRenderable):
# if new_verts is a list of tuples, unpack into flat list
if type(new_verts[0]) is tuple:
new_verts_unpacked = []
for (x, y, z) in new_verts:
for x, y, z in new_verts:
new_verts_unpacked += [x, y, z]
new_verts = new_verts_unpacked
new_size = int(line_items + len(new_verts))
@ -300,13 +346,16 @@ class DebugLineRenderable(WorldLineRenderable):
new_elem_size = int(old_elem_size + len(new_verts) / self.vert_items)
# TODO: "contiguous" parameter that joins new lines with previous
self.elem_array.resize(new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(old_elem_size,
new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(
old_elem_size, new_elem_size
)
# grow color buffer
old_color_size = len(self.color_array)
new_color_size = int(old_color_size + len(new_verts) / self.vert_items * 4)
self.color_array.resize(new_color_size)
self.color_array[old_color_size:new_color_size] = new_colors or self.color * int(len(new_verts) / self.vert_items)
self.color_array[old_color_size:new_color_size] = (
new_colors or self.color * int(len(new_verts) / self.vert_items)
)
self.rebind_buffers()
def reset_lines(self):
@ -326,12 +375,11 @@ class DebugLineRenderable(WorldLineRenderable):
class OriginIndicatorRenderable(WorldLineRenderable):
"classic 3-axis thingy showing location/rotation/scale"
red = (1.0, 0.1, 0.1, 1.0)
red = (1.0, 0.1, 0.1, 1.0)
green = (0.1, 1.0, 0.1, 1.0)
blue = (0.1, 0.1, 1.0, 1.0)
blue = (0.1, 0.1, 1.0, 1.0)
origin = (0, 0, 0)
x_axis = (1, 0, 0)
y_axis = (0, 1, 0)
@ -357,13 +405,23 @@ class OriginIndicatorRenderable(WorldLineRenderable):
self.scale_z = obj.scale_z
def build_geo(self):
self.vert_array = np.array([self.origin, self.x_axis,
self.origin, self.y_axis,
self.origin, self.z_axis],
dtype=np.float32)
self.vert_array = np.array(
[
self.origin,
self.x_axis,
self.origin,
self.y_axis,
self.origin,
self.z_axis,
],
dtype=np.float32,
)
self.elem_array = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint32)
self.color_array = np.array([self.red, self.red, self.green, self.green,
self.blue, self.blue], dtype=np.float32)
self.color_array = np.array(
[self.red, self.red, self.green, self.green, self.blue, self.blue],
dtype=np.float32,
)
class BoundsIndicatorRenderable(WorldLineRenderable):
color = (1, 1, 1, 0.5)
@ -393,19 +451,27 @@ class BoundsIndicatorRenderable(WorldLineRenderable):
return (1, 1, 1, 1)
def get_line_width(self):
return self.line_width_active if self.go in self.app.gw.selected_objects else self.line_width_inactive
return (
self.line_width_active
if self.go in self.app.gw.selected_objects
else self.line_width_inactive
)
def get_quad_size(self):
if not self.go:
return 1, 1
return self.art.width * self.art.quad_width, self.art.height * self.art.quad_height
return (
self.art.width * self.art.quad_width,
self.art.height * self.art.quad_height,
)
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class CollisionRenderable(WorldLineRenderable):
# green = dynamic, blue = static
dynamic_color = (0, 1, 0, 1)
static_color = (0, 0, 1, 1)
@ -435,7 +501,6 @@ def get_circle_points(radius, steps=24):
class CircleCollisionRenderable(CollisionRenderable):
line_width = 2
segments = 24
@ -460,7 +525,7 @@ class CircleCollisionRenderable(CollisionRenderable):
y = math.sin(angle)
verts.append((x, y))
last_x, last_y = x, y
elements.append((i, i+1))
elements.append((i, i + 1))
i += 2
colors.append([self.color * 2])
self.vert_array = np.array(verts, dtype=np.float32)
@ -469,7 +534,6 @@ class CircleCollisionRenderable(CollisionRenderable):
class BoxCollisionRenderable(CollisionRenderable):
line_width = 2
def get_quad_size(self):
@ -483,12 +547,16 @@ class BoxCollisionRenderable(CollisionRenderable):
def build_geo(self):
verts = [(-0.5, 0.5), (0.5, 0.5), (0.5, -0.5), (-0.5, -0.5)]
self.vert_array, self.elem_array, self.color_array = get_box_arrays(verts, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
verts, self.color
)
class TileBoxCollisionRenderable(BoxCollisionRenderable):
"box for each tile in a CST_TILE object"
line_width = 1
def get_loc(self):
# draw at Z level of collision layer
return self.x, self.y, self.go.get_layer_z(self.go.col_layer_name)

View file

@ -1,18 +1,20 @@
import ctypes, time
import numpy as np
import ctypes
import time
import numpy as np
from OpenGL import GL
from PIL import Image
from texture import Texture
class SpriteRenderable:
class SpriteRenderable:
"basic renderable object using an image for a texture"
vert_array = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.float32)
vert_shader_source = 'sprite_v.glsl'
frag_shader_source = 'sprite_f.glsl'
texture_filename = 'ui/icon.png'
vert_shader_source = "sprite_v.glsl"
frag_shader_source = "sprite_f.glsl"
texture_filename = "ui/icon.png"
alpha = 1
tex_scale_x, tex_scale_y = 1, 1
blend = True
@ -21,7 +23,7 @@ class SpriteRenderable:
def __init__(self, app, texture_filename=None, image_data=None):
self.app = app
self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.unique_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
self.x, self.y, self.z = self.get_initial_position()
self.scale_x, self.scale_y, self.scale_z = self.get_initial_scale()
if self.app.use_vao:
@ -33,29 +35,36 @@ class SpriteRenderable:
self.texture_filename = texture_filename
if not image_data:
image_data = Image.open(self.texture_filename)
image_data = image_data.convert('RGBA')
image_data = image_data.convert("RGBA")
if self.flip_y:
image_data = image_data.transpose(Image.FLIP_TOP_BOTTOM)
w, h = image_data.size
self.texture = Texture(image_data.tobytes(), w, h)
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.tex_uniform = self.shader.get_uniform_location('texture0')
self.tex_scale_uniform = self.shader.get_uniform_location('texScale')
self.alpha_uniform = self.shader.get_uniform_location('alpha')
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.tex_uniform = self.shader.get_uniform_location("texture0")
self.tex_scale_uniform = self.shader.get_uniform_location("texScale")
self.alpha_uniform = self.shader.get_uniform_location("alpha")
self.vert_buffer = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
self.vert_count = 4
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, 2,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
@ -87,8 +96,12 @@ class SpriteRenderable:
GL.glUniform1i(self.tex_uniform, 0)
GL.glUniform2f(self.tex_scale_uniform, *self.get_texture_scale())
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture.gltex)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix())
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix())
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()
)
GL.glUniform3f(self.position_uniform, self.x, self.y, self.z)
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform1f(self.alpha_uniform, self.alpha)
@ -96,8 +109,9 @@ class SpriteRenderable:
GL.glBindVertexArray(self.vao)
else:
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(self.pos_attrib, 2,
GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0))
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0)
)
GL.glEnableVertexAttribArray(self.pos_attrib)
if self.blend:
GL.glEnable(GL.GL_BLEND)
@ -111,7 +125,6 @@ class SpriteRenderable:
class UISpriteRenderable(SpriteRenderable):
def get_projection_matrix(self):
return self.app.ui.view_matrix
@ -122,7 +135,7 @@ class UISpriteRenderable(SpriteRenderable):
class UIBGTextureRenderable(UISpriteRenderable):
alpha = 0.8
tex_wrap = True
texture_filename = 'ui/bgnoise_alpha.png'
texture_filename = "ui/bgnoise_alpha.png"
tex_scale_x, tex_scale_y = 8, 8
def get_initial_position(self):

View file

@ -1,10 +1,11 @@
import math
import numpy as np
from renderable_line import LineRenderable
class SelectionRenderable(LineRenderable):
class SelectionRenderable(LineRenderable):
color = (0.8, 0.8, 0.8, 1)
line_width = 2
x, y, z = 0, 0, 0
@ -31,14 +32,16 @@ class SelectionRenderable(LineRenderable):
below = self.get_adjacent_tile(tiles, x, y, 0, 1)
left = self.get_adjacent_tile(tiles, x, y, -1, 0)
right = self.get_adjacent_tile(tiles, x, y, 1, 0)
top_left = ( x, -y)
top_right = (x+1, -y)
bottom_right = (x+1, -y-1)
bottom_left = ( x, -y-1)
top_left = (x, -y)
top_right = (x + 1, -y)
bottom_right = (x + 1, -y - 1)
bottom_left = (x, -y - 1)
def add_line(vert_a, vert_b, verts, elems, colors, element_index):
verts += [vert_a, vert_b]
elems += [element_index, element_index+1]
elems += [element_index, element_index + 1]
colors += self.color * 2
# verts = corners
if not above:
# top edge

View file

@ -1,11 +1,14 @@
import os.path, time, platform
import os.path
import platform
import time
from OpenGL import GL
from OpenGL.GL import shaders
SHADER_PATH = 'shaders/'
SHADER_PATH = "shaders/"
class ShaderLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
@ -17,15 +20,21 @@ class ShaderLord:
def new_shader(self, vert_source_file, frag_source_file):
self.last_check = 0
for shader in self.shaders:
if shader.vert_source_file == vert_source_file and shader.frag_source_file == frag_source_file:
#self.app.log('%s already uses same source' % shader)
if (
shader.vert_source_file == vert_source_file
and shader.frag_source_file == frag_source_file
):
# self.app.log('%s already uses same source' % shader)
return shader
s = Shader(self, vert_source_file, frag_source_file)
self.shaders.append(s)
return s
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
for shader in self.shaders:
@ -41,7 +50,6 @@ class ShaderLord:
class Shader:
log_compile = False
"If True, log shader compilation"
# per-platform shader versions, declared here for easier CFG fiddling
@ -57,36 +65,46 @@ class Shader:
self.last_vert_change = time.time()
vert_source = self.get_shader_source(self.vert_source_file)
if self.log_compile:
self.sl.app.log('Compiling vertex shader %s...' % self.vert_source_file)
self.vert_shader = self.try_compile_shader(vert_source, GL.GL_VERTEX_SHADER, self.vert_source_file)
self.sl.app.log("Compiling vertex shader %s..." % self.vert_source_file)
self.vert_shader = self.try_compile_shader(
vert_source, GL.GL_VERTEX_SHADER, self.vert_source_file
)
if self.log_compile and self.vert_shader:
self.sl.app.log('Compiled vertex shader %s in %.6f seconds' % (self.vert_source_file, time.time() - self.last_vert_change))
self.sl.app.log(
"Compiled vertex shader %s in %.6f seconds"
% (self.vert_source_file, time.time() - self.last_vert_change)
)
# fragment shader
self.frag_source_file = frag_source_file
self.last_frag_change = time.time()
frag_source = self.get_shader_source(self.frag_source_file)
if self.log_compile:
self.sl.app.log('Compiling fragment shader %s...' % self.frag_source_file)
self.frag_shader = self.try_compile_shader(frag_source, GL.GL_FRAGMENT_SHADER, self.frag_source_file)
self.sl.app.log("Compiling fragment shader %s..." % self.frag_source_file)
self.frag_shader = self.try_compile_shader(
frag_source, GL.GL_FRAGMENT_SHADER, self.frag_source_file
)
if self.log_compile and self.frag_shader:
self.sl.app.log('Compiled fragment shader %s in %.6f seconds' % (self.frag_source_file, time.time() - self.last_frag_change))
self.sl.app.log(
"Compiled fragment shader %s in %.6f seconds"
% (self.frag_source_file, time.time() - self.last_frag_change)
)
# shader program
if self.vert_shader and self.frag_shader:
self.program = shaders.compileProgram(self.vert_shader, self.frag_shader)
def get_shader_source(self, source_file):
src = open(SHADER_PATH + source_file, 'rb').read()
src = open(SHADER_PATH + source_file, "rb").read()
# prepend shader version for different platforms
if self.sl.app.context_es:
shader_version = self.glsl_version_es
elif platform.system() == 'Windows':
elif platform.system() == "Windows":
shader_version = self.glsl_version_windows
elif platform.system() == 'Darwin':
elif platform.system() == "Darwin":
shader_version = self.glsl_version_macos
else:
shader_version = self.glsl_version_unix
version_string = '#version %s\n' % shader_version
src = bytes(version_string, 'utf-8') + src
version_string = "#version %s\n" % shader_version
src = bytes(version_string, "utf-8") + src
return src
def try_compile_shader(self, source, shader_type, source_filename):
@ -94,12 +112,12 @@ class Shader:
try:
shader = shaders.compileShader(source, shader_type)
except Exception as e:
self.sl.app.log('%s: ' % source_filename)
lines = e.args[0].split('\\n')
self.sl.app.log("%s: " % source_filename)
lines = e.args[0].split("\\n")
# salvage block after "shader compile failure" enclosed in b""
pre = lines.pop(0).split('b"')
for line in pre + lines[:-1]:
self.sl.app.log(' ' + line)
self.sl.app.log(" " + line)
return
return shader
@ -124,9 +142,9 @@ class Shader:
try:
new_shader = shaders.compileShader(new_shader_source, shader_type)
# TODO: use try_compile_shader instead here, make sure exception passes thru ok
self.sl.app.log('ShaderLord: success reloading %s' % file_to_reload)
self.sl.app.log("ShaderLord: success reloading %s" % file_to_reload)
except:
self.sl.app.log('ShaderLord: failed reloading %s' % file_to_reload)
self.sl.app.log("ShaderLord: failed reloading %s" % file_to_reload)
return
# recompile program with new shader
if shader_type == GL.GL_VERTEX_SHADER:

View file

@ -1,13 +1,13 @@
import numpy as np
from OpenGL import GL
class Texture:
class Texture:
# TODO: move texture data init to a set method to make hot reload trivial(?)
mag_filter = GL.GL_NEAREST
min_filter = GL.GL_NEAREST
#min_filter = GL.GL_NEAREST_MIPMAP_NEAREST
# min_filter = GL.GL_NEAREST_MIPMAP_NEAREST
packing = GL.GL_UNPACK_ALIGNMENT
def __init__(self, data, width, height):
@ -18,8 +18,17 @@ class Texture:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
self.set_filter(self.mag_filter, self.min_filter, False)
self.set_wrap(False, False)
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, width, height, 0,
GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, img_data)
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
GL.GL_RGBA,
width,
height,
0,
GL.GL_RGBA,
GL.GL_UNSIGNED_BYTE,
img_data,
)
if bool(GL.glGenerateMipmap):
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)

270
ui.py
View file

@ -1,24 +1,45 @@
import sdl2
import numpy as np
from PIL import Image
import sdl2
from OpenGL import GL
from PIL import Image
from art import (
UV_FLIP270,
UV_NORMAL,
uv_names,
)
from edit_command import EditCommand, EditCommandTile, EntireArtCommand
from texture import Texture
from ui_element import UIArt, FPSCounterUI, MessageLineUI, DebugTextUI, GameSelectionLabel, GameHoverLabel, ToolTip
from ui_colors import UIColors
from ui_console import ConsoleUI
from ui_status_bar import StatusBarUI
from ui_popup import ToolPopup
from ui_edit_panel import EditListPanel
from ui_element import (
DebugTextUI,
FPSCounterUI,
GameHoverLabel,
GameSelectionLabel,
MessageLineUI,
ToolTip,
UIArt,
)
from ui_menu_bar import ArtMenuBar, GameMenuBar
from ui_menu_pulldown import PulldownMenu
from ui_edit_panel import EditListPanel
from ui_object_panel import EditObjectPanel
from ui_colors import UIColors
from ui_tool import PencilTool, EraseTool, GrabTool, RotateTool, TextTool, SelectTool, PasteTool, FillTool
from ui_popup import ToolPopup
from ui_status_bar import StatusBarUI
from ui_tool import (
EraseTool,
FillTool,
GrabTool,
PasteTool,
PencilTool,
RotateTool,
SelectTool,
TextTool,
)
from ui_toolbar import ArtToolBar
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270, uv_names
from edit_command import EditCommand, EditCommandTile, EntireArtCommand
UI_ASSET_DIR = 'ui/'
UI_ASSET_DIR = "ui/"
SCALE_INCREMENT = 0.25
# spacing factor of each non-active document's scale from active document
MDI_MARGIN = 1.1
@ -30,39 +51,46 @@ OIS_FILL = 2
class UI:
# user-configured UI scale factor
scale = 1.0
max_onion_alpha = 0.5
charset_name = 'ui'
palette_name = 'c64_original'
charset_name = "ui"
palette_name = "c64_original"
# red color for warnings
error_color_index = UIColors.brightred
# low-contrast background texture that distinguishes UI from flat color
grain_texture_path = UI_ASSET_DIR + 'bgnoise_alpha.png'
grain_texture_path = UI_ASSET_DIR + "bgnoise_alpha.png"
# expose to classes that don't want to import this module
asset_dir = UI_ASSET_DIR
visible = True
logg = False
popup_hold_to_show = False
flip_affects_xforms = True
tool_classes = [ PencilTool, EraseTool, GrabTool, RotateTool, TextTool,
SelectTool, PasteTool, FillTool ]
tool_selected_log = 'tool selected'
art_selected_log = 'Now editing'
frame_selected_log = 'Now editing frame %s (hold time %ss)'
layer_selected_log = 'Now editing layer: %s'
swap_color_log = 'Swapped FG/BG colors'
affects_char_on_log = 'will affect characters'
affects_char_off_log = 'will not affect characters'
affects_fg_on_log = 'will affect foreground colors'
affects_fg_off_log = 'will not affect foreground colors'
affects_bg_on_log = 'will affect background colors'
affects_bg_off_log = 'will not affect background colors'
affects_xform_on_log = 'will affect character rotation/flip'
affects_xform_off_log = 'will not affect character rotation/flip'
xform_selected_log = 'Selected character transform:'
show_edit_ui_log = 'Edit UI hidden, press %s to unhide.'
tool_classes = [
PencilTool,
EraseTool,
GrabTool,
RotateTool,
TextTool,
SelectTool,
PasteTool,
FillTool,
]
tool_selected_log = "tool selected"
art_selected_log = "Now editing"
frame_selected_log = "Now editing frame %s (hold time %ss)"
layer_selected_log = "Now editing layer: %s"
swap_color_log = "Swapped FG/BG colors"
affects_char_on_log = "will affect characters"
affects_char_off_log = "will not affect characters"
affects_fg_on_log = "will affect foreground colors"
affects_fg_off_log = "will not affect foreground colors"
affects_bg_on_log = "will affect background colors"
affects_bg_off_log = "will not affect background colors"
affects_xform_on_log = "will affect character rotation/flip"
affects_xform_off_log = "will not affect character rotation/flip"
xform_selected_log = "Selected character transform:"
show_edit_ui_log = "Edit UI hidden, press %s to unhide."
def __init__(self, app, active_art):
self.app = app
@ -92,7 +120,7 @@ class UI:
# create tools
for t in self.tool_classes:
new_tool = t(self)
tool_name = '%s_tool' % new_tool.name
tool_name = "%s_tool" % new_tool.name
setattr(self, tool_name, new_tool)
# stick in a list for popup tool tab
self.tools.append(new_tool)
@ -125,17 +153,27 @@ class UI:
self.edit_object_panel = EditObjectPanel(self)
self.game_selection_label = GameSelectionLabel(self)
self.game_hover_label = GameHoverLabel(self)
self.elements += [self.fps_counter, self.status_bar, self.popup,
self.message_line, self.debug_text, self.pulldown,
self.art_menu_bar, self.game_menu_bar, self.tooltip,
self.art_toolbar,
self.edit_list_panel, self.edit_object_panel,
self.game_hover_label, self.game_selection_label]
self.elements += [
self.fps_counter,
self.status_bar,
self.popup,
self.message_line,
self.debug_text,
self.pulldown,
self.art_menu_bar,
self.game_menu_bar,
self.tooltip,
self.art_toolbar,
self.edit_list_panel,
self.edit_object_panel,
self.game_hover_label,
self.game_selection_label,
]
# add console last so it draws last
self.elements.append(self.console)
# grain texture
img = Image.open(self.grain_texture_path)
img = img.convert('RGBA')
img = img.convert("RGBA")
width, height = img.size
self.grain_texture = Texture(img.tobytes(), width, height)
self.grain_texture.set_wrap(True)
@ -155,8 +193,12 @@ class UI:
aspect = float(self.app.window_width) / self.app.window_height
inv_aspect = float(self.app.window_height) / self.app.window_width
# MAYBE-TODO: this math is correct but hard to follow, rewrite for clarity
width = self.app.window_width / (self.charset.char_width * self.scale * inv_aspect)
height = self.app.window_height / (self.charset.char_height * self.scale * inv_aspect)
width = self.app.window_width / (
self.charset.char_width * self.scale * inv_aspect
)
height = self.app.window_height / (
self.charset.char_height * self.scale * inv_aspect
)
# any new UI elements created should use new scale
UIArt.quad_width = 2 / width * aspect
UIArt.quad_height = 2 / height * aspect
@ -165,7 +207,10 @@ class UI:
# tell elements to refresh
self.set_elements_scale()
if self.scale != old_scale:
self.message_line.post_line('UI scale is now %s (%.3f x %.3f)' % (self.scale, self.width_tiles, self.height_tiles))
self.message_line.post_line(
"UI scale is now %s (%.3f x %.3f)"
% (self.scale, self.width_tiles, self.height_tiles)
)
def set_elements_scale(self):
for e in self.elements:
@ -231,16 +276,17 @@ class UI:
# rescale/reposition overlay image
self.size_and_position_overlay_image()
# tell select tool renderables
for r in [self.select_tool.select_renderable,
self.select_tool.drag_renderable]:
for r in [self.select_tool.select_renderable, self.select_tool.drag_renderable]:
r.quad_size_ref = new_art
r.rebuild_geo(self.select_tool.selected_tiles)
self.app.update_window_title()
if self.app.can_edit:
self.message_line.post_line('%s %s' % (self.art_selected_log, self.active_art.filename))
self.message_line.post_line(
"%s %s" % (self.art_selected_log, self.active_art.filename)
)
def set_active_art_by_filename(self, art_filename):
for i,art in enumerate(self.app.art_loaded_for_edit):
for i, art in enumerate(self.app.art_loaded_for_edit):
if art_filename == art.filename:
break
new_active_art = self.app.art_loaded_for_edit.pop(i)
@ -272,7 +318,7 @@ class UI:
if self.app.game_mode:
return
# don't re-select same tool, except to cycle fill tool (see below)
if new_tool == self.selected_tool and not type(new_tool) is FillTool:
if new_tool == self.selected_tool and type(new_tool) is not FillTool:
return
# bail out of text entry if active
if self.selected_tool is self.text_tool:
@ -285,16 +331,22 @@ class UI:
# if we're selecting the fill tool and it's already selected,
# cycle through its 3 modes (char/fg/bg boundary)
cycled_fill = False
if type(self.selected_tool) is FillTool and \
type(self.previous_tool) is FillTool:
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[self.selected_tool.boundary_mode]
if (
type(self.selected_tool) is FillTool
and type(self.previous_tool) is FillTool
):
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[
self.selected_tool.boundary_mode
]
# TODO: do we need a message line message for this?
#self.app.log(self.selected_tool.boundary_mode)
# self.app.log(self.selected_tool.boundary_mode)
cycled_fill = True
# close menu if we selected tool from it
if self.menu_bar.active_menu_name and not cycled_fill:
self.menu_bar.close_active_menu()
self.message_line.post_line('%s %s' % (self.selected_tool.get_button_caption(), self.tool_selected_log))
self.message_line.post_line(
"%s %s" % (self.selected_tool.get_button_caption(), self.tool_selected_log)
)
def cycle_fill_tool_mode(self):
self.set_selected_tool(self.fill_tool)
@ -322,11 +374,12 @@ class UI:
self.selected_xform = new_xform
self.popup.set_xform(new_xform)
self.tool_settings_changed = True
line = '%s %s' % (self.xform_selected_log, uv_names[self.selected_xform])
line = "%s %s" % (self.xform_selected_log, uv_names[self.selected_xform])
self.message_line.post_line(line)
def cycle_selected_xform(self, back=False):
if self.app.game_mode: return
if self.app.game_mode:
return
xform = self.selected_xform
if back:
xform -= 1
@ -340,33 +393,35 @@ class UI:
new_art = new_art or self.active_art
alpha = self.max_onion_alpha
total_onion_frames = 0
def set_onion(r, new_frame, alpha):
# scale back if fewer than MAX_ONION_FRAMES in either direction
if total_onion_frames >= new_art.frames:
r.visible = False
return
r.visible = True
if not new_art is r.art:
if new_art is not r.art:
r.set_art(new_art)
r.set_frame(new_frame)
r.alpha = alpha
# make BG dimmer so it's easier to see
r.bg_alpha = alpha / 2
# populate "next" frames first
for i,r in enumerate(self.app.onion_renderables_next):
for i, r in enumerate(self.app.onion_renderables_next):
total_onion_frames += 1
new_frame = new_art.active_frame + i + 1
set_onion(r, new_frame, alpha)
alpha /= 2
#print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
# print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
alpha = self.max_onion_alpha
for i,r in enumerate(self.app.onion_renderables_prev):
for i, r in enumerate(self.app.onion_renderables_prev):
total_onion_frames += 1
new_frame = new_art.active_frame - (i + 1)
set_onion(r, new_frame, alpha)
# each successive onion layer is dimmer
alpha /= 2
#print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
# print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
def set_active_frame(self, new_frame):
if not self.active_art.set_active_frame(new_frame):
@ -398,10 +453,12 @@ class UI:
# wrap at last valid index
self.selected_char = new_char_index % self.active_art.charset.last_index
# only update char tooltip if it was already up; avoid stomping others
char_cycle_button = self.status_bar.button_map['char_cycle']
char_toggle_button = self.status_bar.button_map['char_toggle']
if char_cycle_button in self.status_bar.hovered_buttons or \
char_toggle_button in self.status_bar.hovered_buttons:
char_cycle_button = self.status_bar.button_map["char_cycle"]
char_toggle_button = self.status_bar.button_map["char_toggle"]
if (
char_cycle_button in self.status_bar.hovered_buttons
or char_toggle_button in self.status_bar.hovered_buttons
):
char_toggle_button.update_tooltip()
self.tool_settings_changed = True
@ -415,10 +472,20 @@ class UI:
else:
self.selected_bg_color = new_color_index
# same don't-stomp-another-tooltip check as above
toggle_button = self.status_bar.button_map['fg_toggle'] if fg else self.status_bar.button_map['bg_toggle']
cycle_button = self.status_bar.button_map['fg_cycle'] if fg else self.status_bar.button_map['bg_cycle']
if toggle_button in self.status_bar.hovered_buttons or \
cycle_button in self.status_bar.hovered_buttons:
toggle_button = (
self.status_bar.button_map["fg_toggle"]
if fg
else self.status_bar.button_map["bg_toggle"]
)
cycle_button = (
self.status_bar.button_map["fg_cycle"]
if fg
else self.status_bar.button_map["bg_cycle"]
)
if (
toggle_button in self.status_bar.hovered_buttons
or cycle_button in self.status_bar.hovered_buttons
):
toggle_button.update_tooltip()
self.tool_settings_changed = True
@ -455,7 +522,9 @@ class UI:
for tile in self.select_tool.selected_tiles:
new_tile_command = EditCommandTile(self.active_art)
new_tile_command.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at(
frame, layer, *tile
)
new_tile_command.set_before(b_char, b_fg, b_bg, b_xform)
a_char = a_fg = 0
a_xform = UV_NORMAL
@ -523,7 +592,7 @@ class UI:
command = EntireArtCommand(art, min_x, min_y)
command.save_tiles(before=True)
art.resize(w, h, min_x, min_y)
self.app.log('Resized %s to %s x %s' % (art.filename, w, h))
self.app.log("Resized %s to %s x %s" % (art.filename, w, h))
art.set_unsaved_changes(True)
# clear selection to avoid having tiles we know are OoB selected
self.select_tool.selected_tiles = {}
@ -535,7 +604,7 @@ class UI:
def reset_edit_renderables(self):
# reposition all art renderables and change their opacity
x, y = 0, 0
for i,r in enumerate(self.app.edit_renderables):
for i, r in enumerate(self.app.edit_renderables):
# always put active art at 0,0
if r in self.active_art.renderables:
r.alpha = 1
@ -615,14 +684,19 @@ class UI:
if self.console.visible:
continue
# only check visible elements
if self.app.has_mouse_focus and e.is_visible() and e.can_hover and e.is_inside(mx, my):
if (
self.app.has_mouse_focus
and e.is_visible()
and e.can_hover
and e.is_inside(mx, my)
):
self.hovered_elements.append(e)
# only hover if we weren't last update
if not e in was_hovering:
if e not in was_hovering:
e.hovered()
for e in was_hovering:
# unhover if app window loses mouse focus
if not self.app.has_mouse_focus or not e in self.hovered_elements:
if not self.app.has_mouse_focus or e not in self.hovered_elements:
e.unhovered()
# update all elements, regardless of whether they're being hovered etc
for e in self.elements:
@ -642,7 +716,11 @@ class UI:
if e.clicked(mouse_button):
handled = True
# close pulldown if clicking outside it / the menu bar
if self.pulldown.visible and not self.pulldown in self.hovered_elements and not self.menu_bar in self.hovered_elements:
if (
self.pulldown.visible
and self.pulldown not in self.hovered_elements
and self.menu_bar not in self.hovered_elements
):
self.menu_bar.close_active_menu()
return handled
@ -660,10 +738,12 @@ class UI:
# an SDL keycode from that?
if self.active_dialog:
keycode = sdl2.SDLK_UP if wheel_y > 0 else sdl2.SDLK_DOWN
self.active_dialog.handle_input(keycode,
self.app.il.shift_pressed,
self.app.il.alt_pressed,
self.app.il.ctrl_pressed)
self.active_dialog.handle_input(
keycode,
self.app.il.shift_pressed,
self.app.il.alt_pressed,
self.app.il.ctrl_pressed,
)
handled = True
elif len(self.hovered_elements) > 0:
for e in self.hovered_elements:
@ -696,7 +776,7 @@ class UI:
self.active_dialog = dialog
self.keyboard_focus_element = self.active_dialog
# insert dialog at index 0 so it draws first instead of last
#self.elements.insert(0, dialog)
# self.elements.insert(0, dialog)
self.elements.remove(self.console)
self.elements.append(dialog)
self.elements.append(self.console)
@ -712,11 +792,11 @@ class UI:
# relinquish keyboard focus in play mode
self.keyboard_focus_element = None
if show_message and self.app.il:
bind = self.app.il.get_command_shortcut('toggle_game_edit_ui')
bind = self.app.il.get_command_shortcut("toggle_game_edit_ui")
bind = bind.title()
self.message_line.post_line(self.show_edit_ui_log % bind, 10)
else:
self.message_line.post_line('')
self.message_line.post_line("")
self.app.update_window_title()
def object_selection_changed(self):
@ -727,9 +807,11 @@ class UI:
def switch_edit_panel_focus(self, reverse=False):
# only allow tabbing away if list panel is in allowed mode
lp = self.edit_list_panel
if self.keyboard_focus_element is lp and \
lp.list_operation in lp.list_operations_allow_kb_focus and \
self.active_dialog:
if (
self.keyboard_focus_element is lp
and lp.list_operation in lp.list_operations_allow_kb_focus
and self.active_dialog
):
self.keyboard_focus_element = self.active_dialog
# prevent any other tabbing away from active dialog
if self.active_dialog:
@ -746,9 +828,9 @@ class UI:
# handle shift-tab
if reverse:
focus_elements.reverse()
for i,element in enumerate(focus_elements[:-1]):
for i, element in enumerate(focus_elements[:-1]):
if self.keyboard_focus_element is element:
self.keyboard_focus_element = focus_elements[i+1]
self.keyboard_focus_element = focus_elements[i + 1]
break
# update keyboard hover for both
self.edit_object_panel.update_keyboard_hover()
@ -764,9 +846,15 @@ class UI:
self.keyboard_focus_element = self.popup
elif self.pulldown.visible:
self.keyboard_focus_element = self.pulldown
elif self.edit_list_panel.is_visible() and not self.edit_object_panel.is_visible():
elif (
self.edit_list_panel.is_visible()
and not self.edit_object_panel.is_visible()
):
self.keyboard_focus_element = self.edit_list_panel
elif self.edit_object_panel.is_visible() and not self.edit_list_panel.is_visible():
elif (
self.edit_object_panel.is_visible()
and not self.edit_list_panel.is_visible()
):
self.keyboard_focus_element = self.edit_object_panel
def keyboard_navigate(self, move_x, move_y):
@ -774,9 +862,7 @@ class UI:
def toggle_game_edit_ui(self):
# if editing is disallowed, only run this once to disable UI
if not self.app.can_edit:
return
elif not self.app.game_mode:
if not self.app.can_edit or not self.app.game_mode:
return
self.set_game_edit_ui_visibility(not self.game_menu_bar.visible)

View file

@ -1,30 +1,35 @@
import os.path
from ui_dialog import UIDialog, Field
from ui_chooser_dialog import ChooserDialog, ChooserItemButton, ChooserItem
from ui_console import OpenCommand, SaveCommand
from art import ART_DIR, ART_FILE_EXTENSION, DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_FRAME_DELAY, DEFAULT_LAYER_Z_OFFSET
from art import (
ART_DIR,
ART_FILE_EXTENSION,
DEFAULT_FRAME_DELAY,
DEFAULT_HEIGHT,
DEFAULT_LAYER_Z_OFFSET,
DEFAULT_WIDTH,
)
from palette import PaletteFromFile
from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton
from ui_console import SaveCommand
from ui_dialog import Field, UIDialog
class BaseFileDialog(UIDialog):
invalid_filename_error = 'Filename is not valid.'
filename_exists_error = 'File by that name already exists.'
invalid_filename_error = "Filename is not valid."
filename_exists_error = "File by that name already exists."
def get_file_extension(self):
return ''
return ""
def get_dir(self):
return ''
return ""
def get_full_filename(self, filename, dir=None):
for forbidden_char in self.ui.app.forbidden_filename_chars:
if forbidden_char in filename:
return
full_filename = self.get_dir() + '/' + filename
full_filename += '.' + self.get_file_extension()
full_filename = self.get_dir() + "/" + filename
full_filename += "." + self.get_file_extension()
return full_filename
def is_filename_valid(self, field_number):
@ -40,41 +45,41 @@ class BaseFileDialog(UIDialog):
return True, self.filename_exists_error
return True, None
class NewArtDialog(BaseFileDialog):
title = 'New art'
field0_label = 'Filename of new art:'
field2_label = 'Width:'
field4_label = 'Height:'
field6_label = 'Save folder:'
field7_label = ' %s'
class NewArtDialog(BaseFileDialog):
title = "New art"
field0_label = "Filename of new art:"
field2_label = "Width:"
field4_label = "Height:"
field6_label = "Save folder:"
field7_label = " %s"
tile_width = 60
field0_width = 56
y_spacing = 0
field1_width = field2_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field2_label, type=int, width=field1_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field4_label, type=int, width=field2_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field6_label, type=None, width=0, oneline=True),
Field(label=field7_label, type=None, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
confirm_caption = 'Create'
invalid_width_error = 'Invalid width.'
invalid_height_error = 'Invalid height.'
confirm_caption = "Create"
invalid_width_error = "Invalid width."
invalid_height_error = "Invalid height."
def get_initial_field_text(self, field_number):
if field_number == 0:
return 'new%s' % len(self.ui.app.art_loaded_for_edit)
return "new%s" % len(self.ui.app.art_loaded_for_edit)
elif field_number == 2:
return str(DEFAULT_WIDTH)
elif field_number == 4:
return str(DEFAULT_HEIGHT)
return ''
return ""
def get_field_label(self, field_index):
label = self.fields[field_index].label
@ -98,37 +103,39 @@ class NewArtDialog(BaseFileDialog):
return self.is_filename_valid(0)
def is_valid_dimension(self, dimension, max_dimension):
try: dimension = int(dimension)
except: return False
try:
dimension = int(dimension)
except:
return False
return 0 < dimension <= max_dimension
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
name = self.field_texts[0]
w, h = int(self.field_texts[2]), int(self.field_texts[4])
self.ui.app.new_art_for_edit(name, w, h)
self.ui.app.log('Created %s.psci with size %s x %s' % (name, w, h))
self.ui.app.log("Created %s.psci with size %s x %s" % (name, w, h))
self.dismiss()
class SaveAsDialog(BaseFileDialog):
title = 'Save art'
field0_label = 'New filename for art:'
field2_label = 'Save folder:'
field3_label = ' %s'
title = "Save art"
field0_label = "New filename for art:"
field2_label = "Save folder:"
field3_label = " %s"
tile_width = 60
field0_width = 56
y_spacing = 0
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field2_label, type=None, width=0, oneline=True),
Field(label=field3_label, type=None, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
confirm_caption = 'Save'
confirm_caption = "Save"
always_redraw_labels = True
def get_initial_field_text(self, field_number):
@ -137,12 +144,14 @@ class SaveAsDialog(BaseFileDialog):
# it to documents dir to avoid writing to application dir
# (still possible if you open other files)
if os.path.dirname(self.ui.active_art.filename) == ART_DIR[:-1]:
self.ui.active_art.filename = self.ui.app.documents_dir + self.ui.active_art.filename
self.ui.active_art.filename = (
self.ui.app.documents_dir + self.ui.active_art.filename
)
# TODO: handle other files from app dir as well? not as important
filename = os.path.basename(self.ui.active_art.filename)
filename = os.path.splitext(filename)[0]
return filename
return ''
return ""
def get_file_extension(self):
"""
@ -166,16 +175,18 @@ class SaveAsDialog(BaseFileDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
SaveCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss()
class ConvertItemButton(ChooserItemButton):
width = 15
big_width = 20
class ConvertChooserItem(ChooserItem):
class ConvertChooserItem(ChooserItem):
def picked(self, element):
# TODO: following is c+p'd from BaseFileChooserItem.picked,
@ -196,13 +207,15 @@ class ConvertChooserItem(ChooserItem):
element.first_selection_made = False
def get_description_lines(self):
return self.description.split('\n')
return self.description.split("\n")
class ConvertFileDialog(ChooserDialog):
"Common functionality for importer and exporter selection dialogs"
tile_width, big_width = 85, 90
tile_height, big_height = 15, 25
confirm_caption = 'Choose'
confirm_caption = "Choose"
show_preview_image = False
item_button_class = ConvertItemButton
chooser_item_class = ConvertChooserItem
@ -227,7 +240,7 @@ class ConvertFileDialog(ChooserDialog):
class ImportFileDialog(ConvertFileDialog):
title = 'Choose an importer'
title = "Choose an importer"
def get_converters(self):
return self.ui.app.get_importers()
@ -241,30 +254,37 @@ class ImportFileDialog(ConvertFileDialog):
self.dismiss()
self.ui.open_dialog(self.ui.app.importer.file_chooser_dialog_class)
class ImportOptionsDialog(UIDialog):
"Generic base class for importer options"
confirm_caption = 'Import'
confirm_caption = "Import"
def do_import(app, filename, options):
"Common 'run importer' code for end of import options dialog"
# if importer needs no options, run it
importer = app.importer(app, filename, options)
if importer.success:
if app.importer.completes_instantly:
app.log('Imported %s successfully.' % filename)
app.log("Imported %s successfully." % filename)
app.importer = None
class ExportOptionsDialog(UIDialog):
"Generic base class for exporter options"
confirm_caption = 'Export'
confirm_caption = "Export"
def do_export(app, filename, options):
"Common 'run exporter' code for end of import options dialog"
# if importer needs no options, run it
exporter = app.exporter(app, filename, options)
if exporter.success:
app.log('Exported %s successfully.' % exporter.out_filename)
app.log("Exported %s successfully." % exporter.out_filename)
class ExportFileDialog(ConvertFileDialog):
title = 'Choose an exporter'
title = "Choose an exporter"
def get_converters(self):
return self.ui.app.get_exporters()
@ -280,9 +300,9 @@ class ExportFileDialog(ConvertFileDialog):
class ExportFilenameInputDialog(SaveAsDialog):
title = 'Export art'
field0_label = 'New filename for exported art:'
confirm_caption = 'Export'
title = "Export art"
field0_label = "New filename for exported art:"
confirm_caption = "Export"
def get_initial_field_text(self, field_number):
# base output filename on art filename
@ -297,24 +317,23 @@ class ExportFilenameInputDialog(SaveAsDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
filename = self.field_texts[0]
self.dismiss()
# invoke options dialog if exporter has one, else invoke exporter
if self.ui.app.exporter.options_dialog_class:
# pass filename into new dialog
options = {'filename': filename}
self.ui.open_dialog(self.ui.app.exporter.options_dialog_class,
options)
options = {"filename": filename}
self.ui.open_dialog(self.ui.app.exporter.options_dialog_class, options)
else:
ExportOptionsDialog.do_export(self.ui.app, filename, {})
class QuitUnsavedChangesDialog(UIDialog):
title = 'Unsaved changes'
message = 'Save changes to %s?'
confirm_caption = 'Save'
title = "Unsaved changes"
message = "Save changes to %s?"
confirm_caption = "Save"
other_button_visible = True
other_caption = "Don't Save"
@ -338,7 +357,6 @@ class QuitUnsavedChangesDialog(UIDialog):
class CloseUnsavedChangesDialog(QuitUnsavedChangesDialog):
def confirm_pressed(self):
SaveCommand.execute(self.ui.console, [])
self.dismiss()
@ -351,10 +369,9 @@ class CloseUnsavedChangesDialog(QuitUnsavedChangesDialog):
class RevertChangesDialog(UIDialog):
title = 'Revert changes'
message = 'Revert changes to %s?'
confirm_caption = 'Revert'
title = "Revert changes"
message = "Revert changes to %s?"
confirm_caption = "Revert"
def confirm_pressed(self):
self.ui.app.revert_active_art()
@ -366,25 +383,24 @@ class RevertChangesDialog(UIDialog):
class ResizeArtDialog(UIDialog):
title = 'Resize art'
title = "Resize art"
field_width = UIDialog.default_short_field_width
field0_label = 'New Width:'
field1_label = 'New Height:'
field2_label = 'Crop Start X:'
field3_label = 'Crop Start Y:'
field4_label = 'Fill new tiles with BG color'
field0_label = "New Width:"
field1_label = "New Height:"
field2_label = "Crop Start X:"
field3_label = "Crop Start Y:"
field4_label = "Fill new tiles with BG color"
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=True),
Field(label=field1_label, type=int, width=field_width, oneline=True),
Field(label=field2_label, type=int, width=field_width, oneline=True),
Field(label=field3_label, type=int, width=field_width, oneline=True),
Field(label=field4_label, type=bool, width=0, oneline=True)
Field(label=field4_label, type=bool, width=0, oneline=True),
]
confirm_caption = 'Resize'
invalid_width_error = 'Invalid width.'
invalid_height_error = 'Invalid height.'
invalid_start_error = 'Invalid crop origin.'
confirm_caption = "Resize"
invalid_width_error = "Invalid width."
invalid_height_error = "Invalid height."
invalid_start_error = "Invalid crop origin."
def get_initial_field_text(self, field_number):
if field_number == 0:
@ -394,7 +410,7 @@ class ResizeArtDialog(UIDialog):
elif field_number == 4:
return UIDialog.true_field_text
else:
return '0'
return "0"
def is_input_valid(self):
"file can't already exist, dimensions must be >0 and <= max"
@ -402,24 +418,31 @@ class ResizeArtDialog(UIDialog):
return False, self.invalid_width_error
if not self.is_valid_dimension(self.field_texts[1], self.ui.app.max_art_height):
return False, self.invalid_height_error
try: int(self.field_texts[2])
except: return False, self.invalid_start_error
try:
int(self.field_texts[2])
except:
return False, self.invalid_start_error
if not 0 <= int(self.field_texts[2]) < self.ui.active_art.width:
return False, self.invalid_start_error
try: int(self.field_texts[3])
except: return False, self.invalid_start_error
try:
int(self.field_texts[3])
except:
return False, self.invalid_start_error
if not 0 <= int(self.field_texts[3]) < self.ui.active_art.height:
return False, self.invalid_start_error
return True, None
def is_valid_dimension(self, dimension, max_dimension):
try: dimension = int(dimension)
except: return False
try:
dimension = int(dimension)
except:
return False
return 0 < dimension <= max_dimension
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
w, h = int(self.field_texts[0]), int(self.field_texts[1])
start_x, start_y = int(self.field_texts[2]), int(self.field_texts[3])
bg_fill = bool(self.field_texts[4].strip())
@ -431,19 +454,19 @@ class ResizeArtDialog(UIDialog):
# layer menu dialogs
#
class AddFrameDialog(UIDialog):
title = 'Add new frame'
field0_label = 'Index to add frame before:'
field1_label = 'Hold time (in seconds) for new frame:'
class AddFrameDialog(UIDialog):
title = "Add new frame"
field0_label = "Index to add frame before:"
field1_label = "Hold time (in seconds) for new frame:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=True),
Field(label=field1_label, type=float, width=field_width, oneline=False)
Field(label=field1_label, type=float, width=field_width, oneline=False),
]
confirm_caption = 'Add'
invalid_index_error = 'Invalid index. (1-%s allowed)'
invalid_delay_error = 'Invalid hold time.'
confirm_caption = "Add"
invalid_index_error = "Invalid index. (1-%s allowed)"
invalid_delay_error = "Invalid hold time."
def get_initial_field_text(self, field_number):
if field_number == 0:
@ -452,15 +475,19 @@ class AddFrameDialog(UIDialog):
return str(DEFAULT_FRAME_DELAY)
def is_valid_frame_index(self, index):
try: index = int(index)
except: return False
try:
index = int(index)
except:
return False
if index < 1 or index > self.ui.active_art.frames + 1:
return False
return True
def is_valid_frame_delay(self, delay):
try: delay = float(delay)
except: return False
try:
delay = float(delay)
except:
return False
return delay > 0
def is_input_valid(self):
@ -472,31 +499,35 @@ class AddFrameDialog(UIDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
index = int(self.field_texts[0])
delay = float(self.field_texts[1])
self.ui.active_art.insert_frame_before_index(index - 1, delay)
self.dismiss()
class DuplicateFrameDialog(AddFrameDialog):
title = 'Duplicate frame'
confirm_caption = 'Duplicate'
title = "Duplicate frame"
confirm_caption = "Duplicate"
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
index = int(self.field_texts[0])
delay = float(self.field_texts[1])
self.ui.active_art.duplicate_frame(self.ui.active_art.active_frame, index - 1, delay)
self.ui.active_art.duplicate_frame(
self.ui.active_art.active_frame, index - 1, delay
)
self.dismiss()
class FrameDelayDialog(AddFrameDialog):
field0_label = 'New hold time (in seconds) for frame:'
class FrameDelayDialog(AddFrameDialog):
field0_label = "New hold time (in seconds) for frame:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=float, width=field_width, oneline=False)
]
confirm_caption = 'Set'
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
confirm_caption = "Set"
def get_initial_field_text(self, field_number):
if field_number == 0:
@ -509,33 +540,33 @@ class FrameDelayDialog(AddFrameDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
delay = float(self.field_texts[0])
self.ui.active_art.frame_delays[self.ui.active_art.active_frame] = delay
self.dismiss()
class FrameDelayAllDialog(FrameDelayDialog):
field0_label = 'New hold time (in seconds) for all frames:'
field0_label = "New hold time (in seconds) for all frames:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=float, width=field_width, oneline=False)
]
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
delay = float(self.field_texts[0])
for i in range(self.ui.active_art.frames):
self.ui.active_art.frame_delays[i] = delay
self.dismiss()
class FrameIndexDialog(AddFrameDialog):
field0_label = 'Move this frame before index:'
field0_label = "Move this frame before index:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=False)
]
confirm_caption = 'Set'
fields = [Field(label=field0_label, type=int, width=field_width, oneline=False)]
confirm_caption = "Set"
def is_input_valid(self):
if not self.is_valid_frame_index(self.field_texts[0]):
@ -544,10 +575,13 @@ class FrameIndexDialog(AddFrameDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
# set new frame index (effectively moving it in the sequence)
dest_index = int(self.field_texts[0])
self.ui.active_art.move_frame_to_index(self.ui.active_art.active_frame, dest_index)
self.ui.active_art.move_frame_to_index(
self.ui.active_art.active_frame, dest_index
)
self.dismiss()
@ -555,29 +589,32 @@ class FrameIndexDialog(AddFrameDialog):
# layer menu dialogs
#
class AddLayerDialog(UIDialog):
title = 'Add new layer'
field0_label = 'Name for new layer:'
field1_label = 'Z-depth for new layer:'
class AddLayerDialog(UIDialog):
title = "Add new layer"
field0_label = "Name for new layer:"
field1_label = "Z-depth for new layer:"
field0_width = UIDialog.default_field_width
field1_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label=field1_label, type=float, width=field1_width, oneline=True)
Field(label=field1_label, type=float, width=field1_width, oneline=True),
]
confirm_caption = 'Add'
name_exists_error = 'Layer by that name already exists.'
invalid_z_error = 'Invalid number.'
confirm_caption = "Add"
name_exists_error = "Layer by that name already exists."
invalid_z_error = "Invalid number."
def get_initial_field_text(self, field_number):
if field_number == 0:
return 'Layer %s' % str(self.ui.active_art.layers + 1)
return "Layer %s" % str(self.ui.active_art.layers + 1)
elif field_number == 1:
return str(self.ui.active_art.layers_z[self.ui.active_art.active_layer] + DEFAULT_LAYER_Z_OFFSET)
return str(
self.ui.active_art.layers_z[self.ui.active_art.active_layer]
+ DEFAULT_LAYER_Z_OFFSET
)
def is_valid_layer_name(self, name, exclude_active_layer=False):
for i,layer_name in enumerate(self.ui.active_art.layer_names):
for i, layer_name in enumerate(self.ui.active_art.layer_names):
if exclude_active_layer and i == self.ui.active_layer:
continue
if layer_name == name:
@ -588,13 +625,16 @@ class AddLayerDialog(UIDialog):
valid_name = self.is_valid_layer_name(self.field_texts[0])
if not valid_name:
return False, self.name_exists_error
try: z = float(self.field_texts[1])
except: return False, self.invalid_z_error
try:
z = float(self.field_texts[1])
except:
return False, self.invalid_z_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
name = self.field_texts[0]
z = float(self.field_texts[1])
self.ui.active_art.add_layer(z, name)
@ -602,12 +642,13 @@ class AddLayerDialog(UIDialog):
class DuplicateLayerDialog(AddLayerDialog):
title = 'Duplicate layer'
confirm_caption = 'Duplicate'
title = "Duplicate layer"
confirm_caption = "Duplicate"
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
name = self.field_texts[0]
z = float(self.field_texts[1])
self.ui.active_art.duplicate_layer(self.ui.active_art.active_layer, z, name)
@ -615,14 +656,11 @@ class DuplicateLayerDialog(AddLayerDialog):
class SetLayerNameDialog(AddLayerDialog):
title = 'Set layer name'
field0_label = 'New name for this layer:'
title = "Set layer name"
field0_label = "New name for this layer:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Rename'
fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Rename"
def confirm_pressed(self):
new_name = self.field_texts[0]
@ -632,14 +670,12 @@ class SetLayerNameDialog(AddLayerDialog):
class SetLayerZDialog(UIDialog):
title = 'Set layer Z-depth'
field0_label = 'Z-depth for layer:'
title = "Set layer Z-depth"
field0_label = "Z-depth for layer:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=float, width=field_width, oneline=False)
]
confirm_caption = 'Set'
invalid_z_error = 'Invalid number.'
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
confirm_caption = "Set"
invalid_z_error = "Invalid number."
def get_initial_field_text(self, field_number):
# populate with existing z
@ -647,13 +683,16 @@ class SetLayerZDialog(UIDialog):
return str(self.ui.active_art.layers_z[self.ui.active_art.active_layer])
def is_input_valid(self):
try: z = float(self.field_texts[0])
except: return False, self.invalid_z_error
try:
z = float(self.field_texts[0])
except:
return False, self.invalid_z_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
new_z = float(self.field_texts[0])
self.ui.active_art.layers_z[self.ui.active_art.active_layer] = new_z
self.ui.active_art.set_unsaved_changes(True)
@ -662,31 +701,33 @@ class SetLayerZDialog(UIDialog):
class PaletteFromFileDialog(UIDialog):
title = 'Create palette from file'
field0_label = 'Filename to create palette from:'
field1_label = 'Filename for new palette:'
field2_label = 'Colors in new palette:'
title = "Create palette from file"
field0_label = "Filename to create palette from:"
field1_label = "Filename for new palette:"
field2_label = "Colors in new palette:"
field0_width = field1_width = UIDialog.default_field_width
field2_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label=field1_label, type=str, width=field1_width, oneline=False),
Field(label=field2_label, type=int, width=field2_width, oneline=True)
Field(label=field2_label, type=int, width=field2_width, oneline=True),
]
confirm_caption = 'Create'
invalid_color_error = 'Palettes must be between 2 and 256 colors.'
bad_output_filename_error = 'Enter a filename for the new palette.'
confirm_caption = "Create"
invalid_color_error = "Palettes must be between 2 and 256 colors."
bad_output_filename_error = "Enter a filename for the new palette."
def get_initial_field_text(self, field_number):
# NOTE: PaletteFromImageChooserDialog.confirm_pressed which invokes us
# sets fields 0 and 1
if field_number == 2:
return str(256)
return ''
return ""
def valid_colors(self, colors):
try: c = int(colors)
except: return False
try:
c = int(colors)
except:
return False
return 2 <= c <= 256
def is_input_valid(self):
@ -699,7 +740,8 @@ class PaletteFromFileDialog(UIDialog):
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
src_filename = self.field_texts[0]
palette_filename = self.field_texts[1]
colors = int(self.field_texts[2])
@ -708,32 +750,33 @@ class PaletteFromFileDialog(UIDialog):
class SetCameraZoomDialog(UIDialog):
title = 'Set camera zoom'
field0_label = 'New camera zoom %:'
title = "Set camera zoom"
field0_label = "New camera zoom %:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=float, width=field_width, oneline=True)
]
confirm_caption = 'Set'
invalid_zoom_error = 'Zoom % must be a number greater than zero.'
fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)]
confirm_caption = "Set"
invalid_zoom_error = "Zoom % must be a number greater than zero."
all_modes_visible = True
game_mode_visible = True
def get_initial_field_text(self, field_number):
if field_number == 0:
return '%.1f' % self.ui.app.camera.get_current_zoom_pct()
return ''
return "%.1f" % self.ui.app.camera.get_current_zoom_pct()
return ""
def is_input_valid(self):
try: zoom = float(self.field_texts[0])
except: return False, self.invalid_zoom_error
try:
zoom = float(self.field_texts[0])
except:
return False, self.invalid_zoom_error
if zoom <= 0:
return False, self.invalid_zoom_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
new_zoom_pct = float(self.field_texts[0])
camera = self.ui.app.camera
camera.z = camera.get_base_zoom() / (new_zoom_pct / 100)
@ -741,30 +784,31 @@ class SetCameraZoomDialog(UIDialog):
class OverlayImageOpacityDialog(UIDialog):
title = 'Set overlay image opacity'
field0_label = 'New overlay opacity %:'
title = "Set overlay image opacity"
field0_label = "New overlay opacity %:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=float, width=field_width, oneline=True)
]
confirm_caption = 'Set'
invalid_opacity_error = 'Opacity % must be between 0 and 100.'
fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)]
confirm_caption = "Set"
invalid_opacity_error = "Opacity % must be between 0 and 100."
def get_initial_field_text(self, field_number):
if field_number == 0:
return '%.1f' % (self.ui.app.overlay_renderable.alpha * 100)
return ''
return "%.1f" % (self.ui.app.overlay_renderable.alpha * 100)
return ""
def is_input_valid(self):
try: opacity = float(self.field_texts[0])
except: return False, self.invalid_opacity_error
try:
opacity = float(self.field_texts[0])
except:
return False, self.invalid_opacity_error
if opacity <= 0 or opacity > 100:
return False, self.invalid_opacity_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
new_opacity = float(self.field_texts[0])
self.ui.app.overlay_renderable.alpha = new_opacity / 100
self.dismiss()

View file

@ -1,20 +1,19 @@
from ui_colors import UIColors
TEXT_LEFT = 0
TEXT_CENTER = 1
TEXT_RIGHT = 2
BUTTON_STATES = ['normal', 'hovered', 'clicked', 'dimmed']
BUTTON_STATES = ["normal", "hovered", "clicked", "dimmed"]
class UIButton:
"clickable button that does something in a UIElement"
# x/y/width/height given in tile scale
x, y = 0, 0
width, height = 1, 1
caption = 'TEST'
caption = "TEST"
caption_justify = TEXT_LEFT
# paint caption from string, or not
should_draw_caption = True
@ -47,36 +46,43 @@ class UIButton:
def __init__(self, element, starting_state=None):
self.element = element
self.state = starting_state or 'normal'
self.state = starting_state or "normal"
def log_event(self, event_type):
"common code for button event logging"
if self.element.ui.logg:
self.element.ui.app.log("UIButton: %s's %s %s" % (self.element.__class__.__name__, self.__class__.__name__, event_type))
self.element.ui.app.log(
"UIButton: %s's %s %s"
% (self.element.__class__.__name__, self.__class__.__name__, event_type)
)
def set_state(self, new_state):
if not new_state in BUTTON_STATES:
self.element.ui.app.log('Unrecognized state for button %s: %s' % (self.__class__.__name__, new_state))
if new_state not in BUTTON_STATES:
self.element.ui.app.log(
"Unrecognized state for button %s: %s"
% (self.__class__.__name__, new_state)
)
return
self.dimmed = new_state == 'dimmed'
self.dimmed = new_state == "dimmed"
self.state = new_state
self.set_state_colors()
def get_state_colors(self, state):
fg = getattr(self, '%s_fg_color' % state)
bg = getattr(self, '%s_bg_color' % state)
fg = getattr(self, "%s_fg_color" % state)
bg = getattr(self, "%s_bg_color" % state)
return fg, bg
def set_state_colors(self):
if self.never_draw:
return
# set colors for entire button area based on current state
if self.dimmed and self.state == 'normal':
self.state = 'dimmed'
if self.dimmed and self.state == "normal":
self.state = "dimmed"
# just bail if we're trying to draw something out of bounds
if self.x + self.width > self.element.art.width:
return
elif self.y + self.height > self.element.art.height:
if (
self.x + self.width > self.element.art.width
or self.y + self.height > self.element.art.height
):
return
fg, bg = self.get_state_colors(self.state)
for y in range(self.height):
@ -91,18 +97,18 @@ class UIButton:
tt.reset_loc()
def hover(self):
self.log_event('hovered')
self.set_state('hovered')
self.log_event("hovered")
self.set_state("hovered")
if self.tooltip_on_hover:
self.element.ui.tooltip.visible = True
self.update_tooltip()
def unhover(self):
self.log_event('unhovered')
self.log_event("unhovered")
if self.dimmed:
self.set_state('dimmed')
self.set_state("dimmed")
else:
self.set_state('normal')
self.set_state("normal")
if self.tooltip_on_hover:
# if two buttons are adjacent, we might be unhovering this one
# right after hovering the other in the same frame. if so,
@ -117,11 +123,11 @@ class UIButton:
self.element.ui.tooltip.visible = False
def click(self):
self.log_event('clicked')
self.set_state('clicked')
self.log_event("clicked")
self.set_state("clicked")
def unclick(self):
self.log_event('unclicked')
self.log_event("unclicked")
if self in self.element.hovered_buttons:
self.hover()
else:
@ -129,7 +135,7 @@ class UIButton:
def get_tooltip_text(self):
"override in a subclass to define this button's tooltip text"
return 'ERROR'
return "ERROR"
def get_tooltip_location(self):
"override in a subclass to define this button's tooltip screen location"
@ -139,7 +145,7 @@ class UIButton:
y = self.y + self.caption_y
text = self.caption
# trim if too long
text = text[:self.width]
text = text[: self.width]
if self.caption_justify == TEXT_CENTER:
text = text.center(self.width)
elif self.caption_justify == TEXT_RIGHT:
@ -150,7 +156,7 @@ class UIButton:
if self.clear_before_caption_draw:
for ty in range(self.height):
for tx in range(self.width):
self.element.art.set_char_index_at(0, 0, self.x+tx, y+ty, 0)
self.element.art.set_char_index_at(0, 0, self.x + tx, y + ty, 0)
# leave FG color None; should already have been set
self.element.art.write_string(0, 0, self.x, y, text, None)

View file

@ -1,17 +1,15 @@
# coding=utf-8
import os
import sdl2
from art import UV_FLIPY, UV_NORMAL
from renderable_sprite import UISpriteRenderable
from ui_dialog import UIDialog, Field
from ui_button import UIButton
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY
from ui_colors import UIColors
from ui_dialog import Field, UIDialog
class ChooserItemButton(UIButton):
"button representing a ChooserItem"
item = None
@ -33,7 +31,6 @@ class ChooserItemButton(UIButton):
class ScrollArrowButton(UIButton):
"button that scrolls up or down in a chooser item view"
arrow_char = 129
@ -44,8 +41,9 @@ class ScrollArrowButton(UIButton):
def draw_caption(self):
xform = [UV_FLIPY, UV_NORMAL][self.up]
self.element.art.set_tile_at(0, 0, self.x, self.y + self.caption_y,
self.arrow_char, None, None, xform)
self.element.art.set_tile_at(
0, 0, self.x, self.y + self.caption_y, self.arrow_char, None, None, xform
)
def callback(self):
if self.up and self.element.scroll_index > 0:
@ -59,8 +57,7 @@ class ScrollArrowButton(UIButton):
class ChooserItem:
label = 'Chooser item'
label = "Chooser item"
def __init__(self, index, name):
self.index = index
@ -70,13 +67,17 @@ class ChooserItem:
# validity flag lets ChooserItem subclasses exclude themselves
self.valid = True
def get_label(self): return self.name
def get_label(self):
return self.name
def get_description_lines(self): return []
def get_description_lines(self):
return []
def get_preview_texture(self): return None
def get_preview_texture(self):
return None
def load(self, app): pass
def load(self, app):
pass
def picked(self, element):
# set item selected and refresh preview
@ -84,22 +85,19 @@ class ChooserItem:
class ChooserDialog(UIDialog):
title = 'Chooser'
confirm_caption = 'Set'
cancel_caption = 'Close'
message = ''
title = "Chooser"
confirm_caption = "Set"
cancel_caption = "Close"
message = ""
# if True, chooser shows files; show filename on first line of description
show_filenames = False
directory_aware = False
tile_width, tile_height = 60, 20
# use these if screen is big enough
big_width, big_height = 80, 30
fields = [
Field(label='', type=str, width=tile_width - 4, oneline=True)
]
fields = [Field(label="", type=str, width=tile_width - 4, oneline=True)]
item_start_x, item_start_y = 2, 4
no_preview_label = 'No preview available!'
no_preview_label = "No preview available!"
show_preview_image = True
item_button_class = ChooserItemButton
chooser_item_class = ChooserItem
@ -113,12 +111,13 @@ class ChooserDialog(UIDialog):
self.first_selection_made = False
if self.ui.width_tiles - 20 > self.big_width:
self.tile_width = self.big_width
self.fields[0] = Field(label='', type=str,
width=self.tile_width - 4, oneline=True)
self.fields[0] = Field(
label="", type=str, width=self.tile_width - 4, oneline=True
)
if self.ui.height_tiles - 30 > self.big_height:
self.tile_height = self.big_height
self.items_in_view = self.tile_height - self.item_start_y - 3
self.field_texts = ['']
self.field_texts = [""]
# set active field earlier than UIDialog.init so set_initial_dir
# can change its text
self.active_field = 0
@ -170,7 +169,7 @@ class ChooserDialog(UIDialog):
def set_initial_dir(self):
# for directory-aware dialogs, subclasses specify here where to start
self.current_dir = '.'
self.current_dir = "."
def change_current_dir(self, new_dir):
# check permissions:
@ -178,13 +177,13 @@ class ChooserDialog(UIDialog):
# so try/catch listdir instead
try:
l = os.listdir(new_dir)
except PermissionError as e:
line = 'No permission to access %s!' % os.path.abspath(new_dir)
except PermissionError:
line = "No permission to access %s!" % os.path.abspath(new_dir)
self.ui.message_line.post_line(line, error=True)
return False
self.current_dir = new_dir
if not self.current_dir.endswith('/'):
self.current_dir += '/'
if not self.current_dir.endswith("/"):
self.current_dir += "/"
# redo items and redraw
self.selected_item_index = 0
self.scroll_index = 0
@ -193,8 +192,7 @@ class ChooserDialog(UIDialog):
self.reset_art(False)
return True
def set_selected_item_index(self, new_index, set_field_text=True,
update_view=True):
def set_selected_item_index(self, new_index, set_field_text=True, update_view=True):
"""
set the view's selected item to specified index
perform usually-necessary refresh functions for convenience
@ -202,17 +200,25 @@ class ChooserDialog(UIDialog):
move_dir = new_index - self.selected_item_index
self.selected_item_index = new_index
can_scroll = len(self.items) > self.items_in_view
should_scroll = self.selected_item_index >= self.scroll_index + self.items_in_view or self.selected_item_index < self.scroll_index
should_scroll = (
self.selected_item_index >= self.scroll_index + self.items_in_view
or self.selected_item_index < self.scroll_index
)
if not can_scroll:
self.scroll_index = 0
elif should_scroll:
# keep selection in bounds
self.selected_item_index = min(self.selected_item_index, len(self.items)-1)
self.selected_item_index = min(
self.selected_item_index, len(self.items) - 1
)
# scrolling up
if move_dir <= 0:
self.scroll_index = self.selected_item_index
# scrolling down
elif move_dir > 0 and self.selected_item_index - self.scroll_index == self.items_in_view:
elif (
move_dir > 0
and self.selected_item_index - self.scroll_index == self.items_in_view
):
self.scroll_index = self.selected_item_index - self.items_in_view + 1
# keep scroll in bounds
self.scroll_index = min(self.scroll_index, self.get_max_scroll())
@ -231,7 +237,11 @@ class ChooserDialog(UIDialog):
def get_selected_item(self):
# return None if out of bounds
return self.items[self.selected_item_index] if self.selected_item_index < len(self.items) else None
return (
self.items[self.selected_item_index]
if self.selected_item_index < len(self.items)
else None
)
def load_selected_item(self):
item = self.get_selected_item()
@ -261,16 +271,25 @@ class ChooserDialog(UIDialog):
self.preview_renderable.x = self.x + x
self.preview_renderable.scale_x = (self.tile_width - 2) * qw - x
# determine height based on width, then y position
img_inv_aspect = self.preview_renderable.texture.height / self.preview_renderable.texture.width
img_inv_aspect = (
self.preview_renderable.texture.height
/ self.preview_renderable.texture.width
)
screen_aspect = self.ui.app.window_width / self.ui.app.window_height
self.preview_renderable.scale_y = self.preview_renderable.scale_x * img_inv_aspect * screen_aspect
self.preview_renderable.scale_y = (
self.preview_renderable.scale_x * img_inv_aspect * screen_aspect
)
y = (self.description_end_y + 1) * qh
# if preview height is above max allotted size, set height to fill size
# and scale down width
max_y = (self.tile_height - 3) * qh
if self.preview_renderable.scale_y > max_y - y:
self.preview_renderable.scale_y = max_y - y
self.preview_renderable.scale_x = self.preview_renderable.scale_y * (1 / img_inv_aspect) * (1 / screen_aspect)
self.preview_renderable.scale_x = (
self.preview_renderable.scale_y
* (1 / img_inv_aspect)
* (1 / screen_aspect)
)
# flip in Y for some (palettes) but not for others (charsets)
if self.flip_preview_y:
self.preview_renderable.scale_y = -self.preview_renderable.scale_y
@ -283,7 +302,7 @@ class ChooserDialog(UIDialog):
def reset_buttons(self):
# (re)generate buttons from contents of self.items
for i,button in enumerate(self.item_buttons):
for i, button in enumerate(self.item_buttons):
# ??? each button's callback loads charset/palette/whatev
if i >= len(self.items):
button.never_draw = True
@ -310,9 +329,9 @@ class ChooserDialog(UIDialog):
if not self.up_arrow_button:
return
# dim scroll buttons if we don't have enough items to scroll
state, hover = 'normal', True
state, hover = "normal", True
if len(self.items) <= self.items_in_view:
state = 'dimmed'
state = "dimmed"
hover = False
for button in [self.up_arrow_button, self.down_arrow_button]:
button.set_state(state)
@ -324,7 +343,7 @@ class ChooserDialog(UIDialog):
max_width = self.tile_width
max_width -= self.item_start_x + self.item_button_width + 5
if len(item.name) > max_width - 1:
return '' + item.name[-max_width:]
return "" + item.name[-max_width:]
return item.name
def get_selected_description_lines(self):
@ -343,8 +362,7 @@ class ChooserDialog(UIDialog):
# trim line if it's too long
max_width = self.tile_width - self.item_button_width - 7
line = line[:max_width]
self.art.write_string(0, 0, x, y, line, None, None,
right_justify=True)
self.art.write_string(0, 0, x, y, line, None, None, right_justify=True)
y += 1
self.description_end_y = y
@ -363,8 +381,9 @@ class ChooserDialog(UIDialog):
if len(self.items) <= self.items_in_view:
fg = self.up_arrow_button.dimmed_fg_color
for y in range(self.up_arrow_button.y + 1, self.down_arrow_button.y):
self.art.set_tile_at(0, 0, self.up_arrow_button.x, y,
self.scrollbar_shade_char, fg)
self.art.set_tile_at(
0, 0, self.up_arrow_button.x, y, self.scrollbar_shade_char, fg
)
def update_drag(self, mouse_dx, mouse_dy):
UIDialog.update_drag(self, mouse_dx, mouse_dy)
@ -376,20 +395,20 @@ class ChooserDialog(UIDialog):
# up/down keys navigate list
new_index = self.selected_item_index
navigated = False
if keystr == 'Return':
if keystr == "Return":
# if handle_enter returns True, bail before rest of input handling -
# make sure any changes to handle_enter are safe for this!
if self.handle_enter(shift_pressed, alt_pressed, ctrl_pressed):
return
elif keystr == 'Up':
elif keystr == "Up":
navigated = True
if self.selected_item_index > 0:
new_index -= 1
elif keystr == 'Down':
elif keystr == "Down":
navigated = True
if self.selected_item_index < len(self.items) - 1:
new_index += 1
elif keystr == 'PageUp':
elif keystr == "PageUp":
navigated = True
page_size = int(self.items_in_view / 2)
new_index -= page_size
@ -397,7 +416,7 @@ class ChooserDialog(UIDialog):
# scroll follows selection jumps
self.scroll_index -= page_size
self.scroll_index = max(0, self.scroll_index)
elif keystr == 'PageDown':
elif keystr == "PageDown":
navigated = True
page_size = int(self.items_in_view / 2)
new_index += page_size
@ -405,11 +424,11 @@ class ChooserDialog(UIDialog):
self.scroll_index += page_size
self.scroll_index = min(self.scroll_index, self.get_max_scroll())
# home/end: beginning/end of list, respectively
elif keystr == 'Home':
elif keystr == "Home":
navigated = True
new_index = 0
self.scroll_index = 0
elif keystr == 'End':
elif keystr == "End":
navigated = True
new_index = len(self.items) - 1
self.scroll_index = len(self.items) - self.items_in_view
@ -422,17 +441,17 @@ class ChooserDialog(UIDialog):
def text_input_seek(self):
field_text = self.field_texts[self.active_field]
if field_text.strip() == '':
if field_text.strip() == "":
return
# seek should be case-insensitive
field_text = field_text.lower()
# field text may be a full path; only care about the base
field_text = os.path.basename(field_text)
for i,item in enumerate(self.items):
for i, item in enumerate(self.items):
# match to base item name within dir
# (if it's a dir, snip last / for match)
item_base = item.name.lower()
if item_base.endswith('/'):
if item_base.endswith("/"):
item_base = item_base[:-1]
item_base = os.path.basename(item_base)
item_base = os.path.splitext(item_base)[0]
@ -445,7 +464,7 @@ class ChooserDialog(UIDialog):
# if selected item is already in text field, pick it
field_text = self.field_texts[self.active_field]
selected_item = self.get_selected_item()
if field_text.strip() == '':
if field_text.strip() == "":
self.field_texts[self.active_field] = field_text = selected_item.name
return True
if field_text == selected_item.name:
@ -459,9 +478,13 @@ class ChooserDialog(UIDialog):
self.field_texts[self.active_field] = selected_item.name
return True
# special case for parent dir ..
if self.directory_aware and field_text == self.current_dir and selected_item.name == '..':
if (
self.directory_aware
and field_text == self.current_dir
and selected_item.name == ".."
):
self.first_selection_made = True
return self.change_current_dir('..')
return self.change_current_dir("..")
if self.directory_aware and os.path.isdir(field_text):
self.first_selection_made = True
return self.change_current_dir(field_text)
@ -470,7 +493,7 @@ class ChooserDialog(UIDialog):
# if a file, change to its dir and select it
if self.directory_aware and file_dir_name != self.current_dir:
if self.change_current_dir(file_dir_name):
for i,item in enumerate(self.items):
for i, item in enumerate(self.items):
if item.name == field_text:
self.set_selected_item_index(i)
item.picked(self)

View file

@ -1,6 +1,6 @@
class UIColors:
"color indices for UI (c64 original) palette"
white = 2
lightgrey = 16
medgrey = 13

View file

@ -1,46 +1,45 @@
import os
import sdl2
from math import ceil
from ui_element import UIElement
from art import UV_FLIPY
from key_shifts import SHIFT_MAP
from image_convert import ImageConverter
from palette import PaletteFromFile
from image_export import export_still_image, export_animation
from PIL import Image
import sdl2
# imports for console execution namespace - be careful!
from OpenGL import GL
from art import UV_FLIPY
from image_convert import ImageConverter
from image_export import export_animation, export_still_image
from key_shifts import SHIFT_MAP
from palette import PaletteFromFile
from ui_element import UIElement
CONSOLE_HISTORY_FILENAME = "console_history"
CONSOLE_HISTORY_FILENAME = 'console_history'
class ConsoleCommand:
"parent class for console commands"
description = '[Enter a description for this command!]'
description = "[Enter a description for this command!]"
def execute(console, args):
return 'Test command executed.'
return "Test command executed."
class QuitCommand(ConsoleCommand):
description = 'Quit Playscii.'
description = "Quit Playscii."
def execute(console, args):
console.ui.app.should_quit = True
class SaveCommand(ConsoleCommand):
description = 'Save active art, under new filename if given.'
description = "Save active art, under new filename if given."
def execute(console, args):
# save currently active file
art = console.ui.active_art
# set new filename if given
if len(args) > 0:
old_filename = art.filename
art.set_filename(' '.join(args))
art.set_filename(" ".join(args))
art.save_to_file()
console.ui.app.load_art_for_edit(old_filename)
console.ui.set_active_art_by_filename(art.filename)
@ -50,71 +49,88 @@ class SaveCommand(ConsoleCommand):
class OpenCommand(ConsoleCommand):
description = 'Open art with given filename.'
description = "Open art with given filename."
def execute(console, args):
if len(args) == 0:
return 'Usage: open [art filename]'
filename = ' '.join(args)
return "Usage: open [art filename]"
filename = " ".join(args)
console.ui.app.load_art_for_edit(filename)
class RevertArtCommand(ConsoleCommand):
description = 'Revert active art to last saved version.'
description = "Revert active art to last saved version."
def execute(console, args):
console.ui.app.revert_active_art()
class LoadPaletteCommand(ConsoleCommand):
description = 'Set the given color palette as active.'
description = "Set the given color palette as active."
def execute(console, args):
if len(args) == 0:
return 'Usage: pal [palette filename]'
filename = ' '.join(args)
return "Usage: pal [palette filename]"
filename = " ".join(args)
# load AND set
palette = console.ui.app.load_palette(filename)
console.ui.active_art.set_palette(palette)
console.ui.popup.set_active_palette(palette)
class LoadCharSetCommand(ConsoleCommand):
description = 'Set the given character set as active.'
description = "Set the given character set as active."
def execute(console, args):
if len(args) == 0:
return 'Usage: char [character set filename]'
filename = ' '.join(args)
return "Usage: char [character set filename]"
filename = " ".join(args)
charset = console.ui.app.load_charset(filename)
console.ui.active_art.set_charset(charset)
console.ui.popup.set_active_charset(charset)
class ImageExportCommand(ConsoleCommand):
description = 'Export active art as PNG image.'
description = "Export active art as PNG image."
def execute(console, args):
export_still_image(console.ui.app, console.ui.active_art)
class AnimExportCommand(ConsoleCommand):
description = 'Export active art as animated GIF image.'
description = "Export active art as animated GIF image."
def execute(console, args):
export_animation(console.ui.app, console.ui.active_art)
class ConvertImageCommand(ConsoleCommand):
description = 'Convert given bitmap image to current character set + color palette.'
description = "Convert given bitmap image to current character set + color palette."
def execute(console, args):
if len(args) == 0:
return 'Usage: conv [image filename]'
image_filename = ' '.join(args)
return "Usage: conv [image filename]"
image_filename = " ".join(args)
ImageConverter(console.ui.app, image_filename, console.ui.active_art)
console.ui.app.update_window_title()
class OverlayImageCommand(ConsoleCommand):
description = 'Draw given bitmap image over active art document.'
description = "Draw given bitmap image over active art document."
def execute(console, args):
if len(args) == 0:
return 'Usage: img [image filename]'
image_filename = ' '.join(args)
return "Usage: img [image filename]"
image_filename = " ".join(args)
console.ui.app.set_overlay_image(image_filename)
class ImportCommand(ConsoleCommand):
description = 'Import file using an ArtImport class'
description = "Import file using an ArtImport class"
def execute(console, args):
if len(args) < 2:
return 'Usage: imp [ArtImporter class name] [filename]'
return "Usage: imp [ArtImporter class name] [filename]"
importers = console.ui.app.get_importers()
importer_classname, filename = args[0], args[1]
importer_class = None
@ -127,11 +143,13 @@ class ImportCommand(ConsoleCommand):
console.ui.app.log("Couldn't find file %s" % filename)
importer = importer_class(console.ui.app, filename)
class ExportCommand(ConsoleCommand):
description = 'Export current art using an ArtExport class'
description = "Export current art using an ArtExport class"
def execute(console, args):
if len(args) < 2:
return 'Usage: exp [ArtExporter class name] [filename]'
return "Usage: exp [ArtExporter class name] [filename]"
exporters = console.ui.app.get_exporters()
exporter_classname, filename = args[0], args[1]
exporter_class = None
@ -142,116 +160,134 @@ class ExportCommand(ConsoleCommand):
console.ui.app.log("Couldn't find exporter class %s" % exporter_classname)
exporter = exporter_class(console.ui.app, filename)
class PaletteFromImageCommand(ConsoleCommand):
description = 'Convert given image into a palette file.'
description = "Convert given image into a palette file."
def execute(console, args):
if len(args) == 0:
return 'Usage: getpal [image filename]'
src_filename = ' '.join(args)
return "Usage: getpal [image filename]"
src_filename = " ".join(args)
new_pal = PaletteFromFile(console.ui.app, src_filename, src_filename)
if not new_pal.init_success:
return
#console.ui.app.load_palette(new_pal.filename)
# console.ui.app.load_palette(new_pal.filename)
console.ui.app.palettes.append(new_pal)
console.ui.active_art.set_palette(new_pal)
console.ui.popup.set_active_palette(new_pal)
class SetGameDirCommand(ConsoleCommand):
description = 'Load game from the given folder.'
description = "Load game from the given folder."
def execute(console, args):
if len(args) == 0:
return 'Usage: setgame [game dir name]'
game_dir_name = ' '.join(args)
return "Usage: setgame [game dir name]"
game_dir_name = " ".join(args)
console.ui.app.gw.set_game_dir(game_dir_name, True)
class LoadGameStateCommand(ConsoleCommand):
description = 'Load the given game state save file.'
description = "Load the given game state save file."
def execute(console, args):
if len(args) == 0:
return 'Usage: game [game state filename]'
gs_name = ' '.join(args)
return "Usage: game [game state filename]"
gs_name = " ".join(args)
console.ui.app.gw.load_game_state(gs_name)
class SaveGameStateCommand(ConsoleCommand):
description = 'Save the current game state as the given filename.'
description = "Save the current game state as the given filename."
def execute(console, args):
"Usage: savegame [game state filename]"
gs_name = ' '.join(args)
gs_name = " ".join(args)
console.ui.app.gw.save_to_file(gs_name)
class SpawnObjectCommand(ConsoleCommand):
description = 'Spawn an object of the given class name.'
description = "Spawn an object of the given class name."
def execute(console, args):
if len(args) == 0:
return 'Usage: spawn [class name]'
class_name = ' '.join(args)
return "Usage: spawn [class name]"
class_name = " ".join(args)
console.ui.app.gw.spawn_object_of_class(class_name)
class CommandListCommand(ConsoleCommand):
description = 'Show the list of console commands.'
description = "Show the list of console commands."
def execute(console, args):
# TODO: print a command with usage if available
console.ui.app.log('Commands:')
console.ui.app.log("Commands:")
# alphabetize command list
command_list = list(commands.keys())
command_list.sort()
for command in command_list:
desc = commands[command].description
console.ui.app.log(' %s - %s' % (command, desc))
console.ui.app.log(" %s - %s" % (command, desc))
class RunArtScriptCommand(ConsoleCommand):
description = 'Run art script with given filename on active art.'
description = "Run art script with given filename on active art."
def execute(console, args):
if len(args) == 0:
return 'Usage: src [art script filename]'
filename = ' '.join(args)
return "Usage: src [art script filename]"
filename = " ".join(args)
console.ui.active_art.run_script(filename)
class RunEveryArtScriptCommand(ConsoleCommand):
description = 'Run art script with given filename on active art at given rate.'
description = "Run art script with given filename on active art at given rate."
def execute(console, args):
if len(args) < 2:
return 'Usage: srcev [rate] [art script filename]'
return "Usage: srcev [rate] [art script filename]"
rate = float(args[0])
filename = ' '.join(args[1:])
filename = " ".join(args[1:])
console.ui.active_art.run_script_every(filename, rate)
# hide so user can immediately see what script is doing
console.hide()
class StopArtScriptsCommand(ConsoleCommand):
description = 'Stop all actively running art scripts.'
description = "Stop all actively running art scripts."
def execute(console, args):
console.ui.active_art.stop_all_scripts()
# map strings to command classes for ConsoleUI.parse
commands = {
'exit': QuitCommand,
'quit': QuitCommand,
'save': SaveCommand,
'open': OpenCommand,
'char': LoadCharSetCommand,
'pal': LoadPaletteCommand,
'imgexp': ImageExportCommand,
'animexport': AnimExportCommand,
'conv': ConvertImageCommand,
'getpal': PaletteFromImageCommand,
'setgame': SetGameDirCommand,
'game': LoadGameStateCommand,
'savegame': SaveGameStateCommand,
'spawn': SpawnObjectCommand,
'help': CommandListCommand,
'scr': RunArtScriptCommand,
'screv': RunEveryArtScriptCommand,
'scrstop': StopArtScriptsCommand,
'revert': RevertArtCommand,
'img': OverlayImageCommand,
'imp': ImportCommand,
'exp': ExportCommand
"exit": QuitCommand,
"quit": QuitCommand,
"save": SaveCommand,
"open": OpenCommand,
"char": LoadCharSetCommand,
"pal": LoadPaletteCommand,
"imgexp": ImageExportCommand,
"animexport": AnimExportCommand,
"conv": ConvertImageCommand,
"getpal": PaletteFromImageCommand,
"setgame": SetGameDirCommand,
"game": LoadGameStateCommand,
"savegame": SaveGameStateCommand,
"spawn": SpawnObjectCommand,
"help": CommandListCommand,
"scr": RunArtScriptCommand,
"screv": RunEveryArtScriptCommand,
"scrstop": StopArtScriptsCommand,
"revert": RevertArtCommand,
"img": OverlayImageCommand,
"imp": ImportCommand,
"exp": ExportCommand,
}
class ConsoleUI(UIElement):
visible = False
snap_top = True
snap_left = True
@ -260,18 +296,18 @@ class ConsoleUI(UIElement):
# how long (seconds) to shift/fade into view when invoked
show_anim_time = 0.75
bg_alpha = 0.75
prompt = '>'
prompt = ">"
# _ ish char
bottom_line_char_index = 76
right_margin = 3
# transient, but must be set here b/c UIElement.init calls reset_art
current_line = ''
current_line = ""
game_mode_visible = True
all_modes_visible = True
def __init__(self, ui):
self.bg_color_index = ui.colors.darkgrey
self.highlight_color = 8 # yellow
self.highlight_color = 8 # yellow
UIElement.__init__(self, ui)
# state stuff for console move/fade
self.alpha = 0
@ -283,18 +319,18 @@ class ConsoleUI(UIElement):
self.last_lines = []
self.history_filename = self.ui.app.config_dir + CONSOLE_HISTORY_FILENAME
if os.path.exists(self.history_filename):
self.history_file = open(self.history_filename, 'r')
self.history_file = open(self.history_filename)
try:
self.command_history = self.history_file.readlines()
except:
self.command_history = []
self.history_file = open(self.history_filename, 'a')
self.history_file = open(self.history_filename, "a")
else:
self.history_file = open(self.history_filename, 'w+')
self.history_file = open(self.history_filename, "w+")
self.command_history = []
self.history_index = 0
# junk data in last user line so it changes on first update
self.last_user_line = 'test'
self.last_user_line = "test"
# max line length = width of console minus prompt + _
self.max_line_length = int(self.art.width) - self.right_margin
@ -302,7 +338,9 @@ class ConsoleUI(UIElement):
self.width = ceil(self.ui.width_tiles * self.ui.scale)
# % of screen must take aspect into account
inv_aspect = self.ui.app.window_height / self.ui.app.window_width
self.height = int(self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale)
self.height = int(
self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale
)
# dim background
self.renderable.bg_alpha = self.bg_alpha
# must resize here, as window width will vary
@ -311,10 +349,10 @@ class ConsoleUI(UIElement):
self.text_color = self.ui.palette.lightest_index
self.clear()
# truncate current user line if it's too long for new width
self.current_line = self.current_line[:self.max_line_length]
#self.update_user_line()
self.current_line = self.current_line[: self.max_line_length]
# self.update_user_line()
# empty log lines so they refresh from app
self.last_user_line = 'XXtestXX'
self.last_user_line = "XXtestXX"
self.last_lines = []
def toggle(self):
@ -364,25 +402,34 @@ class ConsoleUI(UIElement):
self.art.clear_frame_layer(0, 0, self.bg_color_index)
# line -1 is always a line of ____________
for x in range(self.width):
self.art.set_tile_at(0, 0, x, -1, self.bottom_line_char_index, self.text_color, None, UV_FLIPY)
self.art.set_tile_at(
0,
0,
x,
-1,
self.bottom_line_char_index,
self.text_color,
None,
UV_FLIPY,
)
def update_user_line(self):
"draw current user input on second to last line, with >_ prompt"
# clear entire user line first
self.art.write_string(0, 0, 0, -2, ' ' * self.width, self.text_color)
self.art.write_string(0, 0, 0, -2, '%s ' % self.prompt, self.text_color)
self.art.write_string(0, 0, 0, -2, " " * self.width, self.text_color)
self.art.write_string(0, 0, 0, -2, "%s " % self.prompt, self.text_color)
# if first item of line is a valid command, change its color
items = self.current_line.split()
if len(items) > 0 and items[0] in commands:
self.art.write_string(0, 0, 2, -2, items[0], self.highlight_color)
offset = 2 + len(items[0]) + 1
args = ' '.join(items[1:])
args = " ".join(items[1:])
self.art.write_string(0, 0, offset, -2, args, self.text_color)
else:
self.art.write_string(0, 0, 2, -2, self.current_line, self.text_color)
# draw underscore for caret at end of input string
x = len(self.prompt) + len(self.current_line) + 1
i = self.ui.charset.get_char_index('_')
i = self.ui.charset.get_char_index("_")
self.art.set_char_index_at(0, 0, x, -2, i)
def update_log_lines(self):
@ -395,7 +442,7 @@ class ConsoleUI(UIElement):
break
# trim line to width of console
if len(line) >= self.max_line_length:
line = line[:self.max_line_length]
line = line[: self.max_line_length]
self.art.write_string(0, 0, 1, y, line, self.text_color)
log_index -= 1
@ -436,31 +483,34 @@ class ConsoleUI(UIElement):
keystr = sdl2.SDL_GetKeyName(key).decode()
# TODO: get console bound key from InputLord, detect that instead of
# hard-coded backquote
if keystr == '`':
if keystr == "`":
self.toggle()
return
elif keystr == 'Return':
line = '%s %s' % (self.prompt, self.current_line)
elif keystr == "Return":
line = "%s %s" % (self.prompt, self.current_line)
self.ui.app.log(line)
# if command is same as last, don't repeat it
if len(self.command_history) == 0 or (len(self.command_history) > 0 and self.current_line != self.command_history[-1]):
if len(self.command_history) == 0 or (
len(self.command_history) > 0
and self.current_line != self.command_history[-1]
):
# don't add blank lines to history
if self.current_line.strip():
self.command_history.append(self.current_line)
self.history_file.write(self.current_line + '\n')
self.history_file.write(self.current_line + "\n")
self.parse(self.current_line)
self.current_line = ''
self.current_line = ""
self.history_index = 0
elif keystr == 'Tab':
elif keystr == "Tab":
# TODO: autocomplete (commands, filenames)
pass
elif keystr == 'Up':
elif keystr == "Up":
# page back through command history
self.visit_command_history(self.history_index - 1)
elif keystr == 'Down':
elif keystr == "Down":
# page forward through command history
self.visit_command_history(self.history_index + 1)
elif keystr == 'Backspace' and len(self.current_line) > 0:
elif keystr == "Backspace" and len(self.current_line) > 0:
# alt-backspace: delete to last delimiter, eg periods
if alt_pressed:
# "index to delete to"
@ -472,7 +522,7 @@ class ConsoleUI(UIElement):
if delete_index > -1:
self.current_line = self.current_line[:delete_index]
else:
self.current_line = ''
self.current_line = ""
# user is bailing on whatever they were typing,
# reset position in cmd history
self.history_index = 0
@ -481,15 +531,15 @@ class ConsoleUI(UIElement):
if len(self.current_line) == 0:
# same as above: reset position in cmd history
self.history_index = 0
elif keystr == 'Space':
keystr = ' '
elif keystr == "Space":
keystr = " "
# ignore any other non-character keys
if len(keystr) > 1:
return
if keystr.isalpha() and not shift_pressed:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, '')
keystr = SHIFT_MAP.get(keystr, "")
if len(self.current_line) < self.max_line_length:
self.current_line += keystr
@ -511,23 +561,27 @@ class ConsoleUI(UIElement):
camera = app.camera
art = ui.active_art
player = app.gw.player
sel = None if len(app.gw.selected_objects) == 0 else app.gw.selected_objects[0]
sel = (
None
if len(app.gw.selected_objects) == 0
else app.gw.selected_objects[0]
)
world = app.gw
hud = app.gw.hud
# special handling of assignment statements, eg x = 3:
# detect strings that pattern-match, send them to exec(),
# send all other strings to eval()
eq_index = line.find('=')
is_assignment = eq_index != -1 and line[eq_index+1] != '='
eq_index = line.find("=")
is_assignment = eq_index != -1 and line[eq_index + 1] != "="
if is_assignment:
exec(line)
else:
output = str(eval(line))
except Exception as e:
# try to output useful error text
output = '%s: %s' % (e.__class__.__name__, str(e))
output = "%s: %s" % (e.__class__.__name__, str(e))
# commands CAN return None, so only log if there's something
if output and output != 'None':
if output and output != "None":
self.ui.app.log(output)
def destroy(self):
@ -535,4 +589,4 @@ class ConsoleUI(UIElement):
# delimiters - alt-backspace deletes to most recent one of these
delimiters = [' ', '.', ')', ']', ',', '_']
delimiters = [" ", ".", ")", "]", ",", "_"]

View file

@ -1,49 +1,57 @@
import platform
import sdl2
from collections import namedtuple
from ui_element import UIElement
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_colors import UIColors
import sdl2
from key_shifts import SHIFT_MAP
from ui_button import TEXT_CENTER, UIButton
from ui_colors import UIColors
from ui_element import UIElement
Field = namedtuple('Field', ['label', # text label for field
'type', # supported: str int float bool
'width', # width in tiles of the field
'oneline']) # label and field drawn on same line
Field = namedtuple(
"Field",
[
"label", # text label for field
"type", # supported: str int float bool
"width", # width in tiles of the field
"oneline",
],
) # label and field drawn on same line
# "null" field type that tells UI drawing to skip it
class SkipFieldType: pass
class SkipFieldType:
pass
class ConfirmButton(UIButton):
caption = 'Confirm'
caption = "Confirm"
caption_justify = TEXT_CENTER
width = len(caption) + 2
dimmed_fg_color = UIColors.lightgrey
dimmed_bg_color = UIColors.white
class CancelButton(ConfirmButton):
caption = 'Cancel'
caption = "Cancel"
width = len(caption) + 2
class OtherButton(ConfirmButton):
"button for 3rd option in some dialogs, eg Don't Save"
caption = 'Other'
caption = "Other"
width = len(caption) + 2
visible = False
class UIDialog(UIElement):
tile_width, tile_height = 40, 8
# extra lines added to height beyond contents length
extra_lines = 0
fg_color = UIColors.black
bg_color = UIColors.white
title = 'Test Dialog Box'
title = "Test Dialog Box"
# string message not tied to a specific field
message = None
other_button_visible = False
@ -72,23 +80,25 @@ class UIDialog(UIElement):
radio_true_char_index = 127
radio_false_char_index = 126
# field text set for bool fields with True value
true_field_text = 'x'
true_field_text = "x"
# if True, field labels will redraw with fields after handling input
always_redraw_labels = False
def __init__(self, ui, options):
self.ui = ui
# apply options, eg passed in from UI.open_dialog
for k,v in options.items():
for k, v in options.items():
setattr(self, k, v)
self.confirm_button = ConfirmButton(self)
self.other_button = OtherButton(self)
self.cancel_button = CancelButton(self)
# handle caption overrides
def caption_override(button, alt_caption):
if alt_caption and button.caption != alt_caption:
button.caption = alt_caption
button.width = len(alt_caption) + 2
caption_override(self.confirm_button, self.confirm_caption)
caption_override(self.other_button, self.other_caption)
caption_override(self.cancel_button, self.cancel_caption)
@ -98,7 +108,7 @@ class UIDialog(UIElement):
self.buttons = [self.confirm_button, self.other_button, self.cancel_button]
# populate fields with text
self.field_texts = []
for i,field in enumerate(self.fields):
for i, field in enumerate(self.fields):
self.field_texts.append(self.get_initial_field_text(i))
# field cursor starts on
self.active_field = 0
@ -108,7 +118,7 @@ class UIDialog(UIElement):
def get_initial_field_text(self, field_number):
"subclasses specify a given field's initial text here"
return ''
return ""
def get_height(self, msg_lines):
"determine size based on contents (subclasses can use custom logic)"
@ -138,20 +148,19 @@ class UIDialog(UIElement):
self.y = (self.tile_height * qh) / 2
# draw window
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
s = ' ' + self.title.ljust(self.tile_width - 1)
s = " " + self.title.ljust(self.tile_width - 1)
# invert titlebar (if kb focus)
fg = self.titlebar_fg_color
bg = self.titlebar_bg_color
if not self is self.ui.keyboard_focus_element and \
self is self.ui.active_dialog:
if self is not self.ui.keyboard_focus_element and self is self.ui.active_dialog:
fg = self.fg_color
bg = self.bg_color
self.art.write_string(0, 0, 0, 0, s, fg, bg)
# message
if self.message:
y = 2
for i,line in enumerate(msg_lines):
self.art.write_string(0, 0, 2, y+i, line)
for i, line in enumerate(msg_lines):
self.art.write_string(0, 0, 2, y + i, line)
# field caption(s)
self.draw_fields()
# position buttons
@ -167,7 +176,7 @@ class UIDialog(UIElement):
# create field buttons so you can click em
if clear_buttons:
self.buttons = [self.confirm_button, self.other_button, self.cancel_button]
for i,field in enumerate(self.fields):
for i, field in enumerate(self.fields):
# None-type field = just a label
if field.type is None:
continue
@ -175,7 +184,9 @@ class UIDialog(UIElement):
field_button.field_number = i
# field settings mean button can be in a variety of places
field_button.width = 1 if field.type is bool else field.width
field_button.x = 2 if not field.oneline or field.type is bool else len(field.label) + 1
field_button.x = (
2 if not field.oneline or field.type is bool else len(field.label) + 1
)
field_button.y = self.get_field_y(i)
if not field.oneline:
field_button.y += 1
@ -191,8 +202,9 @@ class UIDialog(UIElement):
def hovered(self):
# mouse hover on focus
if (self.ui.app.mouse_dx or self.ui.app.mouse_dy) and \
not self is self.ui.keyboard_focus_element:
if (
self.ui.app.mouse_dx or self.ui.app.mouse_dy
) and self is not self.ui.keyboard_focus_element:
self.ui.keyboard_focus_element = self
self.reset_art()
@ -206,21 +218,21 @@ class UIDialog(UIElement):
bottom_y = self.tile_height - 1
# first clear any previous warnings
self.art.clear_line(0, 0, bottom_y)
self.confirm_button.set_state('normal')
self.confirm_button.set_state("normal")
# some dialogs use reason for warning + valid input
if reason:
fg = self.ui.error_color_index
self.art.write_string(0, 0, 1, bottom_y, reason, fg)
if not valid:
self.confirm_button.set_state('dimmed')
self.confirm_button.set_state("dimmed")
UIElement.update(self)
def get_message(self):
# if a triple quoted string, split line breaks
msg = self.message.rstrip().split('\n')
msg = self.message.rstrip().split("\n")
msg_lines = []
for line in msg:
if line != '':
if line != "":
msg_lines.append(line)
# TODO: split over multiple lines if too long
return msg_lines
@ -241,7 +253,7 @@ class UIDialog(UIElement):
y = 2
if self.message:
y += len(self.get_message()) + 1
for i,field in enumerate(self.fields):
for i, field in enumerate(self.fields):
if field.type is SkipFieldType:
continue
x = 2
@ -256,7 +268,11 @@ class UIDialog(UIElement):
# true/false ~ field text is 'x'
field_true = self.field_texts[i] == self.true_field_text
if is_radio:
char = self.radio_true_char_index if field_true else self.radio_false_char_index
char = (
self.radio_true_char_index
if field_true
else self.radio_false_char_index
)
else:
char = self.checkbox_char_index if field_true else 0
fg, bg = self.get_field_colors(i)
@ -275,14 +291,14 @@ class UIDialog(UIElement):
else:
y += 1
# draw field contents
if not field.type in [bool, None]:
if field.type not in [bool, None]:
fg, bg = self.get_field_colors(i)
text = self.field_texts[i]
# caret for active field (if kb focus)
if i == self.active_field and self is self.ui.keyboard_focus_element:
blink_on = int(self.ui.app.get_elapsed_time() / 250) % 2
if blink_on:
text += '_'
text += "_"
# pad with spaces to full width of field
text = text.ljust(field.width)
self.art.write_string(0, 0, x, y, text, fg, bg)
@ -312,11 +328,11 @@ class UIDialog(UIElement):
if not on:
for i in group:
if i != field_index:
self.field_texts[i] = ' '
self.field_texts[i] = " "
break
# toggle checkbox
if not radio_button:
return ' ' if on else self.true_field_text
return " " if on else self.true_field_text
# only toggle radio button on; selecting others toggles it off
elif on:
return field_text
@ -326,86 +342,96 @@ class UIDialog(UIElement):
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
field = None
field_text = ''
field_text = ""
if self.active_field < len(self.fields):
field = self.fields[self.active_field]
field_text = self.field_texts[self.active_field]
# special case: shortcut 'D' for 3rd button if no field input
if len(self.fields) == 0 and keystr.lower() == 'd':
if len(self.fields) == 0 and keystr.lower() == "d":
self.other_pressed()
return
if keystr == '`' and not shift_pressed:
if keystr == "`" and not shift_pressed:
self.ui.console.toggle()
return
# if list panel is up don't let user tab away
lp = self.ui.edit_list_panel
# only allow tab to focus shift IF list panel accepts it
if keystr == 'Tab' and lp.is_visible() and \
lp.list_operation in lp.list_operations_allow_kb_focus:
if (
keystr == "Tab"
and lp.is_visible()
and lp.list_operation in lp.list_operations_allow_kb_focus
):
self.ui.keyboard_focus_element = self.ui.edit_list_panel
return
elif keystr == 'Return':
elif keystr == "Return":
self.confirm_pressed()
elif keystr == 'Escape':
elif keystr == "Escape":
self.cancel_pressed()
# cycle through fields with up/down
elif keystr == 'Up' or (keystr == 'Tab' and shift_pressed):
elif keystr == "Up" or (keystr == "Tab" and shift_pressed):
if len(self.fields) > 1:
self.active_field -= 1
self.active_field %= len(self.fields)
# skip over None-type fields aka dead labels
while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType:
while (
self.fields[self.active_field].type is None
or self.fields[self.active_field].type is SkipFieldType
):
self.active_field -= 1
self.active_field %= len(self.fields)
return
elif keystr == 'Down' or keystr == 'Tab':
elif keystr == "Down" or keystr == "Tab":
if len(self.fields) > 1:
self.active_field += 1
self.active_field %= len(self.fields)
while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType:
while (
self.fields[self.active_field].type is None
or self.fields[self.active_field].type is SkipFieldType
):
self.active_field += 1
self.active_field %= len(self.fields)
return
elif keystr == 'Backspace':
if len(field_text) == 0:
pass
# don't let user clear a bool value
# TODO: allow for checkboxes but not radio buttons
elif field and field.type is bool:
elif keystr == "Backspace":
if len(field_text) == 0 or field and field.type is bool:
pass
elif alt_pressed:
# for file dialogs, delete back to last slash
last_slash = field_text[:-1].rfind('/')
last_slash = field_text[:-1].rfind("/")
# on windows, recognize backslash as well
if platform.system() == 'Windows':
last_backslash = field_text[:-1].rfind('\\')
if platform.system() == "Windows":
last_backslash = field_text[:-1].rfind("\\")
if last_backslash != -1 and last_slash != -1:
last_slash = min(last_backslash, last_slash)
if last_slash == -1:
field_text = ''
field_text = ""
else:
field_text = field_text[:last_slash+1]
field_text = field_text[: last_slash + 1]
else:
field_text = field_text[:-1]
elif keystr == 'Space':
elif keystr == "Space":
# if field.type is bool, toggle value
if field.type is bool:
field_text = self.get_toggled_bool_field(self.active_field)
else:
field_text += ' '
field_text += " "
elif len(keystr) > 1:
return
# alphanumeric text input
elif field and not field.type is bool:
elif field and field.type is not bool:
if field.type is str:
if not shift_pressed:
keystr = keystr.lower()
if not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, '')
elif field.type is int and not keystr.isdigit() and keystr != '-':
return
# this doesn't guard against things like 0.00.001
elif field.type is float and not keystr.isdigit() and keystr != '.' and keystr != '-':
keystr = SHIFT_MAP.get(keystr, "")
elif (
field.type is int
and not keystr.isdigit()
and keystr != "-"
or field.type is float
and not keystr.isdigit()
and keystr != "."
and keystr != "-"
):
return
field_text += keystr
# apply new field text and redraw
@ -437,10 +463,9 @@ class UIDialog(UIElement):
class DialogFieldButton(UIButton):
"invisible button that provides clickability for input fields"
caption = ''
caption = ""
# re-set by dialog constructor
field_number = 0
never_draw = True
@ -450,6 +475,8 @@ class DialogFieldButton(UIButton):
self.element.active_field = self.field_number
# toggle if a bool field
if self.element.fields[self.field_number].type is bool:
self.element.field_texts[self.field_number] = self.element.get_toggled_bool_field(self.field_number)
self.element.field_texts[self.field_number] = (
self.element.get_toggled_bool_field(self.field_number)
)
# redraw fields & labels
self.element.draw_fields(self.element.always_redraw_labels)

View file

@ -1,17 +1,28 @@
import os
from ui_element import UIElement
from game_world import STATE_FILE_EXTENSION, TOP_GAME_DIR
from ui_button import UIButton
from ui_game_dialog import LoadGameStateDialog, SaveGameStateDialog
from ui_chooser_dialog import ScrollArrowButton
from ui_colors import UIColors
from game_world import TOP_GAME_DIR, STATE_FILE_EXTENSION
from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, LO_SET_ROOM_CAMERA
from ui_element import UIElement
from ui_list_operations import (
LO_LOAD_STATE,
LO_NONE,
LO_OPEN_GAME_DIR,
LO_SELECT_OBJECTS,
LO_SET_OBJECT_ROOMS,
LO_SET_ROOM,
LO_SET_ROOM_CAMERA,
LO_SET_ROOM_EDGE_OBJ,
LO_SET_ROOM_EDGE_WARP,
LO_SET_ROOM_OBJECTS,
LO_SET_SPAWN_CLASS,
)
class GamePanel(UIElement):
"base class of game edit UI panels"
tile_y = 5
game_mode_visible = True
fg_color = UIColors.black
@ -31,10 +42,15 @@ class GamePanel(UIElement):
self.create_buttons()
self.keyboard_nav_index = 0
def create_buttons(self): pass
def create_buttons(self):
pass
# label and main item draw functions - overridden in subclasses
def get_label(self): pass
def refresh_items(self): pass
def get_label(self):
pass
def refresh_items(self):
pass
# reset all buttons to default state
def clear_buttons(self, button_list=None):
@ -58,12 +74,20 @@ class GamePanel(UIElement):
def draw_titlebar(self):
# only shade titlebar if panel has keyboard focus
fg = self.titlebar_fg if self is self.ui.keyboard_focus_element else self.fg_color
bg = self.titlebar_bg if self is self.ui.keyboard_focus_element else self.bg_color
fg = (
self.titlebar_fg
if self is self.ui.keyboard_focus_element
else self.fg_color
)
bg = (
self.titlebar_bg
if self is self.ui.keyboard_focus_element
else self.bg_color
)
self.art.clear_line(0, 0, 0, fg, bg)
label = self.get_label()
if len(label) > self.tile_width:
label = label[:self.tile_width]
label = label[: self.tile_width]
if self.text_left:
self.art.write_string(0, 0, 0, 0, label)
else:
@ -82,8 +106,11 @@ class GamePanel(UIElement):
def hovered(self):
# mouse hover on focus
if self.ui.app.mouse_dx or self.ui.app.mouse_dy and \
not self is self.ui.keyboard_focus_element:
if (
self.ui.app.mouse_dx
or self.ui.app.mouse_dy
and self is not self.ui.keyboard_focus_element
):
self.ui.keyboard_focus_element = self
if self.ui.active_dialog:
self.ui.active_dialog.reset_art()
@ -93,16 +120,20 @@ class ListButton(UIButton):
width = 28
clear_before_caption_draw = True
class ListScrollArrowButton(ScrollArrowButton):
x = ListButton.width
normal_bg_color = UIButton.normal_bg_color
class ListScrollUpArrowButton(ListScrollArrowButton):
y = 1
class ListScrollDownArrowButton(ListScrollArrowButton):
up = False
class EditListPanel(GamePanel):
tile_width = ListButton.width + 1
tile_y = 5
@ -110,34 +141,37 @@ class EditListPanel(GamePanel):
# height will change based on how many items in list
tile_height = 30
snap_left = True
spawn_msg = 'Click anywhere in the world view to spawn a %s'
spawn_msg = "Click anywhere in the world view to spawn a %s"
# transient state
titlebar = 'List titlebar'
titlebar = "List titlebar"
items = []
# text helping user know how to bail
cancel_tip = 'ESC cancels'
cancel_tip = "ESC cancels"
list_operation_labels = {
LO_NONE: 'Stuff:',
LO_SELECT_OBJECTS: 'Select objects:',
LO_SET_SPAWN_CLASS: 'Class to spawn:',
LO_LOAD_STATE: 'State to load:',
LO_SET_ROOM: 'Change room:',
LO_NONE: "Stuff:",
LO_SELECT_OBJECTS: "Select objects:",
LO_SET_SPAWN_CLASS: "Class to spawn:",
LO_LOAD_STATE: "State to load:",
LO_SET_ROOM: "Change room:",
LO_SET_ROOM_OBJECTS: "Set objects for %s:",
LO_SET_OBJECT_ROOMS: "Set rooms for %s:",
LO_OPEN_GAME_DIR: 'Open game:',
LO_SET_ROOM_EDGE_WARP: 'Set edge warp room/object:',
LO_SET_ROOM_EDGE_OBJ: 'Set edge bounds object:',
LO_SET_ROOM_CAMERA: 'Set room camera marker:'
LO_OPEN_GAME_DIR: "Open game:",
LO_SET_ROOM_EDGE_WARP: "Set edge warp room/object:",
LO_SET_ROOM_EDGE_OBJ: "Set edge bounds object:",
LO_SET_ROOM_CAMERA: "Set room camera marker:",
}
list_operations_allow_kb_focus = [
LO_SET_ROOM_EDGE_WARP,
LO_SET_ROOM_EDGE_OBJ,
LO_SET_ROOM_CAMERA
LO_SET_ROOM_CAMERA,
]
class ListItem:
def __init__(self, name, obj): self.name, self.obj = name, obj
def __str__(self): return self.name
def __init__(self, name, obj):
self.name, self.obj = name, obj
def __str__(self):
return self.name
def __init__(self, ui):
# topmost index of items to show in view
@ -149,29 +183,31 @@ class EditListPanel(GamePanel):
for list_op in self.list_operation_labels:
self.scroll_indices[list_op] = 0
# map list operations to list builder functions
self.list_functions = {LO_NONE: self.list_none,
LO_SELECT_OBJECTS: self.list_objects,
LO_SET_SPAWN_CLASS: self.list_classes,
LO_LOAD_STATE: self.list_states,
LO_SET_ROOM: self.list_rooms,
LO_SET_ROOM_OBJECTS: self.list_objects,
LO_SET_OBJECT_ROOMS: self.list_rooms,
LO_OPEN_GAME_DIR: self.list_games,
LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects,
LO_SET_ROOM_EDGE_OBJ: self.list_objects,
LO_SET_ROOM_CAMERA: self.list_objects
self.list_functions = {
LO_NONE: self.list_none,
LO_SELECT_OBJECTS: self.list_objects,
LO_SET_SPAWN_CLASS: self.list_classes,
LO_LOAD_STATE: self.list_states,
LO_SET_ROOM: self.list_rooms,
LO_SET_ROOM_OBJECTS: self.list_objects,
LO_SET_OBJECT_ROOMS: self.list_rooms,
LO_OPEN_GAME_DIR: self.list_games,
LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects,
LO_SET_ROOM_EDGE_OBJ: self.list_objects,
LO_SET_ROOM_CAMERA: self.list_objects,
}
# map list operations to "item clicked" functions
self.click_functions = {LO_SELECT_OBJECTS: self.select_object,
LO_SET_SPAWN_CLASS: self.set_spawn_class,
LO_LOAD_STATE: self.load_state,
LO_SET_ROOM: self.set_room,
LO_SET_ROOM_OBJECTS: self.set_room_object,
LO_SET_OBJECT_ROOMS: self.set_object_room,
LO_OPEN_GAME_DIR: self.open_game_dir,
LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp,
LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj,
LO_SET_ROOM_CAMERA: self.set_room_camera
self.click_functions = {
LO_SELECT_OBJECTS: self.select_object,
LO_SET_SPAWN_CLASS: self.set_spawn_class,
LO_LOAD_STATE: self.load_state,
LO_SET_ROOM: self.set_room,
LO_SET_ROOM_OBJECTS: self.set_room_object,
LO_SET_OBJECT_ROOMS: self.set_object_room,
LO_OPEN_GAME_DIR: self.open_game_dir,
LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp,
LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj,
LO_SET_ROOM_CAMERA: self.set_room_camera,
}
# separate lists for item buttons vs other controls
self.list_buttons = []
@ -181,9 +217,11 @@ class EditListPanel(GamePanel):
def create_buttons(self):
def list_callback(item=None):
if not item: return
if not item:
return
self.clicked_item(item)
for y in range(self.tile_height-1):
for y in range(self.tile_height - 1):
button = ListButton(self)
button.y = y + 1
button.callback = list_callback
@ -203,8 +241,9 @@ class EditListPanel(GamePanel):
GamePanel.reset_art(self)
x = self.tile_width - 1
for y in range(1, self.tile_height):
self.art.set_tile_at(0, 0, x, y, self.scrollbar_shade_char,
UIColors.medgrey)
self.art.set_tile_at(
0, 0, x, y, self.scrollbar_shade_char, UIColors.medgrey
)
def cancel(self):
self.set_list_operation(LO_NONE)
@ -216,7 +255,7 @@ class EditListPanel(GamePanel):
def scroll_list_down(self):
max_scroll = len(self.items) - self.tile_height
#max_scroll = len(self.element.items) - self.element.items_in_view
# max_scroll = len(self.element.items) - self.element.items_in_view
if self.list_scroll_index <= max_scroll:
self.list_scroll_index += 1
@ -257,9 +296,12 @@ class EditListPanel(GamePanel):
self.list_scroll_index = min(self.list_scroll_index, len(self.items))
def get_label(self):
label = '%s (%s)' % (self.list_operation_labels[self.list_operation], self.cancel_tip)
label = "%s (%s)" % (
self.list_operation_labels[self.list_operation],
self.cancel_tip,
)
# some labels contain variables
if '%s' in label:
if "%s" in label:
if self.list_operation == LO_SET_ROOM_OBJECTS:
if self.world.current_room:
label %= self.world.current_room.name
@ -280,9 +322,14 @@ class EditListPanel(GamePanel):
elif self.list_operation == LO_SET_ROOM:
return self.world.current_room and item.name == self.world.current_room.name
elif self.list_operation == LO_SET_ROOM_OBJECTS:
return self.world.current_room and item.name in self.world.current_room.objects
return (
self.world.current_room and item.name in self.world.current_room.objects
)
elif self.list_operation == LO_SET_OBJECT_ROOMS:
return len(self.world.selected_objects) == 1 and item.name in self.world.selected_objects[0].rooms
return (
len(self.world.selected_objects) == 1
and item.name in self.world.selected_objects[0].rooms
)
return False
def game_reset(self):
@ -296,9 +343,9 @@ class EditListPanel(GamePanel):
self.keyboard_nav_index = len(self.items) - 1
def refresh_items(self):
for i,b in enumerate(self.list_buttons):
for i, b in enumerate(self.list_buttons):
if i >= len(self.items):
b.caption = ''
b.caption = ""
b.cb_arg = None
self.reset_button(b)
b.can_hover = False
@ -306,7 +353,7 @@ class EditListPanel(GamePanel):
index = self.list_scroll_index + i
item = self.items[index]
b.cb_arg = item
b.caption = item.name[:self.tile_width - 1]
b.caption = item.name[: self.tile_width - 1]
b.can_hover = True
# change button appearance if this item should remain
# highlighted/selected
@ -355,9 +402,9 @@ class EditListPanel(GamePanel):
#
def list_classes(self):
items = []
base_class = self.world.modules['game_object'].GameObject
base_class = self.world.modules["game_object"].GameObject
# get list of available classes from GameWorld
for classname,classdef in self.world._get_all_loaded_classes().items():
for classname, classdef in self.world._get_all_loaded_classes().items():
# ignore non-GO classes, eg GameRoom, GameHUD
if not issubclass(classdef, base_class):
continue
@ -377,7 +424,10 @@ class EditListPanel(GamePanel):
for obj in all_objects.values():
if obj.exclude_from_object_list:
continue
if self.world.list_only_current_room_objects and not self.world.current_room.name in obj.rooms:
if (
self.world.list_only_current_room_objects
and self.world.current_room.name not in obj.rooms
):
continue
li = self.ListItem(obj.name, obj)
items.append(li)
@ -389,7 +439,7 @@ class EditListPanel(GamePanel):
items = []
# list state files in current game dir
for filename in os.listdir(self.world.game_dir):
if filename.endswith('.' + STATE_FILE_EXTENSION):
if filename.endswith("." + STATE_FILE_EXTENSION):
li = self.ListItem(filename[:-3], None)
items.append(li)
items.sort(key=lambda i: i.name)
@ -410,6 +460,7 @@ class EditListPanel(GamePanel):
if os.path.isdir(dirname + filename):
dirs.append(filename)
return dirs
# get list of both app dir games and user dir games
docs_game_dir = self.ui.app.documents_dir + TOP_GAME_DIR
items = []
@ -423,8 +474,8 @@ class EditListPanel(GamePanel):
def list_rooms_and_objects(self):
items = self.list_rooms()
# prefix room names with "ROOM:"
for i,item in enumerate(items):
item.name = 'ROOM: %s' % item.name
for i, item in enumerate(items):
item.name = "ROOM: %s" % item.name
items += self.list_objects()
return items
@ -447,7 +498,9 @@ class EditListPanel(GamePanel):
def set_spawn_class(self, item):
# set this class to be the one spawned when GameWorld is clicked
self.world.classname_to_spawn = item.name
self.ui.message_line.post_line(self.spawn_msg % self.world.classname_to_spawn, 5)
self.ui.message_line.post_line(
self.spawn_msg % self.world.classname_to_spawn, 5
)
def load_state(self, item):
self.world.load_game_state(item.name)

View file

@ -1,15 +1,12 @@
import time
import numpy as np
from math import ceil
import vector
from art import Art
from renderable import TileRenderable
from renderable_line import LineRenderable
from ui_button import UIButton
class UIElement:
# size, in tiles
tile_width, tile_height = 1, 1
snap_top, snap_bottom, snap_left, snap_right = False, False, False, False
@ -40,8 +37,15 @@ class UIElement:
self.ui = ui
self.hovered_buttons = []
# generate a unique name
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.art = UIArt(art_name, self.ui.app, self.ui.charset, self.ui.palette, self.tile_width, self.tile_height)
art_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
self.art = UIArt(
art_name,
self.ui.app,
self.ui.charset,
self.ui.palette,
self.tile_width,
self.tile_height,
)
self.renderable = UIRenderable(self.ui.app, self.art)
self.renderable.ui = self.ui
# some elements add their own renderables before calling this
@ -58,7 +62,7 @@ class UIElement:
"returns True if given point is inside this element's bounds"
w = self.tile_width * self.art.quad_width
h = self.tile_height * self.art.quad_height
return self.x <= x <= self.x+w and self.y >= y >= self.y-h
return self.x <= x <= self.x + w and self.y >= y >= self.y - h
def is_inside_button(self, x, y, button):
"returns True if given point is inside the given button's bounds"
@ -82,17 +86,17 @@ class UIElement:
button.draw()
def hovered(self):
self.log_event('hovered')
self.log_event("hovered")
def unhovered(self):
self.log_event('unhovered')
self.log_event("unhovered")
def wheel_moved(self, wheel_y):
handled = False
return handled
def clicked(self, mouse_button):
self.log_event('clicked', mouse_button)
self.log_event("clicked", mouse_button)
# return if a button did something
handled = False
# tell any hovered buttons they've been clicked
@ -118,7 +122,7 @@ class UIElement:
return handled
def unclicked(self, mouse_button):
self.log_event('unclicked', mouse_button)
self.log_event("unclicked", mouse_button)
handled = False
for b in self.hovered_buttons:
b.unclick()
@ -128,16 +132,22 @@ class UIElement:
return handled
def log_event(self, event_type, mouse_button=None):
mouse_button = mouse_button or '[n/a]'
mouse_button = mouse_button or "[n/a]"
if self.ui.logg:
self.ui.app.log('UIElement: %s %s with mouse button %s' % (self.__class__.__name__, event_type, mouse_button))
self.ui.app.log(
"UIElement: %s %s with mouse button %s"
% (self.__class__.__name__, event_type, mouse_button)
)
def is_visible(self):
if self.all_modes_visible:
return self.visible
elif not self.ui.app.game_mode and self.game_mode_visible:
return False
elif self.ui.app.game_mode and not self.game_mode_visible:
elif (
not self.ui.app.game_mode
and self.game_mode_visible
or self.ui.app.game_mode
and not self.game_mode_visible
):
return False
return self.visible
@ -174,7 +184,10 @@ class UIElement:
self.keyboard_nav_index %= len(self.buttons) + self.keyboard_nav_offset
tries = 0
# recognize two different kinds of inactive items: empty caption and dim state
while tries < len(self.buttons) and (self.buttons[self.keyboard_nav_index].caption == '' or self.buttons[self.keyboard_nav_index].state == 'dimmed'):
while tries < len(self.buttons) and (
self.buttons[self.keyboard_nav_index].caption == ""
or self.buttons[self.keyboard_nav_index].state == "dimmed"
):
# move_y might be zero, give it a direction to avoid infinite loop
# if menu item 0 is dimmed
self.keyboard_nav_index += move_y or 1
@ -188,19 +201,19 @@ class UIElement:
def update_keyboard_hover(self):
if not self.support_keyboard_navigation:
return
for i,button in enumerate(self.buttons):
for i, button in enumerate(self.buttons):
# don't higlhight if this panel doesn't have focus
if self.keyboard_nav_index == i and self is self.ui.keyboard_focus_element:
button.set_state('hovered')
elif button.state != 'dimmed':
button.set_state('normal')
button.set_state("hovered")
elif button.state != "dimmed":
button.set_state("normal")
def keyboard_select_item(self):
if not self.support_keyboard_navigation:
return
button = self.buttons[self.keyboard_nav_index]
# don't allow selecting dimmed buttons
if button.state == 'dimmed':
if button.state == "dimmed":
return
# check for None; cb_arg could be 0
if button.cb_arg is not None:
@ -225,12 +238,16 @@ class UIElement:
for b in self.buttons:
# element.clicked might have been set it non-hoverable, acknowledge
# its hoveredness here so it can unhover correctly
if b.visible and (b.can_hover or b.state == 'clicked') and self.is_inside_button(mx, my, b):
if (
b.visible
and (b.can_hover or b.state == "clicked")
and self.is_inside_button(mx, my, b)
):
self.hovered_buttons.append(b)
if not b in was_hovering:
if b not in was_hovering:
b.hover()
for b in was_hovering:
if not b in self.hovered_buttons:
if b not in self.hovered_buttons:
b.unhover()
# tiles might have just changed
self.art.update()
@ -258,7 +275,6 @@ class UIArt(Art):
class UIRenderable(TileRenderable):
grain_strength = 0.2
def get_projection_matrix(self):
@ -271,7 +287,6 @@ class UIRenderable(TileRenderable):
class FPSCounterUI(UIElement):
tile_y = 1
tile_width, tile_height = 12, 2
snap_right = True
@ -288,11 +303,11 @@ class FPSCounterUI(UIElement):
color = self.ui.colors.yellow
if self.ui.app.fps < 10:
color = self.ui.colors.red
text = '%.1f fps' % self.ui.app.fps
text = "%.1f fps" % self.ui.app.fps
x = self.tile_width - 1
self.art.write_string(0, 0, x, 0, text, color, None, True)
# display last tick time; frame_time includes delay, is useless
text = '%.1f ms ' % self.ui.app.frame_time
text = "%.1f ms " % self.ui.app.frame_time
self.art.write_string(0, 0, x, 1, text, color, None, True)
def render(self):
@ -302,7 +317,6 @@ class FPSCounterUI(UIElement):
class MessageLineUI(UIElement):
"when console outputs something new, show last line here before fading out"
tile_y = 2
@ -318,7 +332,7 @@ class MessageLineUI(UIElement):
def __init__(self, ui):
UIElement.__init__(self, ui)
# line we're currently displaying (even after fading out)
self.line = ''
self.line = ""
self.last_post = self.ui.app.get_elapsed_time()
self.hold_time = self.default_hold_time
self.alpha = 1
@ -336,7 +350,7 @@ class MessageLineUI(UIElement):
color = self.ui.error_color_index if error else self.ui.colors.white
start_x = 1
# trim to screen width
self.line = str(new_line)[:self.tile_width-start_x-1]
self.line = str(new_line)[: self.tile_width - start_x - 1]
self.art.clear_frame_layer(0, 0, 0, color)
self.art.write_string(0, 0, start_x, 0, self.line)
self.alpha = 1
@ -352,12 +366,14 @@ class MessageLineUI(UIElement):
def render(self):
# TODO: draw if popup is visible but not obscuring message line?
if not self.ui.popup in self.ui.hovered_elements and not self.ui.console.visible:
if (
self.ui.popup not in self.ui.hovered_elements
and not self.ui.console.visible
):
UIElement.render(self)
class DebugTextUI(UIElement):
"simple UI element for posting debug text"
tile_x, tile_y = 1, 4
@ -384,18 +400,17 @@ class DebugTextUI(UIElement):
def update(self):
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
for y,line in enumerate(self.lines):
for y, line in enumerate(self.lines):
self.art.write_string(0, 0, 0, y, line)
def render(self):
UIElement.render(self)
if self.clear_lines_after_render:
self.lines = []
#self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
# self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
class ToolTip(UIElement):
"popup text label that is invoked and controlled by a UIButton hover"
visible = False
@ -403,16 +418,16 @@ class ToolTip(UIElement):
tile_x, tile_y = 10, 5
def set_text(self, text):
self.art.write_string(0, 0, 0, 0, text,
self.ui.colors.black, self.ui.colors.white)
self.art.write_string(
0, 0, 0, 0, text, self.ui.colors.black, self.ui.colors.white
)
# clear tiles past end of text
for x in range(len(text), self.tile_width):
self.art.set_color_at(0, 0, x, 0, 0, 0)
def reset_art(self):
UIElement.reset_art(self)
self.art.clear_frame_layer(0, 0,
self.ui.colors.white, self.ui.colors.black)
self.art.clear_frame_layer(0, 0, self.ui.colors.white, self.ui.colors.black)
class GameLabel(UIElement):
@ -423,8 +438,7 @@ class GameLabel(UIElement):
class GameSelectionLabel(GameLabel):
multi_select_label = '[%s selected]'
multi_select_label = "[%s selected]"
def update(self):
self.visible = False
@ -435,7 +449,7 @@ class GameSelectionLabel(GameLabel):
self.visible = True
if len(self.ui.app.gw.selected_objects) == 1:
obj = self.ui.app.gw.selected_objects[0]
text = obj.name[:self.tile_width-1]
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
else:
# draw "[N selected]" at avg of selected object locations
@ -453,8 +467,8 @@ class GameSelectionLabel(GameLabel):
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
class GameHoverLabel(GameLabel):
class GameHoverLabel(GameLabel):
alpha = 0.75
def update(self):
@ -465,7 +479,7 @@ class GameHoverLabel(GameLabel):
return
self.visible = True
obj = self.ui.app.gw.hovered_focus_object
text = obj.name[:self.tile_width-1]
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
self.art.clear_line(0, 0, 0, self.ui.colors.white, -1)
self.art.write_string(0, 0, 0, 0, text)

View file

@ -1,26 +1,32 @@
import os, time, json
import json
import os
import time
from PIL import Image
from texture import Texture
from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton
from ui_console import OpenCommand, LoadCharSetCommand, LoadPaletteCommand
from ui_art_dialog import PaletteFromFileDialog, ImportOptionsDialog
from art import ART_DIR, ART_FILE_EXTENSION, THUMBNAIL_CACHE_DIR, SCRIPT_FILE_EXTENSION, ART_SCRIPT_DIR
from palette import Palette, PALETTE_DIR, PALETTE_EXTENSIONS
from charset import CharacterSet, CHARSET_DIR, CHARSET_FILE_EXTENSION
from art import (
ART_DIR,
ART_FILE_EXTENSION,
ART_SCRIPT_DIR,
SCRIPT_FILE_EXTENSION,
THUMBNAIL_CACHE_DIR,
)
from charset import CHARSET_DIR, CHARSET_FILE_EXTENSION
from image_export import write_thumbnail
from palette import PALETTE_DIR, PALETTE_EXTENSIONS
from texture import Texture
from ui_art_dialog import ImportOptionsDialog, PaletteFromFileDialog
from ui_chooser_dialog import ChooserDialog, ChooserItem
from ui_console import OpenCommand
class BaseFileChooserItem(ChooserItem):
hide_file_extension = False
def get_short_dir_name(self):
# name should end in / but don't assume
dir_name = self.name[:-1] if self.name.endswith('/') else self.name
return os.path.basename(dir_name) + '/'
dir_name = self.name[:-1] if self.name.endswith("/") else self.name
return os.path.basename(dir_name) + "/"
def get_label(self):
if os.path.isdir(self.name):
@ -34,8 +40,8 @@ class BaseFileChooserItem(ChooserItem):
def get_description_lines(self):
if os.path.isdir(self.name):
if self.name == '..':
return ['[parent folder]']
if self.name == "..":
return ["[parent folder]"]
# TODO: # of items in dir?
return []
return None
@ -52,8 +58,8 @@ class BaseFileChooserItem(ChooserItem):
if not element.first_selection_made:
element.first_selection_made = True
return
if self.name == '..' and self.name != '/':
new_dir = os.path.abspath(os.path.abspath(element.current_dir) + '/..')
if self.name == ".." and self.name != "/":
new_dir = os.path.abspath(os.path.abspath(element.current_dir) + "/..")
element.change_current_dir(new_dir)
elif os.path.isdir(self.name):
new_dir = element.current_dir + self.get_short_dir_name()
@ -62,9 +68,10 @@ class BaseFileChooserItem(ChooserItem):
element.confirm_pressed()
element.first_selection_made = False
class BaseFileChooserDialog(ChooserDialog):
class BaseFileChooserDialog(ChooserDialog):
"base class for choosers whose items correspond with files"
chooser_item_class = BaseFileChooserItem
show_filenames = True
file_extensions = []
@ -80,21 +87,21 @@ class BaseFileChooserDialog(ChooserDialog):
def get_sorted_dir_list(self):
"common code for getting sorted directory + file lists"
# list parent, then dirs, then filenames with extension(s)
parent = [] if self.current_dir == '/' else ['..']
parent = [] if self.current_dir == "/" else [".."]
if not os.path.exists(self.current_dir):
return parent
dirs, files = [], []
for filename in os.listdir(self.current_dir):
# skip unix-hidden files
if filename.startswith('.'):
if filename.startswith("."):
continue
full_filename = self.current_dir + filename
# if no extensions specified, take any file
if len(self.file_extensions) == 0:
self.file_extensions = ['']
self.file_extensions = [""]
for ext in self.file_extensions:
if os.path.isdir(full_filename):
dirs += [full_filename + '/']
dirs += [full_filename + "/"]
break
elif filename.lower().endswith(ext.lower()):
files += [full_filename]
@ -123,8 +130,8 @@ class BaseFileChooserDialog(ChooserDialog):
# art chooser
#
class ArtChooserItem(BaseFileChooserItem):
class ArtChooserItem(BaseFileChooserItem):
# set in load()
art_width = None
hide_file_extension = True
@ -136,28 +143,30 @@ class ArtChooserItem(BaseFileChooserItem):
if not self.art_width:
return []
mod_time = time.gmtime(self.art_mod_time)
mod_time = time.strftime('%Y-%m-%d %H:%M:%S', mod_time)
lines = ['last change: %s' % mod_time]
line = '%s x %s, ' % (self.art_width, self.art_height)
line += '%s frame' % self.art_frames
mod_time = time.strftime("%Y-%m-%d %H:%M:%S", mod_time)
lines = ["last change: %s" % mod_time]
line = "%s x %s, " % (self.art_width, self.art_height)
line += "%s frame" % self.art_frames
# pluralize properly
line += 's' if self.art_frames > 1 else ''
line += ', %s layer' % self.art_layers
line += 's' if self.art_layers > 1 else ''
line += "s" if self.art_frames > 1 else ""
line += ", %s layer" % self.art_layers
line += "s" if self.art_layers > 1 else ""
lines += [line]
lines += ['char: %s, pal: %s' % (self.art_charset, self.art_palette)]
lines += ["char: %s, pal: %s" % (self.art_charset, self.art_palette)]
return lines
def get_preview_texture(self, app):
if os.path.isdir(self.name):
return
thumbnail_filename = app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + '.png'
thumbnail_filename = (
app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + ".png"
)
# create thumbnail if it doesn't exist
if not os.path.exists(thumbnail_filename):
write_thumbnail(app, self.name, thumbnail_filename)
# read thumbnail
img = Image.open(thumbnail_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
@ -172,18 +181,17 @@ class ArtChooserItem(BaseFileChooserItem):
self.art_hash = app.get_file_hash(self.name)
# rather than load the entire art, just get some high level stats
d = json.load(open(self.name))
self.art_width, self.art_height = d['width'], d['height']
self.art_frames = len(d['frames'])
self.art_layers = len(d['frames'][0]['layers'])
self.art_charset = d['charset']
self.art_palette = d['palette']
self.art_width, self.art_height = d["width"], d["height"]
self.art_frames = len(d["frames"])
self.art_layers = len(d["frames"][0]["layers"])
self.art_charset = d["charset"]
self.art_palette = d["palette"]
class ArtChooserDialog(BaseFileChooserDialog):
title = 'Open art'
confirm_caption = 'Open'
cancel_caption = 'Cancel'
title = "Open art"
confirm_caption = "Open"
cancel_caption = "Cancel"
chooser_item_class = ArtChooserItem
flip_preview_y = False
directory_aware = True
@ -195,7 +203,11 @@ class ArtChooserDialog(BaseFileChooserDialog):
if self.ui.app.last_art_dir:
self.current_dir = self.ui.app.last_art_dir
else:
self.current_dir = self.ui.app.gw.game_dir if self.ui.app.gw.game_dir else self.ui.app.documents_dir
self.current_dir = (
self.ui.app.gw.game_dir
if self.ui.app.gw.game_dir
else self.ui.app.documents_dir
)
self.current_dir += ART_DIR
self.field_texts[self.active_field] = self.current_dir
@ -211,10 +223,9 @@ class ArtChooserDialog(BaseFileChooserDialog):
# generic file chooser for importers
#
class GenericImportChooserDialog(BaseFileChooserDialog):
title = 'Import %s'
confirm_caption = 'Import'
cancel_caption = 'Cancel'
title = "Import %s"
confirm_caption = "Import"
cancel_caption = "Cancel"
# allowed extensions set by invoking
file_extensions = []
show_preview_image = False
@ -240,15 +251,13 @@ class GenericImportChooserDialog(BaseFileChooserDialog):
self.dismiss()
# importer might offer a dialog for options
if self.ui.app.importer.options_dialog_class:
options = {'filename': filename}
self.ui.open_dialog(self.ui.app.importer.options_dialog_class,
options)
options = {"filename": filename}
self.ui.open_dialog(self.ui.app.importer.options_dialog_class, options)
else:
ImportOptionsDialog.do_import(self.ui.app, filename, {})
class ImageChooserItem(BaseFileChooserItem):
def get_preview_texture(self, app):
if os.path.isdir(self.name):
return
@ -258,25 +267,25 @@ class ImageChooserItem(BaseFileChooserItem):
except:
return
try:
img = img.convert('RGBA')
img = img.convert("RGBA")
except:
# (probably) PIL bug: some images just crash! return None
return
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
class ImageFileChooserDialog(BaseFileChooserDialog):
cancel_caption = 'Cancel'
class ImageFileChooserDialog(BaseFileChooserDialog):
cancel_caption = "Cancel"
chooser_item_class = ImageChooserItem
flip_preview_y = False
directory_aware = True
file_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'gif']
file_extensions = ["png", "jpg", "jpeg", "bmp", "gif"]
class PaletteFromImageChooserDialog(ImageFileChooserDialog):
title = 'Palette from image'
confirm_caption = 'Choose'
title = "Palette from image"
confirm_caption = "Choose"
def confirm_pressed(self):
if not os.path.exists(self.field_texts[0]):
@ -291,18 +300,19 @@ class PaletteFromImageChooserDialog(ImageFileChooserDialog):
palette_filename = os.path.splitext(palette_filename)[0]
self.ui.active_dialog.field_texts[1] = palette_filename
#
# palette chooser
#
class PaletteChooserItem(BaseFileChooserItem):
class PaletteChooserItem(BaseFileChooserItem):
def get_label(self):
return os.path.splitext(self.name)[0]
def get_description_lines(self):
colors = len(self.palette.colors)
return ['Unique colors: %s' % str(colors - 1)]
return ["Unique colors: %s" % str(colors - 1)]
def get_preview_texture(self, app):
return self.palette.src_texture
@ -312,8 +322,7 @@ class PaletteChooserItem(BaseFileChooserItem):
class PaletteChooserDialog(BaseFileChooserDialog):
title = 'Choose palette'
title = "Choose palette"
chooser_item_class = PaletteChooserItem
def get_initial_selection(self):
@ -324,7 +333,7 @@ class PaletteChooserDialog(BaseFileChooserDialog):
# eg filename minus extension
if item.label == self.ui.active_art.palette.name:
return item.index
#print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
# print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
return 0
def get_filenames(self):
@ -343,24 +352,25 @@ class PaletteChooserDialog(BaseFileChooserDialog):
self.ui.active_art.set_palette(item.palette, log=True)
self.ui.popup.set_active_palette(item.palette)
#
# charset chooser
#
class CharsetChooserItem(BaseFileChooserItem):
class CharsetChooserItem(BaseFileChooserItem):
def get_label(self):
return os.path.splitext(self.name)[0]
def get_description_lines(self):
# first comment in file = description
lines = []
for line in open(self.charset.filename, encoding='utf-8').readlines():
for line in open(self.charset.filename, encoding="utf-8").readlines():
line = line.strip()
if line.startswith('//'):
if line.startswith("//"):
lines.append(line[2:])
break
lines.append('Characters: %s' % str(self.charset.last_index))
lines.append("Characters: %s" % str(self.charset.last_index))
return lines
def get_preview_texture(self, app):
@ -371,8 +381,7 @@ class CharsetChooserItem(BaseFileChooserItem):
class CharSetChooserDialog(BaseFileChooserDialog):
title = 'Choose character set'
title = "Choose character set"
flip_preview_y = False
chooser_item_class = CharsetChooserItem
@ -382,7 +391,7 @@ class CharSetChooserDialog(BaseFileChooserDialog):
for item in self.items:
if item.label == self.ui.active_art.charset.name:
return item.index
#print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
# print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
return 0
def get_filenames(self):
@ -405,7 +414,6 @@ class CharSetChooserDialog(BaseFileChooserDialog):
class ArtScriptChooserItem(BaseFileChooserItem):
def get_label(self):
label = os.path.splitext(self.name)[0]
return os.path.basename(label)
@ -417,10 +425,10 @@ class ArtScriptChooserItem(BaseFileChooserItem):
line = line.strip()
if not line:
continue
if not line.startswith('#'):
if not line.startswith("#"):
break
# snip #
line = line[line.index('#')+1:]
line = line[line.index("#") + 1 :]
lines.append(line)
return lines
@ -429,8 +437,7 @@ class ArtScriptChooserItem(BaseFileChooserItem):
class RunArtScriptDialog(BaseFileChooserDialog):
title = 'Run Artscript'
title = "Run Artscript"
tile_width, big_width = 70, 90
tile_height, big_height = 15, 25
chooser_item_class = ArtScriptChooserItem
@ -454,9 +461,8 @@ class RunArtScriptDialog(BaseFileChooserDialog):
class OverlayImageFileChooserDialog(ImageFileChooserDialog):
title = 'Choose overlay image'
confirm_caption = 'Choose'
title = "Choose overlay image"
confirm_caption = "Choose"
def confirm_pressed(self):
filename = self.field_texts[0]

View file

@ -1,20 +1,20 @@
from ui_dialog import UIDialog, Field
from ui_console import SetGameDirCommand, LoadGameStateCommand, SaveGameStateCommand
from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP
from ui_console import LoadGameStateCommand, SaveGameStateCommand
from ui_dialog import Field, UIDialog
from ui_list_operations import (
LO_NONE,
)
class NewGameDirDialog(UIDialog):
title = 'New game'
field0_label = 'Name of new game folder:'
field1_label = 'Name of new game:'
title = "New game"
field0_label = "Name of new game folder:"
field1_label = "Name of new game:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False),
Field(label=field1_label, type=str, width=field_width, oneline=False)
Field(label=field1_label, type=str, width=field_width, oneline=False),
]
confirm_caption = 'Create'
confirm_caption = "Create"
game_mode_visible = True
# TODO: only allow names that don't already exist
@ -22,7 +22,7 @@ class NewGameDirDialog(UIDialog):
def get_initial_field_text(self, field_number):
# provide a reasonable non-blank name
if field_number == 0:
return 'newgame'
return "newgame"
elif field_number == 1:
return type(self.ui.app.gw).game_title
@ -31,15 +31,13 @@ class NewGameDirDialog(UIDialog):
self.ui.app.enter_game_mode()
self.dismiss()
class LoadGameStateDialog(UIDialog):
title = 'Open game state'
field_label = 'Game state file to open:'
class LoadGameStateDialog(UIDialog):
title = "Open game state"
field_label = "Game state file to open:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Open'
fields = [Field(label=field_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Open"
game_mode_visible = True
# TODO: only allow valid game state file in current game directory
@ -48,59 +46,58 @@ class LoadGameStateDialog(UIDialog):
LoadGameStateCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss()
class SaveGameStateDialog(UIDialog):
title = 'Save game state'
field_label = 'New filename for game state:'
class SaveGameStateDialog(UIDialog):
title = "Save game state"
field_label = "New filename for game state:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Save'
fields = [Field(label=field_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Save"
game_mode_visible = True
def confirm_pressed(self):
SaveGameStateCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss()
class AddRoomDialog(UIDialog):
title = 'Add new room'
field0_label = 'Name for new room:'
field1_label = 'Class of new room:'
title = "Add new room"
field0_label = "Name for new room:"
field1_label = "Class of new room:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False),
Field(label=field1_label, type=str, width=field_width, oneline=False)
Field(label=field1_label, type=str, width=field_width, oneline=False),
]
confirm_caption = 'Add'
confirm_caption = "Add"
game_mode_visible = True
invalid_room_name_error = 'Invalid room name.'
invalid_room_name_error = "Invalid room name."
def get_initial_field_text(self, field_number):
# provide a reasonable non-blank name
if field_number == 0:
return 'Room ' + str(len(self.ui.app.gw.rooms) + 1)
return "Room " + str(len(self.ui.app.gw.rooms) + 1)
elif field_number == 1:
return 'GameRoom'
return "GameRoom"
def is_input_valid(self):
return self.field_texts[0] != '', self.invalid_room_name_error
return self.field_texts[0] != "", self.invalid_room_name_error
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
self.ui.app.gw.add_room(self.field_texts[0], self.field_texts[1])
self.dismiss()
class SetRoomCamDialog(UIDialog):
title = 'Set room camera marker'
title = "Set room camera marker"
tile_width = 48
field0_label = 'Name of location marker object for this room:'
field0_label = "Name of location marker object for this room:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Set'
fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Set"
game_mode_visible = True
def dismiss(self):
@ -111,28 +108,33 @@ class SetRoomCamDialog(UIDialog):
self.ui.app.gw.current_room.set_camera_marker_name(self.field_texts[0])
self.dismiss()
class SetRoomEdgeWarpsDialog(UIDialog):
title = 'Set room edge warps'
title = "Set room edge warps"
tile_width = 48
fields = 4
field0_label = 'Name of room/object to warp at LEFT edge:'
field1_label = 'Name of room/object to warp at RIGHT edge:'
field2_label = 'Name of room/object to warp at TOP edge:'
field3_label = 'Name of room/object to warp at BOTTOM edge:'
field0_label = "Name of room/object to warp at LEFT edge:"
field1_label = "Name of room/object to warp at RIGHT edge:"
field2_label = "Name of room/object to warp at TOP edge:"
field3_label = "Name of room/object to warp at BOTTOM edge:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False),
Field(label=field1_label, type=str, width=field_width, oneline=False),
Field(label=field2_label, type=str, width=field_width, oneline=False),
Field(label=field3_label, type=str, width=field_width, oneline=False)
Field(label=field3_label, type=str, width=field_width, oneline=False),
]
confirm_caption = 'Set'
confirm_caption = "Set"
game_mode_visible = True
def get_initial_field_text(self, field_number):
room = self.ui.app.gw.current_room
names = {0: room.left_edge_warp_dest_name, 1: room.right_edge_warp_dest_name,
2: room.top_edge_warp_dest_name, 3: room.bottom_edge_warp_dest_name}
names = {
0: room.left_edge_warp_dest_name,
1: room.right_edge_warp_dest_name,
2: room.top_edge_warp_dest_name,
3: room.bottom_edge_warp_dest_name,
}
return names[field_number]
def dismiss(self):
@ -148,14 +150,13 @@ class SetRoomEdgeWarpsDialog(UIDialog):
room.reset_edge_warps()
self.dismiss()
class SetRoomBoundsObjDialog(UIDialog):
title = 'Set room edge object'
field0_label = 'Name of object to use for room bounds:'
title = "Set room edge object"
field0_label = "Name of object to use for room bounds:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Set'
fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Set"
game_mode_visible = True
def get_initial_field_text(self, field_number):
@ -172,27 +173,27 @@ class SetRoomBoundsObjDialog(UIDialog):
room.reset_edge_warps()
self.dismiss()
class RenameRoomDialog(UIDialog):
title = 'Rename room'
field0_label = 'New name for current room:'
title = "Rename room"
field0_label = "New name for current room:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Rename'
fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Rename"
game_mode_visible = True
invalid_room_name_error = 'Invalid room name.'
invalid_room_name_error = "Invalid room name."
def get_initial_field_text(self, field_number):
if field_number == 0:
return self.ui.app.gw.current_room.name
def is_input_valid(self):
return self.field_texts[0] != '', self.invalid_room_name_error
return self.field_texts[0] != "", self.invalid_room_name_error
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
world = self.ui.app.gw
world.rename_room(world.current_room, self.field_texts[0])
self.dismiss()

View file

@ -1,6 +1,14 @@
# coding=utf-8
from ui_menu_pulldown_item import (
FileQuitItem,
PulldownMenuData,
PulldownMenuItem,
SeparatorItem,
ViewSetZoomItem,
ViewToggleCameraTiltItem,
ViewToggleCRTItem,
ViewToggleGridItem,
)
from ui_menu_pulldown_item import PulldownMenuItem, SeparatorItem, PulldownMenuData, FileQuitItem, ViewToggleCRTItem, ViewToggleCameraTiltItem, ViewSetZoomItem, ViewToggleGridItem
class GameModePulldownMenuItem(PulldownMenuItem):
# unless overridden, game mode items not allowed in art mode
@ -11,297 +19,405 @@ class GameModePulldownMenuItem(PulldownMenuItem):
# game menu
#
class HideEditUIItem(GameModePulldownMenuItem):
label = 'Hide edit UI'
command = 'toggle_game_edit_ui'
label = "Hide edit UI"
command = "toggle_game_edit_ui"
close_on_select = True
always_active = True
class NewGameDirItem(GameModePulldownMenuItem):
label = 'New game…'
command = 'new_game_dir'
label = "New game…"
command = "new_game_dir"
always_active = True
class SetGameDirItem(GameModePulldownMenuItem):
label = 'Open game…'
command = 'set_game_dir'
label = "Open game…"
command = "set_game_dir"
close_on_select = True
always_active = True
class PauseGameItem(GameModePulldownMenuItem):
label = 'blah'
command = 'toggle_anim_playback'
label = "blah"
command = "toggle_anim_playback"
always_active = True
def get_label(app):
return ['Pause game', 'Unpause game'][app.gw.paused]
return ["Pause game", "Unpause game"][app.gw.paused]
class OpenConsoleItem(GameModePulldownMenuItem):
label = 'Open dev console'
command = 'toggle_console'
label = "Open dev console"
command = "toggle_console"
close_on_select = True
always_active = True
art_mode_allowed = True
#
# state menu
#
class ResetStateItem(GameModePulldownMenuItem):
label = 'Reset to last state'
command = 'reset_game'
label = "Reset to last state"
command = "reset_game"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class LoadStateItem(GameModePulldownMenuItem):
label = 'Load state…'
command = 'load_game_state'
label = "Load state…"
command = "load_game_state"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class SaveStateItem(GameModePulldownMenuItem):
label = 'Save current state'
command = 'save_current'
label = "Save current state"
command = "save_current"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class SaveNewStateItem(GameModePulldownMenuItem):
label = 'Save new state…'
command = 'save_game_state'
label = "Save new state…"
command = "save_game_state"
def should_dim(app):
return not app.gw.game_dir
#
# view menu
#
class ObjectsToCameraItem(GameModePulldownMenuItem):
label = 'Move selected object(s) to camera'
command = 'objects_to_camera'
label = "Move selected object(s) to camera"
command = "objects_to_camera"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class CameraToObjectsItem(GameModePulldownMenuItem):
label = 'Move camera to selected object'
command = 'camera_to_objects'
label = "Move camera to selected object"
command = "camera_to_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) != 1
class ToggleDebugObjectsItem(GameModePulldownMenuItem):
label = ' Draw debug objects'
command = 'toggle_debug_objects'
label = " Draw debug objects"
command = "toggle_debug_objects"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.properties and ui.app.gw.properties.draw_debug_objects
class ToggleOriginVizItem(GameModePulldownMenuItem):
label = ' Show all object origins'
command = 'toggle_all_origin_viz'
label = " Show all object origins"
command = "toggle_all_origin_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_origin_all
class ToggleBoundsVizItem(GameModePulldownMenuItem):
label = ' Show all object bounds'
command = 'toggle_all_bounds_viz'
label = " Show all object bounds"
command = "toggle_all_bounds_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_bounds_all
class ToggleCollisionVizItem(GameModePulldownMenuItem):
label = ' Show all object collision'
command = 'toggle_all_collision_viz'
label = " Show all object collision"
command = "toggle_all_collision_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_collision_all
#
# world menu
#
class EditWorldPropertiesItem(GameModePulldownMenuItem):
label = 'Edit world properties…'
command = 'edit_world_properties'
label = "Edit world properties…"
command = "edit_world_properties"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
#
# room menu
#
class ChangeRoomItem(GameModePulldownMenuItem):
label = 'Change current room…'
command = 'change_current_room'
label = "Change current room…"
command = "change_current_room"
close_on_select = True
def should_dim(app):
return len(app.gw.rooms) == 0
class AddRoomItem(GameModePulldownMenuItem):
label = 'Add room…'
command = 'add_room'
label = "Add room…"
command = "add_room"
def should_dim(app):
return not app.gw.game_dir
class SetRoomObjectsItem(GameModePulldownMenuItem):
label = 'Add/remove objects from room…'
command = 'set_room_objects'
label = "Add/remove objects from room…"
command = "set_room_objects"
close_on_select = True
def should_dim(app):
return app.gw.current_room is None
class RemoveRoomItem(GameModePulldownMenuItem):
label = 'Remove this room'
command = 'remove_current_room'
label = "Remove this room"
command = "remove_current_room"
close_on_select = True
def should_dim(app):
return app.gw.current_room is None
class RenameRoomItem(GameModePulldownMenuItem):
label = 'Rename this room…'
command = 'rename_current_room'
label = "Rename this room…"
command = "rename_current_room"
def should_dim(app):
return app.gw.current_room is None
class ToggleAllRoomsVizItem(GameModePulldownMenuItem):
label = 'blah'
command = 'toggle_all_rooms_visible'
label = "blah"
command = "toggle_all_rooms_visible"
def should_dim(app):
return len(app.gw.rooms) == 0
def get_label(app):
return ['Show all rooms', 'Show only current room'][app.gw.show_all_rooms]
return ["Show all rooms", "Show only current room"][app.gw.show_all_rooms]
class ToggleListOnlyRoomObjectItem(GameModePulldownMenuItem):
label = ' List only objects in this room'
command = 'toggle_list_only_room_objects'
label = " List only objects in this room"
command = "toggle_list_only_room_objects"
def should_dim(app):
return len(app.gw.rooms) == 0
def should_mark(ui):
return ui.app.gw.list_only_current_room_objects
class ToggleRoomCamerasItem(GameModePulldownMenuItem):
label = ' Camera changes with room'
command = 'toggle_room_camera_changes'
label = " Camera changes with room"
command = "toggle_room_camera_changes"
def should_dim(app):
return len(app.gw.rooms) == 0
def should_mark(ui):
return ui.app.gw.room_camera_changes_enabled
class SetRoomCameraItem(GameModePulldownMenuItem):
label = "Set this room's camera marker…"
command = 'set_room_camera_marker'
command = "set_room_camera_marker"
def should_dim(app):
return app.gw.current_room is None
class SetRoomEdgeDestinationsItem(GameModePulldownMenuItem):
label = "Set this room's edge warps…"
command = 'set_room_edge_warps'
command = "set_room_edge_warps"
def should_dim(app):
return app.gw.current_room is None
class SetRoomBoundsObject(GameModePulldownMenuItem):
label = "Set this room's edge object…"
command = 'set_room_bounds_obj'
command = "set_room_bounds_obj"
def should_dim(app):
return app.gw.current_room is None
class AddSelectedToCurrentRoomItem(GameModePulldownMenuItem):
label = 'Add selected objects to this room'
command = 'add_selected_to_room'
label = "Add selected objects to this room"
command = "add_selected_to_room"
def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0
class RemoveSelectedFromCurrentRoomItem(GameModePulldownMenuItem):
label = 'Remove selected objects from this room'
command = 'remove_selected_from_room'
label = "Remove selected objects from this room"
command = "remove_selected_from_room"
def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0
#
# object menu
#
class SpawnObjectItem(GameModePulldownMenuItem):
label = 'Spawn object…'
command = 'choose_spawn_object_class'
label = "Spawn object…"
command = "choose_spawn_object_class"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class DuplicateObjectsItem(GameModePulldownMenuItem):
label = 'Duplicate selected objects'
command = 'duplicate_selected_objects'
label = "Duplicate selected objects"
command = "duplicate_selected_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class SelectObjectsItem(GameModePulldownMenuItem):
label = 'Select objects…'
command = 'select_objects'
label = "Select objects…"
command = "select_objects"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class EditArtForObjectsItem(GameModePulldownMenuItem):
label = 'Edit art for selected…'
command = 'edit_art_for_selected_objects'
label = "Edit art for selected…"
command = "edit_art_for_selected_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class SetObjectRoomsItem(GameModePulldownMenuItem):
label = 'Add/remove this object from rooms…'
command = 'set_object_rooms'
label = "Add/remove this object from rooms…"
command = "set_object_rooms"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) != 1
class DeleteSelectedObjectsItem(GameModePulldownMenuItem):
label = 'Delete selected object(s)'
command = 'erase_selection_or_art'
label = "Delete selected object(s)"
command = "erase_selection_or_art"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class GameMenuData(PulldownMenuData):
items = [HideEditUIItem, OpenConsoleItem, SeparatorItem,
NewGameDirItem, SetGameDirItem, PauseGameItem, SeparatorItem,
FileQuitItem]
items = [
HideEditUIItem,
OpenConsoleItem,
SeparatorItem,
NewGameDirItem,
SetGameDirItem,
PauseGameItem,
SeparatorItem,
FileQuitItem,
]
class GameStateMenuData(PulldownMenuData):
items = [ResetStateItem, LoadStateItem, SaveStateItem, SaveNewStateItem]
class GameViewMenuData(PulldownMenuData):
items = [ViewToggleCRTItem, ViewToggleGridItem, SeparatorItem,
ViewSetZoomItem, ViewToggleCameraTiltItem, SeparatorItem,
ObjectsToCameraItem, CameraToObjectsItem, ToggleDebugObjectsItem,
ToggleOriginVizItem, ToggleBoundsVizItem, ToggleCollisionVizItem]
items = [
ViewToggleCRTItem,
ViewToggleGridItem,
SeparatorItem,
ViewSetZoomItem,
ViewToggleCameraTiltItem,
SeparatorItem,
ObjectsToCameraItem,
CameraToObjectsItem,
ToggleDebugObjectsItem,
ToggleOriginVizItem,
ToggleBoundsVizItem,
ToggleCollisionVizItem,
]
def should_mark_item(item, ui):
if hasattr(item, 'should_mark'):
if hasattr(item, "should_mark"):
return item.should_mark(ui)
return False
class GameWorldMenuData(PulldownMenuData):
items = [EditWorldPropertiesItem]
class GameRoomMenuData(PulldownMenuData):
items = [ChangeRoomItem, AddRoomItem, RemoveRoomItem, RenameRoomItem,
ToggleAllRoomsVizItem, ToggleListOnlyRoomObjectItem, ToggleRoomCamerasItem, SeparatorItem,
AddSelectedToCurrentRoomItem, RemoveSelectedFromCurrentRoomItem,
SetRoomObjectsItem, SeparatorItem,
SetRoomCameraItem, SetRoomEdgeDestinationsItem, SetRoomBoundsObject,
SeparatorItem
items = [
ChangeRoomItem,
AddRoomItem,
RemoveRoomItem,
RenameRoomItem,
ToggleAllRoomsVizItem,
ToggleListOnlyRoomObjectItem,
ToggleRoomCamerasItem,
SeparatorItem,
AddSelectedToCurrentRoomItem,
RemoveSelectedFromCurrentRoomItem,
SetRoomObjectsItem,
SeparatorItem,
SetRoomCameraItem,
SetRoomEdgeDestinationsItem,
SetRoomBoundsObject,
SeparatorItem,
]
def should_mark_item(item, ui):
"show checkmark for current room"
if not ui.app.gw.current_room:
return False
if hasattr(item, 'should_mark'):
if hasattr(item, "should_mark"):
return item.should_mark(ui)
return ui.app.gw.current_room.name == item.cb_arg
@ -320,18 +436,21 @@ class GameRoomMenuData(PulldownMenuData):
if len(item.label) + 1 > longest_line:
longest_line = len(item.label) + 1
# cap at max allowed line length
for room_name,room in app.gw.rooms.items():
class TempMenuItemClass(GameModePulldownMenuItem): pass
for room_name, room in app.gw.rooms.items():
class TempMenuItemClass(GameModePulldownMenuItem):
pass
item = TempMenuItemClass
# leave spaces for mark
item.label = ' %s' % room_name
item.label = " %s" % room_name
# pad, put Z depth on far right
item.label = item.label.ljust(longest_line)
# trim to keep below a max length
item.label = item.label[:longest_line]
# tell PulldownMenu's button creation process not to auto-pad
item.no_pad = True
item.command = 'change_current_room_to'
item.command = "change_current_room_to"
item.cb_arg = room_name
items.append(item)
# sort room list alphabetically so it's stable, if arbitrary
@ -340,6 +459,12 @@ class GameRoomMenuData(PulldownMenuData):
class GameObjectMenuData(PulldownMenuData):
items = [SpawnObjectItem, DuplicateObjectsItem, SeparatorItem,
SelectObjectsItem, EditArtForObjectsItem, SetObjectRoomsItem,
DeleteSelectedObjectsItem]
items = [
SpawnObjectItem,
DuplicateObjectsItem,
SeparatorItem,
SelectObjectsItem,
EditArtForObjectsItem,
SetObjectRoomsItem,
DeleteSelectedObjectsItem,
]

View file

@ -1,19 +1,19 @@
import sdl2
from ui_element import UIElement
from ui_dialog import UIDialog
from ui_element import UIElement
class PagedInfoDialog(UIDialog):
"dialog that presents multiple pages of info w/ buttons to navigate next/last page"
title = 'Info'
title = "Info"
# message = list of page strings, each can be triple-quoted / contain line breaks
message = ['']
message = [""]
tile_width = 54
confirm_caption = '>>'
other_caption = '<<'
cancel_caption = 'Done'
confirm_caption = ">>"
other_caption = "<<"
cancel_caption = "Done"
other_button_visible = True
extra_lines = 1
@ -26,29 +26,29 @@ class PagedInfoDialog(UIDialog):
# disable prev/next buttons if we're at either end of the page list
if self.page == 0:
self.other_button.can_hover = False
self.other_button.set_state('dimmed')
self.other_button.set_state("dimmed")
elif self.page == len(self.message) - 1:
self.confirm_button.can_hover = False
self.confirm_button.set_state('dimmed')
self.confirm_button.set_state("dimmed")
else:
for button in [self.confirm_button, self.other_button]:
button.can_hover = True
button.dimmed = False
if button.state != 'normal':
button.set_state('normal')
if button.state != "normal":
button.set_state("normal")
UIElement.update(self)
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
if keystr == 'Left':
if keystr == "Left":
self.other_pressed()
elif keystr == 'Right':
elif keystr == "Right":
self.confirm_pressed()
elif keystr == 'Escape':
elif keystr == "Escape":
self.cancel_pressed()
def get_message(self):
return self.message[self.page].rstrip().split('\n')
return self.message[self.page].rstrip().split("\n")
def confirm_pressed(self):
# confirm repurposed to "next page"
@ -68,8 +68,8 @@ class PagedInfoDialog(UIDialog):
about_message = [
# max line width 50 characters!
"""
# max line width 50 characters!
"""
by JP LeBreton (c) 2014-2022 |
Playscii was made with the support of many nice
@ -89,7 +89,7 @@ James Noble, David Pittman, Richard Porczak,
Dan Sanderson, Shannon Strucci, Pablo López Soriano,
Jack Turner, Chris Welch, Andrew Yoder
""",
"""
"""
Programming Contributions:
Mattias Gustavsson, Rohit Nirmal, Sean Gubelman,
@ -108,7 +108,7 @@ Anna Anthropy, Andi McClure, Bret Victor,
Tim Sweeney (ZZT), Craig Hickman (Kid Pix),
Bill Atkinson (HyperCard)
""",
"""
"""
Love, Encouragement, Moral Support:
L Stiger
@ -120,14 +120,16 @@ Aubrey Hesselgren
Zak McClendon
Claire Hosking
#tool-design
"""
""",
]
class AboutDialog(PagedInfoDialog):
title = 'Playscii'
title = "Playscii"
message = about_message
game_mode_visible = True
all_modes_visible = True
def __init__(self, ui, options):
self.title += ' %s' % ui.app.version
self.title += " %s" % ui.app.version
PagedInfoDialog.__init__(self, ui, options)

View file

@ -1,4 +1,3 @@
# list operations - tells ListPanel what to do when clicked
LO_NONE = 0

View file

@ -1,15 +1,33 @@
from math import ceil
from ui_element import UIElement
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_menu_pulldown_item import FileMenuData, EditMenuData, ToolMenuData, ViewMenuData, ArtMenuData, FrameMenuData, LayerMenuData, CharColorMenuData, HelpMenuData
from ui_game_menu_pulldown_item import GameMenuData, GameStateMenuData, GameViewMenuData, GameWorldMenuData, GameRoomMenuData, GameObjectMenuData
from ui_info_dialog import AboutDialog
from ui_colors import UIColors
from renderable_sprite import UISpriteRenderable
from ui_button import TEXT_CENTER, UIButton
from ui_colors import UIColors
from ui_element import UIElement
from ui_game_menu_pulldown_item import (
GameMenuData,
GameObjectMenuData,
GameRoomMenuData,
GameStateMenuData,
GameViewMenuData,
GameWorldMenuData,
)
from ui_info_dialog import AboutDialog
from ui_menu_pulldown_item import (
ArtMenuData,
CharColorMenuData,
EditMenuData,
FileMenuData,
FrameMenuData,
HelpMenuData,
LayerMenuData,
ToolMenuData,
ViewMenuData,
)
class MenuButton(UIButton):
caption = 'Base Class Menu Button'
caption = "Base Class Menu Button"
caption_justify = TEXT_CENTER
# menu data is just a class w/ little more than a list of items, partly
# so we don't have to list all the items here in a different module
@ -44,116 +62,135 @@ class MenuButton(UIButton):
# playscii logo button = normal UIButton, opens About screen directly
class PlaysciiMenuButton(UIButton):
name = 'playscii'
caption = ' '
name = "playscii"
caption = " "
caption_justify = TEXT_CENTER
width = len(caption) + 2
normal_bg_color = MenuButton.normal_bg_color
hovered_bg_color = MenuButton.hovered_bg_color
dimmed_bg_color = MenuButton.dimmed_bg_color
#
# art mode menu buttons
#
class FileMenuButton(MenuButton):
name = 'file'
caption = 'File'
name = "file"
caption = "File"
menu_data = FileMenuData
class EditMenuButton(MenuButton):
name = 'edit'
caption = 'Edit'
name = "edit"
caption = "Edit"
menu_data = EditMenuData
class ToolMenuButton(MenuButton):
name = 'tool'
caption = 'Tool'
name = "tool"
caption = "Tool"
menu_data = ToolMenuData
class ViewMenuButton(MenuButton):
name = 'view'
caption = 'View'
name = "view"
caption = "View"
menu_data = ViewMenuData
class ArtMenuButton(MenuButton):
name = 'art'
caption = 'Art'
name = "art"
caption = "Art"
menu_data = ArtMenuData
class FrameMenuButton(MenuButton):
name = 'frame'
caption = 'Frame'
name = "frame"
caption = "Frame"
menu_data = FrameMenuData
class LayerMenuButton(MenuButton):
name = 'layer'
caption = 'Layer'
name = "layer"
caption = "Layer"
menu_data = LayerMenuData
class CharColorMenuButton(MenuButton):
name = 'char_color'
caption = 'Char/Color'
name = "char_color"
caption = "Char/Color"
menu_data = CharColorMenuData
# (appears in both art and game mode menus)
class HelpMenuButton(MenuButton):
name = 'help'
caption = 'Help'
name = "help"
caption = "Help"
menu_data = HelpMenuData
#
# game mode menu buttons
#
class GameMenuButton(MenuButton):
name = 'game'
caption = 'Game'
name = "game"
caption = "Game"
menu_data = GameMenuData
class StateMenuButton(MenuButton):
name = 'state'
caption = 'State'
name = "state"
caption = "State"
menu_data = GameStateMenuData
class GameViewMenuButton(MenuButton):
name = 'view'
caption = 'View'
name = "view"
caption = "View"
menu_data = GameViewMenuData
class WorldMenuButton(MenuButton):
name = 'world'
caption = 'World'
name = "world"
caption = "World"
menu_data = GameWorldMenuData
class RoomMenuButton(MenuButton):
name = 'room'
caption = 'Room'
name = "room"
caption = "Room"
menu_data = GameRoomMenuData
class ObjectMenuButton(MenuButton):
name = 'object'
caption = 'Object'
name = "object"
caption = "Object"
menu_data = GameObjectMenuData
class ModeMenuButton(UIButton):
caption_justify = TEXT_CENTER
normal_bg_color = UIColors.black
normal_fg_color = UIColors.white
#hovered_bg_color = UIColors.lightgrey
#dimmed_bg_color = UIColors.lightgrey
# hovered_bg_color = UIColors.lightgrey
# dimmed_bg_color = UIColors.lightgrey
class ArtModeMenuButton(ModeMenuButton):
caption = 'Game Mode'
caption = "Game Mode"
width = len(caption) + 2
class GameModeMenuButton(ModeMenuButton):
caption = 'Art Mode'
caption = "Art Mode"
width = len(caption) + 2
class MenuBar(UIElement):
"main menu bar element, has lots of buttons which control the pulldown"
snap_top = True
@ -180,7 +217,7 @@ class MenuBar(UIElement):
button.width = len(button.caption) + 2
button.x = x
x += button.width + self.button_padding
setattr(self, '%s_button' % button.name, button)
setattr(self, "%s_button" % button.name, button)
# NOTE: callback already defined in MenuButton class,
# menu data for pulldown with set in MenuButton subclass
button.pulldown = self.ui.pulldown
@ -197,7 +234,9 @@ class MenuBar(UIElement):
if not self.mode_button_class:
return
self.mode_button = self.mode_button_class(self)
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
self.mode_button.callback = self.toggle_game_mode
self.buttons.append(self.mode_button)
@ -227,7 +266,7 @@ class MenuBar(UIElement):
for button in self.menu_buttons:
if button.name == self.active_menu_name:
button.dimmed = False
button.set_state('normal')
button.set_state("normal")
self.active_menu_name = None
self.ui.pulldown.visible = False
self.ui.keyboard_focus_element = None
@ -253,12 +292,12 @@ class MenuBar(UIElement):
return
# don't navigate to the about menu
# (does this mean it's not accessible via kb-only? probably, that's fine)
if self.menu_buttons[index].name == 'playscii':
if self.menu_buttons[index].name == "playscii":
return
self.menu_buttons[index].callback()
def get_menu_index(self, menu_name):
for i,button in enumerate(self.menu_buttons):
for i, button in enumerate(self.menu_buttons):
if button.name == self.active_menu_name:
return i
@ -280,7 +319,9 @@ class MenuBar(UIElement):
self.art.clear_frame_layer(0, 0, bg, fg)
# reposition right-justified mode switch button
if self.mode_button:
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
# draw buttons, etc
UIElement.reset_art(self)
self.reset_icon()
@ -293,15 +334,31 @@ class MenuBar(UIElement):
UIElement.destroy(self)
self.playscii_sprite.destroy()
class ArtMenuBar(MenuBar):
button_classes = [FileMenuButton, EditMenuButton, ToolMenuButton,
ViewMenuButton, ArtMenuButton, FrameMenuButton,
LayerMenuButton, CharColorMenuButton, HelpMenuButton]
button_classes = [
FileMenuButton,
EditMenuButton,
ToolMenuButton,
ViewMenuButton,
ArtMenuButton,
FrameMenuButton,
LayerMenuButton,
CharColorMenuButton,
HelpMenuButton,
]
mode_button_class = GameModeMenuButton
class GameMenuBar(MenuBar):
button_classes = [GameMenuButton, StateMenuButton, GameViewMenuButton,
WorldMenuButton, RoomMenuButton, ObjectMenuButton,
HelpMenuButton]
button_classes = [
GameMenuButton,
StateMenuButton,
GameViewMenuButton,
WorldMenuButton,
RoomMenuButton,
ObjectMenuButton,
HelpMenuButton,
]
game_mode_visible = True
mode_button_class = ArtModeMenuButton

View file

@ -1,9 +1,8 @@
from ui_element import UIElement
from art import UV_FLIPX, UV_FLIPY, UV_ROTATE180
from ui_button import UIButton
from ui_colors import UIColors
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY
from ui_menu_pulldown_item import PulldownMenuItem, PulldownMenuData, SeparatorItem
from ui_element import UIElement
from ui_menu_pulldown_item import PulldownMenuData, PulldownMenuItem, SeparatorItem
class MenuItemButton(UIButton):
@ -13,7 +12,7 @@ class MenuItemButton(UIButton):
def hover(self):
UIButton.hover(self)
# keyboard nav if hovering with mouse
for i,button in enumerate(self.element.buttons):
for i, button in enumerate(self.element.buttons):
if button is self:
self.element.keyboard_nav_index = i
self.element.update_keyboard_hover()
@ -26,7 +25,6 @@ class MenuItemButton(UIButton):
class PulldownMenu(UIElement):
"element that's moved and resized based on currently active pulldown"
label_shortcut_padding = 5
@ -57,7 +55,7 @@ class PulldownMenu(UIElement):
if menu_button.menu_data.get_items is not PulldownMenuData.get_items:
items += menu_button.menu_data.get_items(self.ui.app)
for item in items:
shortcut,command = self.get_shortcut(item)
shortcut, command = self.get_shortcut(item)
shortcuts.append(shortcut)
callbacks.append(command)
# get label, static or dynamic
@ -81,11 +79,18 @@ class PulldownMenu(UIElement):
self.draw_border(menu_button)
# create as many buttons as needed, set their sizes, captions, callbacks
self.buttons = []
for i,item in enumerate(items):
for i, item in enumerate(items):
# skip button creation for separators, just draw a line
if item is SeparatorItem:
for x in range(1, self.tile_width - 1):
self.art.set_tile_at(0, 0, x, i+1, self.border_horizontal_line_char, self.border_color)
self.art.set_tile_at(
0,
0,
x,
i + 1,
self.border_horizontal_line_char,
self.border_color,
)
continue
button = MenuItemButton(self)
# give button a handle to its item
@ -99,23 +104,26 @@ class PulldownMenu(UIElement):
button.caption = full_label
button.width = len(full_label)
button.x = 1
button.y = i+1
button.y = i + 1
button.callback = callbacks[i]
if item.cb_arg is not None:
button.cb_arg = item.cb_arg
# dim items that aren't applicable to current app state
if not item.always_active and item.should_dim(self.ui.app):
button.set_state('dimmed')
button.set_state("dimmed")
button.can_hover = False
self.buttons.append(button)
# set our X and Y, draw buttons, etc
self.reset_loc()
self.reset_art()
# if this menu has special logic for marking items, use it
if not menu_button.menu_data.should_mark_item is PulldownMenuData.should_mark_item:
for i,item in enumerate(items):
if (
menu_button.menu_data.should_mark_item
is not PulldownMenuData.should_mark_item
):
for i, item in enumerate(items):
if menu_button.menu_data.should_mark_item(item, self.ui):
self.art.set_char_index_at(0, 0, 1, i+1, self.mark_char)
self.art.set_char_index_at(0, 0, 1, i + 1, self.mark_char)
# reset keyboard nav state for popups
if reset_keyboard_nav_index:
self.keyboard_nav_index = 0
@ -130,12 +138,12 @@ class PulldownMenu(UIElement):
# top/bottom edges
for x in range(self.tile_width):
self.art.set_tile_at(0, 0, x, 0, char, fg)
self.art.set_tile_at(0, 0, x, self.tile_height-1, char, fg)
self.art.set_tile_at(0, 0, x, self.tile_height - 1, char, fg)
# left/right edges
char = self.border_vertical_line_char
for y in range(self.tile_height):
self.art.set_tile_at(0, 0, 0, y, char, fg)
self.art.set_tile_at(0, 0, self.tile_width-1, y, char, fg)
self.art.set_tile_at(0, 0, self.tile_width - 1, y, char, fg)
# corners: bottom left, bottom right, top right
char = self.border_corner_char
x, y = 0, self.tile_height - 1
@ -157,26 +165,27 @@ class PulldownMenu(UIElement):
# return concise string for bind and the actual function it runs.
def null():
pass
# special handling of SeparatorMenuItem, no command or label
if menu_item is SeparatorItem:
return '', null
return "", null
binds = self.ui.app.il.edit_binds
for bind_tuple in binds:
command_functions = binds[bind_tuple]
for f in command_functions:
if f.__name__ == 'BIND_%s' % menu_item.command:
shortcut = ''
if f.__name__ == "BIND_%s" % menu_item.command:
shortcut = ""
# shift, alt, ctrl
if bind_tuple[1]:
shortcut += 'Shift-'
shortcut += "Shift-"
if bind_tuple[2]:
shortcut += 'Alt-'
shortcut += "Alt-"
if bind_tuple[3]:
# TODO: cmd vs ctrl for mac vs non
shortcut += 'C-'
shortcut += "C-"
# bind strings that start with _ will be disregarded
if not (bind_tuple[0].startswith('_') and len(bind_tuple[0]) > 1):
if not (bind_tuple[0].startswith("_") and len(bind_tuple[0]) > 1):
shortcut += bind_tuple[0]
return shortcut, f
self.ui.app.log('Shortcut/command not found: %s' % menu_item.command)
return '', null
self.ui.app.log("Shortcut/command not found: %s" % menu_item.command)
return "", null

File diff suppressed because it is too large Load diff

View file

@ -1,40 +1,44 @@
import os
from ui_button import UIButton, TEXT_RIGHT
from ui_edit_panel import GamePanel
from ui_dialog import UIDialog, Field
from ui_button import TEXT_RIGHT, UIButton
from ui_colors import UIColors
from ui_dialog import Field, UIDialog
from ui_edit_panel import GamePanel
class ResetObjectButton(UIButton):
caption = 'Reset object properties'
caption = "Reset object properties"
caption_justify = TEXT_RIGHT
def selected(button):
world = button.element.world
world.reset_object_in_place(world.selected_objects[0])
class EditObjectPropertyDialog(UIDialog):
"dialog invoked by panel property click, modified at runtime as needed"
base_title = 'Set %s'
field0_base_label = 'New %s for %s:'
base_title = "Set %s"
field0_base_label = "New %s for %s:"
field_width = UIDialog.default_field_width
fields = [
Field(label=field0_base_label, type=str, width=field_width, oneline=False)
]
confirm_caption = 'Set'
confirm_caption = "Set"
center_in_window = False
game_mode_visible = True
def is_input_valid(self):
try: self.fields[0].type(self.field_texts[0])
except: return False, ''
try:
self.fields[0].type(self.field_texts[0])
except:
return False, ""
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid: return
if not valid:
return
# set property for selected object(s)
new_value = self.fields[0].type(self.field_texts[0])
for obj in self.ui.app.gw.selected_objects:
@ -49,17 +53,18 @@ class EditObjectPropertyButton(UIButton):
class PropertyItem:
multi_value_text = '[various]'
multi_value_text = "[various]"
def __init__(self, prop_name):
self.prop_name = prop_name
# property value & type filled in after creation
self.prop_value = None
self.prop_type = None
def set_value(self, value):
# convert value to a button-friendly string
if type(value) is float:
valstr = '%.3f' % value
valstr = "%.3f" % value
# non-fixed decimal version may be shorter, if so use it
if len(str(value)) < len(valstr):
valstr = str(value)
@ -80,8 +85,8 @@ class PropertyItem:
class EditObjectPanel(GamePanel):
"panel showing info for selected game object"
tile_width = 36
tile_height = 36
snap_right = True
@ -95,18 +100,21 @@ class EditObjectPanel(GamePanel):
def create_buttons(self):
# buttons for persistent unique commands, eg reset object
for i,button_class in enumerate(self.base_button_classes):
for i, button_class in enumerate(self.base_button_classes):
button = button_class(self)
button.caption += ' '
button.caption += " "
button.width = self.tile_width
button.y = i + 1
button.callback = button.selected
if button.clear_before_caption_draw:
button.refresh_caption()
self.base_buttons.append(button)
def callback(item=None):
if not item: return
if not item:
return
self.clicked_item(item)
for y in range(self.tile_height - len(self.base_buttons) - 1):
button = EditObjectPropertyButton(self)
button.y = y + len(self.base_buttons) + 1
@ -130,17 +138,26 @@ class EditObjectPanel(GamePanel):
obj.set_object_property(item.prop_name, not val)
return
# set dialog values appropriate to property being edited
EditObjectPropertyDialog.title = EditObjectPropertyDialog.base_title % item.prop_name
EditObjectPropertyDialog.title = (
EditObjectPropertyDialog.base_title % item.prop_name
)
# can't set named tuple values directly, build a new one and set it
old_field = EditObjectPropertyDialog.fields[0]
new_label = EditObjectPropertyDialog.field0_base_label % (item.prop_type.__name__, item.prop_name)
new_label = EditObjectPropertyDialog.field0_base_label % (
item.prop_type.__name__,
item.prop_name,
)
new_type = item.prop_type
# if None, assume string
if item.prop_type is type(None):
new_type = str
new_field = Field(label=new_label, type=new_type, width=old_field.width,
oneline=old_field.oneline)
new_field = Field(
label=new_label,
type=new_type,
width=old_field.width,
oneline=old_field.oneline,
)
EditObjectPropertyDialog.fields[0] = new_field
tile_x = int(self.ui.width_tiles * self.ui.scale) - self.tile_width
@ -157,11 +174,11 @@ class EditObjectPanel(GamePanel):
selected = len(self.world.selected_objects)
# panel shouldn't draw when nothing selected, fill in anyway
if selected == 0:
return '[nothing selected]'
return "[nothing selected]"
elif selected == 1 and self.world.selected_objects[0]:
return self.world.selected_objects[0].name
else:
return '[%s selected]' % selected
return "[%s selected]" % selected
def refresh_items(self):
if len(self.world.selected_objects) == 0:
@ -174,7 +191,7 @@ class EditObjectPanel(GamePanel):
if obj is None:
continue
for propname in obj.serialized + obj.editable:
if not propname in propnames:
if propname not in propnames:
propnames.append(propname)
# build list of items from properties
items = []
@ -188,7 +205,7 @@ class EditObjectPanel(GamePanel):
item.set_value(getattr(obj, propname))
items.append(item)
# set each line
for i,b in enumerate(self.property_buttons):
for i, b in enumerate(self.property_buttons):
item = None
if i < len(items):
item = items[i]
@ -200,19 +217,19 @@ class EditObjectPanel(GamePanel):
y = button_index + len(self.base_buttons) + 1
self.art.clear_line(0, 0, y, self.fg_color, self.bg_color)
if item is None:
button.caption = ''
button.caption = ""
button.cb_arg = None
button.can_hover = False
return
# set button caption, width, x based on value
button.caption = '%s ' % item.prop_value
button.caption = "%s " % item.prop_value
button.width = len(button.caption) + 1
button.x = self.tile_width - button.width
button.cb_arg = item
button.can_hover = True
# set non-button text to the left correctly
x = button.x + 1
label = '%s: ' % item.prop_name
label = "%s: " % item.prop_name
self.art.write_string(0, 0, x, y, label, UIColors.darkgrey, None, True)
def update(self):

View file

@ -1,103 +1,122 @@
from ui_element import UIElement, UIArt, UIRenderable
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_swatch import CharacterSetSwatch, PaletteSwatch, MIN_CHARSET_WIDTH
from art import UV_FLIPX, UV_FLIPY, UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270
from renderable_line import SwatchSelectionBoxRenderable
from ui_button import TEXT_CENTER, UIButton
from ui_colors import UIColors
from ui_tool import FillTool, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FILL_BOUND_BG_COLOR
from renderable_line import LineRenderable, SwatchSelectionBoxRenderable
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY
from ui_element import UIArt, UIElement
from ui_file_chooser_dialog import CharSetChooserDialog, PaletteChooserDialog
from ui_swatch import MIN_CHARSET_WIDTH, CharacterSetSwatch, PaletteSwatch
from ui_tool import FILL_BOUND_BG_COLOR, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FillTool
TOOL_PANE_WIDTH = 10
class ToolTabButton(UIButton):
x, y = 0, 0
caption_y = 1
# width is set on the fly by popup size in reset_art
height = 3
caption_justify = TEXT_CENTER
caption = 'Tools'
caption = "Tools"
class CharColorTabButton(UIButton):
caption_y = 1
height = ToolTabButton.height
caption_justify = TEXT_CENTER
caption = 'Chars/Colors'
caption = "Chars/Colors"
# charset view scale up/down buttons
class CharSetScaleUpButton(UIButton):
width, height = 3, 1
x, y = -width, ToolTabButton.height + 1
caption = '+'
caption = "+"
caption_justify = TEXT_CENTER
class CharSetScaleDownButton(CharSetScaleUpButton):
x = -CharSetScaleUpButton.width + CharSetScaleUpButton.x
caption = '-'
caption = "-"
# charset flip / rotate buttons
class CharXformButton(UIButton):
hovered_fg_color = UIColors.white
hovered_bg_color = UIColors.medgrey
class CharFlipNoButton(CharXformButton):
x = 3 + len('Flip:') + 1
x = 3 + len("Flip:") + 1
y = CharSetScaleUpButton.y + 1
caption = 'None'
caption = "None"
width = len(caption) + 2
caption_justify = TEXT_CENTER
class CharFlipXButton(CharFlipNoButton):
x = CharFlipNoButton.x + CharFlipNoButton.width + 1
width = 3
caption = 'X'
caption = "X"
class CharFlipYButton(CharFlipXButton):
x = CharFlipXButton.x + CharFlipXButton.width + 1
caption = 'Y'
caption = "Y"
class CharRot0Button(CharXformButton):
x = 3 + len('Rotation:') + 1
x = 3 + len("Rotation:") + 1
y = CharFlipNoButton.y + 1
width = 3
caption = '0'
caption = "0"
caption_justify = TEXT_CENTER
class CharRot90Button(CharRot0Button):
x = CharRot0Button.x + CharRot0Button.width + 1
width = 4
caption = '90'
caption = "90"
class CharRot180Button(CharRot0Button):
x = CharRot90Button.x + CharRot90Button.width + 1
width = 5
caption = '180'
caption = "180"
class CharRot270Button(CharRot0Button):
x = CharRot180Button.x + CharRot180Button.width + 1
width = 5
caption = '270'
caption = "270"
# tool and tool settings buttons
class ToolButton(UIButton):
"a tool entry in the tool tab's left hand pane. populated from UI.tools"
width = TOOL_PANE_WIDTH
caption = 'TOOLZ'
caption = "TOOLZ"
y = ToolTabButton.height + 2
class BrushSizeUpButton(UIButton):
width = 3
y = ToolTabButton.height + 3
caption = '+'
caption = "+"
caption_justify = TEXT_CENTER
normal_fg_color = UIColors.white
normal_bg_color = UIColors.medgrey
class BrushSizeDownButton(BrushSizeUpButton):
caption = '-'
caption = "-"
class AffectCharToggleButton(UIButton):
width = 3
@ -108,38 +127,46 @@ class AffectCharToggleButton(UIButton):
normal_fg_color = UIColors.white
normal_bg_color = UIColors.medgrey
class AffectFgToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 1
class AffectBgToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 2
class AffectXformToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 3
# fill boundary mode items
class FillBoundaryModeCharButton(AffectCharToggleButton):
y = AffectXformToggleButton.y + 3
class FillBoundaryModeFGButton(AffectCharToggleButton):
y = FillBoundaryModeCharButton.y + 1
class FillBoundaryModeBGButton(AffectCharToggleButton):
y = FillBoundaryModeCharButton.y + 2
# charset / palette chooser buttons
class CharSetChooserButton(UIButton):
caption = 'Set:'
caption = "Set:"
x = 1
normal_fg_color = UIColors.black
normal_bg_color = UIColors.white
hovered_fg_color = UIColors.white
hovered_bg_color = UIColors.medgrey
class PaletteChooserButton(CharSetChooserButton):
caption = 'Palette:'
caption = "Palette:"
TAB_TOOLS = 0
@ -147,7 +174,6 @@ TAB_CHAR_COLOR = 1
class ToolPopup(UIElement):
visible = False
# actual width will be based on character set + palette size and scale
tile_width, tile_height = 20, 15
@ -156,19 +182,19 @@ class ToolPopup(UIElement):
fg_color = UIColors.black
bg_color = UIColors.lightgrey
highlight_color = UIColors.white
tool_settings_label = 'Tool Settings:'
brush_size_label = 'Brush size:'
affects_heading_label = 'Affects:'
affects_char_label = 'Character'
affects_fg_label = 'Foreground Color'
affects_bg_label = 'Background Color'
affects_xform_label = 'Rotation/Flip'
fill_boundary_modes_label = 'Fill boundary mode:'
tool_settings_label = "Tool Settings:"
brush_size_label = "Brush size:"
affects_heading_label = "Affects:"
affects_char_label = "Character"
affects_fg_label = "Foreground Color"
affects_bg_label = "Background Color"
affects_xform_label = "Rotation/Flip"
fill_boundary_modes_label = "Fill boundary mode:"
fill_boundary_char_label = affects_char_label
fill_boundary_fg_label = affects_fg_label
fill_boundary_bg_label = affects_bg_label
flip_label = 'Flip:'
rotation_label = 'Rotation:'
flip_label = "Flip:"
rotation_label = "Rotation:"
# index of check mark character in UI charset
check_char_index = 131
# index of off and on radio button characters in UI charset
@ -176,34 +202,34 @@ class ToolPopup(UIElement):
radio_char_1_index = 127
# map classes to member names / callbacks
button_names = {
ToolTabButton: 'tool_tab',
CharColorTabButton: 'char_color_tab',
ToolTabButton: "tool_tab",
CharColorTabButton: "char_color_tab",
}
char_color_tab_button_names = {
CharSetScaleUpButton: 'scale_charset_up',
CharSetScaleDownButton: 'scale_charset_down',
CharSetChooserButton: 'choose_charset',
CharFlipNoButton: 'xform_normal',
CharFlipXButton: 'xform_flipX',
CharFlipYButton: 'xform_flipY',
CharRot0Button: 'xform_0',
CharRot90Button: 'xform_90',
CharRot180Button: 'xform_180',
CharRot270Button: 'xform_270',
PaletteChooserButton: 'choose_palette',
CharSetScaleUpButton: "scale_charset_up",
CharSetScaleDownButton: "scale_charset_down",
CharSetChooserButton: "choose_charset",
CharFlipNoButton: "xform_normal",
CharFlipXButton: "xform_flipX",
CharFlipYButton: "xform_flipY",
CharRot0Button: "xform_0",
CharRot90Button: "xform_90",
CharRot180Button: "xform_180",
CharRot270Button: "xform_270",
PaletteChooserButton: "choose_palette",
}
tool_tab_button_names = {
BrushSizeUpButton: 'brush_size_up',
BrushSizeDownButton: 'brush_size_down',
AffectCharToggleButton: 'toggle_affect_char',
AffectFgToggleButton: 'toggle_affect_fg',
AffectBgToggleButton: 'toggle_affect_bg',
AffectXformToggleButton: 'toggle_affect_xform',
BrushSizeUpButton: "brush_size_up",
BrushSizeDownButton: "brush_size_down",
AffectCharToggleButton: "toggle_affect_char",
AffectFgToggleButton: "toggle_affect_fg",
AffectBgToggleButton: "toggle_affect_bg",
AffectXformToggleButton: "toggle_affect_xform",
}
fill_boundary_mode_button_names = {
FillBoundaryModeCharButton: 'set_fill_boundary_char',
FillBoundaryModeFGButton: 'set_fill_boundary_fg',
FillBoundaryModeBGButton: 'set_fill_boundary_bg'
FillBoundaryModeCharButton: "set_fill_boundary_char",
FillBoundaryModeFGButton: "set_fill_boundary_fg",
FillBoundaryModeBGButton: "set_fill_boundary_bg",
}
def __init__(self, ui):
@ -219,19 +245,26 @@ class ToolPopup(UIElement):
# create buttons from button:name map, button & callback names generated
# group these into lists that can be combined into self.buttons
self.common_buttons = self.create_buttons_from_map(self.button_names)
self.char_color_tab_buttons = self.create_buttons_from_map(self.char_color_tab_button_names)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(self.fill_boundary_mode_button_names)
self.tool_tab_buttons = self.create_buttons_from_map(self.tool_tab_button_names) + self.fill_boundary_mode_buttons
self.char_color_tab_buttons = self.create_buttons_from_map(
self.char_color_tab_button_names
)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(
self.fill_boundary_mode_button_names
)
self.tool_tab_buttons = (
self.create_buttons_from_map(self.tool_tab_button_names)
+ self.fill_boundary_mode_buttons
)
# populate more tool tab buttons from UI's list of tools
# similar to create_buttons_from_map, but class name isn't known
# MAYBE-TODO: is there a way to unify this?
for tool in self.ui.tools:
tool_button = ToolButton(self)
# caption: 1-space padding from left
tool_button.caption = ' %s' % tool.button_caption
tool_button_name = '%s_tool_button' % tool.name
tool_button.caption = " %s" % tool.button_caption
tool_button_name = "%s_tool_button" % tool.name
setattr(self, tool_button_name, tool_button)
cb_name = '%s_pressed' % tool_button_name
cb_name = "%s_pressed" % tool_button_name
tool_button.callback = getattr(self, cb_name)
# set a special property UI can refer to
tool_button.tool_name = tool.name
@ -239,15 +272,17 @@ class ToolPopup(UIElement):
UIElement.__init__(self, ui)
# set initial tab state
self.char_color_tab_button_pressed()
self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color = self.highlight_color
self.xform_0_button.normal_bg_color = (
self.xform_normal_button.normal_bg_color
) = self.highlight_color
def create_buttons_from_map(self, button_dict):
buttons = []
for button_class in button_dict:
button = button_class(self)
button_name = '%s_button' % button_dict[button_class]
button_name = "%s_button" % button_dict[button_class]
setattr(self, button_name, button)
cb_name = '%s_pressed' % button_name
cb_name = "%s_pressed" % button_name
button.callback = getattr(self, cb_name)
buttons.append(button)
return buttons
@ -352,7 +387,7 @@ class ToolPopup(UIElement):
UV_ROTATE180: self.xform_180_button,
UV_ROTATE270: self.xform_270_button,
UV_FLIPX: self.xform_flipX_button,
UV_FLIPY: self.xform_flipY_button
UV_FLIPY: self.xform_flipY_button,
}
for b in button_map:
if b == self.ui.selected_xform:
@ -396,15 +431,21 @@ class ToolPopup(UIElement):
# charset renderable location will be set in update()
charset = self.ui.active_art.charset
palette = self.ui.active_art.palette
cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height
cqw, cqh = (
self.charset_swatch.art.quad_width,
self.charset_swatch.art.quad_height,
)
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
# position & caption charset button
y = self.tab_height + 1
self.choose_charset_button.y = y
self.choose_charset_button.caption = ' %s %s ' % (CharSetChooserButton.caption, charset.name)
self.choose_charset_button.caption = " %s %s " % (
CharSetChooserButton.caption,
charset.name,
)
self.choose_charset_button.width = len(self.choose_charset_button.caption)
# charset scale
charset_scale = '%.2fx' % self.charset_swatch.char_scale
charset_scale = "%.2fx" % self.charset_swatch.char_scale
x = -self.scale_charset_up_button.width * 2
self.art.write_string(0, 0, x, y, charset_scale, None, None, True)
# transform labels and buttons, eg
@ -419,7 +460,10 @@ class ToolPopup(UIElement):
pal_caption_y = (cqh * charset.map_height) / self.art.quad_height
pal_caption_y += self.tab_height + 5
self.choose_palette_button.y = int(pal_caption_y)
self.choose_palette_button.caption = ' %s %s ' % (PaletteChooserButton.caption, palette.name)
self.choose_palette_button.caption = " %s %s " % (
PaletteChooserButton.caption,
palette.name,
)
self.choose_palette_button.width = len(self.choose_palette_button.caption)
# set button states so captions draw properly
tab_width = int(self.tile_width / 2)
@ -435,7 +479,7 @@ class ToolPopup(UIElement):
self.art.set_color_at(0, 0, x, y, self.ui.colors.medgrey, False)
# set selected tool BG lighter
y = self.tab_height + 1
for i,tool in enumerate(self.ui.tools):
for i, tool in enumerate(self.ui.tools):
tool_button = None
for button in self.tool_tab_buttons:
try:
@ -443,7 +487,7 @@ class ToolPopup(UIElement):
tool_button = button
except:
pass
tool_button.y = y+i
tool_button.y = y + i
if tool == self.ui.selected_tool:
tool_button.normal_bg_color = self.ui.colors.lightgrey
else:
@ -451,7 +495,10 @@ class ToolPopup(UIElement):
# draw current tool settings
x = TOOL_PANE_WIDTH + 1
y = self.tab_height + 1
label = '%s %s' % (self.ui.selected_tool.button_caption, self.tool_settings_label)
label = "%s %s" % (
self.ui.selected_tool.button_caption,
self.tool_settings_label,
)
self.art.write_string(0, 0, x, y, label)
x += 1
y += 2
@ -462,8 +509,8 @@ class ToolPopup(UIElement):
label = self.brush_size_label
# calculate X of + and - buttons based on size string
self.brush_size_down_button.x = TOOL_PANE_WIDTH + len(label) + 2
label += ' ' * (self.brush_size_down_button.width + 1)
label += '%s' % self.ui.selected_tool.brush_size
label += " " * (self.brush_size_down_button.width + 1)
label += "%s" % self.ui.selected_tool.brush_size
self.brush_size_up_button.x = TOOL_PANE_WIDTH + len(label) + 3
self.art.write_string(0, 0, x, y, label)
else:
@ -479,19 +526,29 @@ class ToolPopup(UIElement):
y += 2
self.art.write_string(0, 0, x, y, self.affects_heading_label)
y += 1
# set affects-* button labels AND captions
def get_affects_char(affects):
return [0, self.check_char_index][affects]
w = self.toggle_affect_char_button.width
label_toggle_pairs = []
label_toggle_pairs += [(self.affects_char_label, self.ui.selected_tool.affects_char)]
label_toggle_pairs += [(self.affects_fg_label, self.ui.selected_tool.affects_fg_color)]
label_toggle_pairs += [(self.affects_bg_label, self.ui.selected_tool.affects_bg_color)]
label_toggle_pairs += [(self.affects_xform_label, self.ui.selected_tool.affects_xform)]
for label,toggle in label_toggle_pairs:
self.art.write_string(0, 0, x+w+1, y, '%s' % label)
#self.art.set_tile_at(0, 0, x, y, get_affects_char(toggle), 4, 2)
self.art.set_char_index_at(0, 0, x+1, y, get_affects_char(toggle))
label_toggle_pairs += [
(self.affects_char_label, self.ui.selected_tool.affects_char)
]
label_toggle_pairs += [
(self.affects_fg_label, self.ui.selected_tool.affects_fg_color)
]
label_toggle_pairs += [
(self.affects_bg_label, self.ui.selected_tool.affects_bg_color)
]
label_toggle_pairs += [
(self.affects_xform_label, self.ui.selected_tool.affects_xform)
]
for label, toggle in label_toggle_pairs:
self.art.write_string(0, 0, x + w + 1, y, "%s" % label)
# self.art.set_tile_at(0, 0, x, y, get_affects_char(toggle), 4, 2)
self.art.set_char_index_at(0, 0, x + 1, y, get_affects_char(toggle))
y += 1
else:
self.toggle_affect_char_button.visible = False
@ -504,16 +561,22 @@ class ToolPopup(UIElement):
self.art.write_string(0, 0, x, y, self.fill_boundary_modes_label)
y += 1
# boundary mode buttons + labels
#x +=
labels = [self.fill_boundary_char_label,
self.fill_boundary_fg_label,
self.fill_boundary_bg_label]
for i,button in enumerate(self.fill_boundary_mode_buttons):
# x +=
labels = [
self.fill_boundary_char_label,
self.fill_boundary_fg_label,
self.fill_boundary_bg_label,
]
for i, button in enumerate(self.fill_boundary_mode_buttons):
button.visible = True
char = [self.radio_char_0_index, self.radio_char_1_index][i == self.ui.fill_tool.boundary_mode]
#self.ui.app.log(char)
self.art.set_char_index_at(0, 0, x+1, y, char)
self.art.write_string(0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i])
char = [self.radio_char_0_index, self.radio_char_1_index][
i == self.ui.fill_tool.boundary_mode
]
# self.ui.app.log(char)
self.art.set_char_index_at(0, 0, x + 1, y, char)
self.art.write_string(
0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i]
)
y += 1
else:
for button in self.fill_boundary_mode_buttons:
@ -527,7 +590,10 @@ class ToolPopup(UIElement):
# set panel size based on charset size
margin = self.swatch_margin * 2
charset = self.ui.active_art.charset
cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height
cqw, cqh = (
self.charset_swatch.art.quad_width,
self.charset_swatch.art.quad_height,
)
old_width, old_height = self.tile_width, self.tile_height
# min width in case of tiny charsets
charset_tile_width = max(charset.map_width, MIN_CHARSET_WIDTH)
@ -537,7 +603,10 @@ class ToolPopup(UIElement):
# account for popup info lines etc: charset name + palette name + 1 padding each
extra_lines = 7
# account for size of palette + bottom margin
palette_height = ((self.palette_swatch.art.height * self.palette_swatch.art.quad_height) + self.swatch_margin) / UIArt.quad_height
palette_height = (
(self.palette_swatch.art.height * self.palette_swatch.art.quad_height)
+ self.swatch_margin
) / UIArt.quad_height
self.tile_height += self.tab_height + palette_height + extra_lines
if old_width != self.tile_width or old_height != self.tile_height:
self.art.resize(int(self.tile_width), int(self.tile_height))
@ -576,7 +645,10 @@ class ToolPopup(UIElement):
return
x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
# center on mouse
w, h = self.tile_width * self.art.quad_width, self.tile_height * self.art.quad_height
w, h = (
self.tile_width * self.art.quad_width,
self.tile_height * self.art.quad_height,
)
x -= w / 2
y += h / 2
# clamp to edges of screen
@ -624,7 +696,9 @@ class ToolPopup(UIElement):
self.cursor_box.visible = True
elif mouse_moved and self in self.ui.hovered_elements:
self.cursor_box.visible = False
x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
x, y = self.ui.get_screen_coords(
self.ui.app.mouse_x, self.ui.app.mouse_y
)
for e in [self.charset_swatch, self.palette_swatch]:
if e.is_inside(x, y):
self.cursor_box.visible = True
@ -638,7 +712,9 @@ class ToolPopup(UIElement):
self.draw_buttons()
def keyboard_navigate(self, dx, dy):
active_swatch = self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
active_swatch = (
self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
)
# TODO: can't handle cross-swatch navigation properly, restrict to chars
active_swatch = self.charset_swatch
# reverse up/down direction

View file

@ -1,77 +1,106 @@
import os.path, time
import os.path
import time
from math import ceil
from ui_element import UIElement, UIArt, UIRenderable
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_colors import UIColors
from renderable_line import UIRenderableX
from art import uv_names
from renderable_line import UIRenderableX
from ui_button import TEXT_CENTER, TEXT_RIGHT, UIButton
from ui_colors import UIColors
from ui_element import UIArt, UIElement, UIRenderable
# buttons to toggle "affects" status / cycle through choices, respectively
class StatusBarToggleButton(UIButton):
caption_justify = TEXT_RIGHT
class StatusBarCycleButton(UIButton):
# do different stuff for left vs right click
pass_mouse_button = True
should_draw_caption = False
width = 3
class CharToggleButton(StatusBarToggleButton):
x = 0
caption = 'ch:'
caption = "ch:"
width = len(caption) + 1
tooltip_on_hover = True
def get_tooltip_text(self):
return 'character index: %s' % self.element.ui.selected_char
return "character index: %s" % self.element.ui.selected_char
def get_tooltip_location(self):
return 1, self.element.get_tile_y() - 1
class CharCycleButton(StatusBarCycleButton):
x = CharToggleButton.width
tooltip_on_hover = True
# reuse above
def get_tooltip_text(self): return CharToggleButton.get_tooltip_text(self)
def get_tooltip_location(self): return CharToggleButton.get_tooltip_location(self)
def get_tooltip_text(self):
return CharToggleButton.get_tooltip_text(self)
def get_tooltip_location(self):
return CharToggleButton.get_tooltip_location(self)
class FGToggleButton(StatusBarToggleButton):
x = CharCycleButton.x + CharCycleButton.width
caption = 'fg:'
caption = "fg:"
width = len(caption) + 1
tooltip_on_hover = True
def get_tooltip_text(self):
return 'foreground color index: %s' % self.element.ui.selected_fg_color
return "foreground color index: %s" % self.element.ui.selected_fg_color
def get_tooltip_location(self):
return 8, self.element.get_tile_y() - 1
class FGCycleButton(StatusBarCycleButton):
x = FGToggleButton.x + FGToggleButton.width
tooltip_on_hover = True
def get_tooltip_text(self): return FGToggleButton.get_tooltip_text(self)
def get_tooltip_location(self): return FGToggleButton.get_tooltip_location(self)
def get_tooltip_text(self):
return FGToggleButton.get_tooltip_text(self)
def get_tooltip_location(self):
return FGToggleButton.get_tooltip_location(self)
class BGToggleButton(StatusBarToggleButton):
x = FGCycleButton.x + FGCycleButton.width
caption = 'bg:'
caption = "bg:"
width = len(caption) + 1
tooltip_on_hover = True
def get_tooltip_text(self):
return 'background color index: %s' % self.element.ui.selected_bg_color
return "background color index: %s" % self.element.ui.selected_bg_color
def get_tooltip_location(self):
return 15, self.element.get_tile_y() - 1
class BGCycleButton(StatusBarCycleButton):
x = BGToggleButton.x + BGToggleButton.width
tooltip_on_hover = True
def get_tooltip_text(self): return BGToggleButton.get_tooltip_text(self)
def get_tooltip_location(self): return BGToggleButton.get_tooltip_location(self)
def get_tooltip_text(self):
return BGToggleButton.get_tooltip_text(self)
def get_tooltip_location(self):
return BGToggleButton.get_tooltip_location(self)
class XformToggleButton(StatusBarToggleButton):
x = BGCycleButton.x + BGCycleButton.width
caption = 'xform:'
caption = "xform:"
width = len(caption) + 1
# class for things like xform and tool whose captions you can cycle through
class StatusBarTextCycleButton(StatusBarCycleButton):
should_draw_caption = True
@ -83,32 +112,38 @@ class StatusBarTextCycleButton(StatusBarCycleButton):
clicked_fg_color = UIColors.black
clicked_bg_color = UIColors.white
class XformCycleButton(StatusBarTextCycleButton):
x = XformToggleButton.x + XformToggleButton.width
width = len('Rotate 180')
width = len("Rotate 180")
caption = uv_names[0]
class ToolCycleButton(StatusBarTextCycleButton):
x = XformCycleButton.x + XformCycleButton.width + len('tool:') + 1
x = XformCycleButton.x + XformCycleButton.width + len("tool:") + 1
# width and caption are set during status bar init after button is created
class FileCycleButton(StatusBarTextCycleButton):
caption = '[nothing]'
caption = "[nothing]"
class LayerCycleButton(StatusBarTextCycleButton):
caption = 'X/Y'
caption = "X/Y"
width = len(caption)
class FrameCycleButton(StatusBarTextCycleButton):
caption = 'X/Y'
caption = "X/Y"
width = len(caption)
class ZoomSetButton(StatusBarTextCycleButton):
caption = '100.0'
caption = "100.0"
width = len(caption)
class StatusBarUI(UIElement):
class StatusBarUI(UIElement):
snap_bottom = True
snap_left = True
always_consume_input = True
@ -117,47 +152,71 @@ class StatusBarUI(UIElement):
char_swatch_x = CharCycleButton.x
fg_swatch_x = FGCycleButton.x
bg_swatch_x = BGCycleButton.x
tool_label = 'tool:'
tool_label = "tool:"
tool_label_x = XformCycleButton.x + XformCycleButton.width + 1
tile_label = 'tile:'
layer_label = 'layer:'
frame_label = 'frame:'
zoom_label = '%'
right_items_width = len(tile_label) + len(layer_label) + len(frame_label) + (len('X/Y') + 2) * 2 + len('XX/YY') + 2 + len(zoom_label) + 10
tile_label = "tile:"
layer_label = "layer:"
frame_label = "frame:"
zoom_label = "%"
right_items_width = (
len(tile_label)
+ len(layer_label)
+ len(frame_label)
+ (len("X/Y") + 2) * 2
+ len("XX/YY")
+ 2
+ len(zoom_label)
+ 10
)
button_names = {
CharToggleButton: 'char_toggle',
CharCycleButton: 'char_cycle',
FGToggleButton: 'fg_toggle',
FGCycleButton: 'fg_cycle',
BGToggleButton: 'bg_toggle',
BGCycleButton: 'bg_cycle',
XformToggleButton: 'xform_toggle',
XformCycleButton: 'xform_cycle',
ToolCycleButton: 'tool_cycle',
FileCycleButton: 'file_cycle',
LayerCycleButton: 'layer_cycle',
FrameCycleButton: 'frame_cycle',
ZoomSetButton: 'zoom_set'
CharToggleButton: "char_toggle",
CharCycleButton: "char_cycle",
FGToggleButton: "fg_toggle",
FGCycleButton: "fg_cycle",
BGToggleButton: "bg_toggle",
BGCycleButton: "bg_cycle",
XformToggleButton: "xform_toggle",
XformCycleButton: "xform_cycle",
ToolCycleButton: "tool_cycle",
FileCycleButton: "file_cycle",
LayerCycleButton: "layer_cycle",
FrameCycleButton: "frame_cycle",
ZoomSetButton: "zoom_set",
}
def __init__(self, ui):
art = ui.active_art
self.ui = ui
# create 3 custom Arts w/ source charset and palette, renderables for each
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.char_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1)
art_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
self.char_art = UIArt(
art_name, ui.app, art.charset, art.palette, self.swatch_width, 1
)
self.char_renderable = UIRenderable(ui.app, self.char_art)
self.fg_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1)
self.fg_art = UIArt(
art_name, ui.app, art.charset, art.palette, self.swatch_width, 1
)
self.fg_renderable = UIRenderable(ui.app, self.fg_art)
self.bg_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1)
self.bg_art = UIArt(
art_name, ui.app, art.charset, art.palette, self.swatch_width, 1
)
self.bg_renderable = UIRenderable(ui.app, self.bg_art)
# "dimmed out" box
self.dim_art = UIArt(art_name, ui.app, ui.charset, ui.palette, self.swatch_width + self.char_swatch_x, 1)
self.dim_art = UIArt(
art_name,
ui.app,
ui.charset,
ui.palette,
self.swatch_width + self.char_swatch_x,
1,
)
self.dim_renderable = UIRenderable(ui.app, self.dim_art)
self.dim_renderable.alpha = 0.75
# separate dimmed out box for xform, easier this way
xform_width = XformToggleButton.width + XformCycleButton.width
self.dim_xform_art = UIArt(art_name, ui.app, ui.charset, ui.palette, xform_width, 1)
self.dim_xform_art = UIArt(
art_name, ui.app, ui.charset, ui.palette, xform_width, 1
)
self.dim_xform_renderable = UIRenderable(ui.app, self.dim_xform_art)
self.dim_xform_renderable.alpha = 0.75
# create clickable buttons
@ -165,19 +224,26 @@ class StatusBarUI(UIElement):
self.button_map = {}
for button_class, button_name in self.button_names.items():
button = button_class(self)
setattr(self, button_name + '_button', button)
cb_name = '%s_button_pressed' % button_name
setattr(self, button_name + "_button", button)
cb_name = "%s_button_pressed" % button_name
button.callback = getattr(self, cb_name)
self.buttons.append(button)
# keep a mapping of button names to buttons, for eg tooltip updates
self.button_map[button_name] = button
# some button captions, widths, locations will be set in reset_art
# determine total width of left-justified items
self.left_items_width = self.tool_cycle_button.x + self.tool_cycle_button.width + 15
self.left_items_width = (
self.tool_cycle_button.x + self.tool_cycle_button.width + 15
)
# set some properties in bulk
self.renderables = []
for r in [self.char_renderable, self.fg_renderable, self.bg_renderable,
self.dim_renderable, self.dim_xform_renderable]:
for r in [
self.char_renderable,
self.fg_renderable,
self.bg_renderable,
self.dim_renderable,
self.dim_xform_renderable,
]:
r.ui = ui
r.grain_strength = 0
# add to list of renderables to manage eg destroyed on quit
@ -192,44 +258,52 @@ class StatusBarUI(UIElement):
# button callbacks
def char_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_char()
def char_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_char(self.ui.selected_char + 1)
elif mouse_button == 3:
self.ui.select_char(self.ui.selected_char - 1)
def fg_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_fg()
def fg_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_fg(self.ui.selected_fg_color + 1)
elif mouse_button == 3:
self.ui.select_fg(self.ui.selected_fg_color - 1)
def bg_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_bg()
def bg_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_bg(self.ui.selected_bg_color + 1)
elif mouse_button == 3:
self.ui.select_bg(self.ui.selected_bg_color - 1)
def xform_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_xform()
def xform_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.cycle_selected_xform()
elif mouse_button == 3:
@ -238,7 +312,8 @@ class StatusBarUI(UIElement):
self.xform_cycle_button.caption = uv_names[self.ui.selected_xform]
def tool_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.cycle_selected_tool()
elif mouse_button == 3:
@ -246,32 +321,40 @@ class StatusBarUI(UIElement):
self.tool_cycle_button.caption = self.ui.selected_tool.get_button_caption()
def file_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.next_active_art()
elif mouse_button == 3:
self.ui.previous_active_art()
def layer_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.set_active_layer(self.ui.active_art.active_layer + 1)
elif mouse_button == 3:
self.ui.set_active_layer(self.ui.active_art.active_layer - 1)
def frame_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.set_active_frame(self.ui.active_art.active_frame + 1)
elif mouse_button == 3:
self.ui.set_active_frame(self.ui.active_art.active_frame - 1)
def zoom_set_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.app.camera.zoom_proportional(1)
elif mouse_button == 3:
@ -305,8 +388,9 @@ class StatusBarUI(UIElement):
if self.tile_width < self.left_items_width:
return
# draw tool label
self.art.write_string(0, 0, self.tool_label_x, 0, self.tool_label,
self.ui.palette.darkest_index)
self.art.write_string(
0, 0, self.tool_label_x, 0, self.tool_label, self.ui.palette.darkest_index
)
# only draw right side info if the window is wide enough
if self.art.width > self.left_items_width + self.right_items_width:
self.file_cycle_button.visible = True
@ -330,7 +414,13 @@ class StatusBarUI(UIElement):
def get_tile_y(self):
"returns tile coordinate Y position of bar"
return int(self.ui.app.window_height / (self.ui.charset.char_height * self.ui.scale)) - 1
return (
int(
self.ui.app.window_height
/ (self.ui.charset.char_height * self.ui.scale)
)
- 1
)
def update_button_captions(self):
"set captions for buttons that change from selections"
@ -339,21 +429,23 @@ class StatusBarUI(UIElement):
self.tool_cycle_button.caption = self.ui.selected_tool.get_button_caption()
self.tool_cycle_button.width = len(self.tool_cycle_button.caption) + 2
# right edge elements
self.file_cycle_button.caption = os.path.basename(art.filename) if art else FileCycleButton.caption
self.file_cycle_button.caption = (
os.path.basename(art.filename) if art else FileCycleButton.caption
)
self.file_cycle_button.width = len(self.file_cycle_button.caption) + 2
# NOTE: button X offsets will be set in write_right_elements
null = '---'
null = "---"
layers = art.layers if art else 0
layer = '%s/%s' % (art.active_layer + 1, layers) if art else null
layer = "%s/%s" % (art.active_layer + 1, layers) if art else null
self.layer_cycle_button.caption = layer
self.layer_cycle_button.width = len(self.layer_cycle_button.caption)
frames = art.frames if art else 0
frame = '%s/%s' % (art.active_frame + 1, frames) if art else null
frame = "%s/%s" % (art.active_frame + 1, frames) if art else null
self.frame_cycle_button.caption = frame
self.frame_cycle_button.width = len(self.frame_cycle_button.caption)
# zoom %
zoom = '%.1f' % self.ui.app.camera.get_current_zoom_pct() if art else null
self.zoom_set_button.caption = zoom[:5] # maintain size
zoom = "%.1f" % self.ui.app.camera.get_current_zoom_pct() if art else null
self.zoom_set_button.caption = zoom[:5] # maintain size
def update(self):
if not self.ui.active_art:
@ -406,7 +498,7 @@ class StatusBarUI(UIElement):
self.zoom_set_button.x = x
x -= padding
# tile
tile = 'X/Y'
tile = "X/Y"
color = light
if self.ui.app.cursor and art:
tile_x, tile_y = self.ui.app.cursor.get_tile()
@ -420,18 +512,18 @@ class StatusBarUI(UIElement):
color = self.dim_color
tile_x = str(tile_x).rjust(3)
tile_y = str(tile_y).rjust(3)
tile = '%s,%s' % (tile_x, tile_y)
tile = "%s,%s" % (tile_x, tile_y)
self.art.write_string(0, 0, x, 0, tile, color, dark, True)
# tile label
x -= len(tile)
self.art.write_string(0, 0, x, 0, self.tile_label, dark, light, True)
# position layer button
x -= (padding + len(self.tile_label) + self.layer_cycle_button.width)
x -= padding + len(self.tile_label) + self.layer_cycle_button.width
self.layer_cycle_button.x = x
# layer label
self.art.write_string(0, 0, x, 0, self.layer_label, dark, light, True)
# position frame button
x -= (padding + len(self.layer_label) + self.frame_cycle_button.width)
x -= padding + len(self.layer_label) + self.frame_cycle_button.width
self.frame_cycle_button.x = x
# frame label
self.art.write_string(0, 0, x, 0, self.frame_label, dark, light, True)

View file

@ -1,14 +1,16 @@
import math, time
import math
import time
import numpy as np
from ui_element import UIElement, UIArt, UIRenderable
from renderable_line import LineRenderable, SwatchSelectionBoxRenderable, UIRenderableX
from ui_element import UIArt, UIElement, UIRenderable
# min width for charset; if charset is tiny adjust to this
MIN_CHARSET_WIDTH = 16
class UISwatch(UIElement):
class UISwatch(UIElement):
def __init__(self, ui, popup):
self.ui = ui
self.popup = popup
@ -18,8 +20,15 @@ class UISwatch(UIElement):
self.tile_width, self.tile_height = self.get_size()
art = self.ui.active_art
# generate a unique name for debug purposes
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.art = UIArt(art_name, self.ui.app, art.charset, art.palette, self.tile_width, self.tile_height)
art_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
self.art = UIArt(
art_name,
self.ui.app,
art.charset,
art.palette,
self.tile_width,
self.tile_height,
)
# tear down existing renderables if any
if not self.renderables:
self.renderables = []
@ -82,7 +91,6 @@ class UISwatch(UIElement):
class CharacterSetSwatch(UISwatch):
# scale the character set will be drawn at
char_scale = 2
min_scale = 1
@ -102,14 +110,18 @@ class CharacterSetSwatch(UISwatch):
self.selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
self.grid = CharacterGridRenderable(self.ui.app, self.art)
self.create_shade()
self.renderables = [self.renderable, self.selection_box, self.grid,
self.shade]
self.renderables = [self.renderable, self.selection_box, self.grid, self.shade]
def create_shade(self):
# shaded box neath chars in case selected colors make em hard to see
self.shade_art = UIArt('charset_shade', self.ui.app,
self.ui.active_art.charset, self.ui.palette,
self.tile_width, self.tile_height)
self.shade_art = UIArt(
"charset_shade",
self.ui.app,
self.ui.active_art.charset,
self.ui.palette,
self.tile_width,
self.tile_height,
)
self.shade_art.clear_frame_layer(0, 0, self.ui.colors.black)
self.shade = UIRenderable(self.ui.app, self.shade_art)
self.shade.ui = self.ui
@ -125,7 +137,9 @@ class CharacterSetSwatch(UISwatch):
aspect = self.ui.app.window_width / self.ui.app.window_height
charset = self.art.charset
self.art.quad_width = UIArt.quad_width * self.char_scale
self.art.quad_height = self.art.quad_width * (charset.char_height / charset.char_width) * aspect
self.art.quad_height = (
self.art.quad_width * (charset.char_height / charset.char_width) * aspect
)
# only need to populate characters on reset_art, but update
# colors every update()
self.art.clear_frame_layer(0, 0, 0)
@ -163,14 +177,12 @@ class CharacterSetSwatch(UISwatch):
tile_x = cursor.tile_x + dx
tile_y = cursor.tile_y + dy
tile_index = (abs(tile_y) * self.art.width) + tile_x
if tile_x < 0 or tile_x >= self.art.width:
return
elif tile_y > 0:
if tile_x < 0 or tile_x >= self.art.width or tile_y > 0:
return
elif tile_y <= -self.art.height:
# TODO: handle "jump" to palette swatch, and back
#cursor.tile_y = 0
#self.popup.palette_swatch.move_cursor(cursor, 0, 0)
# cursor.tile_y = 0
# self.popup.palette_swatch.move_cursor(cursor, 0, 0)
return
elif tile_index >= self.art.charset.last_index:
return
@ -185,7 +197,10 @@ class CharacterSetSwatch(UISwatch):
for x in range(charset.map_width):
self.art.set_tile_at(0, 0, x, y, None, fg, bg, xform)
self.art.update()
if self.shade_art.quad_width != self.art.quad_width or self.shade_art.quad_height != self.art.quad_height:
if (
self.shade_art.quad_width != self.art.quad_width
or self.shade_art.quad_height != self.art.quad_height
):
self.shade_art.quad_width = self.art.quad_width
self.shade_art.quad_height = self.art.quad_height
self.shade_art.geo_changed = True
@ -207,9 +222,10 @@ class CharacterSetSwatch(UISwatch):
def render_bg(self):
# draw shaded box beneath swatch if selected color(s) too similar to BG
def is_hard_to_see(other_color_index):
return self.ui.palette.are_colors_similar(self.popup.bg_color,
self.art.palette,
other_color_index)
return self.ui.palette.are_colors_similar(
self.popup.bg_color, self.art.palette, other_color_index
)
fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color
if is_hard_to_see(fg) or is_hard_to_see(bg):
self.shade.render()
@ -224,27 +240,34 @@ class CharacterSetSwatch(UISwatch):
class PaletteSwatch(UISwatch):
def reset(self):
UISwatch.reset(self)
self.transparent_x = UIRenderableX(self.ui.app, self.art)
self.fg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
self.bg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
# F label for FG color selection
self.f_art = ColorSelectionLabelArt(self.ui, 'F')
self.f_art = ColorSelectionLabelArt(self.ui, "F")
# make character dark
self.f_art.set_color_at(0, 0, 0, 0, self.f_art.palette.darkest_index, True)
self.f_renderable = ColorSelectionLabelRenderable(self.ui.app, self.f_art)
self.f_renderable.ui = self.ui
# B label for BG color seletion
self.b_art = ColorSelectionLabelArt(self.ui, 'B')
self.b_art = ColorSelectionLabelArt(self.ui, "B")
self.b_renderable = ColorSelectionLabelRenderable(self.ui.app, self.b_art)
self.b_renderable.ui = self.ui
self.renderables += self.transparent_x, self.fg_selection_box, self.bg_selection_box, self.f_renderable, self.b_renderable
self.renderables += (
self.transparent_x,
self.fg_selection_box,
self.bg_selection_box,
self.f_renderable,
self.b_renderable,
)
def get_size(self):
# balance rows/columns according to character set swatch width
charmap_width = max(self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH)
charmap_width = max(
self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH
)
colors = len(self.popup.charset_swatch.art.palette.colors)
rows = math.ceil(colors / charmap_width)
columns = math.ceil(colors / rows)
@ -255,7 +278,10 @@ class PaletteSwatch(UISwatch):
def reset_art(self):
# base our quad size on charset's
cqw, cqh = self.popup.charset_swatch.art.quad_width, self.popup.charset_swatch.art.quad_height
cqw, cqh = (
self.popup.charset_swatch.art.quad_width,
self.popup.charset_swatch.art.quad_height,
)
# maximize item size based on row/column determined in get_size()
charmap_width = max(self.art.charset.map_width, MIN_CHARSET_WIDTH)
self.art.quad_width = (charmap_width / self.art.width) * cqw
@ -276,7 +302,10 @@ class PaletteSwatch(UISwatch):
self.x = self.popup.x + self.popup.swatch_margin
self.y = self.popup.charset_swatch.renderable.y
# adjust Y for charset
self.y -= self.popup.charset_swatch.art.quad_height * self.ui.active_art.charset.map_height
self.y -= (
self.popup.charset_swatch.art.quad_height
* self.ui.active_art.charset.map_height
)
# adjust Y for palette caption and character scale
self.y -= self.popup.art.quad_height * 2
self.renderable.x, self.renderable.y = self.x, self.y
@ -294,7 +323,10 @@ class PaletteSwatch(UISwatch):
self.transparent_x.y = self.renderable.y - self.art.quad_height
self.transparent_x.y -= (h - 1) * self.art.quad_height
# set f/b_art's quad size
self.f_art.quad_width, self.f_art.quad_height = self.b_art.quad_width, self.b_art.quad_height = self.popup.art.quad_width, self.popup.art.quad_height
self.f_art.quad_width, self.f_art.quad_height = (
self.b_art.quad_width,
self.b_art.quad_height,
) = self.popup.art.quad_width, self.popup.art.quad_height
self.f_art.geo_changed = True
self.b_art.geo_changed = True
@ -373,7 +405,7 @@ class PaletteSwatch(UISwatch):
class ColorSelectionLabelArt(UIArt):
def __init__(self, ui, letter):
letter_index = ui.charset.get_char_index(letter)
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
art_name = "%s_%s" % (int(time.time()), self.__class__.__name__)
UIArt.__init__(self, art_name, ui.app, ui.charset, ui.palette, 1, 1)
label_color = ui.colors.white
label_bg_color = 0
@ -386,7 +418,6 @@ class ColorSelectionLabelRenderable(UIRenderable):
class CharacterGridRenderable(LineRenderable):
color = (0.5, 0.5, 0.5, 0.25)
def build_geo(self):
@ -396,12 +427,12 @@ class CharacterGridRenderable(LineRenderable):
c = self.color * 4 * w * h
index = 0
for x in range(1, w):
v += [(x, -h+1), (x, 1)]
e += [index, index+1]
v += [(x, -h + 1), (x, 1)]
e += [index, index + 1]
index += 2
for y in range(h-1):
for y in range(h - 1):
v += [(w, -y), (0, -y)]
e += [index, index+1]
e += [index, index + 1]
index += 2
self.vert_array = np.array(v, dtype=np.float32)
self.elem_array = np.array(e, dtype=np.uint32)

View file

@ -1,18 +1,26 @@
import math
import sdl2
from PIL import Image
from texture import Texture
from art import (
UV_FLIP90,
UV_FLIP270,
UV_FLIPX,
UV_FLIPY,
UV_NORMAL,
UV_ROTATE90,
UV_ROTATE180,
UV_ROTATE270,
)
from edit_command import EditCommandTile
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270
from key_shifts import SHIFT_MAP
from selection import SelectionRenderable
from texture import Texture
class UITool:
name = 'DEBUGTESTTOOL'
name = "DEBUGTESTTOOL"
# name visible in popup's tool tab
button_caption = 'Debug Tool'
button_caption = "Debug Tool"
# paint continuously, ie every time mouse enters a new tile
paint_while_dragging = True
# show preview of paint result under cursor
@ -25,7 +33,7 @@ class UITool:
# (false for eg Selection tool)
affects_masks = True
# filename of icon in UI_ASSET_DIR, shown on cursor
icon_filename = 'icon.png'
icon_filename = "icon.png"
def __init__(self, ui):
self.ui = ui
@ -39,7 +47,7 @@ class UITool:
def load_icon_texture(self, img_filename):
img = Image.open(img_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
@ -60,8 +68,13 @@ class UITool:
return
self.affects_char = not self.affects_char
self.ui.tool_settings_changed = True
line = self.button_caption + ' '
line = '%s %s' % (self.button_caption, [self.ui.affects_char_off_log, self.ui.affects_char_on_log][self.affects_char])
line = self.button_caption + " "
line = "%s %s" % (
self.button_caption,
[self.ui.affects_char_off_log, self.ui.affects_char_on_log][
self.affects_char
],
)
self.ui.message_line.post_line(line)
def toggle_affects_fg(self):
@ -69,7 +82,12 @@ class UITool:
return
self.affects_fg_color = not self.affects_fg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][self.affects_fg_color])
line = "%s %s" % (
self.button_caption,
[self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][
self.affects_fg_color
],
)
self.ui.message_line.post_line(line)
def toggle_affects_bg(self):
@ -77,7 +95,12 @@ class UITool:
return
self.affects_bg_color = not self.affects_bg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][self.affects_bg_color])
line = "%s %s" % (
self.button_caption,
[self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][
self.affects_bg_color
],
)
self.ui.message_line.post_line(line)
def toggle_affects_xform(self):
@ -85,7 +108,12 @@ class UITool:
return
self.affects_xform = not self.affects_xform
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][self.affects_xform])
line = "%s %s" % (
self.button_caption,
[self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][
self.affects_xform
],
)
self.ui.message_line.post_line(line)
def get_paint_commands(self):
@ -109,11 +137,10 @@ class UITool:
class PencilTool(UITool):
name = 'pencil'
name = "pencil"
# "Paint" not Pencil so the A mnemonic works :/
button_caption = 'Paint'
icon_filename = 'tool_paint.png'
button_caption = "Paint"
icon_filename = "tool_paint.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
"""
@ -123,7 +150,7 @@ class PencilTool(UITool):
a_char = self.ui.selected_char if self.affects_char else None
# don't paint fg color for blank characters
# (disabled, see BB issue #86)
#a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None
# a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None
a_fg = self.ui.selected_fg_color if self.affects_fg_color else None
a_bg = self.ui.selected_bg_color if self.affects_bg_color else None
a_xform = self.ui.selected_xform if self.affects_xform else None
@ -137,8 +164,8 @@ class PencilTool(UITool):
cur = self.ui.app.cursor
# handle dragging while painting (cursor does the heavy lifting here)
# !!TODO!! finish this, work in progress
if cur.moved_this_frame() and cur.current_command and False: #DEBUG
#print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG
if cur.moved_this_frame() and cur.current_command and False: # DEBUG
# print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG
tiles = cur.get_tiles_under_drag()
else:
tiles = cur.get_tiles_under_brush()
@ -154,7 +181,9 @@ class PencilTool(UITool):
new_tc.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, *tile)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(
b_char, b_fg, b_bg, b_xform
)
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# Note: even if command has same result as another in command_tiles,
# add it anyway as it may be a tool for which subsequent edits to
@ -165,10 +194,9 @@ class PencilTool(UITool):
class EraseTool(PencilTool):
name = 'erase'
button_caption = 'Erase'
icon_filename = 'tool_erase.png'
name = "erase"
button_caption = "Erase"
icon_filename = "tool_erase.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
char = 0 if self.affects_char else None
@ -180,9 +208,8 @@ class EraseTool(PencilTool):
class RotateTool(PencilTool):
name = 'rotate'
button_caption = 'Rotate'
name = "rotate"
button_caption = "Rotate"
update_preview_after_paint = True
rotation_shifts = {
UV_NORMAL: UV_ROTATE90,
@ -193,21 +220,20 @@ class RotateTool(PencilTool):
UV_FLIPX: UV_FLIP270,
UV_FLIP270: UV_FLIPY,
UV_FLIPY: UV_ROTATE270,
UV_FLIP90: UV_FLIPX
UV_FLIP90: UV_FLIPX,
}
icon_filename = 'tool_rotate.png'
icon_filename = "tool_rotate.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
return b_char, b_fg, b_bg, self.rotation_shifts[b_xform]
class GrabTool(UITool):
name = 'grab'
button_caption = 'Grab'
name = "grab"
button_caption = "Grab"
brush_size = None
show_preview = False
icon_filename = 'tool_grab.png'
icon_filename = "tool_grab.png"
def grab(self):
x, y = self.ui.app.cursor.get_tile()
@ -233,12 +259,11 @@ class GrabTool(UITool):
class TextTool(UITool):
name = 'text'
button_caption = 'Text'
name = "text"
button_caption = "Text"
brush_size = None
show_preview = False
icon_filename = 'tool_text.png'
icon_filename = "tool_text.png"
def __init__(self, ui):
UITool.__init__(self, ui)
@ -250,14 +275,20 @@ class TextTool(UITool):
# popup gobbles keyboard input, so always dismiss it if it's up
if self.ui.popup.visible:
self.ui.popup.hide()
if self.cursor.x < 0 or self.cursor.x > self.ui.active_art.width or \
-self.cursor.y < 0 or -self.cursor.y > self.ui.active_art.height:
if (
self.cursor.x < 0
or self.cursor.x > self.ui.active_art.width
or -self.cursor.y < 0
or -self.cursor.y > self.ui.active_art.height
):
return
self.input_active = True
self.reset_cursor_start(self.cursor.x, -self.cursor.y)
self.cursor.start_paint()
#self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line('Started text entry, press Escape to stop entering text.', 5)
# self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line(
"Started text entry, press Escape to stop entering text.", 5
)
def finish_entry(self):
self.input_active = False
@ -265,8 +296,8 @@ class TextTool(UITool):
if self.cursor:
x, y = int(self.cursor.x) + 1, int(-self.cursor.y) + 1
self.cursor.finish_paint()
#self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y))
self.ui.message_line.post_line('Finished text entry.')
# self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y))
self.ui.message_line.post_line("Finished text entry.")
def reset_cursor_start(self, new_x, new_y):
self.start_x, self.start_y = int(new_x), int(new_y)
@ -284,30 +315,32 @@ class TextTool(UITool):
x, y = int(self.cursor.x), int(-self.cursor.y)
char_w, char_h = art.quad_width, art.quad_height
# TODO: if cursor isn't inside selection, bail early
if keystr == 'Return':
if keystr == "Return":
if self.cursor.y < art.width:
self.cursor.x = self.start_x
self.cursor.y -= 1
elif keystr == 'Backspace':
elif keystr == "Backspace":
if self.cursor.x > self.start_x:
self.cursor.x -= char_w
# undo command on previous tile
self.cursor.current_command.undo_commands_for_tile(frame, layer, x-1, y)
elif keystr == 'Space':
keystr = ' '
elif keystr == 'Up':
self.cursor.current_command.undo_commands_for_tile(
frame, layer, x - 1, y
)
elif keystr == "Space":
keystr = " "
elif keystr == "Up":
if -self.cursor.y > 0:
self.cursor.y += 1
elif keystr == 'Down':
elif keystr == "Down":
if -self.cursor.y < art.height - 1:
self.cursor.y -= 1
elif keystr == 'Left':
elif keystr == "Left":
if self.cursor.x > 0:
self.cursor.x -= char_w
elif keystr == 'Right':
elif keystr == "Right":
if self.cursor.x < art.width - 1:
self.cursor.x += char_w
elif keystr == 'Escape':
elif keystr == "Escape":
self.finish_entry()
return
# ignore any other non-character keys
@ -322,9 +355,9 @@ class TextTool(UITool):
if keystr.isalpha() and not shift_pressed and not self.ui.app.il.capslock_on:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, ' ')
keystr = SHIFT_MAP.get(keystr, " ")
# if cursor got out of bounds, don't input
if 0 > x or x >= art.width or 0 > y or y >= art.height:
if x < 0 or x >= art.width or y < 0 or y >= art.height:
return
# create tile command
new_tc = EditCommandTile(art)
@ -340,7 +373,7 @@ class TextTool(UITool):
if self.cursor.current_command:
self.cursor.current_command.add_command_tiles([new_tc])
else:
self.ui.app.log('DEV WARNING: Cursor current command was expected')
self.ui.app.log("DEV WARNING: Cursor current command was expected")
new_tc.apply()
self.cursor.x += char_w
if self.cursor.x >= self.ui.active_art.width:
@ -351,16 +384,15 @@ class TextTool(UITool):
class SelectTool(UITool):
name = 'select'
button_caption = 'Select'
name = "select"
button_caption = "Select"
brush_size = None
affects_masks = False
show_preview = False
icon_filename = 'tool_select_add.png' # used only for toolbar
icon_filename_normal = 'tool_select.png'
icon_filename_add = 'tool_select_add.png'
icon_filename_sub = 'tool_select_sub.png'
icon_filename = "tool_select_add.png" # used only for toolbar
icon_filename_normal = "tool_select.png"
icon_filename_add = "tool_select_add.png"
icon_filename_sub = "tool_select_sub.png"
def __init__(self, ui):
UITool.__init__(self, ui)
@ -395,7 +427,7 @@ class SelectTool(UITool):
self.current_drag = {}
x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
self.drag_start_x, self.drag_start_y = x, y
#print('started select drag at %s,%s' % (x, y))
# print('started select drag at %s,%s' % (x, y))
def finish_select(self, add_to_selection, subtract_from_selection):
self.selection_in_progress = False
@ -410,8 +442,8 @@ class SelectTool(UITool):
for tile in self.current_drag:
self.selected_tiles.pop(tile, None)
self.current_drag = {}
#x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
#print('finished select drag at %s,%s' % (x, y))
# x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
# print('finished select drag at %s,%s' % (x, y))
def update(self):
if not self.ui.active_art:
@ -423,9 +455,15 @@ class SelectTool(UITool):
start_x, start_y = int(self.drag_start_x), int(self.drag_start_y)
end_x, end_y = int(self.ui.app.cursor.x), int(-self.ui.app.cursor.y)
if start_x > end_x:
start_x, end_x, = end_x, start_x
(
start_x,
end_x,
) = end_x, start_x
if start_y > end_y:
start_y, end_y, = end_y, start_y
(
start_y,
end_y,
) = end_y, start_y
# always grow to include cursor's tile
end_x += 1
end_y += 1
@ -454,11 +492,10 @@ class SelectTool(UITool):
class PasteTool(UITool):
name = 'paste'
button_caption = 'Paste'
name = "paste"
button_caption = "Paste"
brush_size = None
icon_filename = 'tool_paste.png'
icon_filename = "tool_paste.png"
# TODO!: dragging large pastes around seems heck of slow, investigate
# why this function might be to blame and see if there's a fix!
@ -488,7 +525,9 @@ class PasteTool(UITool):
if len(self.ui.select_tool.selected_tiles) > 0:
if not self.ui.select_tool.selected_tiles.get((x, y), False):
continue
b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(frame, layer, x, y)
b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(
frame, layer, x, y
)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
new_tc.set_tile(frame, layer, x, y)
# respect affects masks like other tools
@ -502,33 +541,34 @@ class PasteTool(UITool):
commands.append(new_tc)
return commands
# "fill boundary" modes: character, fg color, bg color
FILL_BOUND_CHAR = 0
FILL_BOUND_FG_COLOR = 1
FILL_BOUND_BG_COLOR = 2
class FillTool(UITool):
name = 'fill'
button_caption = 'Fill'
class FillTool(UITool):
name = "fill"
button_caption = "Fill"
brush_size = None
icon_filename = 'tool_fill_char.png' # used only for toolbar
icon_filename = "tool_fill_char.png" # used only for toolbar
# icons and strings for different boundary modes
icon_filename_char = 'tool_fill_char.png'
icon_filename_fg = 'tool_fill_fg.png'
icon_filename_bg = 'tool_fill_bg.png'
icon_filename_char = "tool_fill_char.png"
icon_filename_fg = "tool_fill_fg.png"
icon_filename_bg = "tool_fill_bg.png"
boundary_mode = FILL_BOUND_CHAR
# user-facing names for the boundary modes
boundary_mode_names = {
FILL_BOUND_CHAR : 'character',
FILL_BOUND_FG_COLOR : 'fg color',
FILL_BOUND_BG_COLOR : 'bg color'
FILL_BOUND_CHAR: "character",
FILL_BOUND_FG_COLOR: "fg color",
FILL_BOUND_BG_COLOR: "bg color",
}
# determine cycling order
next_boundary_modes = {
FILL_BOUND_CHAR : FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR : FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR : FILL_BOUND_CHAR
FILL_BOUND_CHAR: FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR: FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR: FILL_BOUND_CHAR,
}
def __init__(self, ui):
@ -542,8 +582,12 @@ class FillTool(UITool):
def get_icon_texture(self):
# show different icon based on boundary type
return [self.icon_texture_char, self.icon_texture_fg,
self.icon_texture_bg][self.boundary_mode]
return [self.icon_texture_char, self.icon_texture_fg, self.icon_texture_bg][
self.boundary_mode
]
def get_button_caption(self):
return '%s (%s bounded)' % (self.button_caption, self.boundary_mode_names[self.boundary_mode])
return "%s (%s bounded)" % (
self.button_caption,
self.boundary_mode_names[self.boundary_mode],
)

View file

@ -1,14 +1,11 @@
from ui_element import UIElement
from ui_button import UIButton
from renderable_sprite import UISpriteRenderable
from renderable_line import ToolSelectionBoxRenderable
from renderable_sprite import UISpriteRenderable
from ui_button import UIButton
from ui_element import UIElement
class ToolBar(UIElement):
tile_width, tile_height = 4, 1 # real size will be set based on buttons
tile_width, tile_height = 4, 1 # real size will be set based on buttons
icon_scale_factor = 4
snap_left = True
@ -58,7 +55,7 @@ class ToolBar(UIElement):
class ToolBarButton(UIButton):
width, height = 4, 2
caption = ''
caption = ""
tooltip_on_hover = True
def get_tooltip_text(self):
@ -66,34 +63,39 @@ class ToolBarButton(UIButton):
def get_tooltip_location(self):
x = self.width
window_height_chars = self.element.ui.app.window_height / (self.element.ui.charset.char_height * self.element.ui.scale)
window_height_chars = self.element.ui.app.window_height / (
self.element.ui.charset.char_height * self.element.ui.scale
)
cursor_y = self.element.ui.app.mouse_y / self.element.ui.app.window_height
y = int(cursor_y * window_height_chars)
return x, y
class ArtToolBar(ToolBar):
def create_toolbar_buttons(self):
for i,tool in enumerate(self.ui.tools):
for i, tool in enumerate(self.ui.tools):
button = ToolBarButton(self)
# button.caption = tool.button_caption # DEBUG
button.x = 0
button.y = i * button.height
# alternate colors
button.normal_bg_color = self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey
button.normal_bg_color = (
self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey
)
button.hovered_bg_color = self.ui.colors.medgrey
# callback: tell ui to set this tool as selected
button.callback = self.ui.set_selected_tool
button.cb_arg = tool
self.buttons.append(button)
# create button icon
sprite = UISpriteRenderable(self.ui.app, self.ui.asset_dir + tool.icon_filename)
sprite = UISpriteRenderable(
self.ui.app, self.ui.asset_dir + tool.icon_filename
)
self.icon_renderables.append(sprite)
def reset_button_icons(self):
button_height = self.art.quad_height * ToolBarButton.height
for i,icon in enumerate(self.icon_renderables):
for i, icon in enumerate(self.icon_renderables):
# scale: same screen size as cursor icon
scale_x = icon.texture.width / self.ui.app.window_width
scale_x *= self.icon_scale_factor * self.ui.scale
@ -104,11 +106,11 @@ class ArtToolBar(ToolBar):
# position
# remember that in renderable space, (0, 0) = center of screen
icon.x = self.x
icon.x += (icon.scale_x / 8)
icon.x += icon.scale_x / 8
icon.y = self.y
icon.y -= button_height * i
icon.y -= icon.scale_y
icon.y -= (icon.scale_y / 8)
icon.y -= icon.scale_y / 8
def update_selection_box(self):
# scale and position box around currently selected tool

281
uv.lock Normal file
View file

@ -0,0 +1,281 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "appdirs"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
name = "playscii"
version = "9.18"
source = { editable = "." }
dependencies = [
{ name = "appdirs" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyopengl" },
{ name = "pysdl2" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "appdirs" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyopengl" },
{ name = "pysdl2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyopengl"
version = "3.1.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" },
]
[[package]]
name = "pysdl2"
version = "0.9.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/ff/8704d84ad4d25f0a7bf7912504f64575e432e8d57dfba2fe35f5b2db7e04/pysdl2-0.9.17.tar.gz", hash = "sha256:48c6ef01a4eb123db5f7e46e1a1b565675755b07e615f3fe20a623c94735b52b", size = 775955, upload-time = "2024-12-30T18:07:27.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/e2/399ea7900e7510096aeb41439e6f1540bef0bdf7e15cc2d8464e4adb71e8/PySDL2-0.9.17-py3-none-any.whl", hash = "sha256:fe923dbf5c7b27bbc1eb2bf58abfa793f8f13fd7ae8b27b1bc2de49920bcbd41", size = 583137, upload-time = "2024-12-30T18:07:25.987Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]

View file

@ -1,17 +1,17 @@
import math
import numpy as np
from OpenGL import GL, GLU
import numpy as np
from OpenGL import GLU
class Vec3:
"Basic 3D vector class. Not used very much currently."
def __init__(self, x=0, y=0, z=0):
self.x, self.y, self.z = x, y, z
def __str__(self):
return 'Vec3 %.4f, %.4f, %.4f' % (self.x, self.y, self.z)
return "Vec3 %.4f, %.4f, %.4f" % (self.x, self.y, self.z)
def __sub__(self, b):
"Return a new vector subtracted from given other vector."
@ -19,7 +19,7 @@ class Vec3:
def length(self):
"Return this vector's scalar length."
return math.sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2)
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
def normalize(self):
"Return a unit length version of this vector."
@ -51,6 +51,7 @@ class Vec3:
"Return a copy of this vector."
return Vec3(self.x, self.y, self.z)
def get_tiles_along_line(x0, y0, x1, y1):
"""
Return list of (x,y) tuples for all tiles crossing given worldspace
@ -63,7 +64,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
n = 1
if dx == 0:
x_inc = 0
error = float('inf')
error = float("inf")
elif x1 > x0:
x_inc = 1
n += math.floor(x1) - x
@ -74,7 +75,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
error = (x0 - math.floor(x0)) * dy
if dy == 0:
y_inc = 0
error -= float('inf')
error -= float("inf")
elif y1 > y0:
y_inc = 1
n += math.floor(y1) - y
@ -95,6 +96,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
n -= 1
return tiles
def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True):
"""
simplified version of get_tiles_along_line using only integer math,
@ -128,6 +130,7 @@ def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True):
n -= 1
return tiles
def cut_xyz(x, y, z, threshold):
"""
Return input x,y,z with each axis clamped to 0 if it's close enough to
@ -138,10 +141,21 @@ def cut_xyz(x, y, z, threshold):
z = z if abs(z) > threshold else 0
return x, y, z
def ray_plane_intersection(plane_x, plane_y, plane_z,
plane_dir_x, plane_dir_y, plane_dir_z,
ray_x, ray_y, ray_z,
ray_dir_x, ray_dir_y, ray_dir_z):
def ray_plane_intersection(
plane_x,
plane_y,
plane_z,
plane_dir_x,
plane_dir_y,
plane_dir_z,
ray_x,
ray_y,
ray_z,
ray_dir_x,
ray_dir_y,
ray_dir_z,
):
# from http://stackoverflow.com/a/39424162
plane = np.array([plane_x, plane_y, plane_z])
plane_dir = np.array([plane_dir_x, plane_dir_y, plane_dir_z])
@ -149,13 +163,14 @@ def ray_plane_intersection(plane_x, plane_y, plane_z,
ray_dir = np.array([ray_dir_x, ray_dir_y, ray_dir_z])
ndotu = plane_dir.dot(ray_dir)
if abs(ndotu) < 0.000001:
#print ("no intersection or line is within plane")
# print ("no intersection or line is within plane")
return 0, 0, 0
w = ray - plane
si = -plane_dir.dot(w) / ndotu
psi = w + si * ray_dir + plane
return psi[0], psi[1], psi[2]
def screen_to_world(app, screen_x, screen_y):
"""
Return 3D (float) world space coordinates for given 2D (int) screen space
@ -174,12 +189,23 @@ def screen_to_world(app, screen_x, screen_y):
# TODO: what Z is appropriate for game mode picking? test multiple planes?
art = app.ui.active_art
plane_z = art.layers_z[art.active_layer] if art and not app.game_mode else 0
x, y, z = ray_plane_intersection(0, 0, plane_z, # plane loc
0, 0, 1, # plane dir
end_x, end_y, end_z, # ray origin
dir_x, dir_y, dir_z) # ray dir
x, y, z = ray_plane_intersection(
0,
0,
plane_z, # plane loc
0,
0,
1, # plane dir
end_x,
end_y,
end_z, # ray origin
dir_x,
dir_y,
dir_z,
) # ray dir
return x, y, z
def world_to_screen(app, world_x, world_y, world_z):
"""
Return 2D screen pixel space coordinates for given 3D (float) world space
@ -193,10 +219,11 @@ def world_to_screen(app, world_x, world_y, world_z):
x, y, z = GLU.gluProject(world_x, world_y, world_z, vm, pjm, viewport)
except:
x, y, z = 0, 0, 0
app.log('GLU.gluProject failed!')
app.log("GLU.gluProject failed!")
# does Z mean anything here?
return x, y
def world_to_screen_normalized(app, world_x, world_y, world_z):
"""
Return normalized (-1 to 1) 2D screen space coordinates for given 3D