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

680
art.py

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,32 @@
import traceback
from art import ART_DIR
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."
def __init__(self, app, out_filename, options={}):
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,15 +41,18 @@ 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
def run_export(self, out_filename, options):
"""
Contains the actual export logic. Write data based on current art,

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 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)
@ -66,20 +73,20 @@ class ArtImporter:
# adjust for new art size and set it active
self.app.ui.adjust_for_art_resize(self.art)
self.app.ui.set_active_art(self.art)
def set_art_charset(self, charset_name):
"Convenience function for setting charset by name from run_import."
self.art.set_charset_by_name(charset_name)
def set_art_palette(self, palette_name):
"Convenience function for setting palette by name from run_import."
self.art.set_palette_by_name(palette_name)
def resize(self, new_width, new_height):
"Convenience function for resizing art from run_import"
self.art.resize(new_width, new_height)
self.app.ui.adjust_for_art_resize(self.art)
def run_import(self, in_filename, options):
"""
Contains the actual import logic. Read input file, set Art

View file

@ -1,31 +1,32 @@
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:
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
self.sound_cb = ctypes.CFUNCTYPE(None, ctypes.c_int)(self.channel_finished)
sdlmixer.Mix_ChannelFinished(self.sound_cb)
def channel_finished(self, channel):
# remove sound from dicts of playing channels and sounds
old_sound = self.playing_channels.pop(channel)
@ -33,7 +34,7 @@ class AudioLord:
# remove empty list
if self.playing_sounds[old_sound.filename] == []:
self.playing_sounds.pop(old_sound.filename)
def reset(self):
self.stop_all_music()
self.stop_all_sounds()
@ -44,24 +45,25 @@ 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 = {}
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,74 +73,75 @@ 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:
self.playing_sounds[sound_filename] = [new_playing_sound]
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]:
if game_object is sound.go:
sdlmixer.Mix_HaltChannel(sound.channel)
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)
for sound_filename in sounds_to_stop:
self.object_stop_sound(game_object, sound_filename)
def stop_all_sounds(self):
sdlmixer.Mix_HaltChannel(-1)
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):
# TODO: fade in support etc
music = self.musics[music_filename]
sdlmixer.Mix_PlayMusic(music, loops)
self.current_music = music_filename
def pause_music(self):
if self.current_music:
sdlmixer.Mix_PauseMusic()
def resume_music(self):
if self.current_music:
sdlmixer.Mix_ResumeMusic()
def stop_music(self, music_filename):
# TODO: fade out support
sdlmixer.Mix_HaltMusic()
self.current_music = None
def is_music_playing(self):
return bool(sdlmixer.Mix_PlayingMusic())
def resume_music(self):
if self.current_music:
sdlmixer.Mix_ResumeMusic()
def stop_all_music(self):
sdlmixer.Mix_HaltMusic()
self.current_music = None
def update(self):
if self.current_music and not self.is_music_playing():
self.current_music = None
def destroy(self):
self.reset()
sdlmixer.Mix_CloseAudio()

119
camera.py
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:
# 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,35 +31,35 @@ 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
far_z = 100000
def __init__(self, app):
self.app = app
self.reset()
self.max_pan_speed = self.base_max_pan_speed
def reset(self):
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
self.calc_projection_matrix()
self.calc_view_matrix()
def calc_projection_matrix(self):
self.projection_matrix = self.get_perspective_matrix()
def calc_view_matrix(self):
eye = vector.Vec3(self.x, self.y, self.z)
up = vector.Vec3(0, 1, 0)
@ -65,24 +68,23 @@ 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
def get_perspective_matrix(self):
zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
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):
width, height = width or self.app.window_width, height or self.app.window_height
m = np.eye(4, 4, dtype=np.float32)
@ -95,12 +97,9 @@ 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):
# modify pan speed based on zoom according to a factor
m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
@ -109,7 +108,7 @@ class Camera:
# for brevity, app passes in whether user appears to be keyboard editing
if keyboard:
self.app.keyboard_editing = True
def zoom(self, dz, keyboard=False, towards_cursor=False):
self.vel_z += dz * self.zoom_accel
# pan towards cursor while zooming?
@ -119,11 +118,11 @@ class Camera:
self.pan(dx, dy, keyboard)
if keyboard:
self.app.keyboard_editing = True
def get_current_zoom_pct(self):
"returns % of base (1:1) for current camera"
return (self.get_base_zoom() / self.z) * 100
def get_base_zoom(self):
"returns camera Z needed for 1:1 pixel zoom"
wh = self.app.window_height
@ -132,10 +131,10 @@ class Camera:
if ch == 8:
ch = 16
return wh / ch
def set_to_base_zoom(self):
self.z = self.get_base_zoom()
def zoom_proportional(self, direction):
"zooms in or out via increments of 1:1 pixel scales for active art"
if not self.app.ui.active_art:
@ -167,7 +166,7 @@ class Camera:
break
# kill all Z velocity for camera so we don't drift out of 1:1
self.vel_z = 0
def find_closest_zoom_extents(self):
def corners_on_screen():
art = self.app.ui.active_art
@ -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
@ -192,16 +191,24 @@ class Camera:
self.zoom_proportional(-1)
self.calc_view_matrix()
tries += 1
def toggle_zoom_extents(self, override=None):
art = self.app.ui.active_art
if override is not None:
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
@ -209,27 +216,27 @@ class Camera:
# kill all camera velocity when snapping
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
art.camera_zoomed_extents = not art.camera_zoomed_extents
def window_resized(self):
self.calc_projection_matrix()
def set_zoom(self, z):
# TODO: set lerp target, clear if keyboard etc call zoom()
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)
def set_for_art(self, art):
# set limits
self.max_x = art.width * art.quad_width
self.min_y = -art.height * art.quad_height
# use saved pan/zoom
self.set_loc(art.camera_x, art.camera_y, art.camera_z)
def mouse_pan(self, dx, dy):
"pan view based on mouse delta"
if dx == 0 and dy == 0:
@ -240,12 +247,13 @@ class Camera:
self.y += dy / self.mouse_pan_rate * m
self.vel_x = self.vel_y = 0
self.mouse_panned = True
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
@ -269,7 +277,7 @@ class Camera:
self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
# apply friction
self.vel_x *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction
if abs(self.vel_x) < self.min_velocity:
self.vel_x = 0
if abs(self.vel_y) < self.min_velocity:
@ -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,23 +1,28 @@
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
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
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
@ -62,19 +74,21 @@ class CharacterSet:
self.app.log("loaded charmap '%s' from %s:" % (self.name, self.filename))
self.report()
self.init_success = True
def load_char_data(self):
"carries out majority of CharacterSet init, including loading image"
char_data_src = open(self.filename, encoding='utf-8').readlines()
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
@ -121,11 +135,11 @@ class CharacterSet:
# store base filename for easy comparisons with not-yet-loaded sets
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
return True
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
@ -143,25 +157,35 @@ class CharacterSet:
# flip image data back and save it for later, eg image conversion
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_data = img
def set_char_dimensions(self):
# store character dimensions and UV size
self.char_width = int(self.image_width / self.map_width)
self.char_height = int(self.image_height / self.map_height)
self.u_width = self.char_width / self.image_width
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(' source texture %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
@ -170,10 +194,10 @@ class CharacterSet:
if img_changed:
self.last_image_change = time.time()
return data_changed or img_changed
def get_char_index(self, char):
return self.char_mapping.get(char, 0)
def get_solid_pixels_in_char(self, char_index):
"Returns # of solid pixels in character at given index"
tile_x = int(char_index % self.map_width)

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,11 +61,11 @@ 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)
def resolve_overlap(self, overlap):
"Resolve this shape's given overlap."
other = overlap.other
@ -97,7 +101,7 @@ class CollisionShape:
world.try_object_method(self.go, self.go.started_colliding, [other.go])
if b_started_a:
world.try_object_method(other.go, other.go.started_colliding, [self.go])
def get_overlapping_static_shapes(self):
"Return a list of static shapes that overlap with this shape."
overlapping_shapes = []
@ -118,80 +122,118 @@ 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
self.go = game_object
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."
return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2)
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
self.go = game_object
# for CST_TILE objects, lists of tile(s) we cover
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."
return point_in_box(x, y, *self.get_box())
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this box overlaps given line segment."
left, top, right, bottom = self.get_box()
return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2)
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
@ -212,7 +256,7 @@ class Collideable:
self.contacts = {}
"Dict of contacts with other objects, by object name"
self.create_shapes()
def create_shapes(self):
"""
Create collision shape(s) appropriate to our game object's
@ -231,7 +275,7 @@ class Collideable:
# update renderables once if static
if not self.go.is_dynamic():
self.update_renderables()
def _clear_shapes(self):
for r in self.renderables:
r.destroy()
@ -240,41 +284,49 @@ class Collideable:
self.cl._remove_shape(shape)
self.shapes = []
"List of CollisionShapes"
def _create_circle(self):
x = self.go.x + self.go.col_offset_x
y = self.go.y + self.go.col_offset_y
shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go)
self.shapes = [shape]
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)]
def _create_merged_tile_boxes(self):
"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):
@ -312,26 +365,26 @@ class Collideable:
r.update()
self.shapes.append(shape)
self.renderables.append(r)
def get_shape_overlapping_point(self, x, y):
"Return shape if it's overlapping given point, None if no overlap."
tile_x, tile_y = self.go.get_tile_at_point(x, y)
return self.tile_shapes.get((tile_x, tile_y), None)
def get_shapes_overlapping_box(self, left, top, right, bottom):
"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
def update(self):
if self.go and self.go.is_dynamic():
self.update_transform_from_object()
def update_transform_from_object(self, obj=None):
"Snap our shapes to location of given object (if unspecified, our GO)."
obj = obj or self.go
@ -341,7 +394,7 @@ class Collideable:
for shape in self.shapes:
shape.x = obj.x + obj.col_offset_x
shape.y = obj.y + obj.col_offset_y
def set_shape_color(self, shape, new_color):
"Set the color of a given shape's debug LineRenderable."
try:
@ -351,15 +404,15 @@ class Collideable:
self.renderables[shape_index].color = new_color
self.renderables[shape_index].build_geo()
self.renderables[shape_index].rebind_buffers()
def update_renderables(self):
for r in self.renderables:
r.update()
def render(self):
for r in self.renderables:
r.render()
def destroy(self):
for r in self.renderables:
r.destroy()
@ -373,26 +426,29 @@ 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
# list of objects processed for collision this frame
self.collisions_this_frame = []
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 = [], []
def _add_circle_shape(self, x, y, radius, game_object):
shape = CircleCollisionShape(x, y, radius, game_object)
if game_object.is_dynamic():
@ -400,7 +456,7 @@ class CollisionLord:
else:
self.static_shapes.append(shape)
return shape
def _add_box_shape(self, x, y, halfwidth, halfheight, game_object):
shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object)
if game_object.is_dynamic():
@ -408,13 +464,13 @@ class CollisionLord:
else:
self.static_shapes.append(shape)
return shape
def _remove_shape(self, shape):
if shape in self.dynamic_shapes:
self.dynamic_shapes.remove(shape)
elif shape in self.static_shapes:
self.static_shapes.remove(shape)
def update(self):
"Resolve overlaps between all relevant world objects."
for i in range(self.iterations):
@ -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

218
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,14 +56,14 @@ 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'
vert_shader_source = "cursor_v.glsl"
frag_shader_source = "cursor_f.glsl"
alpha = 1
icon_scale_factor = 4
logg = False
def __init__(self, app):
self.app = app
self.x, self.y, self.z = 0, 0, 0
@ -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)
@ -122,11 +126,11 @@ class Cursor:
GL.glBindVertexArray(0)
# init tool sprite, tool will provide texture when rendered
self.tool_sprite = UISpriteRenderable(self.app)
def clamp_to_active_art(self):
self.x = max(0, min(self.x, self.app.ui.active_art.width - 1))
self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1))
def keyboard_move(self, delta_x, delta_y):
if not self.app.ui.active_art:
return
@ -136,11 +140,14 @@ 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
def get_tile(self):
# adjust for brush size
size = self.app.ui.selected_tool.brush_size
@ -149,7 +156,7 @@ class Cursor:
return int(self.x + size_offset), int(-self.y + size_offset)
else:
return int(self.x), int(-self.y)
def center_in_art(self):
art = self.app.ui.active_art
if not art:
@ -157,20 +164,20 @@ class Cursor:
self.x = round(art.width / 2) * art.quad_width
self.y = round(-art.height / 2) * art.quad_height
self.moved = True
# !!TODO!! finish this, work in progress
def get_tiles_under_drag(self):
"""
returns list of tuple coordinates of all tiles under cursor's current
position AND tiles it's moved over since last update
"""
# TODO: get vector of last to current position, for each tile under
# current brush, do line trace along grid towards last point
# TODO: this works in two out of four diagonals,
# swap current and last positions to determine delta?
if self.last_x <= self.x:
x0, y0 = self.last_x, -self.last_y
x1, y1 = self.x, -self.y
@ -178,10 +185,10 @@ 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
def get_tiles_under_brush(self):
"""
returns list of tuple coordinates of all tiles under the cursor @ its
@ -194,11 +201,11 @@ class Cursor:
for x in range(x_start, x_start + size):
tiles.append((x, y))
return tiles
def undo_preview_edits(self):
for edit in self.preview_edits:
edit.undo()
def update_cursor_preview(self):
# rebuild list of cursor preview commands
if self.app.ui.selected_tool.show_preview:
@ -207,9 +214,12 @@ class Cursor:
edit.apply()
else:
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,25 +247,27 @@ 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
char_aspect = w / h
# round result for oddly proportioned charsets
self.x = round(math.floor(self.x / w) * w)
self.y = round(math.ceil(self.y / h) * h * char_aspect)
def pre_first_update(self):
# vector.screen_to_world result will be off because camera hasn't
# moved yet, recalc view matrix
@ -261,16 +276,18 @@ class Cursor:
self.snap_to_tile()
self.update_cursor_preview()
self.entered_new_tile()
def update(self):
# save old positions before update
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()
@ -297,36 +314,50 @@ class Cursor:
self.update_cursor_preview()
if self.moved_this_frame():
self.entered_new_tile()
def end_update(self):
"called at the end of App.update"
self.moved = False
def entered_new_tile(self):
if self.current_command and self.app.ui.selected_tool.paint_while_dragging:
# add new tile(s) to current command group
self.current_command.add_command_tiles(self.preview_edits)
self.app.ui.active_art.set_unsaved_changes(True)
self.preview_edits = []
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,9 +1,6 @@
import time
class EditCommand:
"undo/redo-able representation of an art edit (eg paint, erase) operation"
def __init__(self, art):
self.art = art
self.start_time = art.app.get_elapsed_time()
@ -12,7 +9,7 @@ class EditCommand:
# this prevents multiple commands operating on the same tile
# from stomping each other
self.tile_commands = {}
def get_number_of_commands(self):
commands = 0
for frame in self.tile_commands.values():
@ -21,48 +18,52 @@ class EditCommand:
for tile in column.values():
commands += 1
return commands
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):
# no commands at all yet, maybe
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()
def undo(self):
for frame in self.tile_commands.values():
for layer in frame.values():
for column in layer.values():
for tile_command in column.values():
tile_command.undo()
def apply(self):
for frame in self.tile_commands.values():
for layer in frame.values():
@ -72,15 +73,14 @@ 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
# remember origin of resize command
@ -88,14 +88,14 @@ class EntireArtCommand:
self.before_frame = art.active_frame
self.before_layer = art.active_layer
self.start_time = self.finish_time = art.app.get_elapsed_time()
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:
@ -105,7 +105,7 @@ class EntireArtCommand:
self.before_size = (self.art.width, self.art.height)
else:
self.after_size = (self.art.width, self.art.height)
def undo(self):
# undo might remove frames/layers that were added
self.art.set_active_frame(self.before_frame)
@ -114,19 +114,19 @@ 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
self.art.app.ui.adjust_for_art_resize(self.art)
self.art.mark_all_frames_changed()
def apply(self):
if self.before_size != self.after_size:
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()
@ -144,26 +143,40 @@ class EditCommandTile:
self.frame = self.layer = self.x = self.y = None
self.b_char = self.b_fg = self.b_bg = self.b_xform = None
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
@ -173,22 +186,27 @@ class EditCommandTile:
new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform
new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg
return new_ect
def set_tile(self, frame, layer, x, y):
self.frame, self.layer = frame, layer
self.x, self.y = x, y
def set_before(self, char, fg, bg, xform):
self.b_char, self.b_xform = char, xform
self.b_fg, self.b_bg = fg, bg
def set_after(self, char, fg, bg, xform):
self.a_char, self.a_xform = char, xform
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
if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1:
@ -196,37 +214,64 @@ 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):
self.undo_commands += new_commands[:]
self.clear_redo()
def undo(self):
if len(self.undo_commands) == 0:
return
@ -235,7 +280,7 @@ class CommandStack:
command.undo()
self.redo_commands.append(command)
self.art.app.cursor.update_cursor_preview()
def redo(self):
if len(self.redo_commands) == 0:
return
@ -247,6 +292,6 @@ class CommandStack:
# add to end of undo stack
self.undo_commands.append(command)
self.art.app.cursor.update_cursor_preview()
def clear_redo(self):
self.redo_commands = []

View file

@ -1,18 +1,18 @@
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"
i = 0
@ -22,28 +22,28 @@ Assumes 80 columns, DOS character set and EGA palette.
i += 1
seq.append(data[i])
return seq
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,25 +1,25 @@
# 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'
title = "Convert image"
confirm_caption = "Choose"
def confirm_pressed(self):
filename = self.field_texts[0]
if not os.path.exists(filename) or not os.path.isfile(filename):
@ -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'
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,57 +58,61 @@ 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
def get_initial_field_text(self, field_number):
if field_number == 1:
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
@ -127,65 +127,79 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
width *= scale
height *= scale
return int(width), int(height)
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.
"""
file_chooser_dialog_class = ConvertImageChooserDialog
options_dialog_class = ConvertImageOptionsDialog
completes_instantly = False
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,39 +19,38 @@ 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):
ConvertImageOptionsDialog.__init__(self, ui, options)
self.active_field = 6
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
@ -69,15 +71,15 @@ fg/bg color swaps. Suitable for plaintext export.
file_chooser_dialog_class = ConvertImageChooserDialog
options_dialog_class = TwoColorConvertImageOptionsDialog
completes_instantly = False
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
@ -24,7 +24,7 @@ class ImageSequenceConverter:
# queue up first frame
self.next_image(first=True)
self.init_success = True
def next_image(self, first=False):
# pop last image off stack
if not first:
@ -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
@ -49,11 +48,11 @@ class ImageSequenceConverter:
self.image_filename = self.image_filenames[0]
self.preview_sprite = self.current_frame_converter.preview_sprite
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):
# create converter for new frame if current one is done,
# else update current one
@ -61,53 +60,57 @@ class ImageSequenceConverter:
self.next_image()
else:
self.current_frame_converter.update()
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,76 +1,77 @@
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.
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,14 +1,14 @@
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.
Background colors can only be EGA colors 0-8.
"""
def run_import(self, in_filename, options={}):
"""
from http://doomwiki.org/wiki/ENDOOM:
@ -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,15 +1,14 @@
from art_import import ArtImporter
class TextImporter(ArtImporter):
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 and palette will be used.
"""
def run_import(self, in_filename, options={}):
lines = open(in_filename).readlines()
# determine length of longest line

View file

@ -1,41 +1,41 @@
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):
self.outfile.write(data.encode(ENCODING))
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,52 +91,60 @@ 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.
"""
file_extension = FILE_EXTENSION
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,65 +22,86 @@ 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)
def get_crt_enabled(self):
return self.disable_crt or self.start_crt_enabled
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):
self.width, self.height = new_width, new_height
self.setup_texture_and_buffers()
def toggle_crt(self):
self.crt = not self.crt
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
@ -86,7 +109,7 @@ class Framebuffer:
GL.glDeleteRenderbuffers(1, [self.depth_buffer])
GL.glDeleteTextures([self.texture])
GL.glDeleteFramebuffers(1, [self.framebuffer])
def render(self):
if self.crt and not self.disable_crt:
GL.glUseProgram(self.crt_shader.program)
@ -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,33 +12,33 @@ 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):
self.world = world
self.arts, self.renderables = [], []
def update(self):
for art in self.arts:
art.update()
for r in self.renderables:
r.update()
def should_render(self):
return True
def render(self):
if not self.should_render():
return
for r in self.renderables:
r.render()
def destroy(self):
for r in self.renderables:
r.destroy()

File diff suppressed because it is too large Load diff

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,18 +57,19 @@ 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):
self.reset_edge_warps()
def reset_edge_warps(self):
self.edge_obj = self.world.objects.get(self.warp_edge_bounds_obj_name, None)
# 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,26 +77,31 @@ 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
if self is self.world.current_room:
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)
def entered(self, old_room):
"Run when the player enters this room."
if self.log_changes:
@ -100,7 +116,7 @@ class GameRoom:
# tell objects in this room player has entered so eg spawners can fire
for obj in self.objects.values():
obj.room_entered(self, old_room)
def exited(self, new_room):
"Run when the player exits this room."
if self.log_changes:
@ -108,7 +124,7 @@ class GameRoom:
# tell objects in this room player has exited
for obj in self.objects.values():
obj.room_exited(self, new_room)
def add_object_by_name(self, obj_name):
"Add object with given name to this room."
obj = self.world.objects.get(obj_name, None)
@ -116,12 +132,12 @@ class GameRoom:
self.world.app.log("Couldn't find object named %s" % obj_name)
return
self.add_object(obj)
def add_object(self, obj):
"Add object (by reference) to this room."
self.objects[obj.name] = obj
obj.rooms[self.name] = self
def remove_object_by_name(self, obj_name):
"Remove object with given name from this room."
obj = self.world.objects.get(obj_name, None)
@ -129,33 +145,42 @@ class GameRoom:
self.world.app.log("Couldn't find object named %s" % obj_name)
return
self.remove_object(obj)
def remove_object(self, obj):
"Remove object (by reference) from this room."
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):
d[prop_name] = getattr(self, prop_name)
return d
def _check_edge_warp(self, game_object):
# 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
@ -181,11 +206,11 @@ class GameRoom:
# TODO: change room or not? use_marker_room flag a la WarpTrigger?
game_object.set_loc(warp_dest.x, warp_dest.y)
game_object.last_warp_update = self.world.updates
def update(self):
if self is self.world.current_room:
self._check_edge_warp(self.world.player)
def destroy(self):
if self.name in self.world.rooms:
self.world.rooms.pop(self.name)

View file

@ -1,31 +1,41 @@
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."
self.parent = game_object
def update(self):
# very minimal update!
if not self.art.updated_this_tick:
self.art.update()
def post_update(self):
# after parent has moved, snap to its location
self.x = self.parent.x + self.offset_x
@ -36,93 +46,112 @@ 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
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
self.fire_dir_x, self.fire_dir_y = 0, 0
def fire(self, firer, dir_x=0, dir_y=1):
self.set_loc(firer.x, firer.y, firer.z)
self.reset_last_loc()
self.fire_dir_x, self.fire_dir_y = dir_x, dir_y
def update(self):
if (self.fire_dir_x, self.fire_dir_y) != (0, 0):
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
if self.art.frames > 0:
self.start_animating()
def update_state(self):
GameObject.update_state(self)
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:
self.world.player = self
@ -130,40 +159,56 @@ class Player(Character):
self.world.camera.focus_object = self
else:
self.world.camera.focus_object = None
def button_pressed(self, button_index):
pass
def button_unpressed(self, button_index):
pass
class TopDownPlayer(Player):
y_sort = True
attachment_classes = { 'shadow': 'BlobShadow' }
attachment_classes = {"shadow": "BlobShadow"}
facing_changes_art = True
def get_facing_dir(self):
return FACING_DIRS[self.facing]
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,31 +234,36 @@ 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):
setattr(self.world, prop_name, new_value)
def update_from_world(self):
self.camera_x = self.world.camera.x
self.camera_y = self.world.camera.y
@ -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,16 +328,21 @@ 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():
return
@ -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,20 +406,23 @@ 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):
LocationMarker.__init__(self, world, obj_data)
self.times_fired = 0
# list of objects we've spawned
self.spawned_objects = []
def get_spawn_class_name(self):
"Return class name of object to spawn."
return self.spawn_class_name
def get_spawn_location(self):
"Return x,y location we should spawn a new object at."
if not self.spawn_random_in_bounds:
@ -358,11 +431,11 @@ class ObjectSpawner(LocationMarker):
x = left + random.random() * (right - left)
y = top + random.random() * (bottom - top)
return x, y
def can_spawn(self):
"Return True if spawner is allowed to spawn."
return True
def do_spawn(self):
"Spawn and returns object."
class_name = self.get_spawn_class_name()
@ -379,7 +452,7 @@ class ObjectSpawner(LocationMarker):
new_obj.spawner = self
# TODO: put new object in our room(s), apply spawn_obj_data
return new_obj
def trigger(self):
"Poke this spawner to do its thing, returns an object if spawned"
if self.times_to_fire != -1 and self.times_fired >= self.times_to_fire:
@ -389,11 +462,11 @@ class ObjectSpawner(LocationMarker):
if self.times_fired != -1:
self.times_fired += 1
return self.do_spawn()
def room_entered(self, room, old_room):
if self.trigger_on_room_enter:
self.trigger()
def room_exited(self, room, new_room):
if not self.destroy_on_room_exit:
return
@ -403,29 +476,38 @@ 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)
def room_exited(self, room, new_room):
self.stop_sound(self.sound_name)

File diff suppressed because it is too large Load diff

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)
@ -24,34 +22,34 @@ class LineTester(GameObject):
art_width, art_height = 40, 40
art_off_pct_x, art_off_pct_y = 0.0, 0.0
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)
self.line.z = 0.0
self.line.line_width = 3
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
self.art.set_tile_at(0, 0, x, y, char, fg)
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,45 +1,47 @@
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)
# top-down facing
self.direction = DIR_NORTH
self.maze.update_tile_visibilities()
self.art.set_tile_at(0, 0, 0, 0, self.dir_chars[self.direction], self.fg_color)
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,16 +46,16 @@ 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 = []
def is_tile_solid(self, x, y):
return self.art.get_char_index_at(0, 0, x, y) != 0
# world to tile: self.get_tile_at_point(world_x, world_y)
# tile to world: self.get_tile_loc(tile_x, tile_y)
def get_visible_tiles(self, x, y, dir_x, dir_y, tile_range, see_thru_walls=False):
"return tiles visible from given point facing given direction"
# NOTE: all the calculations here are done in this object's art's tile
@ -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,17 +85,21 @@ 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
scan_distance += 1
return tiles
def update_tile_visibilities(self):
"""
update our art's tile visuals based on what tiles can be, can't be,
@ -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,31 +28,30 @@ 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):
self.art.add_layer(z=0.01)
self.target_particles = TARGET_PARTICLES_DEFAULT
@ -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,9 +88,9 @@ 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):
# shift messages on layer 2 upward gradually
if self.app.frames % 10 == 0:
@ -145,18 +144,22 @@ 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)
self.particles.append(p)
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)
x = randint(0, self.art.width - len(msg_text))
@ -164,45 +167,48 @@ class Fireplace(GameObject):
y = randint(int(self.art.height / 2), self.art.height)
# write to second layer
self.art.write_string(0, 1, x, y, msg_text, randint(12, 16))
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)
@ -214,7 +220,7 @@ class FireParticle:
self.bg = randint(0, len(fp.art.palette.colors) - 1)
# hang on to fireplace
self.fp = fp
def update(self):
# no need for out-of-range checks; fireplace will cull particles that
# reach the top of the screen
@ -228,9 +234,9 @@ 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)
self.char += other.char
@ -239,27 +245,26 @@ 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
def clicked(self, button, mouse_x, mouse_y):
if self.visible:
webbrowser.open(MUSIC_URL)
def hovered(self, mouse_x, mouse_y):
# hilight text on hover
for frame, layer, x, y in TileIter(self.art):
self.art.set_color_at(frame, layer, x, y, 2)
def unhovered(self, mouse_x, mouse_y):
for frame, layer, x, y in TileIter(self.art):
self.art.set_color_at(frame, layer, x, y, 16)

View file

@ -1,4 +1,3 @@
from random import choice
from art import TileIter
@ -21,14 +20,14 @@ 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):
GameObject.__init__(self, world, obj_data)
self.reset()
def reset(self):
for frame, layer, x, y in TileIter(self.art):
color = choice(TILE_COLORS)
@ -39,33 +38,33 @@ class Board(GameObject):
self.flood_with_color(start_color)
self.turns = STARTING_TURNS
self.game_state = GS_PLAYING
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))
def color_picked(self, color):
self.flood_with_color(TILE_COLORS[color])
self.turns -= 1
@ -74,14 +73,14 @@ class Board(GameObject):
elif self.turns == 0:
self.game_state = GS_LOST
# TODO: reset after delay / feedback?
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if self.game_state != GS_PLAYING:
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,9 +89,9 @@ 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)
i = 0
@ -103,31 +102,31 @@ 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:
return
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)
def update(self):
GameObject.update(self)
self.draw_text()

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,10 +17,10 @@ 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
self.msg_art.clear_frame_layer(0, 0, 0, self.message_color)

View file

@ -1,27 +1,35 @@
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):
return
self.world.hud.post_msg(self.bark)
def pre_first_update(self):
self.z = 0.1
# TODO: investigate why this random color set doesn't seem to work
@ -30,18 +38,19 @@ 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
x, y = (random.random() * 2) - 1, (random.random() * 2) - 1
@ -52,50 +61,49 @@ 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)
self.holder = None
def started_colliding(self, other):
if not isinstance(other, Player):
return
if self is other.held_object:
return
other.pick_up(self)
def stopped_colliding(self, other):
if not isinstance(other, Player):
return
if self is not other.held_object:
self.enable_collision()
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()
def destroy(self):
if self.holder:
self.holder.held_object = None
self.holder = None
GameObject.destroy(self)
def update(self):
GameObject.update(self)
if not self.holder:
@ -103,33 +111,33 @@ 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)
ch, fg, bg, xform = self.art.get_tile_at(0, 0, 0, 0)
@ -146,21 +154,21 @@ 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
mass = 0.0
key_type = MazeKey
def started_colliding(self, other):
if not isinstance(other, Player):
return
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()
self.visible = False
@ -168,22 +176,21 @@ 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
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,17 +223,17 @@ 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):
return

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,24 +17,24 @@ 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)
self.held_object = None
def pick_up(self, pickup):
# drop any other held item first
if self.held_object:
self.drop(self.held_object, pickup)
self.held_object = pickup
pickup.picked_up(self)
def drop(self, pickup, new_pickup=None):
# drop pickup in place of one we're swapping with, else drop at feet
if new_pickup:
@ -41,11 +42,11 @@ class MazePlayer(Player):
else:
pickup.x, pickup.y = self.x, self.y
pickup.holder = None
def use_item(self):
self.world.hud.post_msg(self.held_object.used_message)
self.held_object.used(self)
def update(self):
Player.update(self)
if type(self.world.current_room) is OutsideRoom:

View file

@ -1,25 +1,22 @@
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):
MazeRoom.entered(self, old_room)
self.world.collision_enabled = False
self.world.app.camera.y_tilt = 4
def exited(self, new_room):
MazeRoom.exited(self, new_room)
self.world.collision_enabled = True

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):
# 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,16 +24,16 @@ 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)
self.jump_time = 0
# don't jump again until jump is released and pressed again
self.jump_ready = True
self.started_jump = False
def started_colliding(self, other):
Player.started_colliding(self, other)
if isinstance(other, PlatformMonster):
@ -42,77 +41,87 @@ class PlatformPlayer(Player):
dx, dy = other.x - self.x, other.y - self.y
if abs(dy) > abs(dx) and dy < -1:
other.destroy()
def is_affected_by_gravity(self):
return True
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == self.jump_key and self.jump_ready:
self.jump()
self.jump_ready = False
self.started_jump = True
def handle_key_up(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == self.jump_key:
self.jump_ready = True
def allow_move_y(self, dy):
# disable regular up/down movement, jump button sets move_y directly
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
return contact.overlap.y < 0
def jump(self):
self.jump_time += self.get_time_since_last_update() / 1000
if self.jump_time < self.max_jump_press_time:
self.move_y += 1
def update(self):
on_ground = self.is_on_ground()
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
col_radius = 1
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
def allow_move_y(self, dy):
return False
def check_wall_hits(self):
"Turn around if a wall is immediately ahead of direction we're moving."
# check collision in direction we're moving
@ -123,20 +132,22 @@ 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
def update(self):
self.move(self.move_dir_x, 0)
Character.update(self)
class PlatformWarpTrigger(WarpTrigger):
warp_class_names = ['Player', 'PlatformMonster']
warp_class_names = ["Player", "PlatformMonster"]

View file

@ -1,20 +1,21 @@
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
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)
# track last death and last fire time for respawn and refire delays
@ -22,33 +23,33 @@ class ShmupPlayer(Player):
self.last_fire_time = 0
# save our start position
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
def update_state(self):
# only two states, ignore stuff parent class does for this
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,38 +59,44 @@ 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
def __init__(self, world, obj_data=None):
ObjectSpawner.__init__(self, world, obj_data)
self.next_spawn_time = 0
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)
# bump up enemy counts as time goes on
@ -106,37 +113,42 @@ 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):
other.die(self)
def fire_proj(self):
proj = ShmupEnemyProjectile(self.world)
# fire downward
proj.fire(self, 0, -1)
def update(self):
self.move(0, -1)
Character.update(self)
class Enemy1(ShmupEnemy):
art_src = 'enemy1'
art_src = "enemy1"
move_accel_y = 100
def update(self):
# sine wave motion in X
time = self.world.get_elapsed_time()
@ -146,16 +158,17 @@ class Enemy1(ShmupEnemy):
self.fire_proj()
ShmupEnemy.update(self)
class Enemy2(ShmupEnemy):
art_src = 'enemy2'
art_src = "enemy2"
animating = True
move_accel_y = 50
def pre_first_update(self):
ShmupEnemy.pre_first_update(self)
# pick random lateral movement goal
self.goal_x, y = self.spawner.get_spawn_location()
def update(self):
# move to random goal X
dx = self.goal_x - self.x
@ -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,48 +206,52 @@ 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):
"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]
def pre_first_update(self):
self.art.clear_frame_layer(0, 0)
def create_star(self):
"create a star at a random point along the top edge"
x = int(random.random() * self.art_width)
char = random.choice(self.star_chars)
color = self.art.palette.get_random_color_index()
self.art.set_tile_at(0, 0, x, 0, char, color)
def update(self):
# maybe create a star at the top, clear bottom line, then shift-wrap
if random.random() < 0.25:

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,19 +14,18 @@ FLOWER_WIDTH, FLOWER_HEIGHT = 16, 16
class FlowerObject(GameObject):
generate_art = True
should_save = False
physics_move = False
art_width, art_height = FLOWER_WIDTH, FLOWER_HEIGHT
min_petals, max_petals = 0, 4
min_fronds, max_fronds = 0, 8
# every flower must have at least this many petals + fronds
minimum_complexity = 4
# app updates per grow update; 1 = grow every frame
ticks_per_grow = 4
# DEBUG: if True, add current time to date seed as a decimal,
# to test with highly specific values
# (note: this turns the seed from an int into a float)
@ -36,13 +33,13 @@ class FlowerObject(GameObject):
# DEBUG: if nonzero, use this seed for testing
debug_seed = 0
debug_log = False
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
# 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,29 +77,36 @@ 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
r = TileRenderable(self.app, self.exportable_art)
def update(self):
GameObject.update(self)
# grow only every few ticks, so you can watch the design grow
@ -108,10 +114,10 @@ class FlowerObject(GameObject):
return
if not self.finished_growing:
self.update_growth()
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,8 +142,8 @@ 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
if x > (self.art_width / 2) - 1 or y > (self.art_height / 2) - 1:
@ -148,13 +154,12 @@ 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
# (art starts with 1 frame, only do this after first frame written)

View file

@ -1,51 +1,57 @@
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
# layer all fronds should paint on
layer = 0
debug = False
def __init__(self, flower, index):
self.flower = flower
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)
@ -65,7 +71,7 @@ class Frond:
else:
self.char = random.randint(1, 255)
# first grow() will paint first character
def grow(self):
"""
grows this frond by another tile
@ -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
@ -104,16 +116,16 @@ class Frond:
grow_x, grow_y = self.get_grow_dir((last_x, last_y))
self.x, self.y = self.x + grow_x, self.y + grow_y
return painted
# paint and growth functions work in top left quadrant, then mirrored
def grow_straight_line(self, last_dir):
return self.grow_line
def grow_wander_outward(self, last_dir):
# (original prototype growth algo)
return random.choice([LEFT_UP, LEFT, UP])
def grow_curl(self, last_dir):
if last_dir == NONE:
return random.choice([LEFT, LEFT_UP, UP])

View file

@ -1,33 +1,41 @@
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
layer = 0
debug = False
def __init__(self, flower, index):
self.flower = flower
self.index = index
@ -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
@ -48,14 +60,23 @@ class Petal:
self.color = self.ramp.color
# random char from predefined list
self.char = random.choice(PETAL_CHARS)
def grow(self):
# grow outward (up and left) from center in "rings"
if self.radius >= self.goal_radius:
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
@ -63,19 +84,20 @@ class Petal:
# roll against chaos to mutate character
if random.random() < self.chaos * self.mutate_char_chance:
self.char = random.choice(PETAL_CHARS)
def paint_ring(self):
tiles = self.get_ring_tiles()
for t in tiles:
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 = []
for x in range(self.radius + 1):
@ -83,7 +105,7 @@ class Petal:
for y in range(self.radius + 1):
tiles.append((self.radius, y))
return tiles
def get_ring_tiles_dealieX(self):
# not sure what to call this but it's a nice shape
tiles = []
@ -91,7 +113,7 @@ class Petal:
for x in range(self.radius):
tiles.append((x - self.radius, y - self.radius))
return tiles
def get_ring_tiles_wings(self):
# not sure what to call this but it's a nice shape
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):
@ -111,7 +132,7 @@ class Petal:
if x + y == self.radius:
tiles.append((x, y))
return tiles
def get_ring_tiles_circle(self):
tiles = []
angle = 0
@ -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
@ -106,7 +104,7 @@ class RampIterator:
# determine starting color, somewhere along ramp
self.start_step = random.randint(0, self.length - 1)
self.color = self.start + (self.start_step * self.stride)
def go_to_next_color(self):
self.color += self.stride
return self.color

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,63 +19,65 @@ 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
def __init__(self, world, obj_data=None):
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
# save to .psci
# hold on last frame
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)
self.art.clear_frame_layer(0, 0)

38
grid.py
View file

@ -8,15 +8,15 @@ 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):
visible = True
draw_axes = False
def get_tile_size(self):
"Returns (width, height) grid size in tiles."
return 1, 1
def build_geo(self):
"build vert, element, and color arrays"
w, h = self.get_tile_size()
@ -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,69 +37,67 @@ 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)
self.elem_array = np.array(e, dtype=np.uint32)
self.color_array = np.array(c, dtype=np.float32)
def reset_loc(self):
self.x = 0
self.y = 0
self.z = 0
def reset(self):
"macro for convenience - rescale, reposition, update renderable"
self.build_geo()
self.reset_loc()
self.rebind_buffers()
def update(self):
pass
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
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]
def reset(self):
self.quad_size_ref = self.app.ui.active_art
Grid.reset(self)
def get_tile_size(self):
return self.app.ui.active_art.width, self.app.ui.active_art.height
class GameGrid(Grid):
draw_axes = True
base_size = 800
def get_tile_size(self):
# TODO: dynamically adjust bounds based on furthest away objects?
return self.base_size, self.base_size
def set_base_size(self, new_size):
self.base_size = new_size
self.reset()
def reset_loc(self):
# center of grid at world zero
qw, qh = self.get_quad_size()

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:
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)
@ -107,39 +113,45 @@ class ImageConverter:
if len(self.char_blocks) > self.art.charset.last_index:
break
self.init_success = True
def get_generated_color_diffs(self, colors):
# build table of color diffs
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
def get_rgb_color_diff(self, color1, color2):
r = abs(color1[0] - color2[0])
g = abs(color1[1] - color2[1])
b = abs(color1[2] - color2[2])
a = abs(color1[3] - color2[3])
return abs(r + g + b + a)
def get_lab_color_diff(self, color1, color2):
l1, a1, b1 = rgb_to_lab(*color1[:3])
l2, a2, b2 = rgb_to_lab(*color2[:3])
return lab_color_diff(l1, a1, b1, l2, a2, b2)
def get_nonlinear_rgb_color_diff(self, color1, color2):
# from http://www.compuphase.com/cmetric.htm
rmean = int((color1[0] + color2[0]) / 2)
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:
return
@ -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
@ -162,7 +181,7 @@ class ImageConverter:
if self.y >= self.art.height:
self.finish()
break
def get_color_combos_for_block(self, src_block):
"""
returns # of unique colors, AND
@ -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
@ -188,7 +207,7 @@ class ImageConverter:
continue
combos.append((color1, color2))
return colors, combos
def get_best_tile_for_block(self, src_block):
"returns a (char, fg, bg) tuple for the best match of given block"
colors, combos = self.get_color_combos_for_block(src_block)
@ -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()

File diff suppressed because it is too large Load diff

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,25 +1,31 @@
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:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
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,27 +94,27 @@ 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):
"return True if source image file has changed since last check"
changed = os.path.getmtime(self.filename) > self.last_image_change
if changed:
self.last_image_change = time.time()
return changed
def generate_image(self):
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):
@ -116,44 +124,48 @@ class Palette:
img.putpixel((x, y), self.colors[color_index])
color_index += 1
return img
def export_as_image(self):
img = self.generate_image()
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):
"returns True if we have any non-opaque (<1 alpha) colors"
for color in self.colors[1:]:
if color[3] < 255:
return False
return True
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.
@ -181,35 +192,34 @@ class Palette:
g_diff = abs(color_a[1] - color_b[1])
b_diff = abs(color_a[2] - color_b[2])
return (r_diff + g_diff + b_diff) <= tolerance
def get_closest_color_index(self, r, g, b):
"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):
# exclude transparent first index
return randint(1, len(self.colors))
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,17 +248,16 @@ 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"
return False
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,22 +19,23 @@ 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
"Use game object's art_off_pct values."
def __init__(self, app, art, game_object=None):
"Create Renderable with given Art, optionally bound to given GameObject"
self.app = app
@ -69,68 +73,128 @@ 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))
def update_tile_buffers(self, update_chars, update_uvs, update_fg, update_bg):
"Update GL data arrays for tile characters, fg/bg colors, transforms."
updates = {}
@ -143,32 +207,58 @@ 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)
def update_buffer(self, buffer_index, array, target, buffer_type, data_type,
attrib_name, attrib_size):
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,
):
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)
def advance_frame(self):
"Advance to our Art's next animation frame."
self.set_frame(self.frame + 1)
def rewind_frame(self):
"Rewind to our Art's previous animation frame."
self.set_frame(self.frame - 1)
def set_frame(self, new_frame_index):
"Set us to display our Art's given animation frame."
if new_frame_index == self.frame:
@ -177,20 +267,22 @@ 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."
self.animating = True
self.anim_timer = 0
def stop_animating(self):
"Pause animation playback on current frame (in game mode)."
self.animating = False
# restore to active frame if stopping
if not self.app.game_mode:
self.set_frame(self.art.active_frame)
def set_art(self, new_art):
"Display and bind to given Art."
if self.art:
@ -202,12 +294,12 @@ 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)
self.height = self.art.height * self.art.quad_height * self.scale_y
def move_to(self, x, y, z, travel_time=None):
"""
Start simple linear interpolation to given destination over given time.
@ -220,20 +312,22 @@ 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
self.goal_x, self.goal_y, self.goal_z = x, y, z
self.ui_moving = False
def update_transform_from_object(self, obj):
"Update our position & scale based on that of given game object."
self.z = obj.z
@ -250,14 +344,14 @@ class TileRenderable:
if obj.flip_x:
self.scale_x *= -1
self.scale_z = obj.scale_z
def update_loc(self):
# TODO: probably time to bust out the ol' vector module for this stuff
# get delta
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,8 +367,8 @@ 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:
self.update_transform_from_object(self.go)
@ -297,31 +391,41 @@ class TileRenderable:
# TODO: if new_frame < self.frame, count anim loop?
self.set_frame(new_frame)
self.last_frame_time = self.app.get_elapsed_time()
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):
"""
UIRenderable overrides this so it doesn't have to override
Renderable.render and duplicate lots of code.
"""
return np.eye(4, 4) if self.exporting else self.camera.projection_matrix
def get_view_matrix(self):
return np.eye(4, 4) if self.exporting else self.camera.view_matrix
def get_loc(self):
"Returns world space location as (x, y, z) tuple."
export_loc = (-1, 1, 0)
return export_loc if self.exporting else (self.x, self.y, self.z)
def get_scale(self):
"Returns world space scale as (x, y, z) tuple."
if not self.exporting:
@ -329,7 +433,7 @@ class TileRenderable:
x = 2 / (self.art.width * self.art.quad_width)
y = 2 / (self.art.height * self.art.quad_height)
return (x, y, 1)
def render_frame_for_export(self, frame):
self.exporting = True
self.set_frame(frame)
@ -344,7 +448,7 @@ class TileRenderable:
self.render()
self.art.app.inactive_layer_visibility = ilv
self.exporting = False
def render(self, layers=None, z_override=None, brightness=1.0):
"""
Render given list of layers at given Z depth.
@ -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,23 +578,21 @@ class TileRenderable:
class OnionTileRenderable(TileRenderable):
"TileRenderable subclass used for onion skin display in Art Mode animation."
# never animate
def start_animating(self):
pass
def stop_animating(self):
pass
class GameObjectRenderable(TileRenderable):
"""
TileRenderable subclass used by GameObjects. Almost no custom logic for now.
"""
def get_loc(self):
"""
Returns world space location as (x, y, z) tuple, offset by our

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
@ -17,12 +22,12 @@ class LineRenderable():
# use game object's art_off_pct values
use_art_offset = True
visible = True
def __init__(self, app, quad_size_ref=None, game_object=None):
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,123 +41,155 @@ 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"
return self.unique_name
def build_geo(self):
"""
create self.vert_array, self.elem_array, self.color_array
"""
pass
def reset_loc(self):
pass
def update(self):
if self.go:
self.update_transform_from_object(self.go)
def reset_size(self):
self.width, self.height = self.get_size()
def update_transform_from_object(self, obj):
TileRenderable.update_transform_from_object(self, obj)
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):
return np.eye(4, 4)
def get_view_matrix(self):
return np.eye(4, 4)
def get_loc(self):
return self.x, self.y, self.z
def get_size(self):
# overriden in subclasses that need specific width/height data
return 1, 1
def get_quad_size(self):
if self.quad_size_ref:
return self.quad_size_ref.quad_width, self.quad_size_ref.quad_height
else:
return 1, 1
def get_color(self):
return (1, 1, 1, 1)
def get_line_width(self):
return self.line_width
def destroy(self):
if self.app.use_vao:
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,11 +243,11 @@ 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
def build_geo(self):
self.vert_array = np.array([(0, 0), (1, 1), (1, 0), (0, 1)], dtype=np.float32)
self.elem_array = np.array([0, 1, 2, 3], dtype=np.uint32)
@ -211,55 +255,56 @@ class UIRenderableX(LineRenderable):
class SwatchSelectionBoxRenderable(LineRenderable):
"used for UI selection boxes etc"
color = (0.5, 0.5, 0.5, 1)
line_width = 2
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):
line_width = 2
def get_color(self):
return (1.0, 1.0, 1.0, 1.0)
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None)
class WorldLineRenderable(LineRenderable):
"any LineRenderable that draws in world, ie in 3D perspective"
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
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
of 3D vertex locations (and, optionally, colors).
"""
color = (0.5, 0, 0, 1)
vert_items = 3
line_width = 3
def set_lines(self, new_verts, new_colors=None):
"replace current debug lines with new given lines"
self.vert_array = np.array(new_verts, dtype=np.float32)
@ -268,20 +313,21 @@ 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):
"changes all debug lines to given color"
self.color = new_color
lines = int(len(self.vert_array) / self.vert_items)
self.color_array = np.array(self.color * lines, dtype=np.float32)
self.rebind_buffers()
def get_quad_size(self):
return 1, 1
def add_lines(self, new_verts, new_colors=None):
"add lines to the current ones"
line_items = len(self.vert_array)
@ -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,24 +346,27 @@ 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):
self.build_geo()
def build_geo(self):
# start empty
self.vert_array = np.array([], dtype=np.float32)
self.elem_array = np.array([], dtype=np.uint32)
self.color_array = np.array([], dtype=np.float32)
def render(self):
# only render if we have any data
if len(self.vert_array) == 0:
@ -326,64 +375,73 @@ 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)
z_axis = (0, 0, 1)
vert_items = 3
vert_items = 3
line_width = 3
use_art_offset = False
def __init__(self, app, game_object):
LineRenderable.__init__(self, app, None, game_object)
def get_quad_size(self):
return 1, 1
def get_size(self):
return self.go.scale_x, self.go.scale_y
def update_transform_from_object(self, obj):
self.x, self.y, self.z = obj.x, obj.y, obj.z
self.scale_x, self.scale_y = obj.scale_x, obj.scale_y
if obj.flip_x:
self.scale_x *= -1
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)
line_width_active = 2
line_width_inactive = 1
def __init__(self, app, game_object):
self.art = game_object.renderable.art
LineRenderable.__init__(self, app, None, game_object)
def set_art(self, new_art):
self.art = new_art
self.reset_size()
def get_size(self):
art = self.go.art
w = (art.width * art.quad_width) * self.go.scale_x
h = (art.height * art.quad_height) * self.go.scale_y
return w, h
def get_color(self):
# pulse if selected
if self.go in self.app.gw.selected_objects:
@ -391,33 +449,41 @@ class BoundsIndicatorRenderable(WorldLineRenderable):
return (color, color, color, 1)
else:
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)
def __init__(self, shape):
self.color = self.dynamic_color if shape.go.is_dynamic() else self.static_color
self.shape = shape
WorldLineRenderable.__init__(self, shape.go.app, None, shape.go)
def update(self):
self.update_transform_from_object(self.shape)
def update_transform_from_object(self, obj):
self.x = obj.x
self.y = obj.y
@ -435,19 +501,18 @@ def get_circle_points(radius, steps=24):
class CircleCollisionRenderable(CollisionRenderable):
line_width = 2
segments = 24
def get_quad_size(self):
return self.shape.radius, self.shape.radius
def get_size(self):
w = h = self.shape.radius * 2
w *= self.go.scale_x
h *= self.go.scale_y
return w, h
def build_geo(self):
verts, elements, colors = [], [], []
angle = 0
@ -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,26 +534,29 @@ class CircleCollisionRenderable(CollisionRenderable):
class BoxCollisionRenderable(CollisionRenderable):
line_width = 2
def get_quad_size(self):
return self.shape.halfwidth * 2, self.shape.halfheight * 2
def get_size(self):
w, h = self.shape.halfwidth * 2, self.shape.halfheight * 2
w *= self.go.scale_x
h *= self.go.scale_y
return w, h
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,27 +1,29 @@
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:
"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
flip_y = True
tex_wrap = False
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,62 +35,73 @@ 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)
self.texture.set_wrap(self.tex_wrap)
def get_initial_position(self):
return 0, 0, 0
def get_initial_scale(self):
return 1, 1, 1
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
def get_texture_scale(self):
return self.tex_scale_x, self.tex_scale_y
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(1, [self.vert_buffer])
def render(self):
GL.glUseProgram(self.shader.program)
GL.glActiveTexture(GL.GL_TEXTURE0)
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,10 +125,9 @@ class SpriteRenderable:
class UISpriteRenderable(SpriteRenderable):
def get_projection_matrix(self):
return self.app.ui.view_matrix
def get_view_matrix(self):
return self.app.ui.view_matrix
@ -122,11 +135,11 @@ 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):
return -1, -1, 0
def get_initial_scale(self):
return 2, 2, 1

View file

@ -1,24 +1,25 @@
import math
import numpy as np
from renderable_line import LineRenderable
class SelectionRenderable(LineRenderable):
color = (0.8, 0.8, 0.8, 1)
line_width = 2
x, y, z = 0, 0, 0
def build_geo(self):
# init empty arrays; geo is rebuilt every time selection changes
self.vert_array = np.array([], dtype=np.float32)
self.elem_array = np.array([], dtype=np.uint32)
self.color_array = np.array([], dtype=np.float32)
def get_adjacent_tile(self, tiles, x, y, dir_x, dir_y):
"returns True or False based on tile dict lookup relative to given tile"
return tiles.get((x + dir_x, y + dir_y), False)
def rebuild_geo(self, tiles):
# array source lists of verts, elements, colors
v, e, c = [], [], []
@ -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
@ -60,16 +63,16 @@ class SelectionRenderable(LineRenderable):
self.vert_array = np.array(v, dtype=np.float32)
self.elem_array = np.array(e, dtype=np.uint32)
self.color_array = np.array(c, dtype=np.float32)
def reset_loc(self):
pass
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
def get_color(self):
# pulse for visibility
a = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)

View file

@ -1,31 +1,40 @@
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
def __init__(self, app):
"AWAKENS THE SHADERLORD"
self.app = app
self.shaders = []
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:
@ -34,14 +43,13 @@ class ShaderLord:
shader.recompile(GL.GL_VERTEX_SHADER)
if frag_shader_updated:
shader.recompile(GL.GL_FRAGMENT_SHADER)
def destroy(self):
for shader in self.shaders:
shader.destroy()
class Shader:
log_compile = False
"If True, log shader compilation"
# per-platform shader versions, declared here for easier CFG fiddling
@ -49,7 +57,7 @@ class Shader:
glsl_version_unix = 130
glsl_version_macos = 150
glsl_version_es = 100
def __init__(self, shader_lord, vert_source_file, frag_source_file):
self.sl = shader_lord
# vertex shader
@ -57,52 +65,62 @@ 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):
"Catch and print shader compilation exceptions"
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
def has_updated(self):
vert_mod_time = os.path.getmtime(SHADER_PATH + self.vert_source_file)
frag_mod_time = os.path.getmtime(SHADER_PATH + self.frag_source_file)
@ -115,7 +133,7 @@ class Shader:
if frag_changed:
self.last_frag_change = time.time()
return vert_changed, frag_changed
def recompile(self, shader_type):
file_to_reload = self.vert_source_file
if shader_type == GL.GL_FRAGMENT_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:
@ -134,13 +152,13 @@ class Shader:
else:
self.frag_shader = new_shader
self.program = shaders.compileProgram(self.vert_shader, self.frag_shader)
def get_uniform_location(self, uniform_name):
return GL.glGetUniformLocation(self.program, uniform_name)
def get_attrib_location(self, attrib_name):
return GL.glGetAttribLocation(self.program, attrib_name)
def destroy(self):
GL.glDeleteProgram(self.program)

View file

@ -1,15 +1,15 @@
import numpy as np
from OpenGL import GL
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):
self.width, self.height = width, height
img_data = np.frombuffer(data, dtype=np.uint8)
@ -18,23 +18,32 @@ 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)
def set_filter(self, new_mag_filter, new_min_filter, bind_first=True):
if bind_first:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, new_mag_filter)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, new_min_filter)
def set_wrap(self, new_wrap, bind_first=True):
if bind_first:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
wrap = GL.GL_REPEAT if new_wrap else GL.GL_CLAMP_TO_EDGE
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, wrap)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, wrap)
def destroy(self):
GL.glDeleteTextures([self.gltex])

374
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,40 +51,47 @@ 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
# the current art being edited
@ -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)
@ -145,7 +183,7 @@ class UI:
# if editing is disallowed, hide game mode UI
if not self.app.can_edit:
self.set_game_edit_ui_visibility(False)
def set_scale(self, new_scale):
old_scale = self.scale
self.scale = new_scale
@ -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,8 +207,11 @@ 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:
e.art.quad_width, e.art.quad_height = UIArt.quad_width, UIArt.quad_height
@ -174,11 +219,11 @@ class UI:
e.reset_art()
e.reset_loc()
e.art.geo_changed = True
def window_resized(self):
# recalc renderables' quad size (same scale, different aspect)
self.set_scale(self.scale)
def size_and_position_overlay_image(self):
# called any time active art changes, or active art changes size
r = self.app.overlay_renderable
@ -197,7 +242,7 @@ class UI:
r.scale_y = self.active_art.height * self.active_art.quad_height
r.y = -r.scale_y
r.z = self.active_art.layers_z[self.active_art.active_layer]
def set_active_art(self, new_art):
self.active_art = new_art
new_charset = self.active_art.charset
@ -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)
@ -248,7 +294,7 @@ class UI:
new_active_renderable = self.app.edit_renderables.pop(i)
self.app.edit_renderables.insert(0, new_active_renderable)
self.set_active_art(new_active_art)
def previous_active_art(self):
"cycles to next art in app.art_loaded_for_edit"
if len(self.app.art_loaded_for_edit) == 1:
@ -258,7 +304,7 @@ class UI:
next_active_renderable = self.app.edit_renderables.pop(-1)
self.app.edit_renderables.insert(0, next_active_renderable)
self.set_active_art(self.app.art_loaded_for_edit[0])
def next_active_art(self):
if len(self.app.art_loaded_for_edit) == 1:
return
@ -267,12 +313,12 @@ class UI:
last_active_renderable = self.app.edit_renderables.pop(0)
self.app.edit_renderables.append(last_active_renderable)
self.set_active_art(self.app.art_loaded_for_edit[0])
def set_selected_tool(self, new_tool):
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,20 +331,26 @@ 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)
def get_longest_tool_name_length(self):
"VERY specific function to help status bar draw its buttons"
longest = 0
@ -306,7 +358,7 @@ class UI:
if len(tool.button_caption) > longest:
longest = len(tool.button_caption)
return longest
def cycle_selected_tool(self, back=False):
if not self.active_art:
return
@ -317,16 +369,17 @@ class UI:
tool_index += 1
tool_index %= len(self.tools)
self.set_selected_tool(self.tools[tool_index])
def set_selected_xform(self, new_xform):
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
@ -334,40 +387,42 @@ class UI:
xform += 1
xform %= UV_FLIP270 + 1
self.set_selected_xform(xform)
def reset_onion_frames(self, new_art=None):
"set correct visibility, frame, and alpha for all onion renderables"
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):
return
@ -377,7 +432,7 @@ class UI:
delay = self.active_art.frame_delays[frame]
if self.app.can_edit:
self.message_line.post_line(self.frame_selected_log % (frame + 1, delay))
def set_active_layer(self, new_layer):
self.active_art.set_active_layer(new_layer)
z = self.active_art.layers_z[self.active_art.active_layer]
@ -391,20 +446,22 @@ class UI:
if self.app.can_edit:
self.message_line.post_line(self.layer_selected_log % layer_name)
self.size_and_position_overlay_image()
def select_char(self, new_char_index):
if not self.active_art:
return
# 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
def select_color(self, new_color_index, fg):
"common code for select_fg/bg"
if not self.active_art:
@ -415,19 +472,29 @@ 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
def select_fg(self, new_fg_index):
self.select_color(new_fg_index, True)
def select_bg(self, new_bg_index):
self.select_color(new_bg_index, False)
def swap_fg_bg_colors(self):
if self.app.game_mode:
return
@ -435,11 +502,11 @@ class UI:
self.selected_fg_color, self.selected_bg_color = bg, fg
self.tool_settings_changed = True
self.message_line.post_line(self.swap_color_log)
def cut_selection(self):
self.copy_selection()
self.erase_tiles_in_selection()
def erase_selection_or_art(self):
if len(self.select_tool.selected_tiles) > 0:
self.erase_tiles_in_selection()
@ -447,7 +514,7 @@ class UI:
self.select_all()
self.erase_tiles_in_selection()
self.select_none()
def erase_tiles_in_selection(self):
# create and commit command group to clear all tiles in selection
frame, layer = self.active_art.active_frame, self.active_art.active_layer
@ -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
@ -466,7 +535,7 @@ class UI:
new_command.apply()
self.active_art.command_stack.commit_commands([new_command])
self.active_art.set_unsaved_changes(True)
def copy_selection(self):
# convert current selection tiles (active frame+layer) into
# EditCommandTiles for Cursor.preview_edits
@ -499,7 +568,7 @@ class UI:
tile_command.set_tile(frame, layer, x, y)
self.clipboard_width = max_x - min_x
self.clipboard_height = max_y - min_y
def crop_to_selection(self, art):
# ignore non-rectangular selection features, use top left and bottom
# right corners
@ -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 = {}
@ -531,11 +600,11 @@ class UI:
# commit command
command.save_tiles(before=False)
art.command_stack.commit_commands([command])
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
@ -553,7 +622,7 @@ class UI:
r.move_to(x * MDI_MARGIN, 0, -i, 0.2)
x += r.art.width * r.art.quad_width
y -= r.art.height * r.art.quad_height
def adjust_for_art_resize(self, art):
if art is not self.active_art:
return
@ -568,7 +637,7 @@ class UI:
if self.app.cursor.y > art.height:
self.app.cursor.y = art.height
self.app.cursor.moved = True
def resize_art(self, art, new_width, new_height, origin_x, origin_y, bg_fill):
# create command for undo/redo
command = EntireArtCommand(art, origin_x, origin_y)
@ -580,16 +649,16 @@ class UI:
command.save_tiles(before=False)
art.command_stack.commit_commands([command])
art.set_unsaved_changes(True)
def select_none(self):
self.select_tool.selected_tiles = {}
def select_all(self):
self.select_tool.selected_tiles = {}
for y in range(self.active_art.height):
for x in range(self.active_art.width):
self.select_tool.selected_tiles[(x, y)] = True
def invert_selection(self):
old_selection = self.select_tool.selected_tiles.copy()
self.select_tool.selected_tiles = {}
@ -597,12 +666,12 @@ class UI:
for x in range(self.active_art.width):
if not old_selection.get((x, y), False):
self.select_tool.selected_tiles[(x, y)] = True
def get_screen_coords(self, window_x, window_y):
x = (2 * window_x) / self.app.window_width - 1
y = (-2 * window_y) / self.app.window_height + 1
return x, y
def update(self):
self.select_tool.update()
# window coordinates -> OpenGL coordinates
@ -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:
@ -632,7 +706,7 @@ class UI:
# art update: tell renderables to refresh buffers
e.art.update()
self.tool_settings_changed = False
def clicked(self, mouse_button):
handled = False
# return True if any button handled the input
@ -642,17 +716,21 @@ 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
def unclicked(self, mouse_button):
handled = False
for e in self.hovered_elements:
if e.unclicked(mouse_button):
handled = True
return handled
def wheel_moved(self, wheel_y):
handled = False
# use wheel to scroll chooser dialogs
@ -660,17 +738,19 @@ 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:
if e.wheel_moved(wheel_y):
handled = True
return handled
def quick_grab(self):
if self.app.game_mode:
return
@ -678,17 +758,17 @@ class UI:
return
self.grab_tool.grab()
self.tool_settings_changed = True
def undo(self):
# if still painting, finish
if self.app.cursor.current_command:
self.app.cursor.finish_paint()
self.active_art.command_stack.undo()
self.active_art.set_unsaved_changes(True)
def redo(self):
self.active_art.command_stack.redo()
def open_dialog(self, dialog_class, options={}):
if self.app.game_mode and not dialog_class.game_mode_visible:
return
@ -696,14 +776,14 @@ 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)
def is_game_edit_ui_visible(self):
return self.game_menu_bar.visible
def set_game_edit_ui_visibility(self, visible, show_message=True):
self.game_menu_bar.visible = visible
self.edit_list_panel.visible = visible
@ -712,24 +792,26 @@ 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):
if len(self.app.gw.selected_objects) == 0:
self.keyboard_focus_element = None
self.refocus_keyboard()
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,14 +828,14 @@ 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()
self.edit_list_panel.update_keyboard_hover()
def refocus_keyboard(self):
"called when an element closes, sets new keyboard_focus_element"
if self.active_dialog:
@ -764,27 +846,31 @@ 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):
self.keyboard_focus_element.keyboard_navigate(move_x, move_y)
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)
def destroy(self):
for e in self.elements:
e.destroy()
self.grain_texture.destroy()
def render(self):
for e in self.elements:
if e.is_visible():

File diff suppressed because it is too large Load diff

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
@ -44,65 +43,72 @@ class UIButton:
# if true, display a tooltip when hovered, and dismiss it when unhovered.
# contents set from get_tooltip_text and positioned by get_tooltip_location.
tooltip_on_hover = False
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):
for x in range(self.width):
self.element.art.set_tile_at(0, 0, self.x + x, self.y + y, None, fg, bg)
def update_tooltip(self):
tt = self.element.ui.tooltip
tt.reset_art()
tt.set_text(self.get_tooltip_text())
tt.tile_x, tt.tile_y = self.get_tooltip_location()
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,
@ -115,31 +121,31 @@ class UIButton:
another_tooltip = True
if not another_tooltip:
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:
self.unhover()
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"
return 10, 10
def draw_caption(self):
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,10 +156,10 @@ 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)
def draw(self):
if self.never_draw:
return

View file

@ -1,31 +1,29 @@
# 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
width = 20
big_width = 30
clear_before_caption_draw = True
def __init__(self, element):
# more room for list items if screen is wide enough
if element.ui.width_tiles - 20 > element.big_width:
self.width = self.big_width
UIButton.__init__(self, element)
self.callback = self.pick_item
def pick_item(self):
if not self.item:
return
@ -33,20 +31,20 @@ class ChooserItemButton(UIButton):
class ScrollArrowButton(UIButton):
"button that scrolls up or down in a chooser item view"
arrow_char = 129
up = True
normal_bg_color = UIDialog.bg_color
dimmed_fg_color = UIColors.medgrey
dimmed_bg_color = UIDialog.bg_color
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:
self.element.scroll_index -= 1
@ -59,9 +57,8 @@ class ScrollArrowButton(UIButton):
class ChooserItem:
label = 'Chooser item'
label = "Chooser item"
def __init__(self, index, name):
self.index = index
# item's unique name, eg a filename
@ -69,43 +66,44 @@ class ChooserItem:
self.label = self.get_label()
# validity flag lets ChooserItem subclasses exclude themselves
self.valid = True
def get_label(self): return self.name
def get_description_lines(self): return []
def get_preview_texture(self): return None
def load(self, app): pass
def get_label(self):
return self.name
def get_description_lines(self):
return []
def get_preview_texture(self):
return None
def load(self, app):
pass
def picked(self, element):
# set item selected and refresh preview
element.set_selected_item_index(self.index)
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
scrollbar_shade_char = 54
flip_preview_y = True
def __init__(self, ui, options):
self.ui = ui
# semikludge: track whether user has selected anything in a new dir,
@ -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
@ -149,7 +148,7 @@ class ChooserDialog(UIDialog):
self.preview_renderable.blend = False
# offset into items list view provided by buttons starts from
self.position_preview()
def init_buttons(self):
for i in range(self.items_in_view):
button = self.item_button_class(self)
@ -167,24 +166,24 @@ class ChooserDialog(UIDialog):
self.down_arrow_button.y = self.item_start_y + self.items_in_view - 1
self.down_arrow_button.up = False
self.buttons += [self.up_arrow_button, self.down_arrow_button]
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:
# os.access(new_dir, os.R_OK) seems to always return True,
# 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
@ -192,9 +191,8 @@ class ChooserDialog(UIDialog):
self.items = self.get_items()
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())
@ -225,31 +231,35 @@ class ChooserDialog(UIDialog):
self.load_selected_item()
self.reset_art(False)
self.position_preview()
def get_max_scroll(self):
return len(self.items) - self.items_in_view
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()
item.load(self.ui.app)
def get_initial_selection(self):
# subclasses return index of initial selection
return 0
def set_preview(self):
item = self.get_selected_item()
if self.show_preview_image:
self.preview_renderable.texture = item.get_preview_texture(self.ui.app)
def get_items(self):
# subclasses generate lists of items here
return []
def position_preview(self, reset=True):
if reset:
self.set_preview()
@ -261,29 +271,38 @@ 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
else:
y += self.preview_renderable.scale_y
self.preview_renderable.y = self.y - y
def get_height(self, msg_lines):
return self.tile_height
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,23 +329,23 @@ 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)
button.can_hover = hover
def get_description_filename(self, item):
"returns a description-appropriate filename for given item"
# truncate from start to fit in description area if needed
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):
item = self.get_selected_item()
lines = []
@ -334,7 +353,7 @@ class ChooserDialog(UIDialog):
lines += [self.get_description_filename(item)]
lines += item.get_description_lines() or []
return lines
def draw_selected_description(self):
x = self.tile_width - 2
y = self.item_start_y
@ -343,11 +362,10 @@ 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
def reset_art(self, resize=True):
self.reset_buttons()
# UIDialog does: clear window, draw titlebar and confirm/cancel buttons
@ -363,33 +381,34 @@ 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)
# update thumbnail renderable's position too
self.position_preview(False)
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
# 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
@ -419,33 +438,33 @@ class ChooserDialog(UIDialog):
# if we didn't navigate, seek based on new alphanumeric input
if not navigated:
self.text_input_seek()
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]
if item_base.startswith(field_text):
self.set_selected_item_index(i, set_field_text=False)
break
def handle_enter(self, shift_pressed, alt_pressed, ctrl_pressed):
"handle Enter key, return False if rest of handle_input should continue"
# 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,13 +493,13 @@ 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)
return True
return False
def render(self):
UIDialog.render(self)
if self.show_preview_image and self.preview_renderable.texture:

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,26 +319,28 @@ 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
def reset_art(self):
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,43 +349,43 @@ 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):
if self.visible:
self.hide()
else:
self.show()
def show(self):
self.visible = True
self.target_alpha = 1
self.target_y = 1
self.ui.menu_bar.visible = False
self.ui.pulldown.visible = False
def hide(self):
self.target_alpha = 0
self.target_y = 2
if self.ui.app.can_edit:
self.ui.menu_bar.visible = True
def update_loc(self):
# TODO: this lerp is super awful, simpler way based on dt?
# TODO: use self.show_anim_time instead of this garbage!
speed = 0.25
if self.y > self.target_y:
self.y -= speed
elif self.y < self.target_y:
self.y += speed
if abs(self.y - self.target_y) < speed:
self.y = self.target_y
if self.alpha > self.target_alpha:
self.alpha -= speed / 2
elif self.alpha < self.target_alpha:
@ -356,35 +394,44 @@ class ConsoleUI(UIElement):
self.alpha = self.target_alpha
if self.alpha == 0:
self.visible = False
self.renderable.y = self.y
self.renderable.alpha = self.alpha
def clear(self):
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):
"update art from log lines"
log_index = -1
@ -395,10 +442,10 @@ 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
def update(self):
"update our Art with the current console log lines + user input"
self.update_loc()
@ -423,44 +470,47 @@ class ConsoleUI(UIElement):
# update user line independently of log, it changes at a different rate
if user_input_changed:
self.update_user_line()
def visit_command_history(self, index):
if len(self.command_history) == 0:
return
self.history_index = index
self.history_index %= len(self.command_history)
self.current_line = self.command_history[self.history_index].strip()
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
"handles a key from Application.input"
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,18 +531,18 @@ 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
def parse(self, line):
# is line in a list of know commands? if so, handle it.
items = line.split()
@ -511,28 +561,32 @@ 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):
self.history_file.close()
# 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,18 +108,18 @@ 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
UIElement.__init__(self, ui)
if self.ui.menu_bar and self.ui.menu_bar.active_menu_name:
self.ui.menu_bar.close_active_menu()
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)"
# base height = 4, titlebar + padding + buttons + padding
@ -125,7 +135,7 @@ class UIDialog(UIElement):
h += self.y_spacing + 2
h += self.extra_lines
return h
def reset_art(self, resize=True, clear_buttons=True):
# get_message splits into >1 line if too long
msg_lines = self.get_message() if self.message else []
@ -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,27 +184,30 @@ 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
self.buttons.append(field_button)
# draw buttons
UIElement.reset_art(self)
def update_drag(self, mouse_dx, mouse_dy):
win_w, win_h = self.ui.app.window_width, self.ui.app.window_height
self.x += (mouse_dx / win_w) * 2
self.y -= (mouse_dy / win_h) * 2
self.renderable.x, self.renderable.y = self.x, self.y
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()
def update(self):
# redraw fields every update for cursor blink
# (seems a waste, no real perf impact tho)
@ -206,25 +218,25 @@ 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
def get_field_colors(self, index):
"return FG and BG colors for field with given index"
fg, bg = self.inactive_field_fg_color, self.inactive_field_bg_color
@ -232,16 +244,16 @@ class UIDialog(UIElement):
if self is self.ui.keyboard_focus_element and index == self.active_field:
fg, bg = self.active_field_fg_color, self.active_field_bg_color
return fg, bg
def get_field_label(self, field_index):
"Subclasses can override to do custom label logic eg string formatting"
return self.fields[field_index].label
def draw_fields(self, with_labels=True):
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,19 +291,19 @@ 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)
y += self.y_spacing + 1
def get_field_y(self, field_index):
"returns a Y value for where the given field (caption) should start"
y = 2
@ -300,7 +316,7 @@ class UIDialog(UIElement):
else:
y += self.y_spacing + 2
return y
def get_toggled_bool_field(self, field_index):
field_text = self.field_texts[field_index]
on = field_text == self.true_field_text
@ -312,111 +328,121 @@ 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
else:
return self.true_field_text
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
if field and (len(field_text) < field.width or field.type is bool):
self.field_texts[self.active_field] = field_text
self.draw_fields(self.always_redraw_labels)
def is_input_valid(self):
"subclasses that want to filter input put logic here"
return True, None
def dismiss(self):
# let UI forget about us
self.ui.active_dialog = None
@ -424,32 +450,33 @@ class UIDialog(UIElement):
self.ui.keyboard_focus_element = None
self.ui.refocus_keyboard()
self.ui.elements.remove(self)
def confirm_pressed(self):
# subclasses do more here :]
self.dismiss()
def cancel_pressed(self):
self.dismiss()
def other_pressed(self):
self.dismiss()
class DialogFieldButton(UIButton):
"invisible button that provides clickability for input fields"
caption = ''
caption = ""
# re-set by dialog constructor
field_number = 0
never_draw = True
def click(self):
UIButton.click(self)
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
@ -22,7 +33,7 @@ class GamePanel(UIElement):
support_keyboard_navigation = True
support_scrolling = True
keyboard_nav_offset = -2
def __init__(self, ui):
self.ui = ui
self.world = self.ui.app.gw
@ -30,60 +41,76 @@ class GamePanel(UIElement):
self.buttons = []
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):
buttons = button_list or self.buttons
for button in buttons:
self.reset_button(button)
def reset_button(self, button):
button.normal_fg_color = UIButton.normal_fg_color
button.normal_bg_color = UIButton.normal_bg_color
button.hovered_fg_color = UIButton.hovered_fg_color
button.hovered_bg_color = UIButton.hovered_bg_color
button.can_hover = True
def highlight_button(self, button):
button.normal_fg_color = UIButton.clicked_fg_color
button.normal_bg_color = UIButton.clicked_bg_color
button.hovered_fg_color = UIButton.clicked_fg_color
button.hovered_bg_color = UIButton.clicked_bg_color
button.can_hover = True
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:
self.art.write_string(0, 0, -1, 0, label, None, None, True)
def reset_art(self):
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
self.draw_titlebar()
self.refresh_items()
UIElement.reset_art(self)
def clicked(self, mouse_button):
# always handle input, even if we didn't hit a button
UIElement.clicked(self, mouse_button)
return True
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,35 +141,38 @@ 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
self.list_scroll_index = 0
@ -149,41 +183,45 @@ 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 = []
# set when game resets
self.should_reset_list = False
GamePanel.__init__(self, ui)
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
@ -198,32 +236,33 @@ class EditListPanel(GamePanel):
# TODO: adjust height according to screen tile height
self.down_button.y = self.tile_height - 1
self.buttons.append(self.down_button)
def reset_art(self):
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)
self.world.classname_to_spawn = None
def scroll_list_up(self):
if self.list_scroll_index > 0:
self.list_scroll_index -= 1
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
def clicked_item(self, item):
# do thing appropriate to current list operation
self.click_functions[self.list_operation](item)
def wheel_moved(self, wheel_y):
if wheel_y > 0:
self.scroll_list_up()
@ -231,7 +270,7 @@ class EditListPanel(GamePanel):
if wheel_y < 0:
self.scroll_list_down()
return True
def set_list_operation(self, new_op):
"changes list type and sets new items"
if new_op == LO_LOAD_STATE and not self.world.game_dir:
@ -255,11 +294,14 @@ class EditListPanel(GamePanel):
self.list_scroll_index = self.scroll_indices[self.list_operation]
# keep in bounds if list size changed since last view
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
@ -267,7 +309,7 @@ class EditListPanel(GamePanel):
if len(self.world.selected_objects) == 1:
label %= self.world.selected_objects[0].name
return label
def should_highlight(self, item):
if self.list_operation == LO_SELECT_OBJECTS:
return item.obj in self.world.selected_objects
@ -280,25 +322,30 @@ 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):
self.should_reset_list = True
def items_changed(self):
"called by anything that changes the items list, eg object add/delete"
self.items = self.list_functions[self.list_operation]()
# change selected item index if it's OOB
if self.keyboard_nav_index >= len(self.items):
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
@ -315,7 +362,7 @@ class EditListPanel(GamePanel):
else:
self.reset_button(b)
self.draw_buttons()
def post_keyboard_navigate(self):
# check for scrolling
if len(self.items) <= len(self.list_buttons):
@ -336,7 +383,7 @@ class EditListPanel(GamePanel):
elif self.keyboard_nav_index < 0:
self.scroll_list_up()
self.keyboard_nav_index += 1
def update(self):
if self.should_reset_list:
self.set_list_operation(self.list_operation)
@ -346,18 +393,18 @@ class EditListPanel(GamePanel):
self.refresh_items()
GamePanel.update(self)
self.renderable.alpha = 1 if self is self.ui.keyboard_focus_element else 0.5
def is_visible(self):
return GamePanel.is_visible(self) and self.list_operation != LO_NONE
#
# list functions
#
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
@ -368,7 +415,7 @@ class EditListPanel(GamePanel):
# sort classes alphabetically
items.sort(key=lambda i: i.name)
return items
def list_objects(self):
items = []
# include just-spawned objects too
@ -377,24 +424,27 @@ 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)
# sort object names alphabetically
items.sort(key=lambda i: i.name)
return items
def list_states(self):
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)
return items
def list_rooms(self):
items = []
for room in self.world.rooms.values():
@ -402,7 +452,7 @@ class EditListPanel(GamePanel):
items.append(li)
items.sort(key=lambda i: i.name)
return items
def list_games(self):
def get_dirs(dirname):
dirs = []
@ -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 = []
@ -419,18 +470,18 @@ class EditListPanel(GamePanel):
li = self.ListItem(game, None)
items.append(li)
return items
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
def list_none(self):
return []
#
# "clicked list item" functions
#
@ -443,25 +494,27 @@ class EditListPanel(GamePanel):
else:
self.world.deselect_all()
self.world.select_object(item.obj, force=True)
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)
def set_room(self, item):
self.world.change_room(item.name)
def set_room_object(self, item):
# add/remove object from current room
if item.name in self.world.current_room.objects:
self.world.current_room.remove_object_by_name(item.name)
else:
self.world.current_room.add_object_by_name(item.name)
def set_object_room(self, item):
# UI can only show a single object's rooms, do nothing if many selected
if len(self.world.selected_objects) != 1:
@ -473,20 +526,20 @@ class EditListPanel(GamePanel):
room.remove_object(obj)
else:
room.add_object(obj)
def open_game_dir(self, item):
self.world.set_game_dir(item.name, True)
def set_room_edge_warp(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.name
self.ui.keyboard_focus_element = dialog
def set_room_bounds_obj(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.name
self.ui.keyboard_focus_element = dialog
def set_room_camera(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.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
@ -35,13 +32,20 @@ class UIElement:
game_mode_visible = False
all_modes_visible = False
keyboard_nav_offset = 0
def __init__(self, ui):
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
@ -53,13 +57,13 @@ class UIElement:
self.reset_loc()
if self.support_keyboard_navigation:
self.keyboard_nav_index = 0
def is_inside(self, x, y):
"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"
aqw, aqh = self.art.quad_width, self.art.quad_height
@ -69,30 +73,30 @@ class UIElement:
bxmin, bymin = self.x + bx, self.y - by
bxmax, bymax = bxmin + bw, bymin - bh
return bxmin <= x <= bxmax and bymin >= y >= bymax
def reset_art(self):
"""
runs on init and resize, restores state.
"""
self.draw_buttons()
def draw_buttons(self):
for button in self.buttons:
if button.visible:
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
@ -116,9 +120,9 @@ class UIElement:
if self.always_consume_input:
return True
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()
@ -126,21 +130,27 @@ class UIElement:
if self.always_consume_input:
return True
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
def reset_loc(self):
if self.snap_top:
self.y = 1
@ -155,7 +165,7 @@ class UIElement:
elif self.tile_x:
self.x = -1 + (self.tile_x * self.art.quad_width)
self.renderable.x, self.renderable.y = self.x, self.y
def keyboard_navigate(self, move_x, move_y):
if not self.support_keyboard_navigation:
return
@ -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
@ -184,23 +197,23 @@ class UIElement:
return
self.post_keyboard_navigate()
self.update_keyboard_hover()
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:
@ -208,11 +221,11 @@ class UIElement:
else:
button.callback()
return button
def post_keyboard_navigate(self):
# subclasses can put stuff here to check scrolling etc
pass
def update(self):
"runs every frame, checks button states"
# this is very similar to UI.update, implying an alternative structure
@ -225,16 +238,20 @@ 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()
def render(self):
# ("is visible" check happens in UI.render, calls our is_visible)
# render drop shadow first
@ -246,7 +263,7 @@ class UIElement:
self.renderable.render(brightness=0.1)
self.renderable.x, self.renderable.y = orig_x, orig_y
self.renderable.render()
def destroy(self):
for r in self.renderables:
r.destroy()
@ -258,27 +275,25 @@ class UIArt(Art):
class UIRenderable(TileRenderable):
grain_strength = 0.2
def get_projection_matrix(self):
# don't use projection matrix, ie identity[0][0]=aspect;
# rather do all aspect correction in UI.set_scale when determining quad size
return self.ui.view_matrix
def get_view_matrix(self):
return self.ui.view_matrix
class FPSCounterUI(UIElement):
tile_y = 1
tile_width, tile_height = 12, 2
snap_right = True
game_mode_visible = True
all_modes_visible = True
visible = False
def update(self):
bg = 0
self.art.clear_frame_layer(0, 0, bg)
@ -288,13 +303,13 @@ 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):
# always show FPS if low
if self.visible or self.ui.app.fps < 30:
@ -302,9 +317,8 @@ class FPSCounterUI(UIElement):
class MessageLineUI(UIElement):
"when console outputs something new, show last line here before fading out"
tile_y = 2
snap_left = True
# just info, don't bother with hover, click etc
@ -314,21 +328,21 @@ class MessageLineUI(UIElement):
game_mode_visible = True
all_modes_visible = True
drop_shadow = True
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
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_line(self, new_line, hold_time=None, error=False):
"write a line to this element (ie so as not to spam console log)"
self.hold_time = hold_time or self.default_hold_time
@ -336,12 +350,12 @@ 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
self.last_post = self.ui.app.get_elapsed_time()
def update(self):
if self.ui.app.get_elapsed_time() > self.last_post + (self.hold_time * 1000):
if self.alpha >= self.fade_rate:
@ -349,70 +363,71 @@ class MessageLineUI(UIElement):
if self.alpha <= self.fade_rate:
self.alpha = 0
self.renderable.alpha = self.alpha
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
tile_height = 20
clear_lines_after_render = True
game_mode_visible = True
visible = False
def __init__(self, ui):
UIElement.__init__(self, ui)
self.lines = []
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_lines(self, lines):
if type(lines) is list:
self.lines += lines
else:
self.lines += [lines]
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
tile_width, tile_height = 30, 1
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,9 +438,8 @@ class GameLabel(UIElement):
class GameSelectionLabel(GameLabel):
multi_select_label = '[%s selected]'
multi_select_label = "[%s selected]"
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
@ -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,10 +467,10 @@ class GameSelectionLabel(GameLabel):
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
class GameHoverLabel(GameLabel):
alpha = 0.75
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
@ -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,27 +1,33 @@
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):
return self.get_short_dir_name()
@ -31,15 +37,15 @@ class BaseFileChooserItem(ChooserItem):
return os.path.splitext(label)[0]
else:
return label
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
def picked(self, element):
# if this is different from the last clicked item, pick it
if element.selected_item_index != self.index:
@ -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,39 +68,40 @@ class BaseFileChooserItem(ChooserItem):
element.confirm_pressed()
element.first_selection_made = False
class BaseFileChooserDialog(ChooserDialog):
"base class for choosers whose items correspond with files"
chooser_item_class = BaseFileChooserItem
show_filenames = True
file_extensions = []
def set_initial_dir(self):
self.current_dir = self.ui.app.documents_dir
self.field_texts[self.active_field] = self.current_dir
def get_filenames(self):
"subclasses override: get list of desired filenames"
return self.get_sorted_dir_list()
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]
@ -102,7 +109,7 @@ class BaseFileChooserDialog(ChooserDialog):
dirs.sort(key=lambda x: x.lower())
files.sort(key=lambda x: x.lower())
return parent + dirs + files
def get_items(self):
"populate and return items from list of files, loading as needed"
items = []
@ -123,12 +130,12 @@ class BaseFileChooserDialog(ChooserDialog):
# art chooser
#
class ArtChooserItem(BaseFileChooserItem):
# set in load()
art_width = None
hide_file_extension = True
def get_description_lines(self):
lines = BaseFileChooserItem.get_description_lines(self)
if lines is not None:
@ -136,31 +143,33 @@ 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)
def load(self, app):
if os.path.isdir(self.name):
return
@ -172,33 +181,36 @@ 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
file_extensions = [ART_FILE_EXTENSION]
def set_initial_dir(self):
# TODO: IF no art in Documents dir yet, start in app/art/ for examples?
# get last opened dir, else start in docs/game art dir
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
def confirm_pressed(self):
if not os.path.exists(self.field_texts[0]):
return
@ -211,27 +223,26 @@ class ArtChooserDialog(BaseFileChooserDialog):
# generic file chooser for importers
#
class GenericImportChooserDialog(BaseFileChooserDialog):
title = 'Import %s'
confirm_caption = 'Import'
cancel_caption = 'Cancel'
# allowed extensions set by invoking
title = "Import %s"
confirm_caption = "Import"
cancel_caption = "Cancel"
# allowed extensions set by invoking
file_extensions = []
show_preview_image = False
directory_aware = True
def __init__(self, ui, options):
self.title %= ui.app.importer.format_name
self.file_extensions = ui.app.importer.allowed_file_extensions
BaseFileChooserDialog.__init__(self, ui, options)
def set_initial_dir(self):
if self.ui.app.last_import_dir:
self.current_dir = self.ui.app.last_import_dir
else:
self.current_dir = self.ui.app.documents_dir
self.field_texts[self.active_field] = self.current_dir
def confirm_pressed(self):
filename = self.field_texts[0]
if not os.path.exists(filename):
@ -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,26 +267,26 @@ 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'
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]):
return
@ -291,31 +300,31 @@ 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):
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
def load(self, app):
self.palette = app.load_palette(self.name)
class PaletteChooserDialog(BaseFileChooserDialog):
title = 'Choose palette'
title = "Choose palette"
chooser_item_class = PaletteChooserItem
def get_initial_selection(self):
if not self.ui.active_art:
return 0
@ -324,9 +333,9 @@ 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):
filenames = []
# search all files in dirs with appropriate extensions
@ -337,54 +346,54 @@ class PaletteChooserDialog(BaseFileChooserDialog):
filenames.append(filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.active_art.set_palette(item.palette, log=True)
self.ui.popup.set_active_palette(item.palette)
#
# charset chooser
#
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):
return self.charset.texture
def load(self, app):
self.charset = app.load_charset(self.name)
class CharSetChooserDialog(BaseFileChooserDialog):
title = 'Choose character set'
title = "Choose character set"
flip_preview_y = False
chooser_item_class = CharsetChooserItem
def get_initial_selection(self):
if not self.ui.active_art:
return 0
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):
filenames = []
# search all files in dirs with appropriate extensions
@ -394,7 +403,7 @@ class CharSetChooserDialog(BaseFileChooserDialog):
filenames.append(filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.active_art.set_charset(item.charset, log=True)
@ -405,11 +414,10 @@ class CharSetChooserDialog(BaseFileChooserDialog):
class ArtScriptChooserItem(BaseFileChooserItem):
def get_label(self):
label = os.path.splitext(self.name)[0]
return os.path.basename(label)
def get_description_lines(self):
lines = []
# read every comment line until a non-comment line is encountered
@ -417,25 +425,24 @@ 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
def load(self, app):
self.script = open(self.name)
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
show_preview_image = False
def get_filenames(self):
filenames = []
# search all files in dirs with appropriate extensions
@ -445,7 +452,7 @@ class RunArtScriptDialog(BaseFileChooserDialog):
filenames.append(dirname + filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.app.last_art_script = item.name
@ -454,10 +461,9 @@ 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]
self.ui.app.set_overlay_image(filename)

View file

@ -1,144 +1,146 @@
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
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
def confirm_pressed(self):
if self.ui.app.gw.create_new_game(self.field_texts[0], self.field_texts[1]):
self.ui.app.enter_game_mode()
self.dismiss()
class LoadGameStateDialog(UIDialog):
title = 'Open game state'
field_label = 'Game state file to open:'
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
def confirm_pressed(self):
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:'
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):
self.ui.edit_list_panel.set_list_operation(LO_NONE)
UIDialog.dismiss(self)
def confirm_pressed(self):
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):
self.ui.edit_list_panel.set_list_operation(LO_NONE)
UIDialog.dismiss(self)
def confirm_pressed(self):
room = self.ui.app.gw.current_room
room.left_edge_warp_dest_name = self.field_texts[0]
@ -148,51 +150,50 @@ 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):
if field_number == 0:
return self.ui.app.gw.current_room.warp_edge_bounds_obj_name
def dismiss(self):
self.ui.edit_list_panel.set_list_operation(LO_NONE)
UIDialog.dismiss(self)
def confirm_pressed(self):
room = self.ui.app.gw.current_room
room.warp_edge_bounds_obj_name = self.field_texts[0]
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,300 +19,408 @@ 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
def get_items(app):
items = []
if len(app.gw.rooms) == 0:
@ -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,65 +1,65 @@
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
def __init__(self, ui, options):
self.page = 0
UIDialog.__init__(self, ui, options)
self.reset_art()
def update(self):
# 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"
if self.page < len(self.message) - 1:
self.page += 1
# redraw, tell reset_art not to resize
self.reset_art(False)
def cancel_pressed(self):
self.dismiss()
def other_pressed(self):
# other repurposed to "previous page"
if self.page > 0:
@ -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
@ -18,11 +36,11 @@ class MenuButton(UIButton):
normal_bg_color = UIColors.white
hovered_bg_color = UIColors.lightgrey
dimmed_bg_color = UIColors.lightgrey
def __init__(self, element):
UIButton.__init__(self, element)
self.callback = self.open_pulldown
def open_pulldown(self):
# don't open menus if a dialog is up
if self.element.ui.active_dialog:
@ -44,118 +62,137 @@ 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
snap_left = True
always_consume_input = True
@ -165,7 +202,7 @@ class MenuBar(UIElement):
mode_button_class = None
# empty tiles between each button
button_padding = 1
def __init__(self, ui):
# bitmap icon for about menu button
self.playscii_sprite = UISpriteRenderable(ui.app)
@ -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,22 +234,24 @@ 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)
def reset_icon(self):
inv_aspect = self.ui.app.window_height / self.ui.app.window_width
self.playscii_sprite.scale_x = self.art.quad_height * inv_aspect
self.playscii_sprite.scale_y = self.art.quad_height
self.playscii_sprite.x = -1 + self.art.quad_width
self.playscii_sprite.y = 1 - self.art.quad_height
def open_about(self):
if self.ui.active_dialog:
return
self.ui.open_dialog(AboutDialog)
def toggle_game_mode(self):
if self.ui.active_dialog:
return
@ -221,18 +260,18 @@ class MenuBar(UIElement):
else:
self.ui.app.exit_game_mode()
self.ui.app.update_window_title()
def close_active_menu(self):
# un-dim active menu button
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
self.ui.refocus_keyboard()
def refresh_active_menu(self):
if not self.ui.pulldown.visible:
return
@ -240,36 +279,36 @@ class MenuBar(UIElement):
if button.name == self.active_menu_name:
# don't reset keyboard nav index
self.ui.pulldown.open_at(button, False)
def open_menu_by_name(self, menu_name):
if not self.ui.app.can_edit:
return
for button in self.menu_buttons:
if button.name == menu_name:
button.callback()
def open_menu_by_index(self, index):
if index > len(self.menu_buttons) - 1:
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
def next_menu(self):
i = self.get_menu_index(self.active_menu_name)
self.open_menu_by_index(i + 1)
def previous_menu(self):
i = self.get_menu_index(self.active_menu_name)
self.open_menu_by_index(i - 1)
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles * self.ui.scale)
# must resize here, as window width will vary
@ -280,28 +319,46 @@ 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()
def render(self):
UIElement.render(self)
self.playscii_sprite.render()
def destroy(self):
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,24 +1,23 @@
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):
dimmed_fg_color = UIColors.medgrey
dimmed_bg_color = UIColors.lightgrey
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()
break
def click(self):
UIButton.click(self)
if self.item.close_on_select:
@ -26,9 +25,8 @@ class MenuItemButton(UIButton):
class PulldownMenu(UIElement):
"element that's moved and resized based on currently active pulldown"
label_shortcut_padding = 5
visible = False
always_consume_input = True
@ -41,7 +39,7 @@ class PulldownMenu(UIElement):
all_modes_visible = True
support_keyboard_navigation = True
keyboard_nav_left_right = True
def open_at(self, menu_button, reset_keyboard_nav_index=True):
# set X and Y based on calling menu button's location
self.tile_x = menu_button.x
@ -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,30 +104,33 @@ 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
self.keyboard_navigate(0, 0)
self.visible = True
self.ui.keyboard_focus_element = self
def draw_border(self, menu_button):
"draws a fancy lil frame around the pulldown's edge"
fg = self.border_color
@ -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
@ -151,32 +159,33 @@ class PulldownMenu(UIElement):
for x in range(1, len(menu_button.caption) + 2):
self.art.set_tile_at(0, 0, x, 0, 0)
self.art.set_tile_at(0, 0, x, 0, char, fg, None, UV_FLIPY)
def get_shortcut(self, menu_item):
# get InputLord's binding from given menu item's command name,
# 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,33 +85,36 @@ class PropertyItem:
class EditObjectPanel(GamePanel):
"panel showing info for selected game object"
tile_width = 36
tile_height = 36
snap_right = True
text_left = False
base_button_classes = [ResetObjectButton]
def __init__(self, ui):
self.base_buttons = []
self.property_buttons = []
GamePanel.__init__(self, ui)
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
@ -117,7 +125,7 @@ class EditObjectPanel(GamePanel):
button.width = 10
self.property_buttons.append(button)
self.buttons = self.base_buttons[:] + self.property_buttons[:]
def clicked_item(self, item):
# if property is a bool just toggle/set it, no need for a dialog
if item.prop_type is bool:
@ -130,19 +138,28 @@ 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
tile_x -= EditObjectPropertyDialog.tile_width
# give dialog a handle to item
@ -151,18 +168,18 @@ class EditObjectPanel(GamePanel):
self.ui.active_dialog.field_texts[0] = str(item.prop_value)
self.ui.active_dialog.tile_x, self.ui.active_dialog.tile_y = tile_x, self.tile_y
self.ui.active_dialog.reset_loc()
def get_label(self):
# if 1 object seleted, show its name; if >1 selected, show #
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:
return
@ -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,33 +205,33 @@ 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]
self.draw_property_line(b, i, item)
self.draw_buttons()
def draw_property_line(self, button, button_index, item):
"set button + label appearance correctly"
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):
# redraw contents every update
if self.is_visible():
@ -222,6 +239,6 @@ class EditObjectPanel(GamePanel):
self.refresh_items()
GamePanel.update(self)
self.renderable.alpha = 1 if self is self.ui.keyboard_focus_element else 0.5
def is_visible(self):
return GamePanel.is_visible(self) and len(self.world.selected_objects) > 0

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,36 +202,36 @@ 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):
self.ui = ui
self.charset_swatch = CharacterSetSwatch(ui, self)
@ -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,19 +272,21 @@ 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
def tool_tab_button_pressed(self):
self.active_tab = TAB_TOOLS
self.char_color_tab_button.can_hover = True
@ -261,7 +296,7 @@ class ToolPopup(UIElement):
self.buttons = self.common_buttons + self.tool_tab_buttons
self.draw_tool_tab()
self.draw_buttons()
def char_color_tab_button_pressed(self):
self.active_tab = TAB_CHAR_COLOR
self.tool_tab_button.can_hover = True
@ -271,79 +306,79 @@ class ToolPopup(UIElement):
self.buttons = self.common_buttons + self.char_color_tab_buttons
self.draw_char_color_tab()
self.draw_buttons()
def scale_charset_up_button_pressed(self):
self.charset_swatch.increase_scale()
self.reset_art()
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def scale_charset_down_button_pressed(self):
self.charset_swatch.decrease_scale()
self.reset_art()
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def brush_size_up_button_pressed(self):
# any changes to tool's setting will force redraw of settings tab
self.ui.selected_tool.increase_brush_size()
def brush_size_down_button_pressed(self):
self.ui.selected_tool.decrease_brush_size()
def toggle_affect_char_button_pressed(self):
self.ui.selected_tool.toggle_affects_char()
def toggle_affect_fg_button_pressed(self):
self.ui.selected_tool.toggle_affects_fg()
def toggle_affect_bg_button_pressed(self):
self.ui.selected_tool.toggle_affects_bg()
def toggle_affect_xform_button_pressed(self):
self.ui.selected_tool.toggle_affects_xform()
def set_fill_boundary_char_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_CHAR
self.ui.tool_settings_changed = True
def set_fill_boundary_fg_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_FG_COLOR
self.ui.tool_settings_changed = True
def set_fill_boundary_bg_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_BG_COLOR
self.ui.tool_settings_changed = True
def pencil_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.pencil_tool)
def erase_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.erase_tool)
def grab_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.grab_tool)
def rotate_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.rotate_tool)
def text_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.text_tool)
def select_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.select_tool)
def paste_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.paste_tool)
def fill_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.fill_tool)
def set_xform(self, new_xform):
"tells UI elements to respect new xform"
self.charset_swatch.set_xform(new_xform)
self.update_xform_buttons()
def update_xform_buttons(self):
# light up button for current selected option
button_map = {
@ -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:
@ -361,50 +396,56 @@ class ToolPopup(UIElement):
button_map[b].normal_bg_color = self.bg_color
self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color
self.draw_buttons()
def xform_normal_button_pressed(self):
self.ui.set_selected_xform(UV_NORMAL)
def xform_flipX_button_pressed(self):
self.ui.set_selected_xform(UV_FLIPX)
def xform_flipY_button_pressed(self):
self.ui.set_selected_xform(UV_FLIPY)
def xform_0_button_pressed(self):
self.ui.set_selected_xform(UV_NORMAL)
def xform_90_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE90)
def xform_180_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE180)
def xform_270_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE270)
def choose_charset_button_pressed(self):
self.hide()
self.ui.open_dialog(CharSetChooserDialog)
def choose_palette_button_pressed(self):
self.hide()
self.ui.open_dialog(PaletteChooserDialog)
def draw_char_color_tab(self):
"draw non-button bits of this tab"
# 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,14 +460,17 @@ 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)
self.tool_tab_button.width = tab_width
self.char_color_tab_button.width = int(self.tile_width) - tab_width
self.char_color_tab_button.x = tab_width
def draw_tool_tab(self):
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
# fill tool bar with dimmer color, highlight selected tool
@ -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,21 +561,27 @@ 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:
button.visible = False
def reset_art(self):
if not self.ui.active_art:
return
@ -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))
@ -549,7 +618,7 @@ class ToolPopup(UIElement):
self.update_xform_buttons()
# draw button captions
UIElement.reset_art(self)
def show(self):
# if already visible, bail - key repeat probably triggered this
if self.visible:
@ -564,19 +633,22 @@ class ToolPopup(UIElement):
if self.ui.pulldown.visible:
self.ui.menu_bar.close_active_menu()
self.reset_loc()
def toggle(self):
if self.visible:
self.hide()
else:
self.show()
def reset_loc(self):
if not self.ui.active_art:
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
@ -586,12 +658,12 @@ class ToolPopup(UIElement):
self.renderable.x, self.renderable.y = self.x, self.y
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def hide(self):
self.visible = False
self.ui.keyboard_focus_element = None
self.ui.refocus_keyboard()
def set_active_charset(self, new_charset):
self.charset_swatch.art.charset = new_charset
self.palette_swatch.art.charset = new_charset
@ -602,7 +674,7 @@ class ToolPopup(UIElement):
# charset width drives palette swatch width
self.palette_swatch.reset()
self.reset_art()
def set_active_palette(self, new_palette):
self.charset_swatch.art.palette = new_palette
self.palette_swatch.art.palette = new_palette
@ -612,7 +684,7 @@ class ToolPopup(UIElement):
self.ui.status_bar.set_active_palette(new_palette)
self.palette_swatch.reset()
self.reset_art()
def update(self):
UIElement.update(self)
if not self.ui.active_art:
@ -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
@ -636,23 +710,25 @@ class ToolPopup(UIElement):
elif self.active_tab == TAB_TOOLS and self.ui.tool_settings_changed:
self.draw_tool_tab()
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
active_swatch.move_cursor(self.cursor_box, dx, -dy)
def keyboard_select_item(self):
# called as ui.keyboard_focus_element
# simulate left/right click in popup to select stuff
self.select_key_pressed(self.ui.app.il.shift_pressed)
def select_key_pressed(self, mod_pressed):
mouse_button = [1, 3][mod_pressed]
self.clicked(mouse_button)
def clicked(self, mouse_button):
handled = UIElement.clicked(self, mouse_button)
if handled:
@ -669,7 +745,7 @@ class ToolPopup(UIElement):
elif mouse_button == 3:
self.ui.selected_bg_color = self.cursor_color
return True
def render(self):
if not self.visible:
return

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):
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
@ -188,95 +254,112 @@ class StatusBarUI(UIElement):
self.x_renderable.status_bar = self
self.renderables.append(self.x_renderable)
UIElement.__init__(self, ui)
# 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:
self.ui.cycle_selected_xform(True)
# update caption with new xform
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:
self.ui.cycle_selected_tool(True)
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:
self.ui.app.camera.zoom_proportional(-1)
def reset_art(self):
UIElement.reset_art(self)
self.tile_width = ceil(self.ui.width_tiles * self.ui.scale)
@ -297,7 +380,7 @@ class StatusBarUI(UIElement):
self.char_art.geo_changed = True
self.fg_art.geo_changed = True
self.bg_art.geo_changed = True
def rewrite_art(self):
bg = self.ui.colors.white
self.art.clear_frame_layer(0, 0, bg)
@ -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
@ -319,19 +403,25 @@ class StatusBarUI(UIElement):
self.layer_cycle_button.visible = False
self.frame_cycle_button.visible = False
self.zoom_set_button.visible = False
def set_active_charset(self, new_charset):
self.char_art.charset = self.fg_art.charset = self.bg_art.charset = new_charset
self.reset_art()
def set_active_palette(self, new_palette):
self.char_art.palette = self.fg_art.palette = self.bg_art.palette = new_palette
self.reset_art()
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"
art = self.ui.active_art
@ -339,22 +429,24 @@ 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:
return
@ -379,14 +471,14 @@ class StatusBarUI(UIElement):
art.update()
self.rewrite_art()
self.draw_buttons()
def position_swatch(self, renderable, x_offset):
renderable.x = (self.char_art.quad_width * x_offset) - 1
renderable.y = self.char_art.quad_height - 1
def reset_loc(self):
UIElement.reset_loc(self)
def write_right_elements(self):
"""
fills in right-justified parts of status bar, eg current
@ -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,22 +512,22 @@ 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)
def render(self):
if not self.ui.active_art:
return

View file

@ -1,25 +1,34 @@
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):
def __init__(self, ui, popup):
self.ui = ui
self.popup = popup
self.reset()
def reset(self):
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 = []
@ -32,20 +41,20 @@ class UISwatch(UIElement):
self.renderable.grain_strength = 0
self.renderables.append(self.renderable)
self.reset_art()
def reset_art(self):
pass
def get_size(self):
return 1, 1
def set_cursor_loc_from_mouse(self, cursor, mouse_x, mouse_y):
# get location within char map
w, h = self.art.quad_width, self.art.quad_height
tile_x = (mouse_x - self.x) / w
tile_y = (mouse_y - self.y) / h
self.set_cursor_loc(cursor, tile_x, tile_y)
def set_cursor_loc(self, cursor, tile_x, tile_y):
"""
common, generalized code for both character and palette swatches:
@ -68,64 +77,69 @@ class UISwatch(UIElement):
cursor.quad_size_ref = self.art
cursor.tile_x, cursor.tile_y = tile_x, tile_y
cursor.x, cursor.y = x, y
def is_selection_index_valid(self, index):
"returns True if given index is valid for choices this swatch offers"
return False
def set_cursor_selection_index(self, index):
"another set_cursor_loc support method, overriden by subclasses"
self.popup.blah = index
def render(self):
self.renderable.render()
class CharacterSetSwatch(UISwatch):
# scale the character set will be drawn at
char_scale = 2
min_scale = 1
max_scale = 5
scale_increment = 0.25
def increase_scale(self):
if self.char_scale <= self.max_scale - self.scale_increment:
self.char_scale += self.scale_increment
def decrease_scale(self):
if self.char_scale >= self.min_scale + self.scale_increment:
self.char_scale -= self.scale_increment
def reset(self):
UISwatch.reset(self)
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
self.shade.alpha = 0.2
def get_size(self):
art = self.ui.active_art
return art.charset.map_width, art.charset.map_height
def reset_art(self):
# MAYBE-TODO: using screen resolution, try to set quad size to an even
# multiple of screen so the sampling doesn't get chunky
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)
@ -135,7 +149,7 @@ class CharacterSetSwatch(UISwatch):
self.art.set_char_index_at(0, 0, x, y, i)
i += 1
self.art.geo_changed = True
def reset_loc(self):
self.x = self.popup.x + self.popup.swatch_margin
self.y = self.popup.y
@ -144,38 +158,36 @@ class CharacterSetSwatch(UISwatch):
self.grid.x, self.grid.y = self.x, self.y
self.grid.y -= self.art.quad_height
self.shade.x, self.shade.y = self.x, self.y
def set_xform(self, new_xform):
for y in range(self.art.height):
for x in range(self.art.width):
self.art.set_char_transform_at(0, 0, x, y, new_xform)
def is_selection_index_valid(self, index):
return index < self.art.charset.last_index
def set_cursor_selection_index(self, index):
self.popup.cursor_char = index
self.popup.cursor_color = -1
def move_cursor(self, cursor, dx, dy):
"moves cursor by specified amount in selection grid"
# determine new cursor tile X/Y
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
self.set_cursor_loc(cursor, tile_x, tile_y)
def update(self):
charset = self.ui.active_art.charset
fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color
@ -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
@ -203,17 +218,18 @@ class CharacterSetSwatch(UISwatch):
self.selection_box.y = self.renderable.y
selection_y = (self.ui.selected_char - selection_x) / charset.map_width
self.selection_box.y -= selection_y * self.art.quad_height
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()
def render(self):
if not self.popup.visible:
return
@ -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)
@ -252,10 +275,13 @@ class PaletteSwatch(UISwatch):
if colors == 129 and columns == 15:
columns = 16
return columns, rows
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
@ -271,12 +297,15 @@ class PaletteSwatch(UISwatch):
self.art.set_color_at(0, 0, x, y, i, False)
i += 1
self.art.geo_changed = True
def reset_loc(self):
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,23 +323,26 @@ 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
def is_selection_index_valid(self, index):
return index < len(self.art.palette.colors)
def set_cursor_selection_index(self, index):
# modulo wrap if selecting last color
self.popup.cursor_color = (index + 1) % len(self.art.palette.colors)
self.popup.cursor_char = -1
def move_cursor(self, cursor, dx, dy):
# similar enough to charset swatch's move_cursor, different enough to
# merit this small bit of duplicate code
pass
def update(self):
self.art.update()
self.f_art.update()
@ -358,7 +390,7 @@ class PaletteSwatch(UISwatch):
self.b_renderable.y = self.bg_selection_box.y
self.b_renderable.x += x_offset
self.b_renderable.y -= y_offset
def render(self):
if not self.popup.visible:
return
@ -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,9 +418,8 @@ class ColorSelectionLabelRenderable(UIRenderable):
class CharacterGridRenderable(LineRenderable):
color = (0.5, 0.5, 0.5, 0.25)
def build_geo(self):
w, h = self.quad_size_ref.width, self.quad_size_ref.height
v = []
@ -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,8 +33,8 @@ 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
self.affects_char = True
@ -36,69 +44,89 @@ class UITool:
# load icon, cursor's sprite renderable will reference this texture
icon_filename = self.ui.asset_dir + self.icon_filename
self.icon_texture = self.load_icon_texture(icon_filename)
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)
def get_icon_texture(self):
"""
Returns icon texture that should display for tool's current state.
(override to eg choose from multiples for mod keys)
"""
return self.icon_texture
def get_button_caption(self):
# normally just returns button_caption, but can be overridden to
# provide custom behavior (eg fill tool)
return self.button_caption
def toggle_affects_char(self):
if not self.affects_masks or self.ui.app.game_mode:
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):
if not self.affects_masks or self.ui.app.game_mode:
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):
if not self.affects_masks or self.ui.app.game_mode:
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):
if not self.affects_masks or self.ui.app.game_mode:
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):
"returns a list of EditCommandTiles for a given paint operation"
return []
def increase_brush_size(self):
if not self.brush_size:
return
self.brush_size += 1
self.ui.app.cursor.set_scale(self.brush_size)
self.ui.tool_settings_changed = True
def decrease_brush_size(self):
if not self.brush_size:
return
@ -109,12 +137,11 @@ 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):
"""
return the tile value changes this tool would perform on a tile -
@ -123,12 +150,12 @@ 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
return a_char, a_fg, a_bg, a_xform
def get_paint_commands(self):
commands = []
art = self.ui.active_art
@ -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,11 +194,10 @@ 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
fg = 0 if self.affects_fg_color 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,22 +220,21 @@ 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()
art = self.ui.active_art
@ -233,44 +259,49 @@ 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)
self.input_active = False
self.cursor = None
def start_entry(self):
self.cursor = self.ui.app.cursor
# 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
self.ui.tool_settings_changed = True
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)
def handle_keyboard_input(self, key, shift_pressed, ctrl_pressed, alt_pressed):
# for now, do nothing on ctrl/alt
if ctrl_pressed or alt_pressed:
@ -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,17 +384,16 @@ 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)
self.selection_in_progress = False
@ -380,7 +412,7 @@ class SelectTool(UITool):
self.icon_texture_add = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_sub
self.icon_texture_sub = self.load_icon_texture(icon)
def get_icon_texture(self):
# show different icons based on mod key status
if self.ui.app.il.shift_pressed:
@ -389,14 +421,14 @@ class SelectTool(UITool):
return self.icon_texture_sub
else:
return self.icon_texture
def start_select(self):
self.selection_in_progress = True
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
# selection boolean operations:
@ -410,9 +442,9 @@ 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:
return
@ -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
@ -445,7 +483,7 @@ class SelectTool(UITool):
self.drag_renderable.rebind_buffers()
self.last_selection = self.selected_tiles.copy()
self.last_drag = self.current_drag.copy()
def render_selections(self):
if len(self.selected_tiles) > 0:
self.select_renderable.render()
@ -454,12 +492,11 @@ 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!
def get_paint_commands(self):
@ -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,35 +541,36 @@ 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'
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):
UITool.__init__(self, ui)
icon = self.ui.asset_dir + self.icon_filename_char
@ -539,11 +579,15 @@ class FillTool(UITool):
self.icon_texture_fg = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_bg
self.icon_texture_bg = self.load_icon_texture(icon)
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,24 +1,21 @@
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
def __init__(self, ui):
self.ui = ui
self.icon_renderables = []
self.create_toolbar_buttons()
UIElement.__init__(self, ui)
self.selection_box = ToolSelectionBoxRenderable(ui.app, self.art)
def reset_art(self):
# by default, a 1D vertical bar
self.tile_width = ToolBarButton.width
@ -26,7 +23,7 @@ class ToolBar(UIElement):
self.tile_height = ToolBarButton.height * len(self.buttons)
self.art.resize(self.tile_width, self.tile_height)
UIElement.reset_art(self)
def reset_loc(self):
UIElement.reset_loc(self)
# by default, a vertical bar centered along left edge of the screen
@ -36,19 +33,19 @@ class ToolBar(UIElement):
self.renderable.x, self.renderable.y = self.x, self.y
# scale and position button icons only now that we're positioned
self.reset_button_icons()
def create_toolbar_buttons(self):
# (override in subclass)
pass
def update_selection_box(self):
# (override in subclass)
pass
def update(self):
UIElement.update(self)
self.update_selection_box()
def render(self):
UIElement.render(self)
for r in self.icon_renderables:
@ -58,42 +55,47 @@ class ToolBar(UIElement):
class ToolBarButton(UIButton):
width, height = 4, 2
caption = ''
caption = ""
tooltip_on_hover = True
def get_tooltip_text(self):
return self.cb_arg.button_caption
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,12 +106,12 @@ 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
self.selection_box.scale_x = ToolBarButton.width

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,26 +1,26 @@
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."
return Vec3(self.x - b.x, self.y - b.y, self.z - b.z)
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."
n = Vec3()
@ -31,26 +31,27 @@ class Vec3:
n.y = self.y * ilength
n.z = self.z * ilength
return n
def cross(self, b):
"Return a new vector of cross product with given other vector."
x = self.y * b.z - self.z * b.y
y = self.z * b.x - self.x * b.z
z = self.x * b.y - self.y * b.x
return Vec3(x, y, z)
def dot(self, b):
"Return scalar dot product with given other vector."
return self.x * b.x + self.y * b.y + self.z * b.z
def inverse(self):
"Return a new vector that is inverse of this vector."
return Vec3(-self.x, -self.y, -self.z)
def copy(self):
"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,11 +96,12 @@ 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,
also from http://playtechs.blogspot.com/2007/03/raytracing-on-grid.html
cut_corners=True: when a 45 degree line is only intersecting the corners
of two tiles, don't count them as overlapped.
"""
@ -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