Compare commits

...

24 commits

Author SHA1 Message Date
aa6af42381
Simplify the readme 2026-02-13 09:20:00 -05:00
955a24da6f
Add uv lock 2026-02-13 09:20:00 -05:00
a91cfd22e9
Remove unneeded requirements 2026-02-13 09:20:00 -05:00
d211d7deab
Add ty as dev dep 2026-02-13 09:20:00 -05:00
6ab43bebc8
Use dict lookup for charset and palette loading 2026-02-13 09:20:00 -05:00
f3c2b6b695
Pre-allocate vertex and element lists in build_geo 2026-02-13 09:20:00 -05:00
44e4219b04
Use set for O(1) palette color deduplication 2026-02-13 09:20:00 -05:00
55cfa7963c
Cache sorted layer indices on Art 2026-02-13 09:20:00 -05:00
14b032eae1
Vectorize UV initialization with numpy broadcasting 2026-02-13 09:20:00 -05:00
8c4def54af
Cache shader attribute locations in TileRenderable 2026-02-13 09:19:59 -05:00
4f8b009a71
Reorganize source into playscii/ package
Move all root .py files into playscii/ package directory.
Rename playscii.py to app.py, add __main__.py entry point.
Convert bare imports to relative (within package) and absolute
(in formats/ and games/). Data dirs stay at root.
2026-02-13 09:19:59 -05:00
c0621ff717
Fix cursor offset when tiling WM resizes window on first tick 2026-02-13 09:19:59 -05:00
d608909b5d
Run dos2unix on project 2026-02-13 09:19:59 -05:00
d5cf379e1f
Add agent config with project guide 2026-02-13 09:19:59 -05:00
98550e6395
Disable lint rules inappropriate for legacy codebase 2026-02-13 09:19:59 -05:00
ad6d36762e
Fix minor lint issues 2026-02-13 09:19:59 -05:00
5cccb9b521
Convert format calls to f-strings 2026-02-13 09:19:59 -05:00
f0438812fc
Fix unused variables and loop variables 2026-02-13 09:19:59 -05:00
d47d1f6280
Replace bare except with except Exception 2026-02-13 09:19:59 -05:00
270f432229
Convert percent formatting to f-strings 2026-02-13 09:19:59 -05:00
804c09ec4e
Apply ruff auto-fixes and formatting 2026-02-13 09:19:59 -05:00
52149750cf
Add justfile for project commands 2026-02-13 09:19:59 -05:00
b6ddfcd74f
Add pyproject.toml with dependencies and ruff config 2026-02-13 09:19:59 -05:00
07a5d0b6d0
Fix initial source commit 2026-02-13 09:19:31 -05:00
103 changed files with 18244 additions and 15453 deletions

93
.claude/CLAUDE.md Normal file
View file

@ -0,0 +1,93 @@
playscii - ascii art and game creation tool (v9.18)
desktop GUI app using SDL2 + OpenGL. NOT a web app, no server component.
originally from https://heptapod.host/jp-lebreton/playscii (mercurial).
we maintain our own git repo.
running
-------
just run launch the app (uv run python -m playscii)
just check lint gate (ruff check + format)
just lint ruff check --fix + ruff format
just typecheck ty check (601 errors, legacy code, not in check gate)
just test pytest (no tests yet, not in check gate)
project structure
-----------------
python source organized as playscii/ package (~50 .py files)
data directories (charsets/, palettes/, etc.) live at repo root
formats/ and games/ also at root (loaded dynamically via importlib)
playscii/
__init__.py package marker
__main__.py entry point for python -m playscii
app.py main application, SDL2/OpenGL setup (was playscii.py)
art.py art/canvas management, the core data model
ui*.py ~20 UI modules (dialogs, panels, menus, toolbar, etc.)
game_*.py game engine (objects, rooms, world, HUD)
renderable*.py rendering pipeline (sprites, lines, framebuffer)
formats/ import/export handlers (ANS, ATA, BMP, EDSCII, PNG, GIF, TXT)
games/ bundled example games (crawler, fireplace, flood, maze, shmup, etc.)
charsets/ 30+ classic computer character sets (.char + .png)
palettes/ color palette definitions
shaders/ GLSL shader files
artscripts/ art animation scripts (.arsc files)
art/ sample ASCII art (.psci files)
docs/ documentation and design notes
ui/ UI image resources
dependencies
------------
runtime: appdirs, numpy, Pillow, PyOpenGL, PySDL2, packaging
system: libSDL2, libSDL2_mixer (must be installed on the OS)
dev: ruff, pytest
gotchas
-------
package structure:
all python source now lives in playscii/ package. files use relative
imports (from .art import ...). formats/ and games/ use absolute imports
(from playscii.art import ...). data directories stay at root since the
app uses CWD-relative paths to load charsets, shaders, etc.
art scripts and exec():
art.py imports random at module level with a noqa comment. art scripts
(.arsc files) are loaded via exec() in art.py's scope. they use random
without importing it themselves. do NOT remove that import even though
ruff flags it as unused. same pattern may apply to other "unused" imports
in art.py — check before removing anything.
pdoc detection:
app.py has a contextlib.suppress block that tries to import pdoc.
the import MUST stay inside the suppress block. if it gets moved or
removed, pdoc_available becomes unconditionally True and the help menu
breaks when pdoc isn't installed.
SDL2 init ordering:
many modules import from app.py's namespace or expect SDL2 to be
initialized before they run. import order in app.py matters — E402
is disabled for this reason.
mutable default arguments:
B006 is disabled. many functions use mutable defaults (lists, dicts).
changing these could alter behavior since some code may rely on the
shared mutable state. do not "fix" these without testing.
variable naming:
E741 is disabled. single-letter vars (l, x, y, z, i, r, g, b) are
everywhere in the math/rendering/game code. this is intentional.
ruff config
-----------
rules: E, F, I, UP, B, SIM
13 rules are explicitly ignored (see pyproject.toml for rationale)
line-length: 88, formatter handles what it can
when adding new code, write it clean. the ignored rules are concessions
to legacy code, not a style guide for new work.

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
venv/*.* venv/*.*
dist/*.* dist/*.*
build/*.* build/*.*
playscii/__pycache__/*.*
__pycache__/*.* __pycache__/*.*
.idea/*.* .idea/*.*
playscii.profile playscii.profile

View file

@ -1,44 +1,31 @@
# PLAYSCII - an ASCII art and game creation tool # playscii
Playscii (pronounced play-skee) is an art, animation, and game creation tool. ascii art and animation program
The latest version will always be available here:
* [http://jp.itch.io/playscii](http://jp.itch.io/playscii) ## forked from jp labreton
* [https://heptapod.host/jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
Playscii's main website is here: - [jp.itch.io/playscii](https://jp.itch.io/playscii)
- [jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
- [cheesetalks.net/jplebreton.php](https://cheesetalks.net/jplebreton.php)
* [https://jplebreton.com/playscii/](https://jplebreton.com/playscii/) ## setup
## Offline documentation requires libSDL2 and libSDL2_mixer installed on the system:
Playscii now includes its own HTML documentation, which you can find in the ```sh
docs/html/ subfolder of the folder where this README resides. # fedora
sudo dnf install SDL2 SDL2_mixer
## Online documentation # debian/ubuntu
sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0
The latest version of the HTML documentation resides here: # mac
brew install sdl2 sdl2_mixer
```
[https://jplebreton.com/playscii/howto_main.html](https://jplebreton.com/playscii/howto_main.html) then, with [uv](https://docs.astral.sh/uv/#installation) installed:
## Bugs ```sh
uv sync
If you run into any issues with Playscii, please report a bug here: just run
```
[https://heptapod.host/jp-lebreton/playscii/issues](https://heptapod.host/jp-lebreton/playscii/issues)
## Roadmap
For possible future features see Playscii's Trello:
[https://trello.com/b/BLQBXn5H/playscii](https://trello.com/b/BLQBXn5H/playscii)
Please don't take anything there as a promise, though. If you'd find something
on there especially valuable, feel free to vote or comment!
## Contact
If you've made something cool with Playscii and/or have any suggestions on how
to improve it, please let JP know!
[https://jplebreton.com/#contact_email](https://jplebreton.com/#contact_email)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,21 @@
from games.crawler.scripts.crawler import (
from game_object import GameObject DIR_EAST,
from game_util_objects import Player DIR_NAMES,
DIR_NORTH,
from games.crawler.scripts.crawler import DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST, LEFT_TURN_DIRS, RIGHT_TURN_DIRS, DIR_NAMES, OPPOSITE_DIRS DIR_SOUTH,
DIR_WEST,
LEFT_TURN_DIRS,
OPPOSITE_DIRS,
RIGHT_TURN_DIRS,
)
from playscii.game_util_objects import Player
class CrawlPlayer(Player): class CrawlPlayer(Player):
should_save = False # we are spawned by maze should_save = False # we are spawned by maze
generate_art = True generate_art = True
art_width, art_height = 1, 1 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 art_off_pct_x, art_off_pct_y = 0, 0
# bespoke grid-based movement method # bespoke grid-based movement method
physics_move = False physics_move = False
@ -17,11 +23,7 @@ class CrawlPlayer(Player):
view_range_tiles = 8 view_range_tiles = 8
fg_color = 8 # yellow fg_color = 8 # yellow
dir_chars = { DIR_NORTH: 147, dir_chars = {DIR_NORTH: 147, DIR_SOUTH: 163, DIR_EAST: 181, DIR_WEST: 180}
DIR_SOUTH: 163,
DIR_EAST: 181,
DIR_WEST: 180
}
def pre_first_update(self): def pre_first_update(self):
Player.pre_first_update(self) Player.pre_first_update(self)
@ -32,14 +34,14 @@ class CrawlPlayer(Player):
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
# turning? # turning?
if key == 'left': if key == "left":
self.direction = LEFT_TURN_DIRS[self.direction] self.direction = LEFT_TURN_DIRS[self.direction]
elif key == 'right': elif key == "right":
self.direction = RIGHT_TURN_DIRS[self.direction] self.direction = RIGHT_TURN_DIRS[self.direction]
# moving? # 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) 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_x = x + self.direction[0]
new_y = y + self.direction[1] new_y = y + self.direction[1]
else: else:
@ -48,8 +50,12 @@ class CrawlPlayer(Player):
# is move valid? # is move valid?
if self.maze.is_tile_solid(new_x, new_y): if self.maze.is_tile_solid(new_x, new_y):
# TEMP negative feedback # TEMP negative feedback
dir_name = DIR_NAMES[self.direction] if key == 'up' else DIR_NAMES[OPPOSITE_DIRS[self.direction]] dir_name = (
self.app.log("can't go %s!" % dir_name) DIR_NAMES[self.direction]
if key == "up"
else DIR_NAMES[OPPOSITE_DIRS[self.direction]]
)
self.app.log(f"can't go {dir_name}!")
else: else:
self.x, self.y = self.maze.x + new_x, self.maze.y - new_y self.x, self.y = self.maze.x + new_x, self.maze.y - new_y
# update art to show facing # update art to show facing

View file

@ -1,16 +1,16 @@
from games.crawler.scripts.crawler import (
from game_object import GameObject DIR_EAST,
from vector import get_tiles_along_integer_line DIR_NORTH,
DIR_SOUTH,
from art import TileIter DIR_WEST,
)
from random import randint # DEBUG from playscii.art import TileIter
from playscii.game_object import GameObject
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 playscii.vector import get_tiles_along_integer_line
class CrawlTopDownView(GameObject): class CrawlTopDownView(GameObject):
art_src = 'maze2' art_src = "maze2"
art_off_pct_x, art_off_pct_y = 0, 0 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 # we will be modifying this view at runtime so don't write on the source art
use_art_instance = True use_art_instance = True
@ -25,18 +25,23 @@ class CrawlTopDownView(GameObject):
# scan art for spot to spawn player # scan art for spot to spawn player
player_x, player_y = -1, -1 player_x, player_y = -1, -1
for frame, layer, x, y in TileIter(self.art): 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 player_x, player_y = self.x + x, self.y - y
# clear the tile at this spot in our art # clear the tile at this spot in our art
self.art.set_char_index_at(frame, layer, x, y, 0) self.art.set_char_index_at(frame, layer, x, y, 0)
break 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 # give player a ref to us
self.world.player.maze = self self.world.player.maze = self
# make a copy of original layer to color for visibility, hide original # make a copy of original layer to color for visibility, hide original
self.art.duplicate_layer(0) self.art.duplicate_layer(0)
self.art.layers_visibility[0] = False self.art.layers_visibility[0] = False
for frame, layer, x, y in TileIter(self.art): for _frame, layer, x, y in TileIter(self.art):
if layer == 0: if layer == 0:
continue continue
# set all tiles undiscovered # set all tiles undiscovered
@ -80,11 +85,15 @@ class CrawlTopDownView(GameObject):
for tile in hit_tiles: for tile in hit_tiles:
tile_x, tile_y = tile[0], tile[1] tile_x, tile_y = tile[0], tile[1]
# skip out-of-bounds tiles # skip out-of-bounds tiles
if 0 > tile_x or tile_x >= self.art.width or \ if (
0 > tile_y or tile_y >= self.art.height: tile_x < 0
or tile_x >= self.art.width
or tile_y < 0
or tile_y >= self.art.height
):
continue continue
# whether this tile is solid or not, we have seen it # 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)) tiles.append((tile_x, tile_y))
if not see_thru_walls and self.is_tile_solid(*tile): if not see_thru_walls and self.is_tile_solid(*tile):
break break
@ -99,25 +108,27 @@ class CrawlTopDownView(GameObject):
previously_visible_tiles = self.player_visible_tiles[:] previously_visible_tiles = self.player_visible_tiles[:]
p = self.world.player p = self.world.player
px, py = self.get_tile_at_point(p.x, p.y) px, py = self.get_tile_at_point(p.x, p.y)
self.player_visible_tiles = self.get_visible_tiles(px, py, self.player_visible_tiles = self.get_visible_tiles(
*p.direction, px, py, *p.direction, p.view_range_tiles, see_thru_walls=False
p.view_range_tiles, )
see_thru_walls=False) # print(self.player_visible_tiles)
#print(self.player_visible_tiles)
# color currently visible tiles # color currently visible tiles
for tile in self.player_visible_tiles: for tile in self.player_visible_tiles:
#print(tile) # print(tile)
if 0 > tile[0] or tile[0] >= self.art.width or \ if (
0 > tile[1] or tile[1] >= self.art.height: tile[0] < 0
or tile[0] >= self.art.width
or tile[1] < 0
or tile[1] >= self.art.height
):
continue continue
if self.is_tile_solid(*tile): if self.is_tile_solid(*tile):
orig_color = self.art.get_fg_color_index_at(0, 0, *tile) orig_color = self.art.get_fg_color_index_at(0, 0, *tile)
self.art.set_color_at(0, 1, *tile, orig_color) self.art.set_color_at(0, 1, *tile, orig_color)
else: 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 pass
# color "previously seen" tiles # color "previously seen" tiles
for tile in previously_visible_tiles: for tile in previously_visible_tiles:
if not tile in self.player_visible_tiles and \ if tile not in self.player_visible_tiles and self.is_tile_solid(*tile):
self.is_tile_solid(*tile):
self.art.set_color_at(0, 1, *tile, self.discovered_color_index) self.art.set_color_at(0, 1, *tile, self.discovered_color_index)

View file

@ -1,31 +1,37 @@
import math from playscii.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): class CronoPlayer(TopDownPlayer):
art_src = 'crono' art_src = "crono"
col_radius = 1.5 col_radius = 1.5
# AABB testing # AABB testing
#collision_shape_type = CST_AABB # collision_shape_type = CST_AABB
#col_offset_x, col_offset_y = 0, 1.25 # col_offset_x, col_offset_y = 0, 1.25
col_width = 3 col_width = 3
col_height = 3 col_height = 3
art_off_pct_y = 0.9 art_off_pct_y = 0.9
class Chest(DynamicBoxObject): class Chest(DynamicBoxObject):
art_src = 'chest' art_src = "chest"
col_width, col_height = 6, 4 col_width, col_height = 6, 4
col_offset_y = -0.5 col_offset_y = -0.5
class Urn(Pickup): class Urn(Pickup):
art_src = 'urn' art_src = "urn"
col_radius = 2 col_radius = 2
art_off_pct_y = 0.85 art_off_pct_y = 0.85
class Bed(StaticTileObject): class Bed(StaticTileObject):
art_src = 'bed' art_src = "bed"
art_off_pct_x, art_off_pct_y = 0.5, 1 art_off_pct_x, art_off_pct_y = 0.5, 1

View file

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

View file

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

View file

@ -1,14 +1,14 @@
from playscii.game_hud import GameHUD, GameHUDRenderable
from game_hud import GameHUD, GameHUDRenderable
class MazeHUD(GameHUD): class MazeHUD(GameHUD):
message_color = 4 message_color = 4
def __init__(self, world): def __init__(self, world):
GameHUD.__init__(self, world) GameHUD.__init__(self, world)
self.msg_art = self.world.app.new_art('mazehud_msg', 42, 1, self.msg_art = self.world.app.new_art(
'jpetscii', 'c64_original') "mazehud_msg", 42, 1, "jpetscii", "c64_original"
)
self.msg = GameHUDRenderable(self.world.app, self.msg_art) self.msg = GameHUDRenderable(self.world.app, self.msg_art)
self.arts = [self.msg_art] self.arts = [self.msg_art]
self.renderables = [self.msg] self.renderables = [self.msg]
@ -17,9 +17,9 @@ class MazeHUD(GameHUD):
aspect = self.world.app.window_height / self.world.app.window_width aspect = self.world.app.window_height / self.world.app.window_width
self.msg.scale_x = 0.075 * aspect self.msg.scale_x = 0.075 * aspect
self.msg.scale_y = 0.05 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.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): def post_msg(self, msg_text):
self.current_msg = msg_text self.current_msg = msg_text

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,10 @@
import random import random
# wildflowers palette ramp definitions # wildflowers palette ramp definitions
PALETTE_RAMPS = { PALETTE_RAMPS = {
# palette name : list of its ramps # palette name : list of its ramps
'dpaint': [ "dpaint": [
# ramp tuple: (start index, length, stride) # ramp tuple: (start index, length, stride)
# generally, lighter / more vivid to darker # generally, lighter / more vivid to darker
(17, 16, 1), # white to black (17, 16, 1), # white to black
@ -21,9 +20,9 @@ PALETTE_RAMPS = {
(161, 16, 1), # light purple to ~black (161, 16, 1), # light purple to ~black
(177, 16, 1), # light magenta to ~black (177, 16, 1), # light magenta to ~black
(193, 24, 1), # pale flesh to ~black (193, 24, 1), # pale flesh to ~black
(225, 22, 1) # ROYGBV rainbow (225, 22, 1), # ROYGBV rainbow
], ],
'doom': [ "doom": [
(17, 27, 1), # very light pink to dark red (17, 27, 1), # very light pink to dark red
(44, 20, 1), # pale flesh to brown (44, 20, 1), # pale flesh to brown
(69, 26, 1), # white to very dark grey (69, 26, 1), # white to very dark grey
@ -37,9 +36,9 @@ PALETTE_RAMPS = {
(180, 7, 1), # white to yellow (180, 7, 1), # white to yellow
(187, 4, 1), # orange to burnt orange (187, 4, 1), # orange to burnt orange
(193, 7, 1), # dark blue to black (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 (16, 15, -1), # white to black
(32, 16, -1), # mustard to black (32, 16, -1), # mustard to black
(48, 16, -1), # lavender to black (48, 16, -1), # lavender to black
@ -57,9 +56,9 @@ PALETTE_RAMPS = {
(233, 4, -1), # yellow to brown (233, 4, -1), # yellow to brown
(236, 3, -1), # light blue to blue (236, 3, -1), # light blue to blue
(240, 4, -1), # red to dark red (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 (35, 35, -1), # white to black
(51, 16, -1), # light grey to dark grey (51, 16, -1), # light grey to dark grey
(65, 14, -1), # white to dark violent-grey (65, 14, -1), # white to dark violent-grey
@ -74,9 +73,9 @@ PALETTE_RAMPS = {
(208, 24, -1), # white to cyan to dark blue (208, 24, -1), # white to cyan to dark blue
(224, 16, -1), # light green to dark green (224, 16, -1), # light green to dark green
(240, 16, -1), # olive to dark olive (240, 16, -1), # olive to dark olive
(247, 7, -1) # red to yellow (247, 7, -1), # red to yellow
], ],
'atari': [ "atari": [
(113, 8, -16), # white to black (113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown (114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown (115, 8, -16), # dull gold to brown
@ -92,13 +91,12 @@ PALETTE_RAMPS = {
(125, 8, -16), # light green to dark green (125, 8, -16), # light green to dark green
(126, 8, -16), # yellow green to dark yellow green (126, 8, -16), # yellow green to dark yellow green
(127, 8, -16), # pale yellow to dark olive (127, 8, -16), # pale yellow to dark olive
(128, 8, -16) # gold to golden brown (128, 8, -16), # gold to golden brown
] ],
} }
class RampIterator: class RampIterator:
def __init__(self, flower): def __init__(self, flower):
ramp_def = random.choice(PALETTE_RAMPS[flower.art.palette.name]) ramp_def = random.choice(PALETTE_RAMPS[flower.art.palette.name])
self.start, self.length, self.stride = ramp_def self.start, self.length, self.stride = ramp_def

View file

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

14
justfile Normal file
View file

@ -0,0 +1,14 @@
lint:
uv run ruff check --fix .
uv run ruff format .
typecheck:
uvx ty check
test:
uv run pytest
check: lint
run:
uv run python -m playscii

1
playscii/__init__.py Normal file
View file

@ -0,0 +1 @@
# playscii package

9
playscii/__main__.py Normal file
View file

@ -0,0 +1,9 @@
import sys
from .app import get_app
app = get_app()
error = app.main_loop()
app.quit()
app.logger.close()
sys.exit(error)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,16 @@
import os.path, string, time import os.path
import string
import time
from PIL import Image from PIL import Image
from texture import Texture from .texture import Texture
CHARSET_DIR = 'charsets/' CHARSET_DIR = "charsets/"
CHARSET_FILE_EXTENSION = 'char' CHARSET_FILE_EXTENSION = "char"
class CharacterSetLord: class CharacterSetLord:
# time in ms between checks for hot reload # time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000 hot_reload_check_interval = 2 * 1000
@ -17,35 +19,44 @@ class CharacterSetLord:
self.last_check = 0 self.last_check = 0
def check_hot_reload(self): 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 return
self.last_check = self.app.get_elapsed_time() self.last_check = self.app.get_elapsed_time()
changed = None
for charset in self.app.charsets: for charset in self.app.charsets:
if charset.has_updated(): if charset.has_updated():
changed = charset.filename
# reload data and image even if only one changed # reload data and image even if only one changed
try: try:
success = charset.load_char_data() success = charset.load_char_data()
if success: if success:
self.app.log('CharacterSetLord: success reloading %s' % charset.filename) self.app.log(
f"CharacterSetLord: success reloading {charset.filename}"
)
else: else:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True) self.app.log(
except: f"CharacterSetLord: failed reloading {charset.filename}",
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True) True,
)
except Exception:
self.app.log(
f"CharacterSetLord: failed reloading {charset.filename}",
True,
)
class CharacterSet: class CharacterSet:
transparent_color = (0, 0, 0) transparent_color = (0, 0, 0)
def __init__(self, app, src_filename, log): def __init__(self, app, src_filename, log):
self.init_success = False self.init_success = False
self.app = app self.app = app
self.filename = self.app.find_filename_path(src_filename, CHARSET_DIR, self.filename = self.app.find_filename_path(
CHARSET_FILE_EXTENSION) src_filename, CHARSET_DIR, CHARSET_FILE_EXTENSION
)
if not self.filename: if not self.filename:
self.app.log("Couldn't find character set data %s" % self.filename) self.app.log(f"Couldn't find character set data {self.filename}")
return return
self.name = os.path.basename(self.filename) self.name = os.path.basename(self.filename)
self.name = os.path.splitext(self.name)[0] self.name = os.path.splitext(self.name)[0]
@ -59,37 +70,39 @@ class CharacterSet:
return return
# report # report
if log and not self.app.game_mode: if log and not self.app.game_mode:
self.app.log("loaded charmap '%s' from %s:" % (self.name, self.filename)) self.app.log(f"loaded charmap '{self.name}' from {self.filename}:")
self.report() self.report()
self.init_success = True self.init_success = True
def load_char_data(self): def load_char_data(self):
"carries out majority of CharacterSet init, including loading image" "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 // # allow comments: discard any line in char data starting with //
# (make sure this doesn't muck up legit mapping data) # (make sure this doesn't muck up legit mapping data)
char_data = [] char_data = []
for line in char_data_src: for line in char_data_src:
if not line.startswith('//'): if not line.startswith("//"):
char_data.append(line) char_data.append(line)
# first line = image file # first line = image file
# hold off assigning to self.image_filename til we know it's valid # 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: if not img_filename:
self.app.log("Couldn't find character set image %s" % self.image_filename) self.app.log(f"Couldn't find character set image {self.image_filename}")
return False return False
self.image_filename = img_filename self.image_filename = img_filename
# now that we know the image file's name, store its last modified time # now that we know the image file's name, store its last modified time
self.last_image_change = os.path.getmtime(self.image_filename) self.last_image_change = os.path.getmtime(self.image_filename)
# second line = character set dimensions # 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.map_width, self.map_height = int(second_line[0]), int(second_line[1])
self.char_mapping = {} self.char_mapping = {}
index = 0 index = 0
for line in char_data: for line in char_data:
# strip newlines from mapping # strip newlines from mapping
for char in line.strip('\r\n'): for char in line.strip("\r\n"):
if not char in self.char_mapping: if char not in self.char_mapping:
self.char_mapping[char] = index self.char_mapping[char] = index
index += 1 index += 1
if index >= self.map_width * self.map_height: if index >= self.map_width * self.map_height:
@ -105,12 +118,12 @@ class CharacterSet:
if has_upper and not has_lower: if has_upper and not has_lower:
for char in string.ascii_lowercase: for char in string.ascii_lowercase:
# set may not have all letters # set may not have all letters
if not char.upper() in self.char_mapping: if char.upper() not in self.char_mapping:
continue continue
self.char_mapping[char] = self.char_mapping[char.upper()] self.char_mapping[char] = self.char_mapping[char.upper()]
elif has_lower and not has_upper: elif has_lower and not has_upper:
for char in string.ascii_uppercase: for char in string.ascii_uppercase:
if not char.lower() in self.char_mapping: if char.lower() not in self.char_mapping:
continue continue
self.char_mapping[char] = self.char_mapping[char.lower()] self.char_mapping[char] = self.char_mapping[char.lower()]
# last valid index a character can be # last valid index a character can be
@ -125,7 +138,7 @@ class CharacterSet:
def load_image_data(self): def load_image_data(self):
# load and process image # load and process image
img = Image.open(self.image_filename) img = Image.open(self.image_filename)
img = img.convert('RGBA') img = img.convert("RGBA")
# flip for openGL # flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM) img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size self.image_width, self.image_height = img.size
@ -152,16 +165,23 @@ class CharacterSet:
self.v_height = self.char_height / self.image_height self.v_height = self.char_height / self.image_height
def report(self): 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(
self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height)) f" source texture {self.image_filename} is {self.image_width} x {self.image_height} pixels"
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(
f" char pixel width/height is {self.char_width} x {self.char_height}"
)
self.app.log(f" char map width/height is {self.map_width} x {self.map_height}")
self.app.log(f" last character index: {self.last_index}")
def has_updated(self): def has_updated(self):
"return True if source image file has changed since last check" "return True if source image file has changed since last check"
# tolerate bad filenames in data, don't check stamps on nonexistent ones # 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 \ if (
not os.path.exists(self.image_filename): not self.image_filename
or not os.path.exists(self.filename)
or not os.path.exists(self.image_filename)
):
return False return False
data_changed = os.path.getmtime(self.filename) > self.last_data_change data_changed = os.path.getmtime(self.filename) > self.last_data_change
img_changed = os.path.getmtime(self.image_filename) > self.last_image_change img_changed = os.path.getmtime(self.image_filename) > self.last_image_change

View file

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

View file

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

View file

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

View file

@ -3,16 +3,18 @@ from OpenGL import GL
class Framebuffer: class Framebuffer:
start_crt_enabled = False start_crt_enabled = False
disable_crt = False disable_crt = False
clear_color = (0, 0, 0, 1) clear_color = (0, 0, 0, 1)
# declared as an option here in case people want to sub their own via CFG # 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): def __init__(self, app, width=None, height=None):
self.app = app 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 # bind vao before compiling shaders
if self.app.use_vao: if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1) self.vao = GL.glGenVertexArrays(1)
@ -20,27 +22,34 @@ class Framebuffer:
self.vbo = GL.glGenBuffers(1) self.vbo = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo)
fb_verts = np.array([-1, -1, 1, -1, -1, 1, 1, 1], dtype=np.float32) 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.glBufferData(
GL.GL_STATIC_DRAW) GL.GL_ARRAY_BUFFER, fb_verts.nbytes, fb_verts, GL.GL_STATIC_DRAW
)
# texture, depth buffer, framebuffer # texture, depth buffer, framebuffer
self.texture = GL.glGenTextures(1) self.texture = GL.glGenTextures(1)
self.depth_buffer = GL.glGenRenderbuffers(1) self.depth_buffer = GL.glGenRenderbuffers(1)
self.framebuffer = GL.glGenFramebuffers(1) self.framebuffer = GL.glGenFramebuffers(1)
self.setup_texture_and_buffers() self.setup_texture_and_buffers()
# shaders # 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: 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() self.crt = self.get_crt_enabled()
# shader uniforms and attributes # shader uniforms and attributes
self.plain_tex_uniform = self.plain_shader.get_uniform_location('fbo_texture') 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_attrib = self.plain_shader.get_attrib_location("v_coord")
GL.glEnableVertexAttribArray(self.plain_attrib) 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: if not self.disable_crt:
self.crt_tex_uniform = self.crt_shader.get_uniform_location('fbo_texture') 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_time_uniform = self.crt_shader.get_uniform_location("elapsed_time")
self.crt_res_uniform = self.crt_shader.get_uniform_location('resolution') self.crt_res_uniform = self.crt_shader.get_uniform_location("resolution")
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao: if self.app.use_vao:
GL.glBindVertexArray(0) GL.glBindVertexArray(0)
@ -50,26 +59,40 @@ class Framebuffer:
def setup_texture_and_buffers(self): def setup_texture_and_buffers(self):
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture) GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
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.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.glTexImage2D(
GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) GL.GL_TEXTURE_2D,
GL.glTexParameterf(GL.GL_TEXTURE_2D, 0,
GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) GL.GL_RGBA,
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, self.width,
self.width, self.height, 0, self.height,
GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None) 0,
GL.GL_RGBA,
GL.GL_UNSIGNED_BYTE,
None,
)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self.depth_buffer) GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self.depth_buffer)
GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, GL.glRenderbufferStorage(
self.width, self.height) GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, self.width, self.height
)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, 0) GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, 0)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.framebuffer) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.framebuffer)
GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.glFramebufferTexture2D(
GL.GL_TEXTURE_2D, self.texture, 0) GL.GL_FRAMEBUFFER,
GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_COLOR_ATTACHMENT0,
GL.GL_RENDERBUFFER, self.depth_buffer) 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) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0)
def resize(self, new_width, new_height): def resize(self, new_width, new_height):
@ -104,7 +127,9 @@ class Framebuffer:
GL.glBindVertexArray(self.vao) GL.glBindVertexArray(self.vao)
else: else:
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) 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.glEnableVertexAttribArray(self.plain_attrib)
GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4)
if self.app.use_vao: if self.app.use_vao:
@ -114,9 +139,13 @@ class Framebuffer:
class ExportFramebuffer(Framebuffer): class ExportFramebuffer(Framebuffer):
clear_color = (0, 0, 0, 0) clear_color = (0, 0, 0, 0)
def get_crt_enabled(self): return True
def get_crt_enabled(self):
return True
class ExportFramebufferNoCRT(Framebuffer): class ExportFramebufferNoCRT(Framebuffer):
clear_color = (0, 0, 0, 0) 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 art import Art from .renderable import TileRenderable
from renderable import TileRenderable
class GameHUDArt(Art): class GameHUDArt(Art):
#recalc_quad_height = False # recalc_quad_height = False
log_creation = False log_creation = False
quad_width = 0.1 quad_width = 0.1
@ -13,12 +12,12 @@ class GameHUDRenderable(TileRenderable):
def get_projection_matrix(self): def get_projection_matrix(self):
# much like UIRenderable, use UI's matrices to render in screen space # much like UIRenderable, use UI's matrices to render in screen space
return self.app.ui.view_matrix return self.app.ui.view_matrix
def get_view_matrix(self): def get_view_matrix(self):
return self.app.ui.view_matrix return self.app.ui.view_matrix
class GameHUD: class GameHUD:
"stub HUD, subclass and put your own stuff here" "stub HUD, subclass and put your own stuff here"
def __init__(self, world): def __init__(self, world):

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import numpy as np import numpy as np
from renderable_line import LineRenderable from .renderable_line import LineRenderable
# grid that displays as guide for Cursor # grid that displays as guide for Cursor
@ -8,8 +8,8 @@ AXIS_COLOR = (0.8, 0.8, 0.8, 0.5)
BASE_COLOR = (0.5, 0.5, 0.5, 0.25) BASE_COLOR = (0.5, 0.5, 0.5, 0.25)
EXTENTS_COLOR = (0, 0, 0, 1) EXTENTS_COLOR = (0, 0, 0, 1)
class Grid(LineRenderable):
class Grid(LineRenderable):
visible = True visible = True
draw_axes = False draw_axes = False
@ -28,7 +28,7 @@ class Grid(LineRenderable):
index = 4 index = 4
# axes - Y and X # axes - Y and X
if self.draw_axes: 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] e += [4, 5, 6, 7]
color = AXIS_COLOR color = AXIS_COLOR
c += color * 4 c += color * 4
@ -37,15 +37,15 @@ class Grid(LineRenderable):
color = BASE_COLOR color = BASE_COLOR
for x in range(1, w): for x in range(1, w):
# skip middle line # 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)] v += [(x, -h), (x, 0)]
e += [index, index+1] e += [index, index + 1]
c += color * 2 c += color * 2
index += 2 index += 2
for y in range(1, h): 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)] v += [(0, -y), (w, -y)]
e += [index, index+1] e += [index, index + 1]
c += color * 2 c += color * 2
index += 2 index += 2
self.vert_array = np.array(v, dtype=np.float32) self.vert_array = np.array(v, dtype=np.float32)
@ -74,7 +74,6 @@ class Grid(LineRenderable):
class ArtGrid(Grid): class ArtGrid(Grid):
def reset_loc(self): def reset_loc(self):
self.x, self.y = 0, 0 self.x, self.y = 0, 0
self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer] self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer]
@ -88,7 +87,6 @@ class ArtGrid(Grid):
class GameGrid(Grid): class GameGrid(Grid):
draw_axes = True draw_axes = True
base_size = 800 base_size = 800

View file

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

View file

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

View file

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

View file

@ -5,9 +5,27 @@ import sdl2
# MAYBE-TODO: find out if this breaks for non-US english KB layouts # MAYBE-TODO: find out if this breaks for non-US english KB layouts
SHIFT_MAP = { SHIFT_MAP = {
'1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', "1": "!",
'9': '(', '0': ')', '-': '_', '=': '+', '`': '~', '[': '{', ']': '}', '\\': '|', "2": "@",
';': ':', "'": '"', ',': '<', '.': '>', '/': '?' "3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
"`": "~",
"[": "{",
"]": "}",
"\\": "|",
";": ":",
"'": '"',
",": "<",
".": ">",
"/": "?",
} }
NUMLOCK_ON_MAP = { NUMLOCK_ON_MAP = {
@ -26,7 +44,7 @@ NUMLOCK_ON_MAP = {
sdl2.SDLK_KP_PLUS: sdl2.SDLK_PLUS, sdl2.SDLK_KP_PLUS: sdl2.SDLK_PLUS,
sdl2.SDLK_KP_MINUS: sdl2.SDLK_MINUS, sdl2.SDLK_KP_MINUS: sdl2.SDLK_MINUS,
sdl2.SDLK_KP_PERIOD: sdl2.SDLK_PERIOD, sdl2.SDLK_KP_PERIOD: sdl2.SDLK_PERIOD,
sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN,
} }
NUMLOCK_OFF_MAP = { NUMLOCK_OFF_MAP = {
@ -40,5 +58,5 @@ NUMLOCK_OFF_MAP = {
sdl2.SDLK_KP_8: sdl2.SDLK_UP, sdl2.SDLK_KP_8: sdl2.SDLK_UP,
sdl2.SDLK_KP_9: sdl2.SDLK_PAGEUP, sdl2.SDLK_KP_9: sdl2.SDLK_PAGEUP,
sdl2.SDLK_KP_PERIOD: sdl2.SDLK_DELETE, 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 import math
def rgb_to_xyz(r, g, b): def rgb_to_xyz(r, g, b):
r /= 255.0 r /= 255.0
g /= 255.0 g /= 255.0
b /= 255.0 b /= 255.0
if r > 0.04045: if r > 0.04045:
r = ((r + 0.055) / 1.055)**2.4 r = ((r + 0.055) / 1.055) ** 2.4
else: else:
r /= 12.92 r /= 12.92
if g > 0.04045: if g > 0.04045:
g = ((g + 0.055) / 1.055)**2.4 g = ((g + 0.055) / 1.055) ** 2.4
else: else:
g /= 12.92 g /= 12.92
if b > 0.04045: if b > 0.04045:
b = ((b + 0.055) / 1.055)**2.4 b = ((b + 0.055) / 1.055) ** 2.4
else: else:
b /= 12.92 b /= 12.92
r *= 100 r *= 100
@ -28,21 +29,22 @@ def rgb_to_xyz(r, g, b):
z = r * 0.0193 + g * 0.1192 + b * 0.9505 z = r * 0.0193 + g * 0.1192 + b * 0.9505
return x, y, z return x, y, z
def xyz_to_lab(x, y, z): def xyz_to_lab(x, y, z):
# observer: 2deg, illuminant: D65 # observer: 2deg, illuminant: D65
x /= 95.047 x /= 95.047
y /= 100.0 y /= 100.0
z /= 108.883 z /= 108.883
if x > 0.008856: if x > 0.008856:
x = x**(1.0/3) x = x ** (1.0 / 3)
else: else:
x = (7.787 * x) + (16.0 / 116) x = (7.787 * x) + (16.0 / 116)
if y > 0.008856: if y > 0.008856:
y = y**(1.0/3) y = y ** (1.0 / 3)
else: else:
y = (7.787 * y) + (16.0 / 116) y = (7.787 * y) + (16.0 / 116)
if z > 0.008856: if z > 0.008856:
z = z**(1.0/3) z = z ** (1.0 / 3)
else: else:
z = (7.787 * z) + (16.0 / 116) z = (7.787 * z) + (16.0 / 116)
l = (116 * y) - 16 l = (116 * y) - 16
@ -50,13 +52,15 @@ def xyz_to_lab(x, y, z):
b = 200 * (y - z) b = 200 * (y - z)
return l, a, b return l, a, b
def rgb_to_lab(r, g, b): def rgb_to_lab(r, g, b):
x, y, z = rgb_to_xyz(r, g, b) x, y, z = rgb_to_xyz(r, g, b)
return xyz_to_lab(x, y, z) return xyz_to_lab(x, y, z)
def lab_color_diff(l1, a1, b1, l2, a2, b2): def lab_color_diff(l1, a1, b1, l2, a2, b2):
"quick n' dirty CIE 1976 color delta" "quick n' dirty CIE 1976 color delta"
dl = (l1 - l2)**2 dl = (l1 - l2) ** 2
da = (a1 - a2)**2 da = (a1 - a2) ** 2
db = (b1 - b2)**2 db = (b1 - b2) ** 2
return math.sqrt(dl + da + db) return math.sqrt(dl + da + db)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,20 @@
from .ui_console import LoadGameStateCommand, SaveGameStateCommand
from ui_dialog import UIDialog, Field from .ui_dialog import Field, UIDialog
from .ui_list_operations import (
from ui_console import SetGameDirCommand, LoadGameStateCommand, SaveGameStateCommand LO_NONE,
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 )
class NewGameDirDialog(UIDialog): class NewGameDirDialog(UIDialog):
title = 'New game' title = "New game"
field0_label = 'Name of new game folder:' field0_label = "Name of new game folder:"
field1_label = 'Name of new game:' field1_label = "Name of new game:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False), 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 game_mode_visible = True
# TODO: only allow names that don't already exist # TODO: only allow names that don't already exist
@ -22,7 +22,7 @@ class NewGameDirDialog(UIDialog):
def get_initial_field_text(self, field_number): def get_initial_field_text(self, field_number):
# provide a reasonable non-blank name # provide a reasonable non-blank name
if field_number == 0: if field_number == 0:
return 'newgame' return "newgame"
elif field_number == 1: elif field_number == 1:
return type(self.ui.app.gw).game_title return type(self.ui.app.gw).game_title
@ -31,15 +31,13 @@ class NewGameDirDialog(UIDialog):
self.ui.app.enter_game_mode() self.ui.app.enter_game_mode()
self.dismiss() self.dismiss()
class LoadGameStateDialog(UIDialog):
title = 'Open game state' class LoadGameStateDialog(UIDialog):
field_label = 'Game state file to open:' title = "Open game state"
field_label = "Game state file to open:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [Field(label=field_label, type=str, width=field_width, oneline=False)]
Field(label=field_label, type=str, width=field_width, oneline=False) confirm_caption = "Open"
]
confirm_caption = 'Open'
game_mode_visible = True game_mode_visible = True
# TODO: only allow valid game state file in current game directory # TODO: only allow valid game state file in current game directory
@ -48,59 +46,58 @@ class LoadGameStateDialog(UIDialog):
LoadGameStateCommand.execute(self.ui.console, [self.field_texts[0]]) LoadGameStateCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss() self.dismiss()
class SaveGameStateDialog(UIDialog):
title = 'Save game state' class SaveGameStateDialog(UIDialog):
field_label = 'New filename for game state:' title = "Save game state"
field_label = "New filename for game state:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [Field(label=field_label, type=str, width=field_width, oneline=False)]
Field(label=field_label, type=str, width=field_width, oneline=False) confirm_caption = "Save"
]
confirm_caption = 'Save'
game_mode_visible = True game_mode_visible = True
def confirm_pressed(self): def confirm_pressed(self):
SaveGameStateCommand.execute(self.ui.console, [self.field_texts[0]]) SaveGameStateCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss() self.dismiss()
class AddRoomDialog(UIDialog): class AddRoomDialog(UIDialog):
title = 'Add new room' title = "Add new room"
field0_label = 'Name for new room:' field0_label = "Name for new room:"
field1_label = 'Class of new room:' field1_label = "Class of new room:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False), 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 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): def get_initial_field_text(self, field_number):
# provide a reasonable non-blank name # provide a reasonable non-blank name
if field_number == 0: 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: elif field_number == 1:
return 'GameRoom' return "GameRoom"
def is_input_valid(self): 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): def confirm_pressed(self):
valid, reason = self.is_input_valid() 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.ui.app.gw.add_room(self.field_texts[0], self.field_texts[1])
self.dismiss() self.dismiss()
class SetRoomCamDialog(UIDialog): class SetRoomCamDialog(UIDialog):
title = 'Set room camera marker' title = "Set room camera marker"
tile_width = 48 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 field_width = UIDialog.default_field_width
fields = [ fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
Field(label=field0_label, type=str, width=field_width, oneline=False) confirm_caption = "Set"
]
confirm_caption = 'Set'
game_mode_visible = True game_mode_visible = True
def dismiss(self): def dismiss(self):
@ -111,28 +108,33 @@ class SetRoomCamDialog(UIDialog):
self.ui.app.gw.current_room.set_camera_marker_name(self.field_texts[0]) self.ui.app.gw.current_room.set_camera_marker_name(self.field_texts[0])
self.dismiss() self.dismiss()
class SetRoomEdgeWarpsDialog(UIDialog): class SetRoomEdgeWarpsDialog(UIDialog):
title = 'Set room edge warps' title = "Set room edge warps"
tile_width = 48 tile_width = 48
fields = 4 fields = 4
field0_label = 'Name of room/object to warp at LEFT edge:' field0_label = "Name of room/object to warp at LEFT edge:"
field1_label = 'Name of room/object to warp at RIGHT edge:' field1_label = "Name of room/object to warp at RIGHT edge:"
field2_label = 'Name of room/object to warp at TOP edge:' field2_label = "Name of room/object to warp at TOP edge:"
field3_label = 'Name of room/object to warp at BOTTOM edge:' field3_label = "Name of room/object to warp at BOTTOM edge:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [
Field(label=field0_label, type=str, width=field_width, oneline=False), 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),
Field(label=field2_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 game_mode_visible = True
def get_initial_field_text(self, field_number): def get_initial_field_text(self, field_number):
room = self.ui.app.gw.current_room room = self.ui.app.gw.current_room
names = {0: room.left_edge_warp_dest_name, 1: room.right_edge_warp_dest_name, names = {
2: room.top_edge_warp_dest_name, 3: room.bottom_edge_warp_dest_name} 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] return names[field_number]
def dismiss(self): def dismiss(self):
@ -148,14 +150,13 @@ class SetRoomEdgeWarpsDialog(UIDialog):
room.reset_edge_warps() room.reset_edge_warps()
self.dismiss() self.dismiss()
class SetRoomBoundsObjDialog(UIDialog): class SetRoomBoundsObjDialog(UIDialog):
title = 'Set room edge object' title = "Set room edge object"
field0_label = 'Name of object to use for room bounds:' field0_label = "Name of object to use for room bounds:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
Field(label=field0_label, type=str, width=field_width, oneline=False) confirm_caption = "Set"
]
confirm_caption = 'Set'
game_mode_visible = True game_mode_visible = True
def get_initial_field_text(self, field_number): def get_initial_field_text(self, field_number):
@ -172,27 +173,27 @@ class SetRoomBoundsObjDialog(UIDialog):
room.reset_edge_warps() room.reset_edge_warps()
self.dismiss() self.dismiss()
class RenameRoomDialog(UIDialog): class RenameRoomDialog(UIDialog):
title = 'Rename room' title = "Rename room"
field0_label = 'New name for current room:' field0_label = "New name for current room:"
field_width = UIDialog.default_field_width field_width = UIDialog.default_field_width
fields = [ fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
Field(label=field0_label, type=str, width=field_width, oneline=False) confirm_caption = "Rename"
]
confirm_caption = 'Rename'
game_mode_visible = True 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): def get_initial_field_text(self, field_number):
if field_number == 0: if field_number == 0:
return self.ui.app.gw.current_room.name return self.ui.app.gw.current_room.name
def is_input_valid(self): 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): def confirm_pressed(self):
valid, reason = self.is_input_valid() valid, reason = self.is_input_valid()
if not valid: return if not valid:
return
world = self.ui.app.gw world = self.ui.app.gw
world.rename_room(world.current_room, self.field_texts[0]) world.rename_room(world.current_room, self.field_texts[0])
self.dismiss() 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): class GameModePulldownMenuItem(PulldownMenuItem):
# unless overridden, game mode items not allowed in art mode # unless overridden, game mode items not allowed in art mode
@ -11,297 +19,405 @@ class GameModePulldownMenuItem(PulldownMenuItem):
# game menu # game menu
# #
class HideEditUIItem(GameModePulldownMenuItem): class HideEditUIItem(GameModePulldownMenuItem):
label = 'Hide edit UI' label = "Hide edit UI"
command = 'toggle_game_edit_ui' command = "toggle_game_edit_ui"
close_on_select = True close_on_select = True
always_active = True always_active = True
class NewGameDirItem(GameModePulldownMenuItem): class NewGameDirItem(GameModePulldownMenuItem):
label = 'New game…' label = "New game…"
command = 'new_game_dir' command = "new_game_dir"
always_active = True always_active = True
class SetGameDirItem(GameModePulldownMenuItem): class SetGameDirItem(GameModePulldownMenuItem):
label = 'Open game…' label = "Open game…"
command = 'set_game_dir' command = "set_game_dir"
close_on_select = True close_on_select = True
always_active = True always_active = True
class PauseGameItem(GameModePulldownMenuItem): class PauseGameItem(GameModePulldownMenuItem):
label = 'blah' label = "blah"
command = 'toggle_anim_playback' command = "toggle_anim_playback"
always_active = True always_active = True
def get_label(app): def get_label(app):
return ['Pause game', 'Unpause game'][app.gw.paused] return ["Pause game", "Unpause game"][app.gw.paused]
class OpenConsoleItem(GameModePulldownMenuItem): class OpenConsoleItem(GameModePulldownMenuItem):
label = 'Open dev console' label = "Open dev console"
command = 'toggle_console' command = "toggle_console"
close_on_select = True close_on_select = True
always_active = True always_active = True
art_mode_allowed = True art_mode_allowed = True
# #
# state menu # state menu
# #
class ResetStateItem(GameModePulldownMenuItem): class ResetStateItem(GameModePulldownMenuItem):
label = 'Reset to last state' label = "Reset to last state"
command = 'reset_game' command = "reset_game"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class LoadStateItem(GameModePulldownMenuItem): class LoadStateItem(GameModePulldownMenuItem):
label = 'Load state…' label = "Load state…"
command = 'load_game_state' command = "load_game_state"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class SaveStateItem(GameModePulldownMenuItem): class SaveStateItem(GameModePulldownMenuItem):
label = 'Save current state' label = "Save current state"
command = 'save_current' command = "save_current"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class SaveNewStateItem(GameModePulldownMenuItem): class SaveNewStateItem(GameModePulldownMenuItem):
label = 'Save new state…' label = "Save new state…"
command = 'save_game_state' command = "save_game_state"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
# #
# view menu # view menu
# #
class ObjectsToCameraItem(GameModePulldownMenuItem): class ObjectsToCameraItem(GameModePulldownMenuItem):
label = 'Move selected object(s) to camera' label = "Move selected object(s) to camera"
command = 'objects_to_camera' command = "objects_to_camera"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) == 0 return len(app.gw.selected_objects) == 0
class CameraToObjectsItem(GameModePulldownMenuItem): class CameraToObjectsItem(GameModePulldownMenuItem):
label = 'Move camera to selected object' label = "Move camera to selected object"
command = 'camera_to_objects' command = "camera_to_objects"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) != 1 return len(app.gw.selected_objects) != 1
class ToggleDebugObjectsItem(GameModePulldownMenuItem): class ToggleDebugObjectsItem(GameModePulldownMenuItem):
label = ' Draw debug objects' label = " Draw debug objects"
command = 'toggle_debug_objects' command = "toggle_debug_objects"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
def should_mark(ui): def should_mark(ui):
return ui.app.gw.properties and ui.app.gw.properties.draw_debug_objects return ui.app.gw.properties and ui.app.gw.properties.draw_debug_objects
class ToggleOriginVizItem(GameModePulldownMenuItem): class ToggleOriginVizItem(GameModePulldownMenuItem):
label = ' Show all object origins' label = " Show all object origins"
command = 'toggle_all_origin_viz' command = "toggle_all_origin_viz"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
def should_mark(ui): def should_mark(ui):
return ui.app.gw.show_origin_all return ui.app.gw.show_origin_all
class ToggleBoundsVizItem(GameModePulldownMenuItem): class ToggleBoundsVizItem(GameModePulldownMenuItem):
label = ' Show all object bounds' label = " Show all object bounds"
command = 'toggle_all_bounds_viz' command = "toggle_all_bounds_viz"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
def should_mark(ui): def should_mark(ui):
return ui.app.gw.show_bounds_all return ui.app.gw.show_bounds_all
class ToggleCollisionVizItem(GameModePulldownMenuItem): class ToggleCollisionVizItem(GameModePulldownMenuItem):
label = ' Show all object collision' label = " Show all object collision"
command = 'toggle_all_collision_viz' command = "toggle_all_collision_viz"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
def should_mark(ui): def should_mark(ui):
return ui.app.gw.show_collision_all return ui.app.gw.show_collision_all
# #
# world menu # world menu
# #
class EditWorldPropertiesItem(GameModePulldownMenuItem): class EditWorldPropertiesItem(GameModePulldownMenuItem):
label = 'Edit world properties…' label = "Edit world properties…"
command = 'edit_world_properties' command = "edit_world_properties"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
# #
# room menu # room menu
# #
class ChangeRoomItem(GameModePulldownMenuItem): class ChangeRoomItem(GameModePulldownMenuItem):
label = 'Change current room…' label = "Change current room…"
command = 'change_current_room' command = "change_current_room"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.rooms) == 0 return len(app.gw.rooms) == 0
class AddRoomItem(GameModePulldownMenuItem): class AddRoomItem(GameModePulldownMenuItem):
label = 'Add room…' label = "Add room…"
command = 'add_room' command = "add_room"
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class SetRoomObjectsItem(GameModePulldownMenuItem): class SetRoomObjectsItem(GameModePulldownMenuItem):
label = 'Add/remove objects from room…' label = "Add/remove objects from room…"
command = 'set_room_objects' command = "set_room_objects"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class RemoveRoomItem(GameModePulldownMenuItem): class RemoveRoomItem(GameModePulldownMenuItem):
label = 'Remove this room' label = "Remove this room"
command = 'remove_current_room' command = "remove_current_room"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class RenameRoomItem(GameModePulldownMenuItem): class RenameRoomItem(GameModePulldownMenuItem):
label = 'Rename this room…' label = "Rename this room…"
command = 'rename_current_room' command = "rename_current_room"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class ToggleAllRoomsVizItem(GameModePulldownMenuItem): class ToggleAllRoomsVizItem(GameModePulldownMenuItem):
label = 'blah' label = "blah"
command = 'toggle_all_rooms_visible' command = "toggle_all_rooms_visible"
def should_dim(app): def should_dim(app):
return len(app.gw.rooms) == 0 return len(app.gw.rooms) == 0
def get_label(app): 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): class ToggleListOnlyRoomObjectItem(GameModePulldownMenuItem):
label = ' List only objects in this room' label = " List only objects in this room"
command = 'toggle_list_only_room_objects' command = "toggle_list_only_room_objects"
def should_dim(app): def should_dim(app):
return len(app.gw.rooms) == 0 return len(app.gw.rooms) == 0
def should_mark(ui): def should_mark(ui):
return ui.app.gw.list_only_current_room_objects return ui.app.gw.list_only_current_room_objects
class ToggleRoomCamerasItem(GameModePulldownMenuItem): class ToggleRoomCamerasItem(GameModePulldownMenuItem):
label = ' Camera changes with room' label = " Camera changes with room"
command = 'toggle_room_camera_changes' command = "toggle_room_camera_changes"
def should_dim(app): def should_dim(app):
return len(app.gw.rooms) == 0 return len(app.gw.rooms) == 0
def should_mark(ui): def should_mark(ui):
return ui.app.gw.room_camera_changes_enabled return ui.app.gw.room_camera_changes_enabled
class SetRoomCameraItem(GameModePulldownMenuItem): class SetRoomCameraItem(GameModePulldownMenuItem):
label = "Set this room's camera marker…" label = "Set this room's camera marker…"
command = 'set_room_camera_marker' command = "set_room_camera_marker"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class SetRoomEdgeDestinationsItem(GameModePulldownMenuItem): class SetRoomEdgeDestinationsItem(GameModePulldownMenuItem):
label = "Set this room's edge warps…" label = "Set this room's edge warps…"
command = 'set_room_edge_warps' command = "set_room_edge_warps"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class SetRoomBoundsObject(GameModePulldownMenuItem): class SetRoomBoundsObject(GameModePulldownMenuItem):
label = "Set this room's edge object…" label = "Set this room's edge object…"
command = 'set_room_bounds_obj' command = "set_room_bounds_obj"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None return app.gw.current_room is None
class AddSelectedToCurrentRoomItem(GameModePulldownMenuItem): class AddSelectedToCurrentRoomItem(GameModePulldownMenuItem):
label = 'Add selected objects to this room' label = "Add selected objects to this room"
command = 'add_selected_to_room' command = "add_selected_to_room"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0 return app.gw.current_room is None or len(app.gw.selected_objects) == 0
class RemoveSelectedFromCurrentRoomItem(GameModePulldownMenuItem): class RemoveSelectedFromCurrentRoomItem(GameModePulldownMenuItem):
label = 'Remove selected objects from this room' label = "Remove selected objects from this room"
command = 'remove_selected_from_room' command = "remove_selected_from_room"
def should_dim(app): def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0 return app.gw.current_room is None or len(app.gw.selected_objects) == 0
# #
# object menu # object menu
# #
class SpawnObjectItem(GameModePulldownMenuItem): class SpawnObjectItem(GameModePulldownMenuItem):
label = 'Spawn object…' label = "Spawn object…"
command = 'choose_spawn_object_class' command = "choose_spawn_object_class"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class DuplicateObjectsItem(GameModePulldownMenuItem): class DuplicateObjectsItem(GameModePulldownMenuItem):
label = 'Duplicate selected objects' label = "Duplicate selected objects"
command = 'duplicate_selected_objects' command = "duplicate_selected_objects"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) == 0 return len(app.gw.selected_objects) == 0
class SelectObjectsItem(GameModePulldownMenuItem): class SelectObjectsItem(GameModePulldownMenuItem):
label = 'Select objects…' label = "Select objects…"
command = 'select_objects' command = "select_objects"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return not app.gw.game_dir return not app.gw.game_dir
class EditArtForObjectsItem(GameModePulldownMenuItem): class EditArtForObjectsItem(GameModePulldownMenuItem):
label = 'Edit art for selected…' label = "Edit art for selected…"
command = 'edit_art_for_selected_objects' command = "edit_art_for_selected_objects"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) == 0 return len(app.gw.selected_objects) == 0
class SetObjectRoomsItem(GameModePulldownMenuItem): class SetObjectRoomsItem(GameModePulldownMenuItem):
label = 'Add/remove this object from rooms…' label = "Add/remove this object from rooms…"
command = 'set_object_rooms' command = "set_object_rooms"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) != 1 return len(app.gw.selected_objects) != 1
class DeleteSelectedObjectsItem(GameModePulldownMenuItem): class DeleteSelectedObjectsItem(GameModePulldownMenuItem):
label = 'Delete selected object(s)' label = "Delete selected object(s)"
command = 'erase_selection_or_art' command = "erase_selection_or_art"
close_on_select = True close_on_select = True
def should_dim(app): def should_dim(app):
return len(app.gw.selected_objects) == 0 return len(app.gw.selected_objects) == 0
class GameMenuData(PulldownMenuData): class GameMenuData(PulldownMenuData):
items = [HideEditUIItem, OpenConsoleItem, SeparatorItem, items = [
NewGameDirItem, SetGameDirItem, PauseGameItem, SeparatorItem, HideEditUIItem,
FileQuitItem] OpenConsoleItem,
SeparatorItem,
NewGameDirItem,
SetGameDirItem,
PauseGameItem,
SeparatorItem,
FileQuitItem,
]
class GameStateMenuData(PulldownMenuData): class GameStateMenuData(PulldownMenuData):
items = [ResetStateItem, LoadStateItem, SaveStateItem, SaveNewStateItem] items = [ResetStateItem, LoadStateItem, SaveStateItem, SaveNewStateItem]
class GameViewMenuData(PulldownMenuData): class GameViewMenuData(PulldownMenuData):
items = [ViewToggleCRTItem, ViewToggleGridItem, SeparatorItem, items = [
ViewSetZoomItem, ViewToggleCameraTiltItem, SeparatorItem, ViewToggleCRTItem,
ObjectsToCameraItem, CameraToObjectsItem, ToggleDebugObjectsItem, ViewToggleGridItem,
ToggleOriginVizItem, ToggleBoundsVizItem, ToggleCollisionVizItem] SeparatorItem,
ViewSetZoomItem,
ViewToggleCameraTiltItem,
SeparatorItem,
ObjectsToCameraItem,
CameraToObjectsItem,
ToggleDebugObjectsItem,
ToggleOriginVizItem,
ToggleBoundsVizItem,
ToggleCollisionVizItem,
]
def should_mark_item(item, ui): def should_mark_item(item, ui):
if hasattr(item, 'should_mark'): if hasattr(item, "should_mark"):
return item.should_mark(ui) return item.should_mark(ui)
return False return False
class GameWorldMenuData(PulldownMenuData): class GameWorldMenuData(PulldownMenuData):
items = [EditWorldPropertiesItem] items = [EditWorldPropertiesItem]
class GameRoomMenuData(PulldownMenuData): class GameRoomMenuData(PulldownMenuData):
items = [ChangeRoomItem, AddRoomItem, RemoveRoomItem, RenameRoomItem, items = [
ToggleAllRoomsVizItem, ToggleListOnlyRoomObjectItem, ToggleRoomCamerasItem, SeparatorItem, ChangeRoomItem,
AddSelectedToCurrentRoomItem, RemoveSelectedFromCurrentRoomItem, AddRoomItem,
SetRoomObjectsItem, SeparatorItem, RemoveRoomItem,
SetRoomCameraItem, SetRoomEdgeDestinationsItem, SetRoomBoundsObject, RenameRoomItem,
SeparatorItem ToggleAllRoomsVizItem,
ToggleListOnlyRoomObjectItem,
ToggleRoomCamerasItem,
SeparatorItem,
AddSelectedToCurrentRoomItem,
RemoveSelectedFromCurrentRoomItem,
SetRoomObjectsItem,
SeparatorItem,
SetRoomCameraItem,
SetRoomEdgeDestinationsItem,
SetRoomBoundsObject,
SeparatorItem,
] ]
def should_mark_item(item, ui): def should_mark_item(item, ui):
"show checkmark for current room" "show checkmark for current room"
if not ui.app.gw.current_room: if not ui.app.gw.current_room:
return False return False
if hasattr(item, 'should_mark'): if hasattr(item, "should_mark"):
return item.should_mark(ui) return item.should_mark(ui)
return ui.app.gw.current_room.name == item.cb_arg return ui.app.gw.current_room.name == item.cb_arg
@ -320,18 +436,21 @@ class GameRoomMenuData(PulldownMenuData):
if len(item.label) + 1 > longest_line: if len(item.label) + 1 > longest_line:
longest_line = len(item.label) + 1 longest_line = len(item.label) + 1
# cap at max allowed line length # cap at max allowed line length
for room_name,room in app.gw.rooms.items(): for room_name in app.gw.rooms:
class TempMenuItemClass(GameModePulldownMenuItem): pass
class TempMenuItemClass(GameModePulldownMenuItem):
pass
item = TempMenuItemClass item = TempMenuItemClass
# leave spaces for mark # leave spaces for mark
item.label = ' %s' % room_name item.label = f" {room_name}"
# pad, put Z depth on far right # pad, put Z depth on far right
item.label = item.label.ljust(longest_line) item.label = item.label.ljust(longest_line)
# trim to keep below a max length # trim to keep below a max length
item.label = item.label[:longest_line] item.label = item.label[:longest_line]
# tell PulldownMenu's button creation process not to auto-pad # tell PulldownMenu's button creation process not to auto-pad
item.no_pad = True item.no_pad = True
item.command = 'change_current_room_to' item.command = "change_current_room_to"
item.cb_arg = room_name item.cb_arg = room_name
items.append(item) items.append(item)
# sort room list alphabetically so it's stable, if arbitrary # sort room list alphabetically so it's stable, if arbitrary
@ -340,6 +459,12 @@ class GameRoomMenuData(PulldownMenuData):
class GameObjectMenuData(PulldownMenuData): class GameObjectMenuData(PulldownMenuData):
items = [SpawnObjectItem, DuplicateObjectsItem, SeparatorItem, items = [
SelectObjectsItem, EditArtForObjectsItem, SetObjectRoomsItem, SpawnObjectItem,
DeleteSelectedObjectsItem] DuplicateObjectsItem,
SeparatorItem,
SelectObjectsItem,
EditArtForObjectsItem,
SetObjectRoomsItem,
DeleteSelectedObjectsItem,
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

49
pyproject.toml Normal file
View file

@ -0,0 +1,49 @@
[project]
name = "playscii"
version = "9.18"
description = "ascii art and game creation tool"
requires-python = ">=3.12"
dependencies = [
"appdirs",
"numpy",
"Pillow",
"PyOpenGL",
"PySDL2",
"packaging",
]
[dependency-groups]
dev = [
"ruff",
"pytest",
"ty",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
src = ["playscii"]
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [
"E402", # module-import-not-at-top (intentional for SDL2 init)
"E501", # line-too-long (formatter handles what it can)
"E741", # ambiguous-variable-name (common in math/game code)
"B006", # mutable-argument-default (legacy code, risky to change)
"SIM102", # collapsible-if (stylistic preference)
"SIM108", # if-else-block-instead-of-if-exp (stylistic preference)
"SIM110", # use-all-instead-of-for-loop (stylistic preference)
"SIM113", # enumerate-for-loop (stylistic preference)
"SIM115", # open-file-with-context-handler (too invasive for legacy code)
"SIM116", # if-else-block-instead-of-dict-lookup (stylistic preference)
"SIM201", # negate-equal-op (stylistic preference)
"SIM222", # expr-or-true (debug code)
"SIM223", # expr-and-false (debug code)
]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -1,5 +0,0 @@
appdirs==1.4.0
numpy==1.20.1
Pillow==8.1.2
PyOpenGL==3.1.5
PySDL2==0.9.7

307
uv.lock Normal file
View file

@ -0,0 +1,307 @@
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" },
{ name = "ty" },
]
[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" },
{ name = "ty" },
]
[[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" },
]
[[package]]
name = "ty"
version = "0.0.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637, upload-time = "2026-02-10T20:24:16.48Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870, upload-time = "2026-02-10T20:24:11.864Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286, upload-time = "2026-02-10T20:24:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660, upload-time = "2026-02-10T20:24:02.704Z" },
{ url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506, upload-time = "2026-02-10T20:24:35.818Z" },
{ url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099, upload-time = "2026-02-10T20:24:43.003Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370, upload-time = "2026-02-10T20:24:33.44Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160, upload-time = "2026-02-10T20:24:25.574Z" },
{ url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892, upload-time = "2026-02-10T20:24:05.914Z" },
{ url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916, upload-time = "2026-02-10T20:24:00.252Z" },
{ url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506, upload-time = "2026-02-10T20:24:28.133Z" },
{ url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486, upload-time = "2026-02-10T20:24:18.62Z" },
{ url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269, upload-time = "2026-02-10T20:24:38.249Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644, upload-time = "2026-02-10T20:24:40.565Z" },
{ url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579, upload-time = "2026-02-10T20:24:30.406Z" },
{ url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043, upload-time = "2026-02-10T20:23:57.51Z" },
{ url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122, upload-time = "2026-02-10T20:24:14.285Z" },
]