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:
## forked from jp labreton
* [http://jp.itch.io/playscii](http://jp.itch.io/playscii)
* [https://heptapod.host/jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii) - [jp.itch.io/playscii](https://jp.itch.io/playscii)
- [jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
Playscii's main website is here: - [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
The latest version of the HTML documentation resides here: sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0
[https://jplebreton.com/playscii/howto_main.html](https://jplebreton.com/playscii/howto_main.html) # mac
brew install sdl2 sdl2_mixer
## Bugs ```
If you run into any issues with Playscii, please report a bug here: then, with [uv](https://docs.astral.sh/uv/#installation) installed:
[https://heptapod.host/jp-lebreton/playscii/issues](https://heptapod.host/jp-lebreton/playscii/issues) ```sh
uv sync
## Roadmap just run
```
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)

19260
art/new.psci

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,37 @@
@echo off @echo off
set BUILD_EXE_PATH=dist\playscii.exe set BUILD_EXE_PATH=dist\playscii.exe
set OUTPUT_DIR=dist\playscii\ set OUTPUT_DIR=dist\playscii\
set ICON_PATH=ui\playscii.ico set ICON_PATH=ui\playscii.ico
set XCOPY_INCLUDE=win_xcopy_include set XCOPY_INCLUDE=win_xcopy_include
set XCOPY_EXCLUDE=win_xcopy_exclude set XCOPY_EXCLUDE=win_xcopy_exclude
set COPY_INCLUDE=win_copy_include set COPY_INCLUDE=win_copy_include
echo Creating new build... echo Creating new build...
REM ==== -F = everything in one file; -w = no console window; -i = path to icon REM ==== -F = everything in one file; -w = no console window; -i = path to icon
python -m PyInstaller -F -w -i %ICON_PATH% --exclude-module pdoc playscii.py python -m PyInstaller -F -w -i %ICON_PATH% --exclude-module pdoc playscii.py
echo Build done! echo Build done!
REM ==== move build so that ZIP will have a subdir enclosing everything REM ==== move build so that ZIP will have a subdir enclosing everything
mkdir %OUTPUT_DIR% mkdir %OUTPUT_DIR%
move %BUILD_EXE_PATH% %OUTPUT_DIR% move %BUILD_EXE_PATH% %OUTPUT_DIR%
echo ----------- echo -----------
echo Copying external files... echo Copying external files...
REM ==== xcopy dirs recursively REM ==== xcopy dirs recursively
for /f "tokens=*" %%i in (%XCOPY_INCLUDE%) DO ( for /f "tokens=*" %%i in (%XCOPY_INCLUDE%) DO (
echo %%i echo %%i
xcopy /E/Y "%%i" "%OUTPUT_DIR%\%%i" /exclude:%XCOPY_EXCLUDE% xcopy /E/Y "%%i" "%OUTPUT_DIR%\%%i" /exclude:%XCOPY_EXCLUDE%
) )
REM ==== regular copy files (non-recursively) REM ==== regular copy files (non-recursively)
for /f "tokens=*" %%i in (%COPY_INCLUDE%) DO ( for /f "tokens=*" %%i in (%COPY_INCLUDE%) DO (
echo %%i echo %%i
copy /Y "%%i" %OUTPUT_DIR% > NUL copy /Y "%%i" %OUTPUT_DIR% > NUL
) )
echo ----------- echo -----------
echo Done! echo Done!
pause pause

View file

@ -1,27 +1,27 @@
// Mattel Intellivision (built-in) // Mattel Intellivision (built-in)
intellivision.png intellivision.png
16, 14 16, 14
!"#$%&'()*+,-./ !"#$%&'()*+,-./
0123456789:;<=>? 0123456789:;<=>?
@ABCDEFGHIJKLMNO @ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^ PQRSTUVWXYZ[\]^
`abcdefghijklmno `abcdefghijklmno
pqrstuvwxyz{|}~ pqrstuvwxyz{|}~

View file

@ -1,18 +1,18 @@
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"
i = 0 i = 0
@ -22,28 +22,28 @@ Assumes 80 columns, DOS character set and EGA palette.
i += 1 i += 1
seq.append(data[i]) seq.append(data[i])
return seq return seq
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,25 +1,24 @@
# 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): class ConvertImageChooserDialog(ImageFileChooserDialog):
title = "Convert image"
title = 'Convert 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]
if not os.path.exists(filename) or not os.path.isfile(filename): if not os.path.exists(filename) or not os.path.isfile(filename):
@ -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): class ConvertImageOptionsDialog(ImportOptionsDialog):
title = "Convert bitmap image options"
title = 'Convert bitmap image options' field0_label = "Color palette:"
field0_label = 'Color palette:' field1_label = "Current palette (%s)"
field1_label = 'Current palette (%s)' field2_label = "From source image; # of colors:"
field2_label = 'From source image; # of colors:' field3_label = " "
field3_label = ' ' field5_label = "Converted art size:"
field5_label = 'Converted art size:' field6_label = "Best fit to current size (%s)"
field6_label = 'Best fit to current size (%s)' field7_label = "%% of source image: (%s)"
field7_label = '%% of source image: (%s)' field8_label = " "
field8_label = ' ' field10_label = "Smooth (bicubic) scale source image"
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,57 +57,61 @@ 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
def get_initial_field_text(self, field_number): def get_initial_field_text(self, field_number):
if field_number == 1: if field_number == 1:
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
@ -127,65 +126,79 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
width *= scale width *= scale
height *= scale height *= scale
return int(width), int(height) return int(width), int(height)
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.
""" """
file_chooser_dialog_class = ConvertImageChooserDialog file_chooser_dialog_class = ConvertImageChooserDialog
options_dialog_class = ConvertImageOptionsDialog options_dialog_class = ConvertImageOptionsDialog
completes_instantly = False completes_instantly = False
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,39 +18,38 @@ 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):
ConvertImageOptionsDialog.__init__(self, ui, options) ConvertImageOptionsDialog.__init__(self, ui, options)
self.active_field = 6 self.active_field = 6
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
@ -69,15 +70,15 @@ fg/bg color swaps. Suitable for plaintext export.
file_chooser_dialog_class = ConvertImageChooserDialog file_chooser_dialog_class = ConvertImageChooserDialog
options_dialog_class = TwoColorConvertImageOptionsDialog options_dialog_class = TwoColorConvertImageOptionsDialog
completes_instantly = False completes_instantly = False
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
@ -24,7 +24,7 @@ class ImageSequenceConverter:
# queue up first frame # queue up first frame
self.next_image(first=True) self.next_image(first=True)
self.init_success = True self.init_success = True
def next_image(self, first=False): def next_image(self, first=False):
# pop last image off stack # pop last image off stack
if not first: if not first:
@ -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:
@ -49,11 +48,11 @@ class ImageSequenceConverter:
self.image_filename = self.image_filenames[0] self.image_filename = self.image_filenames[0]
self.preview_sprite = self.current_frame_converter.preview_sprite self.preview_sprite = self.current_frame_converter.preview_sprite
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):
# create converter for new frame if current one is done, # create converter for new frame if current one is done,
# else update current one # else update current one
@ -61,53 +60,56 @@ class ImageSequenceConverter:
self.next_image() self.next_image()
else: else:
self.current_frame_converter.update() self.current_frame_converter.update()
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,76 +1,77 @@
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.
Current character set and palette will be used. 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,14 +1,14 @@
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.
Background colors can only be EGA colors 0-8. Background colors can only be EGA colors 0-8.
""" """
def run_import(self, in_filename, options={}): def run_import(self, in_filename, options={}):
""" """
from http://doomwiki.org/wiki/ENDOOM: from http://doomwiki.org/wiki/ENDOOM:
@ -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,15 +1,14 @@
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.
Current character set and palette will be used. Current character set and palette will be used.
""" """
def run_import(self, in_filename, options={}): def run_import(self, in_filename, options={}):
lines = open(in_filename).readlines() lines = open(in_filename).readlines()
# determine length of longest line # determine length of longest line

View file

@ -1,41 +1,41 @@
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):
self.outfile.write(data.encode(ENCODING)) self.outfile.write(data.encode(ENCODING))
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(
out_filename, self.app,
crt=options.get('crt', DEFAULT_CRT), self.app.ui.active_art,
scale=options.get('scale', DEFAULT_SCALE)) out_filename,
crt=options.get("crt", DEFAULT_CRT),
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,52 +91,60 @@ 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.
""" """
file_extension = FILE_EXTENSION file_extension = FILE_EXTENSION
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(
art.layer_names[layer], out_filename,
export_frames, export_layers, frame,
self.app.forbidden_filename_chars) art.layer_names[layer],
if not export_still_image(self.app, art, full_filename, export_frames,
crt=options.get('crt', DEFAULT_CRT), export_layers,
scale=options.get('scale', DEFAULT_SCALE)): self.app.forbidden_filename_chars,
)
if not export_still_image(
self.app,
art,
full_filename,
crt=options.get("crt", DEFAULT_CRT),
scale=options.get("scale", DEFAULT_SCALE),
):
success = False 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)
@ -24,34 +22,34 @@ class LineTester(GameObject):
art_width, art_height = 40, 40 art_width, art_height = 40, 40
art_off_pct_x, art_off_pct_y = 0.0, 0.0 art_off_pct_x, art_off_pct_y = 0.0, 0.0
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)
self.line.z = 0.0 self.line.z = 0.0
self.line.line_width = 3 self.line.line_width = 3
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
self.art.set_tile_at(0, 0, x, y, char, fg) self.art.set_tile_at(0, 0, x, y, char, fg)
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,45 +1,47 @@
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
handle_key_events = True handle_key_events = True
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)
# top-down facing # top-down facing
self.direction = DIR_NORTH self.direction = DIR_NORTH
self.maze.update_tile_visibilities() self.maze.update_tile_visibilities()
self.art.set_tile_at(0, 0, 0, 0, self.dir_chars[self.direction], self.fg_color) self.art.set_tile_at(0, 0, 0, 0, self.dir_chars[self.direction], self.fg_color)
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): 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,56 +1,61 @@
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
# first character we find with this index will be where we spawn player # first character we find with this index will be where we spawn player
playerstart_char_index = 147 playerstart_char_index = 147
# undiscovered = player has never seen this tile # undiscovered = player has never seen this tile
undiscovered_color_index = 1 # black undiscovered_color_index = 1 # black
# discovered = player has seen this tile but isn't currently looking at it # discovered = player has seen this tile but isn't currently looking at it
discovered_color_index = 12 # dark grey discovered_color_index = 12 # dark grey
def pre_first_update(self): def pre_first_update(self):
# 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
self.art.set_color_at(0, layer, x, y, self.undiscovered_color_index) self.art.set_color_at(0, layer, x, y, self.undiscovered_color_index)
self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why? self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why?
# keep a list of tiles player can see # keep a list of tiles player can see
self.player_visible_tiles = [] self.player_visible_tiles = []
def is_tile_solid(self, x, y): def is_tile_solid(self, x, y):
return self.art.get_char_index_at(0, 0, x, y) != 0 return self.art.get_char_index_at(0, 0, x, y) != 0
# world to tile: self.get_tile_at_point(world_x, world_y) # world to tile: self.get_tile_at_point(world_x, world_y)
# tile to world: self.get_tile_loc(tile_x, tile_y) # tile to world: self.get_tile_loc(tile_x, tile_y)
def get_visible_tiles(self, x, y, dir_x, dir_y, tile_range, see_thru_walls=False): def get_visible_tiles(self, x, y, dir_x, dir_y, tile_range, see_thru_walls=False):
"return tiles visible from given point facing given direction" "return tiles visible from given point facing given direction"
# NOTE: all the calculations here are done in this object's art's tile # NOTE: all the calculations here are done in this object's art's tile
@ -72,7 +77,7 @@ class CrawlTopDownView(GameObject):
# scan back of frustum tile by tile left to right, # scan back of frustum tile by tile left to right,
# checking each tile hit # checking each tile hit
scan_distance = 0 scan_distance = 0
scan_length = tile_range * 2 + 1 # TODO make sure this is correct scan_length = tile_range * 2 + 1 # TODO make sure this is correct
while scan_distance < scan_length: while scan_distance < scan_length:
scan_x = scan_start_x + (scan_dir_x * scan_distance) scan_x = scan_start_x + (scan_dir_x * scan_distance)
scan_y = scan_start_y + (scan_dir_y * scan_distance) scan_y = scan_start_y + (scan_dir_y * scan_distance)
@ -80,17 +85,21 @@ 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
scan_distance += 1 scan_distance += 1
return tiles return tiles
def update_tile_visibilities(self): def update_tile_visibilities(self):
""" """
update our art's tile visuals based on what tiles can be, can't be, update our art's tile visuals based on what tiles can be, can't be,
@ -99,25 +108,27 @@ class CrawlTopDownView(GameObject):
previously_visible_tiles = self.player_visible_tiles[:] 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,31 +28,30 @@ 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):
self.art.add_layer(z=0.01) self.art.add_layer(z=0.01)
self.target_particles = TARGET_PARTICLES_DEFAULT self.target_particles = TARGET_PARTICLES_DEFAULT
@ -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,9 +88,9 @@ 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):
# shift messages on layer 2 upward gradually # shift messages on layer 2 upward gradually
if self.app.frames % 10 == 0: if self.app.frames % 10 == 0:
@ -145,18 +144,22 @@ class Fireplace(GameObject):
self.art.set_tile_at(frame, layer, x, y, ch, fg - 1, bg - 1) 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)
self.particles.append(p) self.particles.append(p)
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)
x = randint(0, self.art.width - len(msg_text)) x = randint(0, self.art.width - len(msg_text))
@ -164,45 +167,44 @@ class Fireplace(GameObject):
y = randint(int(self.art.height / 2), self.art.height) y = randint(int(self.art.height / 2), self.art.height)
# write to second layer # write to second layer
self.art.write_string(0, 1, x, y, msg_text, randint(12, 16)) self.art.write_string(0, 1, x, y, msg_text, randint(12, 16))
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): 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):
# pick char and color here; Fireplace should just run sim # pick char and color here; Fireplace should just run sim
self.y = fp.art.height self.y = fp.art.height
# spawn at random point along bottom edge, within margin # spawn at random point along bottom edge, within margin
self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X) self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X)
# char here is not character index but density, which decays; # char here is not character index but density, which decays;
# fp.weighted_chars is used to look up actual index # fp.weighted_chars is used to look up actual index
self.char = randint(100, fp.art.charset.last_index - 1) self.char = randint(100, fp.art.charset.last_index - 1)
@ -214,7 +216,7 @@ class FireParticle:
self.bg = randint(0, len(fp.art.palette.colors) - 1) self.bg = randint(0, len(fp.art.palette.colors) - 1)
# hang on to fireplace # hang on to fireplace
self.fp = fp self.fp = fp
def update(self): def update(self):
# no need for out-of-range checks; fireplace will cull particles that # no need for out-of-range checks; fireplace will cull particles that
# reach the top of the screen # reach the top of the screen
@ -228,9 +230,9 @@ 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)
self.char += other.char self.char += other.char
@ -239,27 +241,26 @@ 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
def clicked(self, button, mouse_x, mouse_y): def clicked(self, button, mouse_x, mouse_y):
if self.visible: if self.visible:
webbrowser.open(MUSIC_URL) webbrowser.open(MUSIC_URL)
def hovered(self, mouse_x, mouse_y): def hovered(self, mouse_x, mouse_y):
# hilight text on hover # hilight text on hover
for frame, layer, x, y in TileIter(self.art): for frame, layer, x, y in TileIter(self.art):
self.art.set_color_at(frame, layer, x, y, 2) self.art.set_color_at(frame, layer, x, y, 2)
def unhovered(self, mouse_x, mouse_y): def unhovered(self, mouse_x, mouse_y):
for frame, layer, x, y in TileIter(self.art): for frame, layer, x, y in TileIter(self.art):
self.art.set_color_at(frame, layer, x, y, 16) self.art.set_color_at(frame, layer, x, y, 16)

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

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

View file

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

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

View file

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

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

View file

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

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,19 +14,18 @@ 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
art_width, art_height = FLOWER_WIDTH, FLOWER_HEIGHT art_width, art_height = FLOWER_WIDTH, FLOWER_HEIGHT
min_petals, max_petals = 0, 4 min_petals, max_petals = 0, 4
min_fronds, max_fronds = 0, 8 min_fronds, max_fronds = 0, 8
# every flower must have at least this many petals + fronds # every flower must have at least this many petals + fronds
minimum_complexity = 4 minimum_complexity = 4
# app updates per grow update; 1 = grow every frame # app updates per grow update; 1 = grow every frame
ticks_per_grow = 4 ticks_per_grow = 4
# DEBUG: if True, add current time to date seed as a decimal, # DEBUG: if True, add current time to date seed as a decimal,
# to test with highly specific values # to test with highly specific values
# (note: this turns the seed from an int into a float) # (note: this turns the seed from an int into a float)
@ -36,13 +33,12 @@ class FlowerObject(GameObject):
# DEBUG: if nonzero, use this seed for testing # DEBUG: if nonzero, use this seed for testing
debug_seed = 0 debug_seed = 0
debug_log = False debug_log = False
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)
# 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,21 +50,23 @@ 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
self.world.bg_color[2] = bg_color[2] / 255.0 self.world.bg_color[2] = bg_color[2] / 255.0
self.world.bg_color[3] = 1.0 # set here or alpha is zero? self.world.bg_color[3] = 1.0 # set here or alpha is zero?
self.art.resize(self.art_width, self.art_height) self.art.resize(self.art_width, self.art_height)
self.app.ui.adjust_for_art_resize(self) # grid etc self.app.ui.adjust_for_art_resize(self) # grid etc
self.art.clear_frame_layer(0, 0, bg_color=self.bg_index) 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,29 +76,34 @@ 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.art.charset.name, self.exportable_art = self.app.new_art(
self.art.palette.name) self.export_filename,
self.art_width,
self.art_height,
self.art.charset.name,
self.art.palette.name,
)
# re-set art's filename to be in documents dir rather than game dir :/ # 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
r = TileRenderable(self.app, self.exportable_art) r = TileRenderable(self.app, self.exportable_art)
def update(self): def update(self):
GameObject.update(self) GameObject.update(self)
# grow only every few ticks, so you can watch the design grow # grow only every few ticks, so you can watch the design grow
@ -108,10 +111,10 @@ class FlowerObject(GameObject):
return return
if not self.finished_growing: if not self.finished_growing:
self.update_growth() self.update_growth()
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,8 +139,8 @@ 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
if x > (self.art_width / 2) - 1 or y > (self.art_height / 2) - 1: if x > (self.art_width / 2) - 1 or y > (self.art_height / 2) - 1:
@ -148,13 +151,12 @@ 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
# (art starts with 1 frame, only do this after first frame written) # (art starts with 1 frame, only do this after first frame written)

View file

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

View file

@ -1,33 +1,41 @@
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
layer = 0 layer = 0
debug = False debug = False
def __init__(self, flower, index): def __init__(self, flower, index):
self.flower = flower self.flower = flower
self.index = index self.index = index
@ -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
@ -48,14 +60,16 @@ class Petal:
self.color = self.ramp.color self.color = self.ramp.color
# random char from predefined list # random char from predefined list
self.char = random.choice(PETAL_CHARS) self.char = random.choice(PETAL_CHARS)
def grow(self): def grow(self):
# grow outward (up and left) from center in "rings" # grow outward (up and left) from center in "rings"
if self.radius >= self.goal_radius: if self.radius >= self.goal_radius:
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
@ -63,19 +77,20 @@ class Petal:
# roll against chaos to mutate character # roll against chaos to mutate character
if random.random() < self.chaos * self.mutate_char_chance: if random.random() < self.chaos * self.mutate_char_chance:
self.char = random.choice(PETAL_CHARS) self.char = random.choice(PETAL_CHARS)
def paint_ring(self): def paint_ring(self):
tiles = self.get_ring_tiles() tiles = self.get_ring_tiles()
for t in tiles: for t in tiles:
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 = []
for x in range(self.radius + 1): for x in range(self.radius + 1):
@ -83,7 +98,7 @@ class Petal:
for y in range(self.radius + 1): for y in range(self.radius + 1):
tiles.append((self.radius, y)) tiles.append((self.radius, y))
return tiles return tiles
def get_ring_tiles_dealieX(self): def get_ring_tiles_dealieX(self):
# not sure what to call this but it's a nice shape # not sure what to call this but it's a nice shape
tiles = [] tiles = []
@ -91,7 +106,7 @@ class Petal:
for x in range(self.radius): for x in range(self.radius):
tiles.append((x - self.radius, y - self.radius)) tiles.append((x - self.radius, y - self.radius))
return tiles return tiles
def get_ring_tiles_wings(self): def get_ring_tiles_wings(self):
# not sure what to call this but it's a nice shape # not sure what to call this but it's a nice shape
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):
@ -111,15 +125,15 @@ class Petal:
if x + y == self.radius: if x + y == self.radius:
tiles.append((x, y)) tiles.append((x, y))
return tiles return tiles
def get_ring_tiles_circle(self): def get_ring_tiles_circle(self):
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,53 +1,52 @@
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
(33, 16, 1), # red to black (33, 16, 1), # red to black
(49, 8, 1), # white to red (49, 8, 1), # white to red
(57, 8, 1), # light orange to dark orange (57, 8, 1), # light orange to dark orange
(65, 16, 1), # light yellow to ~black (65, 16, 1), # light yellow to ~black
(81, 8, 1), # light green to green (81, 8, 1), # light green to green
(89, 24, 1), # white to green to ~black (89, 24, 1), # white to green to ~black
(113, 16, 1), # light cyan to ~black (113, 16, 1), # light cyan to ~black
(129, 8, 1), # light blue to blue (129, 8, 1), # light blue to blue
(137, 24, 1), # white to blue to ~black (137, 24, 1), # white to blue to ~black
(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
(95, 14, 1), # bright green to ~black (95, 14, 1), # bright green to ~black
(109, 12, 1), # light tan to dark tan (109, 12, 1), # light tan to dark tan
(126, 4, 1), # olive drab (126, 4, 1), # olive drab
(130, 7, 1), # light gold to gold brown (130, 7, 1), # light gold to gold brown
(137, 18, 1), # white to dark red (137, 18, 1), # white to dark red
(155, 14, 1), # white to dark blue (155, 14, 1), # white to dark blue
(169, 11, 1), # white to orange (169, 11, 1), # white to orange
(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
(63, 15, -1), # olive to black (63, 15, -1), # olive to black
(79, 16, -1), # red to black (79, 16, -1), # red to black
(92, 13, -1), # orange to ~black (92, 13, -1), # orange to ~black
(108, 16, -1), # yellow to orange to ~black (108, 16, -1), # yellow to orange to ~black
(124, 16, -1), # pale flesh to ~black (124, 16, -1), # pale flesh to ~black
(125, 16, 1), # light purple to ~black (125, 16, 1), # light purple to ~black
(141, 13, 1), # purpleish pink to ~black (141, 13, 1), # purpleish pink to ~black
(154, 15, 1), # light tan to ~black (154, 15, 1), # light tan to ~black
@ -57,48 +56,47 @@ 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
(94, 29, -1), # white to dark brown (94, 29, -1), # white to dark brown
(110, 16, -1), # light tan to brown (110, 16, -1), # light tan to brown
(136, 26, -1), # light yellow to dark golden brown (136, 26, -1), # light yellow to dark golden brown
(144, 8, -1), # yellow to orange (144, 8, -1), # yellow to orange
(160, 16, -1), # red to dark red (160, 16, -1), # red to dark red
(168, 8, -1), # white to pink (168, 8, -1), # white to pink
(176, 8, -1), # light magenta to dark magenta (176, 8, -1), # light magenta to dark magenta
(184, 8, -1), # white to purple (184, 8, -1), # white to purple
(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": [
(113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown
(116, 8, -16), # peach to burnt orange
(117, 8, -16), # pink to red
(118, 8, -16), # magenta to dark magenta
(119, 8, -16), # purple to dark purple
(120, 8, -16), # violet to dark violet
(121, 8, -16), # light blue to dark blue
(122, 8, -16), # light cobalt to dark cobalt
(123, 8, -16), # light teal to dark teal
(124, 8, -16), # light sea green to dark sea green
(125, 8, -16), # light green to dark green
(126, 8, -16), # yellow green to dark yellow green
(127, 8, -16), # pale yellow to dark olive
(128, 8, -16), # gold to golden brown
], ],
'atari': [
(113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown
(116, 8, -16), # peach to burnt orange
(117, 8, -16), # pink to red
(118, 8, -16), # magenta to dark magenta
(119, 8, -16), # purple to dark purple
(120, 8, -16), # violet to dark violet
(121, 8, -16), # light blue to dark blue
(122, 8, -16), # light cobalt to dark cobalt
(123, 8, -16), # light teal to dark teal
(124, 8, -16), # light sea green to dark sea green
(125, 8, -16), # light green to dark green
(126, 8, -16), # yellow green to dark yellow green
(127, 8, -16), # pale yellow to dark olive
(128, 8, -16) # gold to golden brown
]
} }
class RampIterator: 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
@ -106,7 +104,7 @@ class RampIterator:
# determine starting color, somewhere along ramp # determine starting color, somewhere along ramp
self.start_step = random.randint(0, self.length - 1) self.start_step = random.randint(0, self.length - 1)
self.color = self.start + (self.start_step * self.stride) self.color = self.start + (self.start_step * self.stride)
def go_to_next_color(self): def go_to_next_color(self):
self.color += self.stride self.color += self.stride
return self.color return self.color

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

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

View file

@ -1,21 +1,21 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2014-2022 JP LeBreton Copyright (c) 2014-2022 JP LeBreton
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.

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

View file

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

View file

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

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,35 +31,35 @@ 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
far_z = 100000 far_z = 100000
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.reset() self.reset()
self.max_pan_speed = self.base_max_pan_speed self.max_pan_speed = self.base_max_pan_speed
def reset(self): def reset(self):
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
self.calc_projection_matrix() self.calc_projection_matrix()
self.calc_view_matrix() self.calc_view_matrix()
def calc_projection_matrix(self): def calc_projection_matrix(self):
self.projection_matrix = self.get_perspective_matrix() self.projection_matrix = self.get_perspective_matrix()
def calc_view_matrix(self): def calc_view_matrix(self):
eye = vector.Vec3(self.x, self.y, self.z) eye = vector.Vec3(self.x, self.y, self.z)
up = vector.Vec3(0, 1, 0) up = vector.Vec3(0, 1, 0)
@ -65,42 +68,37 @@ 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.y, upward.y, -forward.y, 0], [side.x, upward.x, -forward.x, 0],
[side.z, upward.z, -forward.z, 0], [side.y, upward.y, -forward.y, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]] [side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1],
]
self.view_matrix = np.array(m, dtype=np.float32) self.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
def get_perspective_matrix(self): def get_perspective_matrix(self):
zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z) zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
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):
width, height = width or self.app.window_width, height or self.app.window_height width, height = width or self.app.window_width, height or self.app.window_height
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):
# modify pan speed based on zoom according to a factor # modify pan speed based on zoom according to a factor
m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
@ -109,7 +107,7 @@ class Camera:
# for brevity, app passes in whether user appears to be keyboard editing # for brevity, app passes in whether user appears to be keyboard editing
if keyboard: if keyboard:
self.app.keyboard_editing = True self.app.keyboard_editing = True
def zoom(self, dz, keyboard=False, towards_cursor=False): def zoom(self, dz, keyboard=False, towards_cursor=False):
self.vel_z += dz * self.zoom_accel self.vel_z += dz * self.zoom_accel
# pan towards cursor while zooming? # pan towards cursor while zooming?
@ -119,11 +117,11 @@ class Camera:
self.pan(dx, dy, keyboard) self.pan(dx, dy, keyboard)
if keyboard: if keyboard:
self.app.keyboard_editing = True self.app.keyboard_editing = True
def get_current_zoom_pct(self): def get_current_zoom_pct(self):
"returns % of base (1:1) for current camera" "returns % of base (1:1) for current camera"
return (self.get_base_zoom() / self.z) * 100 return (self.get_base_zoom() / self.z) * 100
def get_base_zoom(self): def get_base_zoom(self):
"returns camera Z needed for 1:1 pixel zoom" "returns camera Z needed for 1:1 pixel zoom"
wh = self.app.window_height wh = self.app.window_height
@ -132,10 +130,10 @@ class Camera:
if ch == 8: if ch == 8:
ch = 16 ch = 16
return wh / ch return wh / ch
def set_to_base_zoom(self): def set_to_base_zoom(self):
self.z = self.get_base_zoom() self.z = self.get_base_zoom()
def zoom_proportional(self, direction): def zoom_proportional(self, direction):
"zooms in or out via increments of 1:1 pixel scales for active art" "zooms in or out via increments of 1:1 pixel scales for active art"
if not self.app.ui.active_art: if not self.app.ui.active_art:
@ -167,7 +165,7 @@ class Camera:
break break
# kill all Z velocity for camera so we don't drift out of 1:1 # kill all Z velocity for camera so we don't drift out of 1:1
self.vel_z = 0 self.vel_z = 0
def find_closest_zoom_extents(self): def find_closest_zoom_extents(self):
def corners_on_screen(): def corners_on_screen():
art = self.app.ui.active_art art = self.app.ui.active_art
@ -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
@ -192,16 +190,24 @@ class Camera:
self.zoom_proportional(-1) self.zoom_proportional(-1)
self.calc_view_matrix() self.calc_view_matrix()
tries += 1 tries += 1
def toggle_zoom_extents(self, override=None): def toggle_zoom_extents(self, override=None):
art = self.app.ui.active_art art = self.app.ui.active_art
if override is not None: if override is not None:
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
@ -209,27 +215,27 @@ class Camera:
# kill all camera velocity when snapping # kill all camera velocity when snapping
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0 self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
art.camera_zoomed_extents = not art.camera_zoomed_extents art.camera_zoomed_extents = not art.camera_zoomed_extents
def window_resized(self): def window_resized(self):
self.calc_projection_matrix() self.calc_projection_matrix()
def set_zoom(self, z): def set_zoom(self, z):
# TODO: set lerp target, clear if keyboard etc call zoom() # TODO: set lerp target, clear if keyboard etc call zoom()
self.z = z self.z = z
def set_loc(self, x, y, z): def set_loc(self, x, y, z):
self.x, self.y, self.z = x, y, (z or self.z) # z optional self.x, self.y, self.z = x, y, (z or self.z) # z optional
def set_loc_from_obj(self, game_object): def set_loc_from_obj(self, game_object):
self.set_loc(game_object.x, game_object.y, game_object.z) self.set_loc(game_object.x, game_object.y, game_object.z)
def set_for_art(self, art): def set_for_art(self, art):
# set limits # set limits
self.max_x = art.width * art.quad_width self.max_x = art.width * art.quad_width
self.min_y = -art.height * art.quad_height self.min_y = -art.height * art.quad_height
# use saved pan/zoom # use saved pan/zoom
self.set_loc(art.camera_x, art.camera_y, art.camera_z) self.set_loc(art.camera_x, art.camera_y, art.camera_z)
def mouse_pan(self, dx, dy): def mouse_pan(self, dx, dy):
"pan view based on mouse delta" "pan view based on mouse delta"
if dx == 0 and dy == 0: if dx == 0 and dy == 0:
@ -240,12 +246,13 @@ class Camera:
self.y += dy / self.mouse_pan_rate * m self.y += dy / self.mouse_pan_rate * m
self.vel_x = self.vel_y = 0 self.vel_x = self.vel_y = 0
self.mouse_panned = True self.mouse_panned = True
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
@ -269,7 +276,7 @@ class Camera:
self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed) self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
# apply friction # apply friction
self.vel_x *= 1 - self.pan_friction self.vel_x *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction self.vel_y *= 1 - self.pan_friction
if abs(self.vel_x) < self.min_velocity: if abs(self.vel_x) < self.min_velocity:
self.vel_x = 0 self.vel_x = 0
if abs(self.vel_y) < self.min_velocity: if abs(self.vel_y) < self.min_velocity:
@ -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,51 +1,62 @@
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
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
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
@ -121,11 +134,11 @@ class CharacterSet:
# store base filename for easy comparisons with not-yet-loaded sets # store base filename for easy comparisons with not-yet-loaded sets
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0] self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
return True return True
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
@ -143,25 +156,32 @@ class CharacterSet:
# flip image data back and save it for later, eg image conversion # flip image data back and save it for later, eg image conversion
img = img.transpose(Image.FLIP_TOP_BOTTOM) img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_data = img self.image_data = img
def set_char_dimensions(self): def set_char_dimensions(self):
# store character dimensions and UV size # store character dimensions and UV size
self.char_width = int(self.image_width / self.map_width) self.char_width = int(self.image_width / self.map_width)
self.char_height = int(self.image_height / self.map_height) self.char_height = int(self.image_height / self.map_height)
self.u_width = self.char_width / self.image_width self.u_width = self.char_width / self.image_width
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
@ -170,10 +190,10 @@ class CharacterSet:
if img_changed: if img_changed:
self.last_image_change = time.time() self.last_image_change = time.time()
return data_changed or img_changed return data_changed or img_changed
def get_char_index(self, char): def get_char_index(self, char):
return self.char_mapping.get(char, 0) return self.char_mapping.get(char, 0)
def get_solid_pixels_in_char(self, char_index): def get_solid_pixels_in_char(self, char_index):
"Returns # of solid pixels in character at given index" "Returns # of solid pixels in character at given index"
tile_x = int(char_index % self.map_width) tile_x = int(char_index % self.map_width)

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,11 +61,11 @@ 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)
def resolve_overlap(self, overlap): def resolve_overlap(self, overlap):
"Resolve this shape's given overlap." "Resolve this shape's given overlap."
other = overlap.other other = overlap.other
@ -97,7 +101,7 @@ class CollisionShape:
world.try_object_method(self.go, self.go.started_colliding, [other.go]) world.try_object_method(self.go, self.go.started_colliding, [other.go])
if b_started_a: if b_started_a:
world.try_object_method(other.go, other.go.started_colliding, [self.go]) world.try_object_method(other.go, other.go.started_colliding, [self.go])
def get_overlapping_static_shapes(self): def get_overlapping_static_shapes(self):
"Return a list of static shapes that overlap with this shape." "Return a list of static shapes that overlap with this shape."
overlapping_shapes = [] overlapping_shapes = []
@ -118,80 +122,118 @@ 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
self.go = game_object self.go = game_object
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."
return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2) return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2)
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
self.go = game_object self.go = game_object
# for CST_TILE objects, lists of tile(s) we cover # for CST_TILE objects, lists of tile(s) we cover
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."
return point_in_box(x, y, *self.get_box()) return point_in_box(x, y, *self.get_box())
def overlaps_line(self, x1, y1, x2, y2): def overlaps_line(self, x1, y1, x2, y2):
"Return True if this box overlaps given line segment." "Return True if this box overlaps given line segment."
left, top, right, bottom = self.get_box() left, top, right, bottom = self.get_box()
return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2) return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2)
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
@ -212,7 +256,7 @@ class Collideable:
self.contacts = {} self.contacts = {}
"Dict of contacts with other objects, by object name" "Dict of contacts with other objects, by object name"
self.create_shapes() self.create_shapes()
def create_shapes(self): def create_shapes(self):
""" """
Create collision shape(s) appropriate to our game object's Create collision shape(s) appropriate to our game object's
@ -231,7 +275,7 @@ class Collideable:
# update renderables once if static # update renderables once if static
if not self.go.is_dynamic(): if not self.go.is_dynamic():
self.update_renderables() self.update_renderables()
def _clear_shapes(self): def _clear_shapes(self):
for r in self.renderables: for r in self.renderables:
r.destroy() r.destroy()
@ -240,41 +284,48 @@ class Collideable:
self.cl._remove_shape(shape) self.cl._remove_shape(shape)
self.shapes = [] self.shapes = []
"List of CollisionShapes" "List of CollisionShapes"
def _create_circle(self): def _create_circle(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_circle_shape(x, y, self.go.col_radius, self.go) shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go)
self.shapes = [shape] self.shapes = [shape]
self.renderables = [CircleCollisionRenderable(shape)] self.renderables = [CircleCollisionRenderable(shape)]
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)]
def _create_merged_tile_boxes(self): def _create_merged_tile_boxes(self):
"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):
@ -312,26 +364,26 @@ class Collideable:
r.update() r.update()
self.shapes.append(shape) self.shapes.append(shape)
self.renderables.append(r) self.renderables.append(r)
def get_shape_overlapping_point(self, x, y): def get_shape_overlapping_point(self, x, y):
"Return shape if it's overlapping given point, None if no overlap." "Return shape if it's overlapping given point, None if no overlap."
tile_x, tile_y = self.go.get_tile_at_point(x, y) tile_x, tile_y = self.go.get_tile_at_point(x, y)
return self.tile_shapes.get((tile_x, tile_y), None) return self.tile_shapes.get((tile_x, tile_y), None)
def get_shapes_overlapping_box(self, left, top, right, bottom): def get_shapes_overlapping_box(self, left, top, right, bottom):
"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
def update(self): def update(self):
if self.go and self.go.is_dynamic(): if self.go and self.go.is_dynamic():
self.update_transform_from_object() self.update_transform_from_object()
def update_transform_from_object(self, obj=None): def update_transform_from_object(self, obj=None):
"Snap our shapes to location of given object (if unspecified, our GO)." "Snap our shapes to location of given object (if unspecified, our GO)."
obj = obj or self.go obj = obj or self.go
@ -341,7 +393,7 @@ class Collideable:
for shape in self.shapes: for shape in self.shapes:
shape.x = obj.x + obj.col_offset_x shape.x = obj.x + obj.col_offset_x
shape.y = obj.y + obj.col_offset_y shape.y = obj.y + obj.col_offset_y
def set_shape_color(self, shape, new_color): def set_shape_color(self, shape, new_color):
"Set the color of a given shape's debug LineRenderable." "Set the color of a given shape's debug LineRenderable."
try: try:
@ -351,15 +403,15 @@ class Collideable:
self.renderables[shape_index].color = new_color self.renderables[shape_index].color = new_color
self.renderables[shape_index].build_geo() self.renderables[shape_index].build_geo()
self.renderables[shape_index].rebind_buffers() self.renderables[shape_index].rebind_buffers()
def update_renderables(self): def update_renderables(self):
for r in self.renderables: for r in self.renderables:
r.update() r.update()
def render(self): def render(self):
for r in self.renderables: for r in self.renderables:
r.render() r.render()
def destroy(self): def destroy(self):
for r in self.renderables: for r in self.renderables:
r.destroy() r.destroy()
@ -373,26 +425,28 @@ 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
# list of objects processed for collision this frame # list of objects processed for collision this frame
self.collisions_this_frame = [] self.collisions_this_frame = []
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 = [], []
def _add_circle_shape(self, x, y, radius, game_object): def _add_circle_shape(self, x, y, radius, game_object):
shape = CircleCollisionShape(x, y, radius, game_object) shape = CircleCollisionShape(x, y, radius, game_object)
if game_object.is_dynamic(): if game_object.is_dynamic():
@ -400,7 +454,7 @@ class CollisionLord:
else: else:
self.static_shapes.append(shape) self.static_shapes.append(shape)
return shape return shape
def _add_box_shape(self, x, y, halfwidth, halfheight, game_object): def _add_box_shape(self, x, y, halfwidth, halfheight, game_object):
shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object) shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object)
if game_object.is_dynamic(): if game_object.is_dynamic():
@ -408,16 +462,16 @@ class CollisionLord:
else: else:
self.static_shapes.append(shape) self.static_shapes.append(shape)
return shape return shape
def _remove_shape(self, shape): def _remove_shape(self, shape):
if shape in self.dynamic_shapes: if shape in self.dynamic_shapes:
self.dynamic_shapes.remove(shape) self.dynamic_shapes.remove(shape)
elif shape in self.static_shapes: elif shape in self.static_shapes:
self.static_shapes.remove(shape) self.static_shapes.remove(shape)
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,14 +56,14 @@ BASE_COLOR = (0.8, 0.8, 0.8, 1)
# because a static vertex list wouldn't be able to adjust to different # because a static vertex list wouldn't be able to adjust to different
# character set aspect ratios. # character set aspect ratios.
class Cursor: class Cursor:
vert_shader_source = "cursor_v.glsl"
vert_shader_source = 'cursor_v.glsl' frag_shader_source = "cursor_f.glsl"
frag_shader_source = 'cursor_f.glsl'
alpha = 1 alpha = 1
icon_scale_factor = 4 icon_scale_factor = 4
logg = False logg = False
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.x, self.y, self.z = 0, 0, 0 self.x, self.y, self.z = 0, 0, 0
@ -92,29 +85,40 @@ class Cursor:
self.elem_array = np.array(corner_elems, dtype=np.uint32) self.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)
@ -122,11 +126,11 @@ class Cursor:
GL.glBindVertexArray(0) GL.glBindVertexArray(0)
# init tool sprite, tool will provide texture when rendered # init tool sprite, tool will provide texture when rendered
self.tool_sprite = UISpriteRenderable(self.app) self.tool_sprite = UISpriteRenderable(self.app)
def clamp_to_active_art(self): def clamp_to_active_art(self):
self.x = max(0, min(self.x, self.app.ui.active_art.width - 1)) self.x = max(0, min(self.x, self.app.ui.active_art.width - 1))
self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1)) self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1))
def keyboard_move(self, delta_x, delta_y): def keyboard_move(self, delta_x, delta_y):
if not self.app.ui.active_art: if not self.app.ui.active_art:
return return
@ -136,11 +140,13 @@ 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
def get_tile(self): def get_tile(self):
# adjust for brush size # adjust for brush size
size = self.app.ui.selected_tool.brush_size size = self.app.ui.selected_tool.brush_size
@ -149,7 +155,7 @@ class Cursor:
return int(self.x + size_offset), int(-self.y + size_offset) return int(self.x + size_offset), int(-self.y + size_offset)
else: else:
return int(self.x), int(-self.y) return int(self.x), int(-self.y)
def center_in_art(self): def center_in_art(self):
art = self.app.ui.active_art art = self.app.ui.active_art
if not art: if not art:
@ -157,20 +163,20 @@ class Cursor:
self.x = round(art.width / 2) * art.quad_width self.x = round(art.width / 2) * art.quad_width
self.y = round(-art.height / 2) * art.quad_height self.y = round(-art.height / 2) * art.quad_height
self.moved = True self.moved = True
# !!TODO!! finish this, work in progress # !!TODO!! finish this, work in progress
def get_tiles_under_drag(self): def get_tiles_under_drag(self):
""" """
returns list of tuple coordinates of all tiles under cursor's current returns list of tuple coordinates of all tiles under cursor's current
position AND tiles it's moved over since last update position AND tiles it's moved over since last update
""" """
# TODO: get vector of last to current position, for each tile under # TODO: get vector of last to current position, for each tile under
# current brush, do line trace along grid towards last point # current brush, do line trace along grid towards last point
# TODO: this works in two out of four diagonals, # TODO: this works in two out of four diagonals,
# swap current and last positions to determine delta? # swap current and last positions to determine delta?
if self.last_x <= self.x: if self.last_x <= self.x:
x0, y0 = self.last_x, -self.last_y x0, y0 = self.last_x, -self.last_y
x1, y1 = self.x, -self.y x1, y1 = self.x, -self.y
@ -178,10 +184,10 @@ 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
def get_tiles_under_brush(self): def get_tiles_under_brush(self):
""" """
returns list of tuple coordinates of all tiles under the cursor @ its returns list of tuple coordinates of all tiles under the cursor @ its
@ -194,11 +200,11 @@ class Cursor:
for x in range(x_start, x_start + size): for x in range(x_start, x_start + size):
tiles.append((x, y)) tiles.append((x, y))
return tiles return tiles
def undo_preview_edits(self): def undo_preview_edits(self):
for edit in self.preview_edits: for edit in self.preview_edits:
edit.undo() edit.undo()
def update_cursor_preview(self): def update_cursor_preview(self):
# rebuild list of cursor preview commands # rebuild list of cursor preview commands
if self.app.ui.selected_tool.show_preview: if self.app.ui.selected_tool.show_preview:
@ -207,9 +213,12 @@ class Cursor:
edit.apply() edit.apply()
else: else:
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,25 +246,27 @@ 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
char_aspect = w / h char_aspect = w / h
# round result for oddly proportioned charsets # round result for oddly proportioned charsets
self.x = round(math.floor(self.x / w) * w) self.x = round(math.floor(self.x / w) * w)
self.y = round(math.ceil(self.y / h) * h * char_aspect) self.y = round(math.ceil(self.y / h) * h * char_aspect)
def pre_first_update(self): def pre_first_update(self):
# vector.screen_to_world result will be off because camera hasn't # vector.screen_to_world result will be off because camera hasn't
# moved yet, recalc view matrix # moved yet, recalc view matrix
@ -261,16 +275,18 @@ class Cursor:
self.snap_to_tile() self.snap_to_tile()
self.update_cursor_preview() self.update_cursor_preview()
self.entered_new_tile() self.entered_new_tile()
def update(self): def update(self):
# save old positions before update # save old positions before update
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()
@ -297,36 +313,50 @@ class Cursor:
self.update_cursor_preview() self.update_cursor_preview()
if self.moved_this_frame(): if self.moved_this_frame():
self.entered_new_tile() self.entered_new_tile()
def end_update(self): def end_update(self):
"called at the end of App.update" "called at the end of App.update"
self.moved = False self.moved = False
def entered_new_tile(self): def entered_new_tile(self):
if self.current_command and self.app.ui.selected_tool.paint_while_dragging: if self.current_command and self.app.ui.selected_tool.paint_while_dragging:
# add new tile(s) to current command group # add new tile(s) to current command group
self.current_command.add_command_tiles(self.preview_edits) self.current_command.add_command_tiles(self.preview_edits)
self.app.ui.active_art.set_unsaved_changes(True) self.app.ui.active_art.set_unsaved_changes(True)
self.preview_edits = [] self.preview_edits = []
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:
GL.glBindVertexArray(self.vao) GL.glBindVertexArray(self.vao)
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,9 +1,6 @@
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):
self.art = art self.art = art
self.start_time = art.app.get_elapsed_time() self.start_time = art.app.get_elapsed_time()
@ -12,57 +9,57 @@ class EditCommand:
# this prevents multiple commands operating on the same tile # this prevents multiple commands operating on the same tile
# from stomping each other # from stomping each other
self.tile_commands = {} self.tile_commands = {}
def get_number_of_commands(self): def get_number_of_commands(self):
commands = 0 commands = 0
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):
# no commands at all yet, maybe # no commands at all yet, maybe
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()
def undo(self): def undo(self):
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_command in column.values(): for tile_command in column.values():
tile_command.undo() tile_command.undo()
def apply(self): def apply(self):
for frame in self.tile_commands.values(): for frame in self.tile_commands.values():
for layer in frame.values(): for layer in frame.values():
@ -72,15 +69,14 @@ 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
# remember origin of resize command # remember origin of resize command
@ -88,14 +84,14 @@ class EntireArtCommand:
self.before_frame = art.active_frame self.before_frame = art.active_frame
self.before_layer = art.active_layer self.before_layer = art.active_layer
self.start_time = self.finish_time = art.app.get_elapsed_time() self.start_time = self.finish_time = art.app.get_elapsed_time()
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:
@ -105,7 +101,7 @@ class EntireArtCommand:
self.before_size = (self.art.width, self.art.height) self.before_size = (self.art.width, self.art.height)
else: else:
self.after_size = (self.art.width, self.art.height) self.after_size = (self.art.width, self.art.height)
def undo(self): def undo(self):
# undo might remove frames/layers that were added # undo might remove frames/layers that were added
self.art.set_active_frame(self.before_frame) self.art.set_active_frame(self.before_frame)
@ -114,19 +110,19 @@ 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
self.art.app.ui.adjust_for_art_resize(self.art) self.art.app.ui.adjust_for_art_resize(self.art)
self.art.mark_all_frames_changed() self.art.mark_all_frames_changed()
def apply(self): def apply(self):
if self.before_size != self.after_size: if self.before_size != self.after_size:
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()
@ -144,26 +139,40 @@ class EditCommandTile:
self.frame = self.layer = self.x = self.y = None self.frame = self.layer = self.x = self.y = None
self.b_char = self.b_fg = self.b_bg = self.b_xform = None self.b_char = self.b_fg = self.b_bg = self.b_xform = None
self.a_char = self.a_fg = self.a_bg = self.a_xform = None 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
@ -173,22 +182,27 @@ class EditCommandTile:
new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform
new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg
return new_ect return new_ect
def set_tile(self, frame, layer, x, y): def set_tile(self, frame, layer, x, y):
self.frame, self.layer = frame, layer self.frame, self.layer = frame, layer
self.x, self.y = x, y self.x, self.y = x, y
def set_before(self, char, fg, bg, xform): def set_before(self, char, fg, bg, xform):
self.b_char, self.b_xform = char, xform self.b_char, self.b_xform = char, xform
self.b_fg, self.b_bg = fg, bg self.b_fg, self.b_bg = fg, bg
def set_after(self, char, fg, bg, xform): def set_after(self, char, fg, bg, xform):
self.a_char, self.a_xform = char, xform self.a_char, self.a_xform = char, xform
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
if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1: if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1:
@ -196,37 +210,64 @@ 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):
self.undo_commands += new_commands[:] self.undo_commands += new_commands[:]
self.clear_redo() self.clear_redo()
def undo(self): def undo(self):
if len(self.undo_commands) == 0: if len(self.undo_commands) == 0:
return return
@ -235,7 +276,7 @@ class CommandStack:
command.undo() command.undo()
self.redo_commands.append(command) self.redo_commands.append(command)
self.art.app.cursor.update_cursor_preview() self.art.app.cursor.update_cursor_preview()
def redo(self): def redo(self):
if len(self.redo_commands) == 0: if len(self.redo_commands) == 0:
return return
@ -247,6 +288,6 @@ class CommandStack:
# add to end of undo stack # add to end of undo stack
self.undo_commands.append(command) self.undo_commands.append(command)
self.art.app.cursor.update_cursor_preview() self.art.app.cursor.update_cursor_preview()
def clear_redo(self): def clear_redo(self):
self.redo_commands = [] self.redo_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,65 +22,86 @@ 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)
def get_crt_enabled(self): def get_crt_enabled(self):
return self.disable_crt or self.start_crt_enabled return self.disable_crt or self.start_crt_enabled
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):
self.width, self.height = new_width, new_height self.width, self.height = new_width, new_height
self.setup_texture_and_buffers() self.setup_texture_and_buffers()
def toggle_crt(self): def toggle_crt(self):
self.crt = not self.crt self.crt = not self.crt
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])
@ -86,7 +109,7 @@ class Framebuffer:
GL.glDeleteRenderbuffers(1, [self.depth_buffer]) GL.glDeleteRenderbuffers(1, [self.depth_buffer])
GL.glDeleteTextures([self.texture]) GL.glDeleteTextures([self.texture])
GL.glDeleteFramebuffers(1, [self.framebuffer]) GL.glDeleteFramebuffers(1, [self.framebuffer])
def render(self): def render(self):
if self.crt and not self.disable_crt: if self.crt and not self.disable_crt:
GL.glUseProgram(self.crt_shader.program) GL.glUseProgram(self.crt_shader.program)
@ -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,33 +12,33 @@ 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):
self.world = world self.world = world
self.arts, self.renderables = [], [] self.arts, self.renderables = [], []
def update(self): def update(self):
for art in self.arts: for art in self.arts:
art.update() art.update()
for r in self.renderables: for r in self.renderables:
r.update() r.update()
def should_render(self): def should_render(self):
return True return True
def render(self): def render(self):
if not self.should_render(): if not self.should_render():
return return
for r in self.renderables: for r in self.renderables:
r.render() r.render()
def destroy(self): def destroy(self):
for r in self.renderables: for r in self.renderables:
r.destroy() r.destroy()

File diff suppressed because it is too large Load diff

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,18 +57,19 @@ 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):
self.reset_edge_warps() self.reset_edge_warps()
def reset_edge_warps(self): def reset_edge_warps(self):
self.edge_obj = self.world.objects.get(self.warp_edge_bounds_obj_name, None) self.edge_obj = self.world.objects.get(self.warp_edge_bounds_obj_name, None)
# 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,30 +77,35 @@ 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)
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()
@ -100,62 +116,71 @@ class GameRoom:
# tell objects in this room player has entered so eg spawners can fire # tell objects in this room player has entered so eg spawners can fire
for obj in self.objects.values(): for obj in self.objects.values():
obj.room_entered(self, old_room) obj.room_entered(self, old_room)
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)
def add_object_by_name(self, obj_name): def add_object_by_name(self, obj_name):
"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)
def add_object(self, obj): def add_object(self, obj):
"Add object (by reference) to this room." "Add object (by reference) to this room."
self.objects[obj.name] = obj self.objects[obj.name] = obj
obj.rooms[self.name] = self obj.rooms[self.name] = self
def remove_object_by_name(self, obj_name): def remove_object_by_name(self, obj_name):
"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)
def remove_object(self, obj): def remove_object(self, obj):
"Remove object (by reference) from this room." "Remove object (by reference) from this room."
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):
d[prop_name] = getattr(self, prop_name) d[prop_name] = getattr(self, prop_name)
return d return d
def _check_edge_warp(self, game_object): def _check_edge_warp(self, game_object):
# 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
@ -181,11 +206,11 @@ class GameRoom:
# TODO: change room or not? use_marker_room flag a la WarpTrigger? # TODO: change room or not? use_marker_room flag a la WarpTrigger?
game_object.set_loc(warp_dest.x, warp_dest.y) game_object.set_loc(warp_dest.x, warp_dest.y)
game_object.last_warp_update = self.world.updates game_object.last_warp_update = self.world.updates
def update(self): def update(self):
if self is self.world.current_room: if self is self.world.current_room:
self._check_edge_warp(self.world.player) self._check_edge_warp(self.world.player)
def destroy(self): def destroy(self):
if self.name in self.world.rooms: if self.name in self.world.rooms:
self.world.rooms.pop(self.name) self.world.rooms.pop(self.name)

View file

@ -1,31 +1,41 @@
import os.path
import random
import os.path, random from .collision import (
CST_AABB,
CST_CIRCLE,
CST_TILE,
CT_GENERIC_DYNAMIC,
CT_GENERIC_STATIC,
CT_NONE,
CT_PLAYER,
)
from .game_object import FACING_DIRS, GameObject
from game_object import GameObject, FACING_DIRS
from collision import CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CT_GENERIC_STATIC, CT_GENERIC_DYNAMIC, CT_PLAYER, CTG_STATIC, CTG_DYNAMIC
class GameObjectAttachment(GameObject): 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."
self.parent = game_object self.parent = game_object
def update(self): def update(self):
# very minimal update! # very minimal update!
if not self.art.updated_this_tick: if not self.art.updated_this_tick:
self.art.update() self.art.update()
def post_update(self): def post_update(self):
# after parent has moved, snap to its location # after parent has moved, snap to its location
self.x = self.parent.x + self.offset_x self.x = self.parent.x + self.offset_x
@ -36,93 +46,112 @@ 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
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)
self.fire_dir_x, self.fire_dir_y = 0, 0 self.fire_dir_x, self.fire_dir_y = 0, 0
def fire(self, firer, dir_x=0, dir_y=1): def fire(self, firer, dir_x=0, dir_y=1):
self.set_loc(firer.x, firer.y, firer.z) self.set_loc(firer.x, firer.y, firer.z)
self.reset_last_loc() self.reset_last_loc()
self.fire_dir_x, self.fire_dir_y = dir_x, dir_y self.fire_dir_x, self.fire_dir_y = dir_x, dir_y
def update(self): def update(self):
if (self.fire_dir_x, self.fire_dir_y) != (0, 0): if (self.fire_dir_x, self.fire_dir_y) != (0, 0):
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
if self.art.frames > 0: if self.art.frames > 0:
self.start_animating() self.start_animating()
def update_state(self): def update_state(self):
GameObject.update_state(self) GameObject.update_state(self)
if self.state_changes_art and abs(self.vel_x) > 0.1 or abs(self.vel_y) > 0.1: 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:
self.world.player = self self.world.player = self
@ -130,40 +159,56 @@ class Player(Character):
self.world.camera.focus_object = self self.world.camera.focus_object = self
else: else:
self.world.camera.focus_object = None self.world.camera.focus_object = None
def button_pressed(self, button_index): def button_pressed(self, button_index):
pass pass
def button_unpressed(self, button_index): def button_unpressed(self, button_index):
pass pass
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):
return FACING_DIRS[self.facing] return FACING_DIRS[self.facing]
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,31 +234,36 @@ 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):
setattr(self.world, prop_name, new_value) setattr(self.world, prop_name, new_value)
def update_from_world(self): def update_from_world(self):
self.camera_x = self.world.camera.x self.camera_x = self.world.camera.x
self.camera_y = self.world.camera.y self.camera_y = self.world.camera.y
@ -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,16 +328,21 @@ 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():
return return
@ -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,20 +404,23 @@ 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):
LocationMarker.__init__(self, world, obj_data) LocationMarker.__init__(self, world, obj_data)
self.times_fired = 0 self.times_fired = 0
# list of objects we've spawned # list of objects we've spawned
self.spawned_objects = [] self.spawned_objects = []
def get_spawn_class_name(self): def get_spawn_class_name(self):
"Return class name of object to spawn." "Return class name of object to spawn."
return self.spawn_class_name return self.spawn_class_name
def get_spawn_location(self): def get_spawn_location(self):
"Return x,y location we should spawn a new object at." "Return x,y location we should spawn a new object at."
if not self.spawn_random_in_bounds: if not self.spawn_random_in_bounds:
@ -358,11 +429,11 @@ class ObjectSpawner(LocationMarker):
x = left + random.random() * (right - left) x = left + random.random() * (right - left)
y = top + random.random() * (bottom - top) y = top + random.random() * (bottom - top)
return x, y return x, y
def can_spawn(self): def can_spawn(self):
"Return True if spawner is allowed to spawn." "Return True if spawner is allowed to spawn."
return True return True
def do_spawn(self): def do_spawn(self):
"Spawn and returns object." "Spawn and returns object."
class_name = self.get_spawn_class_name() class_name = self.get_spawn_class_name()
@ -379,7 +450,7 @@ class ObjectSpawner(LocationMarker):
new_obj.spawner = self new_obj.spawner = self
# TODO: put new object in our room(s), apply spawn_obj_data # TODO: put new object in our room(s), apply spawn_obj_data
return new_obj return new_obj
def trigger(self): def trigger(self):
"Poke this spawner to do its thing, returns an object if spawned" "Poke this spawner to do its thing, returns an object if spawned"
if self.times_to_fire != -1 and self.times_fired >= self.times_to_fire: if self.times_to_fire != -1 and self.times_fired >= self.times_to_fire:
@ -389,11 +460,11 @@ class ObjectSpawner(LocationMarker):
if self.times_fired != -1: if self.times_fired != -1:
self.times_fired += 1 self.times_fired += 1
return self.do_spawn() return self.do_spawn()
def room_entered(self, room, old_room): def room_entered(self, room, old_room):
if self.trigger_on_room_enter: if self.trigger_on_room_enter:
self.trigger() self.trigger()
def room_exited(self, room, new_room): def room_exited(self, room, new_room):
if not self.destroy_on_room_exit: if not self.destroy_on_room_exit:
return return
@ -403,29 +474,37 @@ 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)
def room_exited(self, room, new_room): def room_exited(self, room, new_room):
self.stop_sound(self.sound_name) self.stop_sound(self.sound_name)

File diff suppressed because it is too large Load diff

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,27 @@ import sdl2
# MAYBE-TODO: find out if this breaks for non-US english KB layouts # 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,47 +1,54 @@
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
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
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,27 +95,27 @@ 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):
"return True if source image file has changed since last check" "return True if source image file has changed since last check"
changed = os.path.getmtime(self.filename) > self.last_image_change changed = os.path.getmtime(self.filename) > self.last_image_change
if changed: if changed:
self.last_image_change = time.time() self.last_image_change = time.time()
return changed return changed
def generate_image(self): def generate_image(self):
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):
@ -116,44 +125,48 @@ class Palette:
img.putpixel((x, y), self.colors[color_index]) img.putpixel((x, y), self.colors[color_index])
color_index += 1 color_index += 1
return img return img
def export_as_image(self): def export_as_image(self):
img = self.generate_image() img = self.generate_image()
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):
"returns True if we have any non-opaque (<1 alpha) colors" "returns True if we have any non-opaque (<1 alpha) colors"
for color in self.colors[1:]: for color in self.colors[1:]:
if color[3] < 255: if color[3] < 255:
return False return False
return True return True
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.
@ -181,35 +193,34 @@ class Palette:
g_diff = abs(color_a[1] - color_b[1]) g_diff = abs(color_a[1] - color_b[1])
b_diff = abs(color_a[2] - color_b[2]) b_diff = abs(color_a[2] - color_b[2])
return (r_diff + g_diff + b_diff) <= tolerance return (r_diff + g_diff + b_diff) <= tolerance
def get_closest_color_index(self, r, g, b): def get_closest_color_index(self, r, g, b):
"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):
# exclude transparent first index # exclude transparent first index
return randint(1, len(self.colors)) return randint(1, len(self.colors))
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,36 +241,37 @@ 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"
return False return False
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,22 +19,23 @@ 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
"Use game object's art_off_pct values." "Use game object's art_off_pct values."
def __init__(self, app, art, game_object=None): def __init__(self, app, art, game_object=None):
"Create Renderable with given Art, optionally bound to given GameObject" "Create Renderable with given Art, optionally bound to given GameObject"
self.app = app self.app = app
@ -69,68 +73,136 @@ 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))
def update_tile_buffers(self, update_chars, update_uvs, update_fg, update_bg): def update_tile_buffers(self, update_chars, update_uvs, update_fg, update_bg):
"Update GL data arrays for tile characters, fg/bg colors, transforms." "Update GL data arrays for tile characters, fg/bg colors, transforms."
updates = {} updates = {}
@ -143,32 +215,49 @@ 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,
def update_buffer(self, buffer_index, array, target, buffer_type, data_type, GL.GL_DYNAMIC_DRAW,
attrib_name, attrib_size): GL.GL_FLOAT,
None,
None,
)
def update_buffer(
self,
buffer_index,
array,
target,
buffer_type,
data_type,
attrib_name,
attrib_size,
):
if self.log_buffer_updates: 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)
def advance_frame(self): def advance_frame(self):
"Advance to our Art's next animation frame." "Advance to our Art's next animation frame."
self.set_frame(self.frame + 1) self.set_frame(self.frame + 1)
def rewind_frame(self): def rewind_frame(self):
"Rewind to our Art's previous animation frame." "Rewind to our Art's previous animation frame."
self.set_frame(self.frame - 1) self.set_frame(self.frame - 1)
def set_frame(self, new_frame_index): def set_frame(self, new_frame_index):
"Set us to display our Art's given animation frame." "Set us to display our Art's given animation frame."
if new_frame_index == self.frame: if new_frame_index == self.frame:
@ -177,20 +266,20 @@ 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."
self.animating = True self.animating = True
self.anim_timer = 0 self.anim_timer = 0
def stop_animating(self): def stop_animating(self):
"Pause animation playback on current frame (in game mode)." "Pause animation playback on current frame (in game mode)."
self.animating = False self.animating = False
# restore to active frame if stopping # restore to active frame if stopping
if not self.app.game_mode: if not self.app.game_mode:
self.set_frame(self.art.active_frame) self.set_frame(self.art.active_frame)
def set_art(self, new_art): def set_art(self, new_art):
"Display and bind to given Art." "Display and bind to given Art."
if self.art: if self.art:
@ -202,12 +291,12 @@ 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)
self.height = self.art.height * self.art.quad_height * self.scale_y self.height = self.art.height * self.art.quad_height * self.scale_y
def move_to(self, x, y, z, travel_time=None): def move_to(self, x, y, z, travel_time=None):
""" """
Start simple linear interpolation to given destination over given time. Start simple linear interpolation to given destination over given time.
@ -220,20 +309,22 @@ 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
self.goal_x, self.goal_y, self.goal_z = x, y, z self.goal_x, self.goal_y, self.goal_z = x, y, z
self.ui_moving = False self.ui_moving = False
def update_transform_from_object(self, obj): def update_transform_from_object(self, obj):
"Update our position & scale based on that of given game object." "Update our position & scale based on that of given game object."
self.z = obj.z self.z = obj.z
@ -250,14 +341,14 @@ class TileRenderable:
if obj.flip_x: if obj.flip_x:
self.scale_x *= -1 self.scale_x *= -1
self.scale_z = obj.scale_z self.scale_z = obj.scale_z
def update_loc(self): def update_loc(self):
# TODO: probably time to bust out the ol' vector module for this stuff # TODO: probably time to bust out the ol' vector module for this stuff
# get delta # get delta
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,8 +364,8 @@ 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:
self.update_transform_from_object(self.go) self.update_transform_from_object(self.go)
@ -297,31 +388,41 @@ class TileRenderable:
# TODO: if new_frame < self.frame, count anim loop? # TODO: if new_frame < self.frame, count anim loop?
self.set_frame(new_frame) self.set_frame(new_frame)
self.last_frame_time = self.app.get_elapsed_time() self.last_frame_time = self.app.get_elapsed_time()
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):
""" """
UIRenderable overrides this so it doesn't have to override UIRenderable overrides this so it doesn't have to override
Renderable.render and duplicate lots of code. Renderable.render and duplicate lots of code.
""" """
return np.eye(4, 4) if self.exporting else self.camera.projection_matrix return np.eye(4, 4) if self.exporting else self.camera.projection_matrix
def get_view_matrix(self): def get_view_matrix(self):
return np.eye(4, 4) if self.exporting else self.camera.view_matrix return np.eye(4, 4) if self.exporting else self.camera.view_matrix
def get_loc(self): def get_loc(self):
"Returns world space location as (x, y, z) tuple." "Returns world space location as (x, y, z) tuple."
export_loc = (-1, 1, 0) export_loc = (-1, 1, 0)
return export_loc if self.exporting else (self.x, self.y, self.z) return export_loc if self.exporting else (self.x, self.y, self.z)
def get_scale(self): def get_scale(self):
"Returns world space scale as (x, y, z) tuple." "Returns world space scale as (x, y, z) tuple."
if not self.exporting: if not self.exporting:
@ -329,7 +430,7 @@ class TileRenderable:
x = 2 / (self.art.width * self.art.quad_width) x = 2 / (self.art.width * self.art.quad_width)
y = 2 / (self.art.height * self.art.quad_height) y = 2 / (self.art.height * self.art.quad_height)
return (x, y, 1) return (x, y, 1)
def render_frame_for_export(self, frame): def render_frame_for_export(self, frame):
self.exporting = True self.exporting = True
self.set_frame(frame) self.set_frame(frame)
@ -344,7 +445,7 @@ class TileRenderable:
self.render() self.render()
self.art.app.inactive_layer_visibility = ilv self.art.app.inactive_layer_visibility = ilv
self.exporting = False self.exporting = False
def render(self, layers=None, z_override=None, brightness=1.0): def render(self, layers=None, z_override=None, brightness=1.0):
""" """
Render given list of layers at given Z depth. Render given list of layers at given Z depth.
@ -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,23 +571,21 @@ 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
def start_animating(self): def start_animating(self):
pass pass
def stop_animating(self): def stop_animating(self):
pass pass
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.
""" """
def get_loc(self): def get_loc(self):
""" """
Returns world space location as (x, y, z) tuple, offset by our Returns world space location as (x, y, z) tuple, offset by our

View file

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

View file

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
version version
*.default *.default
README.md README.md
license.txt license.txt
code_of_conduct.txt code_of_conduct.txt
*.dll *.dll

Some files were not shown because too many files have changed in this diff Show more