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/*.*
dist/*.*
build/*.*
playscii/__pycache__/*.*
__pycache__/*.*
.idea/*.*
playscii.profile

View file

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

19260
art/new.psci

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,37 @@
@echo off
set BUILD_EXE_PATH=dist\playscii.exe
set OUTPUT_DIR=dist\playscii\
set ICON_PATH=ui\playscii.ico
set XCOPY_INCLUDE=win_xcopy_include
set XCOPY_EXCLUDE=win_xcopy_exclude
set COPY_INCLUDE=win_copy_include
echo Creating new build...
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
echo Build done!
REM ==== move build so that ZIP will have a subdir enclosing everything
mkdir %OUTPUT_DIR%
move %BUILD_EXE_PATH% %OUTPUT_DIR%
echo -----------
echo Copying external files...
REM ==== xcopy dirs recursively
for /f "tokens=*" %%i in (%XCOPY_INCLUDE%) DO (
echo %%i
xcopy /E/Y "%%i" "%OUTPUT_DIR%\%%i" /exclude:%XCOPY_EXCLUDE%
)
REM ==== regular copy files (non-recursively)
for /f "tokens=*" %%i in (%COPY_INCLUDE%) DO (
echo %%i
copy /Y "%%i" %OUTPUT_DIR% > NUL
)
echo -----------
echo Done!
pause
@echo off
set BUILD_EXE_PATH=dist\playscii.exe
set OUTPUT_DIR=dist\playscii\
set ICON_PATH=ui\playscii.ico
set XCOPY_INCLUDE=win_xcopy_include
set XCOPY_EXCLUDE=win_xcopy_exclude
set COPY_INCLUDE=win_copy_include
echo Creating new build...
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
echo Build done!
REM ==== move build so that ZIP will have a subdir enclosing everything
mkdir %OUTPUT_DIR%
move %BUILD_EXE_PATH% %OUTPUT_DIR%
echo -----------
echo Copying external files...
REM ==== xcopy dirs recursively
for /f "tokens=*" %%i in (%XCOPY_INCLUDE%) DO (
echo %%i
xcopy /E/Y "%%i" "%OUTPUT_DIR%\%%i" /exclude:%XCOPY_EXCLUDE%
)
REM ==== regular copy files (non-recursively)
for /f "tokens=*" %%i in (%COPY_INCLUDE%) DO (
echo %%i
copy /Y "%%i" %OUTPUT_DIR% > NUL
)
echo -----------
echo Done!
pause

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,14 @@
from playscii.art_import import ArtImporter
from art_import import ArtImporter
class TextImporter(ArtImporter):
format_name = 'Plain text'
format_name = "Plain text"
format_description = """
ASCII art in ordinary text format.
Assumes single frame, single layer document.
Current character set and palette will be used.
"""
def run_import(self, in_filename, options={}):
lines = open(in_filename).readlines()
# determine length of longest line

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +1,35 @@
import math
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):
z = -0.1
class MazeNPC(GameObject):
art_src = 'npc'
art_src = "npc"
use_art_instance = True
col_radius = 0.5
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_STATIC
bark = 'Well hello there!'
bark = "Well hello there!"
def started_colliding(self, other):
if not isinstance(other, Player):
return
self.world.hud.post_msg(self.bark)
def pre_first_update(self):
self.z = 0.1
# TODO: investigate why this random color set doesn't seem to work
@ -30,18 +38,19 @@ class MazeNPC(GameObject):
for art in self.arts.values():
art.set_all_non_transparent_colors(random_color)
class MazeBaker(MazeNPC):
bark = 'Sorry, all outta bread today!'
bark = "Sorry, all outta bread today!"
class MazeCritter(MazeNPC):
"dynamically-spawned NPC that wobbles around"
collision_type = CT_GENERIC_DYNAMIC
should_save = False
move_rate = 0.25
bark = 'wheee!'
bark = "wheee!"
def update(self):
# skitter around randomly
x, y = (random.random() * 2) - 1, (random.random() * 2) - 1
@ -52,50 +61,49 @@ class MazeCritter(MazeNPC):
class MazePickup(GameObject):
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
col_radius = 0.5
hold_offset_y = 1.2
consume_on_use = True
sound_filenames = {'pickup': 'pickup.ogg'}
sound_filenames = {"pickup": "pickup.ogg"}
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
self.holder = None
def started_colliding(self, other):
if not isinstance(other, Player):
return
if self is other.held_object:
return
other.pick_up(self)
def stopped_colliding(self, other):
if not isinstance(other, Player):
return
if self is not other.held_object:
self.enable_collision()
def picked_up(self, new_holder):
self.holder = new_holder
self.world.hud.post_msg('got %s!' % self.display_name)
self.world.hud.post_msg(f"got {self.display_name}!")
self.disable_collision()
self.play_sound('pickup')
self.play_sound("pickup")
def used(self, user):
if 'used' in self.sound_filenames:
self.play_sound('used')
if "used" in self.sound_filenames:
self.play_sound("used")
if self.consume_on_use:
self.destroy()
def destroy(self):
if self.holder:
self.holder.held_object = None
self.holder = None
GameObject.destroy(self)
def update(self):
GameObject.update(self)
if not self.holder:
@ -103,33 +111,33 @@ class MazePickup(GameObject):
# if held, shadow holder
self.x = self.holder.x
# bob slightly above holder's head
bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10
bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10
self.y = self.holder.y + self.hold_offset_y + bob_y
self.z = self.holder.z
class MazeKey(MazePickup):
art_src = 'key'
display_name = 'a gold key'
used_message = 'unlocked!'
art_src = "key"
display_name = "a gold key"
used_message = "unlocked!"
class MazeAx(MazePickup):
art_src = 'ax'
display_name = 'an ax'
art_src = "ax"
display_name = "an ax"
consume_on_use = False
used_message = 'chop!'
used_message = "chop!"
# TODO: see if there's a way to add to MazePickup's sound dict here :/
sound_filenames = {'pickup': 'pickup.ogg',
'used': 'break.ogg'}
sound_filenames = {"pickup": "pickup.ogg", "used": "break.ogg"}
class MazePortalKey(MazePickup):
art_src = 'artifact'
display_name = 'the Artifact of Zendor'
used_message = '!!??!?!!?!?!?!!'
art_src = "artifact"
display_name = "the Artifact of Zendor"
used_message = "!!??!?!!?!?!?!!"
consume_on_use = False
sound_filenames = {'pickup': 'artifact.ogg',
'used': 'portal.ogg'}
sound_filenames = {"pickup": "artifact.ogg", "used": "portal.ogg"}
def update(self):
MazePickup.update(self)
ch, fg, bg, xform = self.art.get_tile_at(0, 0, 0, 0)
@ -146,21 +154,21 @@ class MazePortalKey(MazePickup):
class MazeLock(StaticTileBG):
art_src = 'lock'
art_src = "lock"
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
col_radius = 0.5
mass = 0.0
key_type = MazeKey
def started_colliding(self, other):
if not isinstance(other, Player):
return
if other.held_object and type(other.held_object) is self.key_type:
self.unlocked(other)
else:
self.world.hud.post_msg('blocked - need %s!' % self.key_type.display_name)
self.world.hud.post_msg(f"blocked - need {self.key_type.display_name}!")
def unlocked(self, other):
self.disable_collision()
self.visible = False
@ -168,22 +176,21 @@ class MazeLock(StaticTileBG):
class MazeBlockage(MazeLock):
art_src = 'debris'
art_src = "debris"
key_type = MazeAx
class MazePortalGate(MazeLock):
art_src = 'portalgate'
art_src = "portalgate"
key_type = MazePortalKey
collision_shape_type = CST_TILE
collision_type = CT_GENERIC_STATIC
def update(self):
MazeLock.update(self)
if self.collision_type == CT_NONE:
if not self.art.is_script_running('evap'):
self.art.run_script_every('evap')
if not self.art.is_script_running("evap"):
self.art.run_script_every("evap")
return
# cycle non-black colors
BLACK = 1
@ -207,7 +214,8 @@ class MazePortalGate(MazeLock):
class MazePortal(GameObject):
art_src = 'portal'
art_src = "portal"
def update(self):
GameObject.update(self)
if self.app.updates % 2 != 0:
@ -215,17 +223,17 @@ class MazePortal(GameObject):
ramps = {11: 10, 10: 3, 3: 11}
for frame, layer, x, y in TileIter(self.art):
ch, fg, bg, xform = self.art.get_tile_at(frame, layer, x, y)
fg = ramps.get(fg, None)
fg = ramps.get(fg)
self.art.set_tile_at(frame, layer, x, y, ch, fg, bg, xform)
class MazeStandingNPC(GameObject):
art_src = 'npc'
art_src = "npc"
col_radius = 0.5
collision_shape_type = CST_CIRCLE
collision_type = CT_GENERIC_DYNAMIC
bark = 'Well hello there!'
bark = "Well hello there!"
def started_colliding(self, other):
if not isinstance(other, Player):
return

View file

@ -1,14 +1,15 @@
import math
from game_util_objects import Player, BlobShadow
from games.maze.scripts.rooms import OutsideRoom
from playscii.game_util_objects import BlobShadow, Player
class PlayerBlobShadow(BlobShadow):
z = 0
fixed_z = True
scale_x = scale_y = 0.5
offset_y = -0.5
def pre_first_update(self):
BlobShadow.pre_first_update(self)
# TODO: figure out why class default scale isn't taking?
@ -16,24 +17,24 @@ class PlayerBlobShadow(BlobShadow):
class MazePlayer(Player):
art_src = 'player'
move_state = 'stand'
art_src = "player"
move_state = "stand"
col_radius = 0.5
# TODO: setting this to 2 fixes tunneling, but shouldn't slow down the player!
fast_move_steps = 2
attachment_classes = { 'shadow': 'PlayerBlobShadow' }
attachment_classes = {"shadow": "PlayerBlobShadow"}
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)
self.held_object = None
def pick_up(self, pickup):
# drop any other held item first
if self.held_object:
self.drop(self.held_object, pickup)
self.held_object = pickup
pickup.picked_up(self)
def drop(self, pickup, new_pickup=None):
# drop pickup in place of one we're swapping with, else drop at feet
if new_pickup:
@ -41,11 +42,11 @@ class MazePlayer(Player):
else:
pickup.x, pickup.y = self.x, self.y
pickup.holder = None
def use_item(self):
self.world.hud.post_msg(self.held_object.used_message)
self.held_object.used(self)
def update(self):
Player.update(self)
if type(self.world.current_room) is OutsideRoom:

View file

@ -1,25 +1,22 @@
from game_room import GameRoom
from playscii.game_room import GameRoom
class MazeRoom(GameRoom):
def exited(self, new_room):
GameRoom.exited(self, new_room)
# clear message line when exiting
if self.world.hud:
self.world.hud.post_msg('')
self.world.hud.post_msg("")
class OutsideRoom(MazeRoom):
camera_follow_player = True
def entered(self, old_room):
MazeRoom.entered(self, old_room)
self.world.collision_enabled = False
self.world.app.camera.y_tilt = 4
def exited(self, new_room):
MazeRoom.exited(self, new_room)
self.world.collision_enabled = True

View file

@ -1,20 +1,19 @@
import math
import random
import math, random
from 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):
draw_col_layer = True
class PlatformPlayer(Player):
# from http://www.piratehearts.com/blog/2010/08/30/40/:
# JumpSpeed = sqrt(2.0f * Gravity * JumpHeight);
art_src = 'player'
#collision_shape_type = CST_AABB
art_src = "player"
# collision_shape_type = CST_AABB
col_width = 2
col_height = 3
handle_key_events = True
@ -25,16 +24,16 @@ class PlatformPlayer(Player):
ground_friction = 20
air_friction = 15
max_jump_press_time = 0.15
editable = Player.editable + ['max_jump_press_time']
jump_key = 'x'
editable = Player.editable + ["max_jump_press_time"]
jump_key = "x"
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)
self.jump_time = 0
# don't jump again until jump is released and pressed again
self.jump_ready = True
self.started_jump = False
def started_colliding(self, other):
Player.started_colliding(self, other)
if isinstance(other, PlatformMonster):
@ -42,77 +41,87 @@ class PlatformPlayer(Player):
dx, dy = other.x - self.x, other.y - self.y
if abs(dy) > abs(dx) and dy < -1:
other.destroy()
def is_affected_by_gravity(self):
return True
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == self.jump_key and self.jump_ready:
self.jump()
self.jump_ready = False
self.started_jump = True
def handle_key_up(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == self.jump_key:
self.jump_ready = True
def allow_move_y(self, dy):
# disable regular up/down movement, jump button sets move_y directly
return False
def update_state(self):
self.state = 'stand' if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0) else 'walk'
self.state = (
"stand"
if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0)
else "walk"
)
def moved_this_frame(self):
delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2)
delta = math.sqrt(
abs(self.last_x - self.x) ** 2
+ abs(self.last_y - self.y) ** 2
+ abs(self.last_z - self.z) ** 2
)
return delta > self.stop_velocity
def is_on_ground(self):
# works for now: just check for -Y contact with first world object
ground = self.world.get_first_object_of_type('PlatformWorld')
ground = self.world.get_first_object_of_type("PlatformWorld")
contact = self.collision.contacts.get(ground.name, None)
if not contact:
return False
return contact.overlap.y < 0
def jump(self):
self.jump_time += self.get_time_since_last_update() / 1000
if self.jump_time < self.max_jump_press_time:
self.move_y += 1
def update(self):
on_ground = self.is_on_ground()
if on_ground and self.jump_time > 0:
self.jump_time = 0
# poll jump key for variable length jump
if self.world.app.il.is_key_pressed(self.jump_key) and \
(self.started_jump or not on_ground):
if self.world.app.il.is_key_pressed(self.jump_key) and (
self.started_jump or not on_ground
):
self.jump()
self.started_jump = False
Player.update(self)
# wobble as we walk a la ELC2
if self.state == 'walk' and on_ground:
if self.state == "walk" and on_ground:
self.y += math.sin(self.world.app.updates) / 5
class PlatformMonster(Character):
art_src = 'monster'
move_state = 'stand'
art_src = "monster"
move_state = "stand"
animating = True
fast_move_steps = 2
move_accel_x = 100
col_radius = 1
def pre_first_update(self):
# pick random starting direction
self.move_dir_x = random.choice([-1, 1])
self.set_timer_function('hit_wall', self.check_wall_hits, 0.2)
self.set_timer_function("hit_wall", self.check_wall_hits, 0.2)
def is_affected_by_gravity(self):
return True
def allow_move_y(self, dy):
return False
def check_wall_hits(self):
"Turn around if a wall is immediately ahead of direction we're moving."
# check collision in direction we're moving
@ -123,20 +132,22 @@ class PlatformMonster(Character):
x = self.x - self.col_radius - margin
y = self.y
# DEBUG see trace destination
#lines = [(self.x, self.y, 0), (x, y, 0)]
#self.app.debug_line_renderable.set_lines(lines)
hits, shapes = self.world.get_colliders_at_point(x, y,
#include_object_names=[],
include_class_names=['PlatformWorld',
'PlatformMonster'],
exclude_object_names=[self.name])
# lines = [(self.x, self.y, 0), (x, y, 0)]
# self.app.debug_line_renderable.set_lines(lines)
hits, shapes = self.world.get_colliders_at_point(
x,
y,
# include_object_names=[],
include_class_names=["PlatformWorld", "PlatformMonster"],
exclude_object_names=[self.name],
)
if len(hits) > 0:
self.move_dir_x = -self.move_dir_x
def update(self):
self.move(self.move_dir_x, 0)
Character.update(self)
class PlatformWarpTrigger(WarpTrigger):
warp_class_names = ['Player', 'PlatformMonster']
warp_class_names = ["Player", "PlatformMonster"]

View file

@ -1,20 +1,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):
state_changes_art = False
move_state = 'stand'
art_src = 'player'
move_state = "stand"
art_src = "player"
handle_key_events = True
invincible = False # DEBUG
serialized = Player.serialized + ['invincible']
invincible = False # DEBUG
serialized = Player.serialized + ["invincible"]
respawn_delay = 3
# refire delay, else holding X chokes game
fire_delay = 0.15
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)
# track last death and last fire time for respawn and refire delays
@ -22,33 +29,33 @@ class ShmupPlayer(Player):
self.last_fire_time = 0
# save our start position
self.start_x, self.start_y = self.x, self.y
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
if key == 'x' and self.state == 'dead':
if key == "x" and self.state == "dead":
# respawn after short delay
time = self.world.get_elapsed_time() / 1000
if time >= self.last_death_time + self.respawn_delay:
self.state = 'stand'
self.state = "stand"
self.set_loc(self.start_x, self.start_y)
self.visible = True
def update_state(self):
# only two states, ignore stuff parent class does for this
pass
def die(self, killer):
if self.invincible or self.state == 'dead':
if self.invincible or self.state == "dead":
return
boom = Boom(self.world)
boom.set_loc(self.x, self.y)
self.state = 'dead'
self.state = "dead"
self.last_death_time = self.world.get_elapsed_time() / 1000
self.visible = False
def update(self):
Player.update(self)
# poll fire key directly for continuous fire (with refire delay)
if self.state != 'dead' and self.world.app.il.is_key_pressed('x'):
if self.state != "dead" and self.world.app.il.is_key_pressed("x"):
time = self.world.get_elapsed_time() / 1000
if time >= self.last_fire_time + self.fire_delay:
proj = ShmupPlayerProjectile(self.world)
@ -58,38 +65,44 @@ class ShmupPlayer(Player):
class PlayerBlocker(StaticTileBG):
"keeps player from advancing too far upfield"
art_src = 'blockline_horiz'
noncolliding_classes = ['Projectile', 'ShmupEnemy']
art_src = "blockline_horiz"
noncolliding_classes = ["Projectile", "ShmupEnemy"]
class EnemySpawner(ObjectSpawner):
"sits at top of screen and spawns enemies"
art_src = 'spawn_area'
art_src = "spawn_area"
spawn_random_in_bounds = True
trigger_on_room_enter = False
def __init__(self, world, obj_data=None):
ObjectSpawner.__init__(self, world, obj_data)
self.next_spawn_time = 0
self.target_enemy_count = 1
def can_spawn(self):
player = self.world.get_first_object_of_type('ShmupPlayer')
player = self.world.get_first_object_of_type("ShmupPlayer")
# only spawn if player has fired, there's room, and it's time
return player and player.state != 'dead' and \
player.last_fire_time > 0 and \
len(self.spawned_objects) < self.target_enemy_count and \
self.world.get_elapsed_time() >= self.next_spawn_time
return (
player
and player.state != "dead"
and player.last_fire_time > 0
and len(self.spawned_objects) < self.target_enemy_count
and self.world.get_elapsed_time() >= self.next_spawn_time
)
def get_spawn_class_name(self):
roll = random.random()
# pick random enemy type to spawn
if roll > 0.8:
return 'Enemy1'
return "Enemy1"
elif roll > 0.6:
return 'Enemy2'
return "Enemy2"
else:
return 'Asteroid'
return "Asteroid"
def update(self):
StaticTileBG.update(self)
# bump up enemy counts as time goes on
@ -106,37 +119,42 @@ class EnemySpawner(ObjectSpawner):
next_delay = random.random() * 3
self.next_spawn_time = self.world.get_elapsed_time() + next_delay * 1000
class EnemyDeleter(StaticTileBG):
"deletes enemies once they hit a certain point on screen"
art_src = 'blockline_horiz'
art_src = "blockline_horiz"
def started_colliding(self, other):
if isinstance(other, ShmupEnemy):
other.destroy()
class ShmupEnemy(Character):
state_changes_art = False
move_state = 'stand'
move_state = "stand"
should_save = False
invincible = False # DEBUG
serialized = Character.serialized + ['invincible']
invincible = False # DEBUG
serialized = Character.serialized + ["invincible"]
def started_colliding(self, other):
if isinstance(other, ShmupPlayer):
other.die(self)
def fire_proj(self):
proj = ShmupEnemyProjectile(self.world)
# fire downward
proj.fire(self, 0, -1)
def update(self):
self.move(0, -1)
Character.update(self)
class Enemy1(ShmupEnemy):
art_src = 'enemy1'
art_src = "enemy1"
move_accel_y = 100
def update(self):
# sine wave motion in X
time = self.world.get_elapsed_time()
@ -146,16 +164,17 @@ class Enemy1(ShmupEnemy):
self.fire_proj()
ShmupEnemy.update(self)
class Enemy2(ShmupEnemy):
art_src = 'enemy2'
art_src = "enemy2"
animating = True
move_accel_y = 50
def pre_first_update(self):
ShmupEnemy.pre_first_update(self)
# pick random lateral movement goal
self.goal_x, y = self.spawner.get_spawn_location()
def update(self):
# move to random goal X
dx = self.goal_x - self.x
@ -171,16 +190,20 @@ class Enemy2(ShmupEnemy):
self.fire_proj()
ShmupEnemy.update(self)
class Asteroid(ShmupEnemy):
"totally inert, just moves slowly down the screen"
art_src = 'asteroid'
art_src = "asteroid"
move_accel_y = 200
class ShmupPlayerProjectile(Projectile):
animating = True
art_src = 'player_proj'
art_src = "player_proj"
use_art_instance = True
noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'Player']
noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "Player"]
def started_colliding(self, other):
if isinstance(other, ShmupEnemy) and not other.invincible:
boom = Boom(self.world)
@ -189,48 +212,52 @@ class ShmupPlayerProjectile(Projectile):
other.destroy()
self.destroy()
class ShmupEnemyProjectile(Projectile):
animating = True
art_src = 'enemy_proj'
art_src = "enemy_proj"
use_art_instance = True
noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'ShmupEnemy']
noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "ShmupEnemy"]
def started_colliding(self, other):
if isinstance(other, ShmupPlayer) and other.state != 'dead':
if isinstance(other, ShmupPlayer) and other.state != "dead":
other.die(self)
self.destroy()
class Boom(GameObject):
art_src = 'boom'
art_src = "boom"
animating = True
use_art_instance = True
should_save = False
z = 0.5
scale_x, scale_y = 3, 3
lifespan = 0.5
def get_acceleration(self, vel_x, vel_y, vel_z):
return 0, 0, -100
class Starfield(GameObject):
"scrolling background with stars generated on-the-fly - no PSCI file!"
generate_art = True
art_width, art_height = 30, 41
art_charset = 'jpetscii'
alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties
art_charset = "jpetscii"
alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties
# indices of star characters
star_chars = [201]
def pre_first_update(self):
self.art.clear_frame_layer(0, 0)
def create_star(self):
"create a star at a random point along the top edge"
x = int(random.random() * self.art_width)
char = random.choice(self.star_chars)
color = self.art.palette.get_random_color_index()
self.art.set_tile_at(0, 0, x, 0, char, color)
def update(self):
# maybe create a star at the top, clear bottom line, then shift-wrap
if random.random() < 0.25:

View file

@ -1,14 +1,12 @@
import random
import time
import time, random
from game_object import GameObject
from art import UV_FLIPX, UV_FLIPY, UV_ROTATE180, ART_DIR
from renderable import TileRenderable
from games.wildflowers.scripts.ramps import PALETTE_RAMPS
from games.wildflowers.scripts.petal import Petal
from games.wildflowers.scripts.frond import Frond
from games.wildflowers.scripts.petal import Petal
from games.wildflowers.scripts.ramps import PALETTE_RAMPS
from 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?
# (should also change camera zoom, probably frond/petal counts)
@ -16,19 +14,18 @@ FLOWER_WIDTH, FLOWER_HEIGHT = 16, 16
class FlowerObject(GameObject):
generate_art = True
should_save = False
physics_move = False
art_width, art_height = FLOWER_WIDTH, FLOWER_HEIGHT
min_petals, max_petals = 0, 4
min_fronds, max_fronds = 0, 8
# every flower must have at least this many petals + fronds
minimum_complexity = 4
# app updates per grow update; 1 = grow every frame
ticks_per_grow = 4
# DEBUG: if True, add current time to date seed as a decimal,
# to test with highly specific values
# (note: this turns the seed from an int into a float)
@ -36,13 +33,12 @@ class FlowerObject(GameObject):
# DEBUG: if nonzero, use this seed for testing
debug_seed = 0
debug_log = False
def __init__(self, world, obj_data=None):
GameObject.__init__(self, world, obj_data)
# set random seed based on date, a different flower each day
t = time.localtime()
year, month, day = t.tm_year, t.tm_mon, t.tm_mday
weekday = t.tm_wday # 0 = monday
date = year * 10000 + month * 100 + day
if self.seed_includes_time:
date += t.tm_hour * 0.01 + t.tm_min * 0.0001 + t.tm_sec * 0.000001
@ -54,21 +50,23 @@ class FlowerObject(GameObject):
# pick a random dark BG color (will be quantized to palette)
r, g, b = random.random() / 10, random.random() / 10, random.random() / 10
# set up art with character set, size, and a random (supported) palette
self.art.set_charset_by_name('jpetscii')
self.art.set_charset_by_name("jpetscii")
palette = random.choice(list(PALETTE_RAMPS.keys()))
self.art.set_palette_by_name(palette)
# quantize bg color and set it for art and world
self.bg_index = self.art.palette.get_closest_color_index(int(r * 255), int(g * 255), int(b * 255))
self.bg_index = self.art.palette.get_closest_color_index(
int(r * 255), int(g * 255), int(b * 255)
)
bg_color = self.art.palette.colors[self.bg_index]
self.world.bg_color[0] = bg_color[0] / 255.0
self.world.bg_color[1] = bg_color[1] / 255.0
self.world.bg_color[2] = bg_color[2] / 255.0
self.world.bg_color[3] = 1.0 # set here or alpha is zero?
self.world.bg_color[3] = 1.0 # set here or alpha is zero?
self.art.resize(self.art_width, self.art_height)
self.app.ui.adjust_for_art_resize(self) # grid etc
self.app.ui.adjust_for_art_resize(self) # grid etc
self.art.clear_frame_layer(0, 0, bg_color=self.bg_index)
# petals on a layer underneath fronds?
#self.art.add_layer(z=-0.001, name='petals')
# self.art.add_layer(z=-0.001, name='petals')
self.finished_growing = False
# some flowers can be more petal-centric or frond-centric,
# but keep a certain minimum complexity
@ -78,29 +76,34 @@ class FlowerObject(GameObject):
petal_count = random.randint(self.min_petals, self.max_petals)
frond_count = random.randint(self.min_fronds, self.max_fronds)
self.petals = []
#petal_count = 5 # DEBUG
# petal_count = 5 # DEBUG
for i in range(petal_count):
self.petals.append(Petal(self, i))
# sort petals by radius largest to smallest,
# so big ones don't totally stomp smaller ones
self.petals.sort(key=lambda item: item.goal_radius, reverse=True)
self.fronds = []
#frond_count = 0 # DEBUG
# frond_count = 0 # DEBUG
for i in range(frond_count):
self.fronds.append(Frond(self, i))
# track # of growth updates we've had
self.grows = 0
# create an art document we can add frames to and later export
self.export_filename = '%s%swildflower_%s' % (self.app.documents_dir, ART_DIR, self.seed)
self.exportable_art = self.app.new_art(self.export_filename,
self.art_width, self.art_height,
self.art.charset.name,
self.art.palette.name)
self.export_filename = (
f"{self.app.documents_dir}{ART_DIR}wildflower_{self.seed}"
)
self.exportable_art = self.app.new_art(
self.export_filename,
self.art_width,
self.art_height,
self.art.charset.name,
self.art.palette.name,
)
# re-set art's filename to be in documents dir rather than game dir :/
self.exportable_art.set_filename(self.export_filename)
# image export process needs a renderable
r = TileRenderable(self.app, self.exportable_art)
def update(self):
GameObject.update(self)
# grow only every few ticks, so you can watch the design grow
@ -108,10 +111,10 @@ class FlowerObject(GameObject):
return
if not self.finished_growing:
self.update_growth()
def update_growth(self):
if self.debug_log:
print('update growth:')
print("update growth:")
grew = False
for p in self.petals:
if not p.finished_growing:
@ -136,8 +139,8 @@ class FlowerObject(GameObject):
self.finished_growing = True
self.exportable_art.set_active_frame(self.exportable_art.frames - 1)
if self.debug_log:
print('flower finished')
print("flower finished")
def paint_mirrored(self, layer, x, y, char, fg, bg=None):
# only paint if in top left quadrant
if x > (self.art_width / 2) - 1 or y > (self.art_height / 2) - 1:
@ -148,13 +151,12 @@ class FlowerObject(GameObject):
top_right = (self.art_width - 1 - x, y)
bottom_left = (x, self.art_height - 1 - y)
bottom_right = (self.art_width - 1 - x, self.art_height - 1 - y)
self.art.set_tile_at(0, layer, *top_right,
char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left,
char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(0, layer, *bottom_right,
char, fg, bg, transform=UV_ROTATE180)
self.art.set_tile_at(0, layer, *top_right, char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left, char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(
0, layer, *bottom_right, char, fg, bg, transform=UV_ROTATE180
)
def copy_new_frame(self):
# add new frame to art for export
# (art starts with 1 frame, only do this after first frame written)

View file

@ -1,51 +1,57 @@
import random
from games.wildflowers.scripts.ramps import RampIterator
# growth direction consts
NONE = (0, 0)
LEFT = (-1, 0)
LEFT_UP = (-1, -1)
UP = (0, -1)
RIGHT_UP = (1, -1)
RIGHT = (1, 0)
NONE = (0, 0)
LEFT = (-1, 0)
LEFT_UP = (-1, -1)
UP = (0, -1)
RIGHT_UP = (1, -1)
RIGHT = (1, 0)
RIGHT_DOWN = (1, 1)
DOWN = (0, 1)
LEFT_DOWN = (-1, 1)
DOWN = (0, 1)
LEFT_DOWN = (-1, 1)
DIRS = [LEFT, LEFT_UP, UP, RIGHT_UP, RIGHT, RIGHT_DOWN, DOWN, LEFT_DOWN]
FROND_CHARS = [
# thick and skinny \
151, 166,
151,
166,
# thick and skinny /
150, 167,
150,
167,
# thick and skinny X
183, 182,
183,
182,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Frond:
min_life, max_life = 3, 16
random_char_chance = 0.5
mutate_char_chance = 0.2
# layer all fronds should paint on
layer = 0
debug = False
def __init__(self, flower, index):
self.flower = flower
self.index = index
self.finished_growing = False
# choose growth function
self.growth_functions = [self.grow_straight_line, self.grow_curl,
self.grow_wander_outward]
self.growth_functions = [
self.grow_straight_line,
self.grow_curl,
self.grow_wander_outward,
]
self.get_grow_dir = random.choice(self.growth_functions)
#self.get_grow_dir = self.grow_curl # DEBUG
# self.get_grow_dir = self.grow_curl # DEBUG
# for straight line growers, set a consistent direction
if self.get_grow_dir == self.grow_straight_line:
self.grow_line = random.choice(DIRS)
@ -65,7 +71,7 @@ class Frond:
else:
self.char = random.randint(1, 255)
# first grow() will paint first character
def grow(self):
"""
grows this frond by another tile
@ -75,16 +81,21 @@ class Frond:
if self.life <= 0 or self.color == self.ramp.end:
self.finished_growing = True
if self.debug:
print(' frond %i finished.' % self.index)
print(f" frond {self.index} finished.")
return painted
if self.debug:
print(' frond %i at (%i, %i) using %s' % (self.index, self.x, self.y, self.get_grow_dir.__name__))
print(
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;
# we might go back in bounds next grow
if 0 <= self.x < self.flower.art_width - 1 and \
0 <= self.y < self.flower.art_height - 1:
self.flower.paint_mirrored(self.layer, self.x, self.y,
self.char, self.color)
if (
0 <= self.x < self.flower.art_width - 1
and 0 <= self.y < self.flower.art_height - 1
):
self.flower.paint_mirrored(
self.layer, self.x, self.y, self.char, self.color
)
painted = True
self.growth_history.append((self.x, self.y))
self.life -= 1
@ -104,16 +115,16 @@ class Frond:
grow_x, grow_y = self.get_grow_dir((last_x, last_y))
self.x, self.y = self.x + grow_x, self.y + grow_y
return painted
# paint and growth functions work in top left quadrant, then mirrored
def grow_straight_line(self, last_dir):
return self.grow_line
def grow_wander_outward(self, last_dir):
# (original prototype growth algo)
return random.choice([LEFT_UP, LEFT, UP])
def grow_curl(self, last_dir):
if last_dir == NONE:
return random.choice([LEFT, LEFT_UP, UP])

View file

@ -1,33 +1,41 @@
import random, math
import math
import random
from games.wildflowers.scripts.ramps import RampIterator
PETAL_CHARS = [
# solid block
255,
# shaded boxes
254, 253,
254,
253,
# solid circle
122,
# curved corner lines, NW NE SE SW
105, 107, 139, 137,
105,
107,
139,
137,
# mostly-solid curved corners, NW NE SE SW
144, 146, 178, 176,
144,
146,
178,
176,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Petal:
min_radius = 3
mutate_char_chance = 0.2
# layer all petals should paint on
layer = 0
debug = False
def __init__(self, flower, index):
self.flower = flower
self.index = index
@ -36,8 +44,12 @@ class Petal:
max_radius = int(self.flower.art_width / 2)
self.goal_radius = random.randint(self.min_radius, max_radius)
self.radius = 0
ring_styles = [self.get_ring_tiles_box, self.get_ring_tiles_wings,
self.get_ring_tiles_diamond, self.get_ring_tiles_circle]
ring_styles = [
self.get_ring_tiles_box,
self.get_ring_tiles_wings,
self.get_ring_tiles_diamond,
self.get_ring_tiles_circle,
]
self.get_ring_tiles = random.choice(ring_styles)
# pick a starting point near center
w, h = self.flower.art_width, self.flower.art_height
@ -48,14 +60,16 @@ class Petal:
self.color = self.ramp.color
# random char from predefined list
self.char = random.choice(PETAL_CHARS)
def grow(self):
# grow outward (up and left) from center in "rings"
if self.radius >= self.goal_radius:
self.finished_growing = True
return
if self.debug:
print(' petal %i at (%i, %i) at radius %i using %s' % (self.index, self.x, self.y, self.radius, self.get_ring_tiles.__name__))
print(
f" petal {self.index} at ({self.x}, {self.y}) at radius {self.radius} using {self.get_ring_tiles.__name__}"
)
self.paint_ring()
# grow and change
self.radius += 1
@ -63,19 +77,20 @@ class Petal:
# roll against chaos to mutate character
if random.random() < self.chaos * self.mutate_char_chance:
self.char = random.choice(PETAL_CHARS)
def paint_ring(self):
tiles = self.get_ring_tiles()
for t in tiles:
x = self.x - t[0]
y = self.y - t[1]
# don't paint out of bounds
if 0 <= x < self.flower.art_width - 1 and \
0 <= y < self.flower.art_height - 1:
self.flower.paint_mirrored(self.layer, x, y,
self.char, self.color)
#print('%s, %s' % (x, y))
if (
0 <= x < self.flower.art_width - 1
and 0 <= y < self.flower.art_height - 1
):
self.flower.paint_mirrored(self.layer, x, y, self.char, self.color)
# print('%s, %s' % (x, y))
def get_ring_tiles_box(self):
tiles = []
for x in range(self.radius + 1):
@ -83,7 +98,7 @@ class Petal:
for y in range(self.radius + 1):
tiles.append((self.radius, y))
return tiles
def get_ring_tiles_dealieX(self):
# not sure what to call this but it's a nice shape
tiles = []
@ -91,7 +106,7 @@ class Petal:
for x in range(self.radius):
tiles.append((x - self.radius, y - self.radius))
return tiles
def get_ring_tiles_wings(self):
# not sure what to call this but it's a nice shape
tiles = []
@ -103,7 +118,6 @@ class Petal:
tiles.append((x, y))
return tiles
def get_ring_tiles_diamond(self):
tiles = []
for y in range(self.radius, -1, -1):
@ -111,15 +125,15 @@ class Petal:
if x + y == self.radius:
tiles.append((x, y))
return tiles
def get_ring_tiles_circle(self):
tiles = []
angle = 0
resolution = 30
for i in range(resolution):
for _ in range(resolution):
angle += math.radians(90.0 / resolution)
x = round(math.cos(angle) * self.radius)
y = round(math.sin(angle) * self.radius)
if not (x, y) in tiles:
if (x, y) not in tiles:
tiles.append((x, y))
return tiles

View file

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

View file

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

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)
Copyright (c) 2014-2022 JP LeBreton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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
THE SOFTWARE.
The MIT License (MIT)
Copyright (c) 2014-2022 JP LeBreton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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
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
from art import ART_DIR
from .art import ART_DIR
class ArtExporter:
"""
Class for exporting an Art into a non-Playscii format.
Export logic happens in run_export; exporter authors simply extend this
class, override run_export and the class properties below.
"""
format_name = 'ERROR - ArtExporter.format_name'
format_name = "ERROR - ArtExporter.format_name"
"User-visible name for this format, shown in export chooser."
format_description = "ERROR - ArtExporter.format_description"
"String (can be triple-quoted) describing format, shown in export chooser."
file_extension = ''
file_extension = ""
"Extension to give the exported file, sans dot."
options_dialog_class = None
"UIDialog subclass exposing export options to user."
def __init__(self, app, out_filename, options={}):
self.app = app
self.art = self.app.ui.active_art
# add file extension to output filename if not present
if self.file_extension and not out_filename.endswith('.%s' % self.file_extension):
out_filename += '.%s' % self.file_extension
if self.file_extension and not out_filename.endswith(f".{self.file_extension}"):
out_filename += f".{self.file_extension}"
# output filename in documents/art dir
if not out_filename.startswith(self.app.documents_dir + ART_DIR):
out_filename = self.app.documents_dir + ART_DIR + out_filename
@ -40,15 +39,15 @@ class ArtExporter:
if self.run_export(out_filename, options):
self.success = True
else:
line = '%s failed to export %s, see console for errors' % (self.__class__.__name__, out_filename)
line = f"{self.__class__.__name__} failed to export {out_filename}, see console for errors"
self.app.log(line)
self.app.ui.message_line.post_line(line, hold_time=10, error=True)
except:
for line in traceback.format_exc().split('\n'):
except Exception:
for line in traceback.format_exc().split("\n"):
self.app.log(line)
# store last used export options for "Export last"
self.app.last_export_options = options
def run_export(self, out_filename, options):
"""
Contains the actual export logic. Write data based on current art,

View file

@ -1,18 +1,18 @@
import os
import traceback
import os, traceback
from .art import ART_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 for creating a new Art from data in non-Playscii format.
Import logic happens in run_import; importer authors simply extend this
class, override run_import and the class properties below.
"""
format_name = 'ERROR - ArtImporter.format_name'
format_name = "ERROR - ArtImporter.format_name"
"User-visible name for this format, shown in import chooser."
format_description = "ERROR - ArtImporter.format_description"
"String (can be triple-quoted) describing format, shown in import chooser."
@ -25,20 +25,27 @@ class ArtImporter:
"""
options_dialog_class = None
"UIDialog subclass exposing import options to user."
generic_error = '%s failed to import %s'
generic_error = "%s failed to import %s"
# if False (eg bitmap conversion), "Imported successfully" message
# won't show on successful creation
completes_instantly = True
def __init__(self, app, in_filename, options={}):
self.app = app
new_filename = '%s.%s' % (os.path.splitext(in_filename)[0],
ART_FILE_EXTENSION)
new_filename = f"{os.path.splitext(in_filename)[0]}.{ART_FILE_EXTENSION}"
self.art = self.app.new_art(new_filename)
# use charset and palette of existing art
charset = self.app.ui.active_art.charset if self.app.ui.active_art else self.app.load_charset(DEFAULT_CHARSET)
charset = (
self.app.ui.active_art.charset
if self.app.ui.active_art
else self.app.load_charset(DEFAULT_CHARSET)
)
self.art.set_charset(charset)
palette = self.app.ui.active_art.palette if self.app.ui.active_art else self.app.load_palette(DEFAULT_PALETTE)
palette = (
self.app.ui.active_art.palette
if self.app.ui.active_art
else self.app.load_palette(DEFAULT_PALETTE)
)
self.art.set_palette(palette)
self.app.set_new_art_for_edit(self.art)
self.art.clear_frame_layer(0, 0, 1)
@ -48,8 +55,8 @@ class ArtImporter:
try:
if self.run_import(in_filename, options):
self.success = True
except:
for line in traceback.format_exc().split('\n'):
except Exception:
for line in traceback.format_exc().split("\n"):
self.app.log(line)
if not self.success:
line = self.generic_error % (self.__class__.__name__, in_filename)
@ -66,20 +73,20 @@ class ArtImporter:
# adjust for new art size and set it active
self.app.ui.adjust_for_art_resize(self.art)
self.app.ui.set_active_art(self.art)
def set_art_charset(self, charset_name):
"Convenience function for setting charset by name from run_import."
self.art.set_charset_by_name(charset_name)
def set_art_palette(self, palette_name):
"Convenience function for setting palette by name from run_import."
self.art.set_palette_by_name(palette_name)
def resize(self, new_width, new_height):
"Convenience function for resizing art from run_import"
self.art.resize(new_width, new_height)
self.app.ui.adjust_for_art_resize(self.art)
def run_import(self, in_filename, options):
"""
Contains the actual import logic. Read input file, set Art

View file

@ -1,31 +1,32 @@
import ctypes
from sdl2 import sdlmixer
class PlayingSound:
"represents a currently playing sound"
def __init__(self, filename, channel, game_object, looping=False):
self.filename = filename
self.channel = channel
self.go = game_object
self.looping = looping
class AudioLord:
sample_rate = 44100
def __init__(self, app):
self.app = app
# initialize audio
sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG|sdlmixer.MIX_INIT_MOD)
sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT,
2, 1024)
sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG | sdlmixer.MIX_INIT_MOD)
sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT, 2, 1024)
self.reset()
# sound callback
# retain handle to C callable even though we don't use it directly
self.sound_cb = ctypes.CFUNCTYPE(None, ctypes.c_int)(self.channel_finished)
sdlmixer.Mix_ChannelFinished(self.sound_cb)
def channel_finished(self, channel):
# remove sound from dicts of playing channels and sounds
old_sound = self.playing_channels.pop(channel)
@ -33,7 +34,7 @@ class AudioLord:
# remove empty list
if self.playing_sounds[old_sound.filename] == []:
self.playing_sounds.pop(old_sound.filename)
def reset(self):
self.stop_all_music()
self.stop_all_sounds()
@ -44,24 +45,25 @@ class AudioLord:
# {channel_number: PlayingSound object}
self.playing_channels = {}
# handle init case where self.musics doesn't exist yet
if hasattr(self, 'musics'):
if hasattr(self, "musics"):
for music in self.musics.values():
sdlmixer.Mix_FreeMusic(music)
self.musics = {}
if hasattr(self, 'sounds'):
if hasattr(self, "sounds"):
for sound in self.sounds.values():
sdlmixer.Mix_FreeChunk(sound)
self.sounds = {}
def register_sound(self, sound_filename):
if sound_filename in self.sounds:
return self.sounds[sound_filename]
new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, 'utf-8'))
new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, "utf-8"))
self.sounds[sound_filename] = new_sound
return new_sound
def object_play_sound(self, game_object, sound_filename,
loops=0, allow_multiple=False):
def object_play_sound(
self, game_object, sound_filename, loops=0, allow_multiple=False
):
# TODO: volume param? sdlmixer.MIX_MAX_VOLUME if not specified
# bail if same object isn't allowed to play same sound multiple times
if not allow_multiple and sound_filename in self.playing_sounds:
@ -71,74 +73,71 @@ class AudioLord:
sound = self.register_sound(sound_filename)
channel = sdlmixer.Mix_PlayChannel(-1, sound, loops)
# add sound to dicts of playing sounds and channels
new_playing_sound = PlayingSound(sound_filename, channel, game_object,
loops == -1)
new_playing_sound = PlayingSound(
sound_filename, channel, game_object, loops == -1
)
if sound_filename in self.playing_sounds:
self.playing_sounds[sound_filename].append(new_playing_sound)
else:
self.playing_sounds[sound_filename] = [new_playing_sound]
self.playing_channels[channel] = new_playing_sound
def object_stop_sound(self, game_object, sound_filename):
if not sound_filename in self.playing_sounds:
if sound_filename not in self.playing_sounds:
return
# stop all instances of this sound object might be playing
for sound in self.playing_sounds[sound_filename]:
if game_object is sound.go:
sdlmixer.Mix_HaltChannel(sound.channel)
def object_stop_all_sounds(self, game_object):
sounds_to_stop = []
for sound_filename,sounds in self.playing_sounds.items():
for sound_filename, sounds in self.playing_sounds.items():
for sound in sounds:
if sound.go is game_object:
sounds_to_stop.append(sound_filename)
for sound_filename in sounds_to_stop:
self.object_stop_sound(game_object, sound_filename)
def stop_all_sounds(self):
sdlmixer.Mix_HaltChannel(-1)
def set_music(self, music_filename):
if music_filename in self.musics:
return
new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, 'utf-8'))
new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, "utf-8"))
self.musics[music_filename] = new_music
def start_music(self, music_filename, loops=-1):
# TODO: fade in support etc
music = self.musics[music_filename]
sdlmixer.Mix_PlayMusic(music, loops)
self.current_music = music_filename
def pause_music(self):
if self.current_music:
sdlmixer.Mix_PauseMusic()
def resume_music(self):
if self.current_music:
sdlmixer.Mix_ResumeMusic()
def stop_music(self, music_filename):
# TODO: fade out support
sdlmixer.Mix_HaltMusic()
self.current_music = None
def is_music_playing(self):
return bool(sdlmixer.Mix_PlayingMusic())
def resume_music(self):
if self.current_music:
sdlmixer.Mix_ResumeMusic()
def stop_all_music(self):
sdlmixer.Mix_HaltMusic()
self.current_music = None
def update(self):
if self.current_music and not self.is_music_playing():
self.current_music = None
def destroy(self):
self.reset()
sdlmixer.Mix_CloseAudio()

View file

@ -1,14 +1,17 @@
import math
import numpy as np
import vector
from . import vector
def clamp(val, lowest, highest):
return min(highest, max(lowest, val))
class Camera:
# good starting values
start_x,start_y = 0,0
start_x, start_y = 0, 0
start_zoom = 2.5
x_tilt, y_tilt = 0, 0
# pan/zoom speed tuning
@ -28,35 +31,35 @@ class Camera:
min_velocity = 0.05
# map extents
# starting values only, bounds are generated according to art size
min_x,max_x = -10, 50
min_y,max_y = -50, 10
min_x, max_x = -10, 50
min_y, max_y = -50, 10
use_bounds = True
min_zoom,max_zoom = 1, 1000
min_zoom, max_zoom = 1, 1000
# matrices -> worldspace renderable vertex shader uniforms
fov = 90
near_z = 0.0001
far_z = 100000
def __init__(self, app):
self.app = app
self.reset()
self.max_pan_speed = self.base_max_pan_speed
def reset(self):
self.x, self.y = self.start_x, self.start_y
self.z = self.start_zoom
# store look vectors so world/screen space conversions can refer to it
self.look_x, self.look_y, self.look_z = None,None,None
self.vel_x, self.vel_y, self.vel_z = 0,0,0
self.look_x, self.look_y, self.look_z = None, None, None
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
self.mouse_panned, self.moved_this_frame = False, False
# GameObject to focus on
self.focus_object = None
self.calc_projection_matrix()
self.calc_view_matrix()
def calc_projection_matrix(self):
self.projection_matrix = self.get_perspective_matrix()
def calc_view_matrix(self):
eye = vector.Vec3(self.x, self.y, self.z)
up = vector.Vec3(0, 1, 0)
@ -65,42 +68,37 @@ class Camera:
forward = (target - eye).normalize()
side = forward.cross(up).normalize()
upward = side.cross(forward)
m = [[side.x, upward.x, -forward.x, 0],
[side.y, upward.y, -forward.y, 0],
[side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]]
m = [
[side.x, upward.x, -forward.x, 0],
[side.y, upward.y, -forward.y, 0],
[side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1],
]
self.view_matrix = np.array(m, dtype=np.float32)
self.look_x, self.look_y, self.look_z = side, upward, forward
def get_perspective_matrix(self):
zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
ymul = 1 / math.tan(self.fov * math.pi / 360)
aspect = self.app.window_width / self.app.window_height
xmul = ymul / aspect
m = [[xmul, 0, 0, 0],
[ 0, ymul, 0, 0],
[ 0, 0, -1, -1],
[ 0, 0, zmul, 0]]
m = [[xmul, 0, 0, 0], [0, ymul, 0, 0], [0, 0, -1, -1], [0, 0, zmul, 0]]
return np.array(m, dtype=np.float32)
def get_ortho_matrix(self, width=None, height=None):
width, height = width or self.app.window_width, height or self.app.window_height
m = np.eye(4, 4, dtype=np.float32)
left, bottom = 0, 0
right, top = width, height
far_z, near_z = -1, 1
x = 2 / (right - left)
y = 2 / (top - bottom)
z = -2 / (self.far_z - self.near_z)
wx = -(right + left) / (right - left)
wy = -(top + bottom) / (top - bottom)
wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z)
m = [[ x, 0, 0, 0],
[ 0, y, 0, 0],
[ 0, 0, z, 0],
[wx, wy, wz, 0]]
m = [[x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [wx, wy, wz, 0]]
return np.array(m, dtype=np.float32)
def pan(self, dx, dy, keyboard=False):
# modify pan speed based on zoom according to a factor
m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
@ -109,7 +107,7 @@ class Camera:
# for brevity, app passes in whether user appears to be keyboard editing
if keyboard:
self.app.keyboard_editing = True
def zoom(self, dz, keyboard=False, towards_cursor=False):
self.vel_z += dz * self.zoom_accel
# pan towards cursor while zooming?
@ -119,11 +117,11 @@ class Camera:
self.pan(dx, dy, keyboard)
if keyboard:
self.app.keyboard_editing = True
def get_current_zoom_pct(self):
"returns % of base (1:1) for current camera"
return (self.get_base_zoom() / self.z) * 100
def get_base_zoom(self):
"returns camera Z needed for 1:1 pixel zoom"
wh = self.app.window_height
@ -132,10 +130,10 @@ class Camera:
if ch == 8:
ch = 16
return wh / ch
def set_to_base_zoom(self):
self.z = self.get_base_zoom()
def zoom_proportional(self, direction):
"zooms in or out via increments of 1:1 pixel scales for active art"
if not self.app.ui.active_art:
@ -167,7 +165,7 @@ class Camera:
break
# kill all Z velocity for camera so we don't drift out of 1:1
self.vel_z = 0
def find_closest_zoom_extents(self):
def corners_on_screen():
art = self.app.ui.active_art
@ -177,12 +175,12 @@ class Camera:
x2 = x1 + art.width * art.quad_width
y2 = y1 - art.height * art.quad_height
right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z)
#print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
# print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
# add 1 tile of UI chars to top and bottom margins
top_margin = 1 - self.app.ui.menu_bar.art.quad_height
bot_margin = -1 + self.app.ui.status_bar.art.quad_height
return left >= -1 and top <= top_margin and \
right <= 1 and bot >= bot_margin
return left >= -1 and top <= top_margin and right <= 1 and bot >= bot_margin
# zoom out from minimum until all corners are visible
self.z = self.min_zoom
# recalc view matrix each move so projection stays correct
@ -192,16 +190,24 @@ class Camera:
self.zoom_proportional(-1)
self.calc_view_matrix()
tries += 1
def toggle_zoom_extents(self, override=None):
art = self.app.ui.active_art
if override is not None:
art.camera_zoomed_extents = not override
if art.camera_zoomed_extents:
# restore cached position
self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z
self.x, self.y, self.z = (
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
)
else:
art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z = self.x, self.y, self.z
(
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
) = self.x, self.y, self.z
# center camera on art
self.x = (art.width * art.quad_width) / 2
self.y = -(art.height * art.quad_height) / 2
@ -209,27 +215,27 @@ class Camera:
# kill all camera velocity when snapping
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
art.camera_zoomed_extents = not art.camera_zoomed_extents
def window_resized(self):
self.calc_projection_matrix()
def set_zoom(self, z):
# TODO: set lerp target, clear if keyboard etc call zoom()
self.z = z
def set_loc(self, x, y, z):
self.x, self.y, self.z = x, y, (z or self.z) # z optional
self.x, self.y, self.z = x, y, (z or self.z) # z optional
def set_loc_from_obj(self, game_object):
self.set_loc(game_object.x, game_object.y, game_object.z)
def set_for_art(self, art):
# set limits
self.max_x = art.width * art.quad_width
self.min_y = -art.height * art.quad_height
# use saved pan/zoom
self.set_loc(art.camera_x, art.camera_y, art.camera_z)
def mouse_pan(self, dx, dy):
"pan view based on mouse delta"
if dx == 0 and dy == 0:
@ -240,12 +246,13 @@ class Camera:
self.y += dy / self.mouse_pan_rate * m
self.vel_x = self.vel_y = 0
self.mouse_panned = True
def update(self):
# zoom-proportional pan scale is based on art
if self.app.ui.active_art:
speed_scale = clamp(self.get_current_zoom_pct(),
self.pan_min_pct, self.pan_max_pct)
speed_scale = clamp(
self.get_current_zoom_pct(), self.pan_min_pct, self.pan_max_pct
)
self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
else:
self.max_pan_speed = self.base_max_pan_speed
@ -256,7 +263,7 @@ class Camera:
# track towards target
# TODO: revisit this for better feel later
dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y
l = math.sqrt(dx ** 2 + dy ** 2)
l = math.sqrt(dx**2 + dy**2)
if l != 0 and l > 0.1:
il = 1 / l
dx *= il
@ -269,7 +276,7 @@ class Camera:
self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
# apply friction
self.vel_x *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction
if abs(self.vel_x) < self.min_velocity:
self.vel_x = 0
if abs(self.vel_y) < self.min_velocity:
@ -296,8 +303,13 @@ class Camera:
self.z = clamp(self.z, self.min_zoom, self.max_zoom)
# set view matrix from xyz
self.calc_view_matrix()
self.moved_this_frame = self.mouse_panned or self.x != self.last_x or self.y != self.last_y or self.z != self.last_z
self.moved_this_frame = (
self.mouse_panned
or self.x != self.last_x
or self.y != self.last_y
or self.z != self.last_z
)
self.mouse_panned = False
def log_loc(self):
self.app.log('camera x=%s, y=%s, z=%s' % (self.x, self.y, self.z))
self.app.log(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 texture import Texture
from .texture import Texture
CHARSET_DIR = 'charsets/'
CHARSET_FILE_EXTENSION = 'char'
CHARSET_DIR = "charsets/"
CHARSET_FILE_EXTENSION = "char"
class CharacterSetLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
changed = None
for charset in self.app.charsets:
if charset.has_updated():
changed = charset.filename
# reload data and image even if only one changed
try:
success = charset.load_char_data()
if success:
self.app.log('CharacterSetLord: success reloading %s' % charset.filename)
self.app.log(
f"CharacterSetLord: success reloading {charset.filename}"
)
else:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
except:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
self.app.log(
f"CharacterSetLord: failed reloading {charset.filename}",
True,
)
except Exception:
self.app.log(
f"CharacterSetLord: failed reloading {charset.filename}",
True,
)
class CharacterSet:
transparent_color = (0, 0, 0)
def __init__(self, app, src_filename, log):
self.init_success = False
self.app = app
self.filename = self.app.find_filename_path(src_filename, CHARSET_DIR,
CHARSET_FILE_EXTENSION)
self.filename = self.app.find_filename_path(
src_filename, CHARSET_DIR, CHARSET_FILE_EXTENSION
)
if not self.filename:
self.app.log("Couldn't find character set data %s" % self.filename)
self.app.log(f"Couldn't find character set data {self.filename}")
return
self.name = os.path.basename(self.filename)
self.name = os.path.splitext(self.name)[0]
@ -59,37 +70,39 @@ class CharacterSet:
return
# report
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.init_success = True
def load_char_data(self):
"carries out majority of CharacterSet init, including loading image"
char_data_src = open(self.filename, encoding='utf-8').readlines()
char_data_src = open(self.filename, encoding="utf-8").readlines()
# allow comments: discard any line in char data starting with //
# (make sure this doesn't muck up legit mapping data)
char_data = []
for line in char_data_src:
if not line.startswith('//'):
if not line.startswith("//"):
char_data.append(line)
# first line = image file
# hold off assigning to self.image_filename til we know it's valid
img_filename = self.app.find_filename_path(char_data.pop(0).strip(), CHARSET_DIR, 'png')
img_filename = self.app.find_filename_path(
char_data.pop(0).strip(), CHARSET_DIR, "png"
)
if not img_filename:
self.app.log("Couldn't find character set image %s" % self.image_filename)
self.app.log(f"Couldn't find character set image {self.image_filename}")
return False
self.image_filename = img_filename
# now that we know the image file's name, store its last modified time
self.last_image_change = os.path.getmtime(self.image_filename)
# second line = character set dimensions
second_line = char_data.pop(0).strip().split(',')
second_line = char_data.pop(0).strip().split(",")
self.map_width, self.map_height = int(second_line[0]), int(second_line[1])
self.char_mapping = {}
index = 0
for line in char_data:
# strip newlines from mapping
for char in line.strip('\r\n'):
if not char in self.char_mapping:
for char in line.strip("\r\n"):
if char not in self.char_mapping:
self.char_mapping[char] = index
index += 1
if index >= self.map_width * self.map_height:
@ -105,12 +118,12 @@ class CharacterSet:
if has_upper and not has_lower:
for char in string.ascii_lowercase:
# set may not have all letters
if not char.upper() in self.char_mapping:
if char.upper() not in self.char_mapping:
continue
self.char_mapping[char] = self.char_mapping[char.upper()]
elif has_lower and not has_upper:
for char in string.ascii_uppercase:
if not char.lower() in self.char_mapping:
if char.lower() not in self.char_mapping:
continue
self.char_mapping[char] = self.char_mapping[char.lower()]
# last valid index a character can be
@ -121,11 +134,11 @@ class CharacterSet:
# store base filename for easy comparisons with not-yet-loaded sets
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
return True
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
@ -143,25 +156,32 @@ class CharacterSet:
# flip image data back and save it for later, eg image conversion
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_data = img
def set_char_dimensions(self):
# store character dimensions and UV size
self.char_width = int(self.image_width / self.map_width)
self.char_height = int(self.image_height / self.map_height)
self.u_width = self.char_width / self.image_width
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(' source texture %s is %s x %s pixels' % (self.image_filename, self.image_width, self.image_height))
self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height))
self.app.log(' char map width/height is %s x %s' % (self.map_width, self.map_height))
self.app.log(' last character index: %s' % self.last_index)
self.app.log(
f" source texture {self.image_filename} is {self.image_width} x {self.image_height} pixels"
)
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):
"return True if source image file has changed since last check"
# tolerate bad filenames in data, don't check stamps on nonexistent ones
if not self.image_filename or not os.path.exists(self.filename) or \
not os.path.exists(self.image_filename):
if (
not self.image_filename
or not os.path.exists(self.filename)
or not os.path.exists(self.image_filename)
):
return False
data_changed = os.path.getmtime(self.filename) > self.last_data_change
img_changed = os.path.getmtime(self.image_filename) > self.last_image_change
@ -170,10 +190,10 @@ class CharacterSet:
if img_changed:
self.last_image_change = time.time()
return data_changed or img_changed
def get_char_index(self, char):
return self.char_mapping.get(char, 0)
def get_solid_pixels_in_char(self, char_index):
"Returns # of solid pixels in character at given index"
tile_x = int(char_index % self.map_width)

View file

@ -1,8 +1,11 @@
import math
from collections import namedtuple
from renderable import TileRenderable
from renderable_line import CircleCollisionRenderable, BoxCollisionRenderable, TileBoxCollisionRenderable
from .renderable_line import (
BoxCollisionRenderable,
CircleCollisionRenderable,
TileBoxCollisionRenderable,
)
# collision shape types
CST_NONE = 0
@ -32,11 +35,11 @@ CTG_DYNAMIC = [CT_GENERIC_DYNAMIC, CT_PLAYER]
__pdoc__ = {}
# named tuples for collision structs that don't merit a class
Contact = namedtuple('Contact', ['overlap', 'timestamp'])
__pdoc__['Contact'] = "Represents a contact between two objects."
Contact = namedtuple("Contact", ["overlap", "timestamp"])
__pdoc__["Contact"] = "Represents a contact between two objects."
ShapeOverlap = namedtuple('ShapeOverlap', ['x', 'y', 'dist', 'area', 'other'])
__pdoc__['ShapeOverlap'] = "Represents a CollisionShape's overlap with another."
ShapeOverlap = namedtuple("ShapeOverlap", ["x", "y", "dist", "area", "other"])
__pdoc__["ShapeOverlap"] = "Represents a CollisionShape's overlap with another."
class CollisionShape:
@ -44,6 +47,7 @@ class CollisionShape:
Abstract class for a shape that can overlap and collide with other shapes.
Shapes are part of a Collideable which in turn is part of a GameObject.
"""
def resolve_overlaps_with_shapes(self, shapes):
"Resolve this shape's overlap(s) with given list of shapes."
overlaps = []
@ -57,11 +61,11 @@ class CollisionShape:
return
# resolve collisions in order of largest -> smallest overlap
overlaps.sort(key=lambda item: item.area, reverse=True)
for i,old_overlap in enumerate(overlaps):
for i, old_overlap in enumerate(overlaps):
# resolve first overlap without recalculating
overlap = self.get_overlap(old_overlap.other) if i > 0 else overlaps[0]
self.resolve_overlap(overlap)
def resolve_overlap(self, overlap):
"Resolve this shape's given overlap."
other = overlap.other
@ -97,7 +101,7 @@ class CollisionShape:
world.try_object_method(self.go, self.go.started_colliding, [other.go])
if b_started_a:
world.try_object_method(other.go, other.go.started_colliding, [self.go])
def get_overlapping_static_shapes(self):
"Return a list of static shapes that overlap with this shape."
overlapping_shapes = []
@ -118,80 +122,118 @@ class CollisionShape:
else:
# skip if even bounds don't overlap
obj_left, obj_top, obj_right, obj_bottom = obj.get_edges()
if not boxes_overlap(shape_left, shape_top, shape_right, shape_bottom,
obj_left, obj_top, obj_right, obj_bottom):
if not boxes_overlap(
shape_left,
shape_top,
shape_right,
shape_bottom,
obj_left,
obj_top,
obj_right,
obj_bottom,
):
continue
overlapping_shapes += obj.collision.get_shapes_overlapping_box(shape_left, shape_top, shape_right, shape_bottom)
overlapping_shapes += obj.collision.get_shapes_overlapping_box(
shape_left, shape_top, shape_right, shape_bottom
)
return overlapping_shapes
class CircleCollisionShape(CollisionShape):
"CollisionShape using a circle area."
def __init__(self, loc_x, loc_y, radius, game_object):
self.x, self.y = loc_x, loc_y
self.radius = radius
self.go = game_object
def get_box(self):
"Return world coordinates of our bounds (left, top, right, bottom)"
return self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius
return (
self.x - self.radius,
self.y - self.radius,
self.x + self.radius,
self.y + self.radius,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius ** 2
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius**2
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this circle overlaps given line segment."
return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2)
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = point_circle_penetration(self.x, self.y,
other.x, other.y,
self.radius + other.radius)
px, py, pdist1, pdist2 = point_circle_penetration(
self.x, self.y, other.x, other.y, self.radius + other.radius
)
elif type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(self.x, self.y,
other.x, other.y,
self.radius, other.halfwidth,
other.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
self.x,
self.y,
other.x,
other.y,
self.radius,
other.halfwidth,
other.halfheight,
)
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
class AABBCollisionShape(CollisionShape):
"CollisionShape using an axis-aligned bounding box area."
def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object):
self.x, self.y = loc_x, loc_y
self.halfwidth, self.halfheight = halfwidth, halfheight
self.go = game_object
# for CST_TILE objects, lists of tile(s) we cover
self.tiles = []
def get_box(self):
return self.x - self.halfwidth, self.y - self.halfheight, self.x + self.halfwidth, self.y + self.halfheight
return (
self.x - self.halfwidth,
self.y - self.halfheight,
self.x + self.halfwidth,
self.y + self.halfheight,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
return point_in_box(x, y, *self.get_box())
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this box overlaps given line segment."
left, top, right, bottom = self.get_box()
return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2)
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = box_penetration(self.x, self.y,
other.x, other.y,
self.halfwidth, self.halfheight,
other.halfwidth, other.halfheight)
px, py, pdist1, pdist2 = box_penetration(
self.x,
self.y,
other.x,
other.y,
self.halfwidth,
self.halfheight,
other.halfwidth,
other.halfheight,
)
elif type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(other.x, other.y,
self.x, self.y,
other.radius, self.halfwidth,
self.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
other.x,
other.y,
self.x,
self.y,
other.radius,
self.halfwidth,
self.halfheight,
)
# reverse result if we're shape B
px, py = -px, -py
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
@ -200,8 +242,10 @@ class AABBCollisionShape(CollisionShape):
class Collideable:
"Collision component for GameObjects. Contains a list of shapes."
use_art_offset = False
"use game object's art_off_pct values"
def __init__(self, obj):
"Create new Collideable for given GameObject."
self.go = obj
@ -212,7 +256,7 @@ class Collideable:
self.contacts = {}
"Dict of contacts with other objects, by object name"
self.create_shapes()
def create_shapes(self):
"""
Create collision shape(s) appropriate to our game object's
@ -231,7 +275,7 @@ class Collideable:
# update renderables once if static
if not self.go.is_dynamic():
self.update_renderables()
def _clear_shapes(self):
for r in self.renderables:
r.destroy()
@ -240,41 +284,48 @@ class Collideable:
self.cl._remove_shape(shape)
self.shapes = []
"List of CollisionShapes"
def _create_circle(self):
x = self.go.x + self.go.col_offset_x
y = self.go.y + self.go.col_offset_y
shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go)
self.shapes = [shape]
self.renderables = [CircleCollisionRenderable(shape)]
def _create_box(self):
x = self.go.x # + self.go.col_offset_x
y = self.go.y # + self.go.col_offset_y
shape = self.cl._add_box_shape(x, y,
self.go.col_width / 2,
self.go.col_height / 2,
self.go)
x = self.go.x # + self.go.col_offset_x
y = self.go.y # + self.go.col_offset_y
shape = self.cl._add_box_shape(
x, y, self.go.col_width / 2, self.go.col_height / 2, self.go
)
self.shapes = [shape]
self.renderables = [BoxCollisionRenderable(shape)]
def _create_merged_tile_boxes(self):
"Create AABB shapes for a CST_TILE object"
# generate fewer, larger boxes!
frame = self.go.renderable.frame
if not self.go.col_layer_name in self.go.art.layer_names:
self.go.app.dev_log("%s: Couldn't find collision layer with name '%s'" % (self.go.name, self.go.col_layer_name))
if self.go.col_layer_name not in self.go.art.layer_names:
self.go.app.dev_log(
f"{self.go.name}: Couldn't find collision layer with name '{self.go.col_layer_name}'"
)
return
layer = self.go.art.layer_names.index(self.go.col_layer_name)
# tile is available if it's not empty and not already covered by a shape
def tile_available(tile_x, tile_y):
return self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0 and not (tile_x, tile_y) in self.tile_shapes
return (
self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0
and (tile_x, tile_y) not in self.tile_shapes
)
def tile_range_available(start_x, end_x, start_y, end_y):
for y in range(start_y, end_y + 1):
for x in range(start_x, end_x + 1):
if not tile_available(x, y):
return False
return True
for y in range(self.go.art.height):
for x in range(self.go.art.width):
if not tile_available(x, y):
@ -286,7 +337,9 @@ class Collideable:
end_x += 1
# then fill top to bottom
end_y = y
while end_y < self.go.art.height - 1 and tile_range_available(x, end_x, y, end_y + 1):
while end_y < self.go.art.height - 1 and tile_range_available(
x, end_x, y, end_y + 1
):
end_y += 1
# compute origin and halfsizes of box covering tile range
wx1, wy1 = self.go.get_tile_loc(x, y, tile_center=True)
@ -299,8 +352,7 @@ class Collideable:
halfheight = (end_y - y) * self.go.art.quad_height
halfheight /= 2
halfheight += self.go.art.quad_height / 2
shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight,
self.go)
shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight, self.go)
# fill in cell(s) in our tile collision dict,
# write list of tiles shape covers to shape.tiles
for tile_y in range(y, end_y + 1):
@ -312,26 +364,26 @@ class Collideable:
r.update()
self.shapes.append(shape)
self.renderables.append(r)
def get_shape_overlapping_point(self, x, y):
"Return shape if it's overlapping given point, None if no overlap."
tile_x, tile_y = self.go.get_tile_at_point(x, y)
return self.tile_shapes.get((tile_x, tile_y), None)
def get_shapes_overlapping_box(self, left, top, right, bottom):
"Return a list of our shapes that overlap given box."
shapes = []
tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom)
for (x, y) in tiles:
for x, y in tiles:
shape = self.tile_shapes.get((x, y), None)
if shape and not shape in shapes:
if shape and shape not in shapes:
shapes.append(shape)
return shapes
def update(self):
if self.go and self.go.is_dynamic():
self.update_transform_from_object()
def update_transform_from_object(self, obj=None):
"Snap our shapes to location of given object (if unspecified, our GO)."
obj = obj or self.go
@ -341,7 +393,7 @@ class Collideable:
for shape in self.shapes:
shape.x = obj.x + obj.col_offset_x
shape.y = obj.y + obj.col_offset_y
def set_shape_color(self, shape, new_color):
"Set the color of a given shape's debug LineRenderable."
try:
@ -351,15 +403,15 @@ class Collideable:
self.renderables[shape_index].color = new_color
self.renderables[shape_index].build_geo()
self.renderables[shape_index].rebind_buffers()
def update_renderables(self):
for r in self.renderables:
r.update()
def render(self):
for r in self.renderables:
r.render()
def destroy(self):
for r in self.renderables:
r.destroy()
@ -373,26 +425,28 @@ class CollisionLord:
Collision manager object, tracks Collideables, detects overlaps and
resolves collisions.
"""
iterations = 7
"""
Number of times to resolve collisions per update. Lower at own risk;
multi-object collisions require multiple iterations to settle correctly.
"""
def __init__(self, world):
self.world = world
self.ticks = 0
# list of objects processed for collision this frame
self.collisions_this_frame = []
self.reset()
def report(self):
print('%s: %s dynamic shapes, %s static shapes' % (self,
len(self.dynamic_shapes),
len(self.static_shapes)))
print(
f"{self}: {len(self.dynamic_shapes)} dynamic shapes, {len(self.static_shapes)} static shapes"
)
def reset(self):
self.dynamic_shapes, self.static_shapes = [], []
def _add_circle_shape(self, x, y, radius, game_object):
shape = CircleCollisionShape(x, y, radius, game_object)
if game_object.is_dynamic():
@ -400,7 +454,7 @@ class CollisionLord:
else:
self.static_shapes.append(shape)
return shape
def _add_box_shape(self, x, y, halfwidth, halfheight, game_object):
shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object)
if game_object.is_dynamic():
@ -408,16 +462,16 @@ class CollisionLord:
else:
self.static_shapes.append(shape)
return shape
def _remove_shape(self, shape):
if shape in self.dynamic_shapes:
self.dynamic_shapes.remove(shape)
elif shape in self.static_shapes:
self.static_shapes.remove(shape)
def update(self):
"Resolve overlaps between all relevant world objects."
for i in range(self.iterations):
for _ in range(self.iterations):
# filter shape lists for anything out of room etc
valid_dynamic_shapes = []
for shape in self.dynamic_shapes:
@ -437,19 +491,25 @@ class CollisionLord:
# collision handling
def point_in_box(x, y, box_left, box_top, box_right, box_bottom):
"Return True if given point lies within box with given corners."
return box_left <= x <= box_right and box_bottom <= y <= box_top
def boxes_overlap(left_a, top_a, right_a, bottom_a,
left_b, top_b, right_b, bottom_b):
def boxes_overlap(left_a, top_a, right_a, bottom_a, left_b, top_b, right_b, bottom_b):
"Return True if given boxes A and B overlap."
for (x, y) in ((left_a, top_a), (right_a, top_a),
(right_a, bottom_a), (left_a, bottom_a)):
for x, y in (
(left_a, top_a),
(right_a, top_a),
(right_a, bottom_a),
(left_a, bottom_a),
):
if left_b <= x <= right_b and bottom_b <= y <= top_b:
return True
return False
def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4):
"Return True if given lines intersect."
denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
@ -465,6 +525,7 @@ def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4):
ub = numer2 / denom
return ua >= 0 and ua <= 1 and ub >= 0 and ub <= 1
def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
"Return point on given line that's closest to given point."
wx, wy = point_x - x1, point_y - y1
@ -473,7 +534,7 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
if proj <= 0:
# line point 1 is closest
return x1, y1
vsq = dir_x ** 2 + dir_y ** 2
vsq = dir_x**2 + dir_y**2
if proj >= vsq:
# line point 2 is closest
return x2, y2
@ -481,25 +542,32 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
# closest point is between 1 and 2
return x1 + (proj / vsq) * dir_x, y1 + (proj / vsq) * dir_y
def circle_overlaps_line(circle_x, circle_y, radius, x1, y1, x2, y2):
"Return True if given circle overlaps given line."
# get closest point on line to circle center
closest_x, closest_y = line_point_closest_to_point(circle_x, circle_y,
x1, y1, x2, y2)
closest_x, closest_y = line_point_closest_to_point(
circle_x, circle_y, x1, y1, x2, y2
)
dist_x, dist_y = closest_x - circle_x, closest_y - circle_y
return dist_x ** 2 + dist_y ** 2 <= radius ** 2
return dist_x**2 + dist_y**2 <= radius**2
def box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2):
"Return True if given box overlaps given line."
# TODO: determine if this is less efficient than slab method below
if point_in_box(x1, y1, left, top, right, bottom) and \
point_in_box(x2, y2, left, top, right, bottom):
if point_in_box(x1, y1, left, top, right, bottom) and point_in_box(
x2, y2, left, top, right, bottom
):
return True
# check left/top/right/bottoms edges
return lines_intersect(left, top, left, bottom, x1, y1, x2, y2) or \
lines_intersect(left, top, right, top, x1, y1, x2, y2) or \
lines_intersect(right, top, right, bottom, x1, y1, x2, y2) or \
lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2)
return (
lines_intersect(left, top, left, bottom, x1, y1, x2, y2)
or lines_intersect(left, top, right, top, x1, y1, x2, y2)
or lines_intersect(right, top, right, bottom, x1, y1, x2, y2)
or lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2)
)
def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
"Return True if given box overlaps given ray."
@ -519,16 +587,18 @@ def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
tmax = min(tmax, max(ty1, ty2))
return tmax >= tmin
def point_circle_penetration(point_x, point_y, circle_x, circle_y, radius):
"Return normalized penetration x, y, and distance for given circles."
dx, dy = circle_x - point_x, circle_y - point_y
pdist = math.sqrt(dx ** 2 + dy ** 2)
pdist = math.sqrt(dx**2 + dy**2)
# point is center of circle, arbitrarily project out in +X
if pdist == 0:
return 1, 0, -radius, -radius
# TODO: calculate other axis of intersection for area?
return dx / pdist, dy / pdist, pdist - radius, pdist - radius
def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
"Return penetration vector and magnitude for given boxes."
left_a, right_a = ax - ahw, ax + ahw
@ -553,25 +623,34 @@ def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
elif dy < 0:
return 0, -1, -py, -px
def circle_box_penetration(circle_x, circle_y, box_x, box_y, circle_radius,
box_hw, box_hh):
def circle_box_penetration(
circle_x, circle_y, box_x, box_y, circle_radius, box_hw, box_hh
):
"Return penetration vector and magnitude for given circle and box."
box_left, box_right = box_x - box_hw, box_x + box_hw
box_top, box_bottom = box_y + box_hh, box_y - box_hh
# if circle center inside box, use box-on-box penetration vector + distance
if point_in_box(circle_x, circle_y, box_left, box_top, box_right, box_bottom):
return box_penetration(circle_x, circle_y, box_x, box_y,
circle_radius, circle_radius, box_hw, box_hh)
return box_penetration(
circle_x,
circle_y,
box_x,
box_y,
circle_radius,
circle_radius,
box_hw,
box_hh,
)
# find point on AABB edges closest to center of circle
# clamp = min(highest, max(lowest, val))
px = min(box_right, max(box_left, circle_x))
py = min(box_top, max(box_bottom, circle_y))
closest_x = circle_x - px
closest_y = circle_y - py
d = math.sqrt(closest_x ** 2 + closest_y ** 2)
d = math.sqrt(closest_x**2 + closest_y**2)
pdist = circle_radius - d
if d == 0:
return
1, 0, -pdist, -pdist
# TODO: calculate other axis of intersection for area?
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
from OpenGL import GL
import vector
from edit_command import EditCommand
from renderable_sprite import UISpriteRenderable
from . import vector
from .edit_command import EditCommand
from .renderable_sprite import UISpriteRenderable
"""
reference diagram:
@ -24,38 +26,29 @@ OUTSIDE_EDGE_SIZE = 0.2
THICKNESS = 0.1
corner_verts = [
0, 0, # A/0
OUTSIDE_EDGE_SIZE, 0, # B/1
OUTSIDE_EDGE_SIZE, -THICKNESS, # C/2
THICKNESS, -THICKNESS, # D/3
THICKNESS, -OUTSIDE_EDGE_SIZE, # E/4
0, -OUTSIDE_EDGE_SIZE # F/5
0,
0, # A/0
OUTSIDE_EDGE_SIZE,
0, # B/1
OUTSIDE_EDGE_SIZE,
-THICKNESS, # C/2
THICKNESS,
-THICKNESS, # D/3
THICKNESS,
-OUTSIDE_EDGE_SIZE, # E/4
0,
-OUTSIDE_EDGE_SIZE, # F/5
]
# vert indices for the above
corner_elems = [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 5, 4
]
corner_elems = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 5, 4]
# X/Y flip transforms to make all 4 corners
# (top left, top right, bottom left, bottom right)
corner_transforms = [
( 1, 1),
(-1, 1),
( 1, -1),
(-1, -1)
]
corner_transforms = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
# offsets to translate the 4 corners by
corner_offsets = [
(0, 0),
(1, 0),
(0, -1),
(1, -1)
]
corner_offsets = [(0, 0), (1, 0), (0, -1), (1, -1)]
BASE_COLOR = (0.8, 0.8, 0.8, 1)
@ -63,14 +56,14 @@ BASE_COLOR = (0.8, 0.8, 0.8, 1)
# because a static vertex list wouldn't be able to adjust to different
# character set aspect ratios.
class Cursor:
vert_shader_source = 'cursor_v.glsl'
frag_shader_source = 'cursor_f.glsl'
vert_shader_source = "cursor_v.glsl"
frag_shader_source = "cursor_f.glsl"
alpha = 1
icon_scale_factor = 4
logg = False
def __init__(self, app):
self.app = app
self.x, self.y, self.z = 0, 0, 0
@ -92,29 +85,40 @@ class Cursor:
self.elem_array = np.array(corner_elems, dtype=np.uint32)
self.vert_count = int(len(self.elem_array))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
# shader, attributes
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# vert positions
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, 2,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.color_uniform = self.shader.get_uniform_location('baseColor')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.xform_uniform = self.shader.get_uniform_location('vertTransform')
self.offset_uniform = self.shader.get_uniform_location('vertOffset')
self.alpha_uniform = self.shader.get_uniform_location('baseAlpha')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.color_uniform = self.shader.get_uniform_location("baseColor")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.xform_uniform = self.shader.get_uniform_location("vertTransform")
self.offset_uniform = self.shader.get_uniform_location("vertOffset")
self.alpha_uniform = self.shader.get_uniform_location("baseAlpha")
# finish
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
@ -122,11 +126,11 @@ class Cursor:
GL.glBindVertexArray(0)
# init tool sprite, tool will provide texture when rendered
self.tool_sprite = UISpriteRenderable(self.app)
def clamp_to_active_art(self):
self.x = max(0, min(self.x, self.app.ui.active_art.width - 1))
self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1))
def keyboard_move(self, delta_x, delta_y):
if not self.app.ui.active_art:
return
@ -136,11 +140,13 @@ class Cursor:
self.moved = True
self.app.keyboard_editing = True
if self.logg:
self.app.log('Cursor: %s,%s,%s scale %.2f,%.2f' % (self.x, self.y, self.z, self.scale_x, self.scale_y))
self.app.log(
f"Cursor: {self.x},{self.y},{self.z} scale {self.scale_x:.2f},{self.scale_y:.2f}"
)
def set_scale(self, new_scale):
self.scale_x = self.scale_y = new_scale
def get_tile(self):
# adjust for brush size
size = self.app.ui.selected_tool.brush_size
@ -149,7 +155,7 @@ class Cursor:
return int(self.x + size_offset), int(-self.y + size_offset)
else:
return int(self.x), int(-self.y)
def center_in_art(self):
art = self.app.ui.active_art
if not art:
@ -157,20 +163,20 @@ class Cursor:
self.x = round(art.width / 2) * art.quad_width
self.y = round(-art.height / 2) * art.quad_height
self.moved = True
# !!TODO!! finish this, work in progress
def get_tiles_under_drag(self):
"""
returns list of tuple coordinates of all tiles under cursor's current
position AND tiles it's moved over since last update
"""
# TODO: get vector of last to current position, for each tile under
# current brush, do line trace along grid towards last point
# TODO: this works in two out of four diagonals,
# swap current and last positions to determine delta?
if self.last_x <= self.x:
x0, y0 = self.last_x, -self.last_y
x1, y1 = self.x, -self.y
@ -178,10 +184,10 @@ class Cursor:
x0, y0 = self.x, -self.y
x1, y1 = self.last_x, -self.last_y
tiles = vector.get_tiles_along_line(x0, y0, x1, y1)
print('drag from %s,%s to %s,%s:' % (x0, y0, x1, y1))
print(f"drag from {x0},{y0} to {x1},{y1}:")
print(tiles)
return tiles
def get_tiles_under_brush(self):
"""
returns list of tuple coordinates of all tiles under the cursor @ its
@ -194,11 +200,11 @@ class Cursor:
for x in range(x_start, x_start + size):
tiles.append((x, y))
return tiles
def undo_preview_edits(self):
for edit in self.preview_edits:
edit.undo()
def update_cursor_preview(self):
# rebuild list of cursor preview commands
if self.app.ui.selected_tool.show_preview:
@ -207,9 +213,12 @@ class Cursor:
edit.apply()
else:
self.preview_edits = []
def start_paint(self):
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
if self.app.ui.selected_tool is self.app.ui.grab_tool:
self.app.ui.grab_tool.grab()
@ -219,11 +228,14 @@ class Cursor:
self.current_command.add_command_tiles(self.preview_edits)
self.preview_edits = []
self.app.ui.active_art.set_unsaved_changes(True)
#print(self.app.ui.active_art.command_stack)
# print(self.app.ui.active_art.command_stack)
def finish_paint(self):
"invoked by mouse button up and undo"
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
# push current command group onto undo stack
if not self.current_command:
@ -234,25 +246,27 @@ class Cursor:
# tools like rotate produce a different change each time, so update again
if self.app.ui.selected_tool.update_preview_after_paint:
self.update_cursor_preview()
#print(self.app.ui.active_art.command_stack)
# print(self.app.ui.active_art.command_stack)
def moved_this_frame(self):
return self.moved or \
int(self.last_x) != int(self.x) or \
int(self.last_y) != int(self.y)
return (
self.moved
or int(self.last_x) != int(self.x)
or int(self.last_y) != int(self.y)
)
def reposition_from_mouse(self):
self.x, self.y, _ = vector.screen_to_world(self.app,
self.app.mouse_x,
self.app.mouse_y)
self.x, self.y, _ = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
def snap_to_tile(self):
w, h = self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height
char_aspect = w / h
# round result for oddly proportioned charsets
self.x = round(math.floor(self.x / w) * w)
self.y = round(math.ceil(self.y / h) * h * char_aspect)
def pre_first_update(self):
# vector.screen_to_world result will be off because camera hasn't
# moved yet, recalc view matrix
@ -261,16 +275,18 @@ class Cursor:
self.snap_to_tile()
self.update_cursor_preview()
self.entered_new_tile()
def update(self):
# save old positions before update
self.last_x, self.last_y = self.x, self.y
# pulse alpha and scale
self.alpha = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)
#self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
# self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
mouse_moved = self.app.mouse_dx != 0 or self.app.mouse_dy != 0
# update cursor from mouse if: mouse moved, camera moved w/o keyboard
if mouse_moved or (not self.app.keyboard_editing and self.app.camera.moved_this_frame):
if mouse_moved or (
not self.app.keyboard_editing and self.app.camera.moved_this_frame
):
# don't let mouse move cursor if text tool input is happening
if not self.app.ui.text_tool.input_active:
self.reposition_from_mouse()
@ -297,36 +313,50 @@ class Cursor:
self.update_cursor_preview()
if self.moved_this_frame():
self.entered_new_tile()
def end_update(self):
"called at the end of App.update"
self.moved = False
def entered_new_tile(self):
if self.current_command and self.app.ui.selected_tool.paint_while_dragging:
# add new tile(s) to current command group
self.current_command.add_command_tiles(self.preview_edits)
self.app.ui.active_art.set_unsaved_changes(True)
self.preview_edits = []
def render(self):
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix)
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix)
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix
)
GL.glUniform3f(self.position_uniform, self.x, self.y, self.z)
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform4fv(self.color_uniform, 1, self.color)
GL.glUniform2f(self.quad_size_uniform, self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height)
GL.glUniform2f(
self.quad_size_uniform,
self.app.ui.active_art.quad_width,
self.app.ui.active_art.quad_height,
)
GL.glUniform1f(self.alpha_uniform, self.alpha)
# VAO vs non-VAO paths
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
attrib = self.shader.get_attrib_location # for brevity
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(attrib('vertPosition'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0,
ctypes.c_void_p(0))
GL.glEnableVertexAttribArray(attrib('vertPosition'))
GL.glVertexAttribPointer(
attrib("vertPosition"),
2,
GL.GL_FLOAT,
GL.GL_FALSE,
0,
ctypes.c_void_p(0),
)
GL.glEnableVertexAttribArray(attrib("vertPosition"))
# bind elem array instead of passing it to glDrawElements - latter
# sends pyopengl a new array, which is deprecated and breaks on Mac.
# thanks Erin Congden!
@ -335,12 +365,13 @@ class Cursor:
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
# draw 4 corners
for i in range(4):
tx,ty = corner_transforms[i][0], corner_transforms[i][1]
ox,oy = corner_offsets[i][0], corner_offsets[i][1]
tx, ty = corner_transforms[i][0], corner_transforms[i][1]
ox, oy = corner_offsets[i][0], corner_offsets[i][1]
GL.glUniform2f(self.xform_uniform, tx, ty)
GL.glUniform2f(self.offset_uniform, ox, oy)
GL.glDrawElements(GL.GL_TRIANGLES, self.vert_count,
GL.GL_UNSIGNED_INT, None)
GL.glDrawElements(
GL.GL_TRIANGLES, self.vert_count, GL.GL_UNSIGNED_INT, None
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
@ -354,7 +385,6 @@ class Cursor:
else:
self.tool_sprite.texture = ui.selected_tool.get_icon_texture()
# 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.icon_scale_factor * self.app.ui.scale
self.tool_sprite.scale_x = scale_x

View file

@ -1,9 +1,6 @@
import time
class EditCommand:
"undo/redo-able representation of an art edit (eg paint, erase) operation"
def __init__(self, art):
self.art = art
self.start_time = art.app.get_elapsed_time()
@ -12,57 +9,57 @@ class EditCommand:
# this prevents multiple commands operating on the same tile
# from stomping each other
self.tile_commands = {}
def get_number_of_commands(self):
commands = 0
for frame in self.tile_commands.values():
for layer in frame.values():
for column in layer.values():
for tile in column.values():
for _tile in column.values():
commands += 1
return commands
def __str__(self):
# get unique-ish ID from memory address
addr = self.__repr__()
addr = addr[addr.find('0'):-1]
s = 'EditCommand_%s: %s tiles, time %s' % (addr, self.get_number_of_commands(),
self.finish_time)
addr = addr[addr.find("0") : -1]
s = f"EditCommand_{addr}: {self.get_number_of_commands()} tiles, time {self.finish_time}"
return s
def add_command_tiles(self, new_command_tiles):
for ct in new_command_tiles:
# create new tables for frames/layers/columns if not present
if not ct.frame in self.tile_commands:
if ct.frame not in self.tile_commands:
self.tile_commands[ct.frame] = {}
if not ct.layer in self.tile_commands[ct.frame]:
if ct.layer not in self.tile_commands[ct.frame]:
self.tile_commands[ct.frame][ct.layer] = {}
if not ct.y in self.tile_commands[ct.frame][ct.layer]:
if ct.y not in self.tile_commands[ct.frame][ct.layer]:
self.tile_commands[ct.frame][ct.layer][ct.y] = {}
# preserve "before" state of any command we overwrite
if ct.x in self.tile_commands[ct.frame][ct.layer][ct.y]:
old_ct = self.tile_commands[ct.frame][ct.layer][ct.y][ct.x]
ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg,
old_ct.b_xform)
ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg, old_ct.b_xform)
self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] = ct
def undo_commands_for_tile(self, frame, layer, x, y):
# no commands at all yet, maybe
if len(self.tile_commands) == 0:
return
# tile might not have undo commands, eg text entry beyond start region
if not y in self.tile_commands[frame][layer] or \
not x in self.tile_commands[frame][layer][y]:
if (
y not in self.tile_commands[frame][layer]
or x not in self.tile_commands[frame][layer][y]
):
return
self.tile_commands[frame][layer][y][x].undo()
def undo(self):
for frame in self.tile_commands.values():
for layer in frame.values():
for column in layer.values():
for tile_command in column.values():
tile_command.undo()
def apply(self):
for frame in self.tile_commands.values():
for layer in frame.values():
@ -72,15 +69,14 @@ class EditCommand:
class EntireArtCommand:
"""
undo/redo-able representation of a whole-art operation, eg:
resize/crop, run art script, add/remove layer, etc
"""
# art arrays to grab
array_types = ['chars', 'fg_colors', 'bg_colors', 'uv_mods']
array_types = ["chars", "fg_colors", "bg_colors", "uv_mods"]
def __init__(self, art, origin_x=0, origin_y=0):
self.art = art
# remember origin of resize command
@ -88,14 +84,14 @@ class EntireArtCommand:
self.before_frame = art.active_frame
self.before_layer = art.active_layer
self.start_time = self.finish_time = art.app.get_elapsed_time()
def save_tiles(self, before=True):
# save copies of tile data lists
prefix = 'b' if before else 'a'
prefix = "b" if before else "a"
for atype in self.array_types:
# save list as eg "b_chars" for "character data before operation"
src_data = getattr(self.art, atype)
var_name = '%s_%s' % (prefix, atype)
var_name = f"{prefix}_{atype}"
# deep copy each frame's data, else before == after
new_data = []
for frame in src_data:
@ -105,7 +101,7 @@ class EntireArtCommand:
self.before_size = (self.art.width, self.art.height)
else:
self.after_size = (self.art.width, self.art.height)
def undo(self):
# undo might remove frames/layers that were added
self.art.set_active_frame(self.before_frame)
@ -114,19 +110,19 @@ class EntireArtCommand:
x, y = self.before_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'b_' + atype)
new_data = getattr(self, "b_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
# Art.resize will set geo_changed and mark all frames changed
self.art.app.ui.adjust_for_art_resize(self.art)
self.art.mark_all_frames_changed()
def apply(self):
if self.before_size != self.after_size:
x, y = self.after_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'a_' + atype)
new_data = getattr(self, "a_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
self.art.app.ui.adjust_for_art_resize(self.art)
@ -134,7 +130,6 @@ class EntireArtCommand:
class EditCommandTile:
def __init__(self, art):
self.art = art
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.b_char = self.b_fg = self.b_bg = self.b_xform = None
self.a_char = self.a_fg = self.a_bg = self.a_xform = None
def __str__(self):
s = 'F%s L%s %s,%s @ %.2f: ' % (self.frame, self.layer, str(self.x).rjust(2, '0'), str(self.y).rjust(2, '0'), self.creation_time)
s += 'c%s f%s b%s x%s -> ' % (self.b_char, self.b_fg, self.b_bg, self.b_xform)
s += 'c%s f%s b%s x%s' % (self.a_char, self.a_fg, self.a_bg, self.a_xform)
s = "F{} L{} {},{} @ {:.2f}: ".format(
self.frame,
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
def __eq__(self, value):
return self.frame == value.frame and self.layer == value.layer and \
self.x == value.x and self.y == value.y and \
self.b_char == value.b_char and self.b_fg == value.b_fg and \
self.b_bg == value.b_bg and self.b_xform == value.b_xform and \
self.a_char == value.a_char and self.a_fg == value.a_fg and \
self.a_bg == value.a_bg and self.a_xform == value.a_xform
return (
self.frame == value.frame
and self.layer == value.layer
and self.x == value.x
and self.y == value.y
and self.b_char == value.b_char
and self.b_fg == value.b_fg
and self.b_bg == value.b_bg
and self.b_xform == value.b_xform
and self.a_char == value.a_char
and self.a_fg == value.a_fg
and self.a_bg == value.a_bg
and self.a_xform == value.a_xform
)
def copy(self):
"returns a deep copy of this tile command"
new_ect = EditCommandTile(self.art)
# TODO: old or new timestamp? does it matter?
#new_ect.creation_time = self.art.app.get_elapsed_time()
# new_ect.creation_time = self.art.app.get_elapsed_time()
new_ect.creation_time = self.creation_time
# copy all properties
new_ect.frame, new_ect.layer = self.frame, self.layer
@ -173,22 +182,27 @@ class EditCommandTile:
new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform
new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg
return new_ect
def set_tile(self, frame, layer, x, y):
self.frame, self.layer = frame, layer
self.x, self.y = x, y
def set_before(self, char, fg, bg, xform):
self.b_char, self.b_xform = char, xform
self.b_fg, self.b_bg = fg, bg
def set_after(self, char, fg, bg, xform):
self.a_char, self.a_xform = char, xform
self.a_fg, self.a_bg = fg, bg
def is_null(self):
return self.a_char == self.b_char and self.a_fg == self.b_fg and self.a_bg == self.b_bg and self.a_xform == self.b_xform
return (
self.a_char == self.b_char
and self.a_fg == self.b_fg
and self.a_bg == self.b_bg
and self.a_xform == self.b_xform
)
def undo(self):
# tile's frame or layer may have been deleted
if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1:
@ -196,37 +210,64 @@ class EditCommandTile:
if self.x >= self.art.width or self.y >= self.art.height:
return
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.b_char, self.b_fg, self.b_bg, self.b_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.b_char,
self.b_fg,
self.b_bg,
self.b_xform,
set_all,
)
def apply(self):
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.a_char, self.a_fg, self.a_bg, self.a_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.a_char,
self.a_fg,
self.a_bg,
self.a_xform,
set_all,
)
class CommandStack:
def __init__(self, art):
self.art = art
self.undo_commands, self.redo_commands = [], []
def __str__(self):
s = 'stack for %s:\n' % self.art.filename
s += '===\nundo:\n'
s = f"stack for {self.art.filename}:\n"
s += "===\nundo:\n"
for cmd in self.undo_commands:
s += str(cmd) + '\n'
s += '\n===\nredo:\n'
s += str(cmd) + "\n"
s += "\n===\nredo:\n"
for cmd in self.redo_commands:
s += str(cmd) + '\n'
s += str(cmd) + "\n"
return s
def commit_commands(self, new_commands):
self.undo_commands += new_commands[:]
self.clear_redo()
def undo(self):
if len(self.undo_commands) == 0:
return
@ -235,7 +276,7 @@ class CommandStack:
command.undo()
self.redo_commands.append(command)
self.art.app.cursor.update_cursor_preview()
def redo(self):
if len(self.redo_commands) == 0:
return
@ -247,6 +288,6 @@ class CommandStack:
# add to end of undo stack
self.undo_commands.append(command)
self.art.app.cursor.update_cursor_preview()
def clear_redo(self):
self.redo_commands = []

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,35 @@
from .game_object import GameObject
from game_object import GameObject
class GameRoom:
"""
A collection of GameObjects within a GameWorld. Can be used to limit scope
of object updates, collisions, etc.
"""
camera_marker_name = ''
camera_marker_name = ""
"If set, camera will move to marker with this name when room entered"
camera_follow_player = False
"If True, camera will follow player while in this room"
left_edge_warp_dest_name, right_edge_warp_dest_name = '', ''
left_edge_warp_dest_name, right_edge_warp_dest_name = "", ""
"If set, warp to room OR marker with this name when edge crossed"
top_edge_warp_dest_name, bottom_edge_warp_dest_name = '', ''
warp_edge_bounds_obj_name = ''
"Object whose art's bounds should be used as our \"edges\" for above"
serialized = ['name', 'camera_marker_name', 'left_edge_warp_dest_name',
'right_edge_warp_dest_name', 'top_edge_warp_dest_name',
'bottom_edge_warp_dest_name', 'warp_edge_bounds_obj_name',
'camera_follow_player']
top_edge_warp_dest_name, bottom_edge_warp_dest_name = "", ""
warp_edge_bounds_obj_name = ""
'Object whose art\'s bounds should be used as our "edges" for above'
serialized = [
"name",
"camera_marker_name",
"left_edge_warp_dest_name",
"right_edge_warp_dest_name",
"top_edge_warp_dest_name",
"bottom_edge_warp_dest_name",
"warp_edge_bounds_obj_name",
"camera_follow_player",
]
"List of string names of members to serialize for this Room class."
log_changes = False
"Log changes to and from this room"
def __init__(self, world, name, room_data=None):
self.world = world
self.name = name
@ -34,8 +42,10 @@ class GameRoom:
# TODO: this is copy-pasted from GameObject, find a way to unify
# TODO: GameWorld.set_data_for that takes instance, serialized list, data dict
for v in self.serialized:
if not v in room_data:
self.world.app.dev_log("Serialized property '%s' not found for room %s" % (v, self.name))
if v not in room_data:
self.world.app.dev_log(
f"Serialized property '{v}' not found for room {self.name}"
)
continue
if not hasattr(self, v):
setattr(self, v, None)
@ -47,18 +57,19 @@ class GameRoom:
else:
setattr(self, v, room_data[v])
# find objects by name and add them
for obj_name in room_data.get('objects', []):
for obj_name in room_data.get("objects", []):
self.add_object_by_name(obj_name)
def pre_first_update(self):
self.reset_edge_warps()
def reset_edge_warps(self):
self.edge_obj = self.world.objects.get(self.warp_edge_bounds_obj_name, None)
# no warping if we don't know our bounds
if not self.edge_obj:
return
edge_dest_name_suffix = '_name'
edge_dest_name_suffix = "_name"
def set_edge_dest(dest_property):
# property name to destination name
dest_name = getattr(self, dest_property)
@ -66,30 +77,35 @@ class GameRoom:
dest_room = self.world.rooms.get(dest_name, None)
dest_obj = self.world.objects.get(dest_name, None)
# derive member name from serialized property name
member_name = dest_property.replace(edge_dest_name_suffix, '')
member_name = dest_property.replace(edge_dest_name_suffix, "")
setattr(self, member_name, dest_room or dest_obj or None)
for pname in ['left_edge_warp_dest_name', 'right_edge_warp_dest_name',
'top_edge_warp_dest_name', 'bottom_edge_warp_dest_name']:
for pname in [
"left_edge_warp_dest_name",
"right_edge_warp_dest_name",
"top_edge_warp_dest_name",
"bottom_edge_warp_dest_name",
]:
set_edge_dest(pname)
def set_camera_marker_name(self, marker_name):
if not marker_name in self.world.objects:
self.world.app.log("Couldn't find camera marker with name %s" % marker_name)
if marker_name not in self.world.objects:
self.world.app.log(f"Couldn't find camera marker with name {marker_name}")
return
self.camera_marker_name = marker_name
if self is self.world.current_room:
self.use_camera_marker()
def use_camera_marker(self):
if not self.camera_marker_name in self.world.objects:
if self.camera_marker_name not in self.world.objects:
return
cam_mark = self.world.objects[self.camera_marker_name]
self.world.camera.set_loc_from_obj(cam_mark)
def entered(self, old_room):
"Run when the player enters this room."
if self.log_changes:
self.world.app.log('Room "%s" entered' % self.name)
self.world.app.log(f'Room "{self.name}" entered')
# set camera if marker is set
if self.world.room_camera_changes_enabled:
self.use_camera_marker()
@ -100,62 +116,71 @@ class GameRoom:
# tell objects in this room player has entered so eg spawners can fire
for obj in self.objects.values():
obj.room_entered(self, old_room)
def exited(self, new_room):
"Run when the player exits this room."
if self.log_changes:
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
for obj in self.objects.values():
obj.room_exited(self, new_room)
def add_object_by_name(self, obj_name):
"Add object with given name to this room."
obj = self.world.objects.get(obj_name, None)
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
self.add_object(obj)
def add_object(self, obj):
"Add object (by reference) to this room."
self.objects[obj.name] = obj
obj.rooms[self.name] = self
def remove_object_by_name(self, obj_name):
"Remove object with given name from this room."
obj = self.world.objects.get(obj_name, None)
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
self.remove_object(obj)
def remove_object(self, obj):
"Remove object (by reference) from this room."
if obj.name in self.objects:
self.objects.pop(obj.name)
else:
self.world.app.log("GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name))
self.world.app.log(
f"GameRoom {self.name} doesn't contain GameObject {obj.name}"
)
if self.name in obj.rooms:
obj.rooms.pop(self.name)
else:
self.world.app.log("GameObject %s not found in GameRoom %s" % (obj.name, self.name))
self.world.app.log(
f"GameObject {obj.name} not found in GameRoom {self.name}"
)
def get_dict(self):
"Return a dict that GameWorld.save_to_file can dump to JSON"
object_names = list(self.objects.keys())
d = {'class_name': type(self).__name__, 'objects': object_names}
d = {"class_name": type(self).__name__, "objects": object_names}
# serialize whatever other vars are declared in self.serialized
for prop_name in self.serialized:
if hasattr(self, prop_name):
d[prop_name] = getattr(self, prop_name)
return d
def _check_edge_warp(self, game_object):
# bail if no bounds or edge warp destinations set
if not self.edge_obj:
return
if not self.left_edge_warp_dest and not self.right_edge_warp_dest and not self.top_edge_warp_dest and not self.bottom_edge_warp_dest:
if (
not self.left_edge_warp_dest
and not self.right_edge_warp_dest
and not self.top_edge_warp_dest
and not self.bottom_edge_warp_dest
):
return
if game_object.warped_recently():
return
@ -181,11 +206,11 @@ class GameRoom:
# TODO: change room or not? use_marker_room flag a la WarpTrigger?
game_object.set_loc(warp_dest.x, warp_dest.y)
game_object.last_warp_update = self.world.updates
def update(self):
if self is self.world.current_room:
self._check_edge_warp(self.world.player)
def destroy(self):
if self.name in self.world.rooms:
self.world.rooms.pop(self.name)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import numpy as np
from renderable_line import LineRenderable
from .renderable_line import LineRenderable
# 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)
EXTENTS_COLOR = (0, 0, 0, 1)
class Grid(LineRenderable):
visible = True
draw_axes = False
def get_tile_size(self):
"Returns (width, height) grid size in tiles."
return 1, 1
def build_geo(self):
"build vert, element, and color arrays"
w, h = self.get_tile_size()
@ -28,7 +28,7 @@ class Grid(LineRenderable):
index = 4
# axes - Y and X
if self.draw_axes:
v += [(w/2, -h), (w/2, 0), (0, -h/2), (w, -h/2)]
v += [(w / 2, -h), (w / 2, 0), (0, -h / 2), (w, -h / 2)]
e += [4, 5, 6, 7]
color = AXIS_COLOR
c += color * 4
@ -37,69 +37,67 @@ class Grid(LineRenderable):
color = BASE_COLOR
for x in range(1, w):
# skip middle line
if not self.draw_axes or x != w/2:
if not self.draw_axes or x != w / 2:
v += [(x, -h), (x, 0)]
e += [index, index+1]
e += [index, index + 1]
c += color * 2
index += 2
for y in range(1, h):
if not self.draw_axes or y != h/2:
if not self.draw_axes or y != h / 2:
v += [(0, -y), (w, -y)]
e += [index, index+1]
e += [index, index + 1]
c += color * 2
index += 2
self.vert_array = np.array(v, dtype=np.float32)
self.elem_array = np.array(e, dtype=np.uint32)
self.color_array = np.array(c, dtype=np.float32)
def reset_loc(self):
self.x = 0
self.y = 0
self.z = 0
def reset(self):
"macro for convenience - rescale, reposition, update renderable"
self.build_geo()
self.reset_loc()
self.rebind_buffers()
def update(self):
pass
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
class ArtGrid(Grid):
def reset_loc(self):
self.x, self.y = 0, 0
self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer]
def reset(self):
self.quad_size_ref = self.app.ui.active_art
Grid.reset(self)
def get_tile_size(self):
return self.app.ui.active_art.width, self.app.ui.active_art.height
class GameGrid(Grid):
draw_axes = True
base_size = 800
def get_tile_size(self):
# TODO: dynamically adjust bounds based on furthest away objects?
return self.base_size, self.base_size
def set_base_size(self, new_size):
self.base_size = new_size
self.reset()
def reset_loc(self):
# center of grid at world zero
qw, qh = self.get_quad_size()

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,8 +1,11 @@
import os, math, ctypes
import ctypes
import math
import numpy as np
from OpenGL import GL
from art import VERT_LENGTH
from palette import MAX_COLORS
from .art import VERT_LENGTH
from .palette import MAX_COLORS
# inactive layer alphas
LAYER_VIS_FULL = 1
@ -16,22 +19,23 @@ class TileRenderable:
rectangular OpenGL triangle-pairs. Animation frames are uploaded into our
buffers from source Art's numpy arrays.
"""
vert_shader_source = 'renderable_v.glsl'
vert_shader_source = "renderable_v.glsl"
"vertex shader: includes view projection matrix, XYZ camera uniforms."
frag_shader_source = 'renderable_f.glsl'
frag_shader_source = "renderable_f.glsl"
"Pixel shader: handles FG/BG colors."
log_create_destroy = False
log_animation = False
log_buffer_updates = False
grain_strength = 0.
alpha = 1.
grain_strength = 0.0
alpha = 1.0
"Alpha (0 to 1) for entire Renderable."
bg_alpha = 1.
bg_alpha = 1.0
"Alpha (0 to 1) *only* for tile background colors."
default_move_rate = 1
use_art_offset = True
"Use game object's art_off_pct values."
def __init__(self, app, art, game_object=None):
"Create Renderable with given Art, optionally bound to given GameObject"
self.app = app
@ -69,68 +73,136 @@ class TileRenderable:
if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1)
GL.glBindVertexArray(self.vao)
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.charset_width_uniform = self.shader.get_uniform_location('charMapWidth')
self.charset_height_uniform = self.shader.get_uniform_location('charMapHeight')
self.char_uv_width_uniform = self.shader.get_uniform_location('charUVWidth')
self.char_uv_height_uniform = self.shader.get_uniform_location('charUVHeight')
self.charset_tex_uniform = self.shader.get_uniform_location('charset')
self.palette_tex_uniform = self.shader.get_uniform_location('palette')
self.grain_tex_uniform = self.shader.get_uniform_location('grain')
self.palette_width_uniform = self.shader.get_uniform_location('palTextureWidth')
self.grain_strength_uniform = self.shader.get_uniform_location('grainStrength')
self.alpha_uniform = self.shader.get_uniform_location('alpha')
self.brightness_uniform = self.shader.get_uniform_location('brightness')
self.bg_alpha_uniform = self.shader.get_uniform_location('bgColorAlpha')
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.charset_width_uniform = self.shader.get_uniform_location("charMapWidth")
self.charset_height_uniform = self.shader.get_uniform_location("charMapHeight")
self.char_uv_width_uniform = self.shader.get_uniform_location("charUVWidth")
self.char_uv_height_uniform = self.shader.get_uniform_location("charUVHeight")
self.charset_tex_uniform = self.shader.get_uniform_location("charset")
self.palette_tex_uniform = self.shader.get_uniform_location("palette")
self.grain_tex_uniform = self.shader.get_uniform_location("grain")
self.palette_width_uniform = self.shader.get_uniform_location("palTextureWidth")
self.grain_strength_uniform = self.shader.get_uniform_location("grainStrength")
self.alpha_uniform = self.shader.get_uniform_location("alpha")
self.brightness_uniform = self.shader.get_uniform_location("brightness")
self.bg_alpha_uniform = self.shader.get_uniform_location("bgColorAlpha")
self.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()
# finish
if self.app.use_vao:
GL.glBindVertexArray(0)
if self.log_create_destroy:
self.app.log('created: %s' % self)
self.app.log(f"created: {self}")
def __str__(self):
"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:
i = idx
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):
# vertex positions and elements
# determine vertex count needed for render
self.vert_count = int(len(self.art.elem_array))
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
self.update_buffer(self.vert_buffer, self.art.vert_array,
GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, 'vertPosition', VERT_LENGTH)
self.update_buffer(self.elem_buffer, self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None)
self.update_buffer(
self.vert_buffer,
self.art.vert_array,
GL.GL_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_FLOAT,
"vertPosition",
VERT_LENGTH,
)
self.update_buffer(
self.elem_buffer,
self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_UNSIGNED_INT,
None,
None,
)
# tile data buffers
# use GL_DYNAMIC_DRAW given they change every time a char/color changes
self.char_buffer, self.uv_buffer = GL.glGenBuffers(2)
# character indices (which become vertex UVs)
self.update_buffer(self.char_buffer, self.art.chars[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'charIndex', 1)
self.update_buffer(
self.char_buffer,
self.art.chars[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"charIndex",
1,
)
# UV "mods" - modify UV derived from character index
self.update_buffer(self.uv_buffer, self.art.uv_mods[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'uvMod', 2)
self.update_buffer(
self.uv_buffer,
self.art.uv_mods[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"uvMod",
2,
)
self.fg_buffer, self.bg_buffer = GL.glGenBuffers(2)
# foreground/background color indices (which become rgba colors)
self.update_buffer(self.fg_buffer, self.art.fg_colors[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'fgColorIndex', 1)
self.update_buffer(self.bg_buffer, self.art.bg_colors[self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'bgColorIndex', 1)
self.update_buffer(
self.fg_buffer,
self.art.fg_colors[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"fgColorIndex",
1,
)
self.update_buffer(
self.bg_buffer,
self.art.bg_colors[self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
"bgColorIndex",
1,
)
def update_geo_buffers(self):
self.update_buffer(self.vert_buffer, self.art.vert_array, GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, None, None)
self.update_buffer(self.elem_buffer, self.art.elem_array, GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None)
self.update_buffer(
self.vert_buffer,
self.art.vert_array,
GL.GL_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_FLOAT,
None,
None,
)
self.update_buffer(
self.elem_buffer,
self.art.elem_array,
GL.GL_ELEMENT_ARRAY_BUFFER,
GL.GL_STATIC_DRAW,
GL.GL_UNSIGNED_INT,
None,
None,
)
# total vertex count probably changed
self.vert_count = int(len(self.art.elem_array))
def update_tile_buffers(self, update_chars, update_uvs, update_fg, update_bg):
"Update GL data arrays for tile characters, fg/bg colors, transforms."
updates = {}
@ -143,32 +215,49 @@ class TileRenderable:
if update_bg:
updates[self.bg_buffer] = self.art.bg_colors
for update in updates:
self.update_buffer(update, updates[update][self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT, None, None)
def update_buffer(self, buffer_index, array, target, buffer_type, data_type,
attrib_name, attrib_size):
self.update_buffer(
update,
updates[update][self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
None,
None,
)
def update_buffer(
self,
buffer_index,
array,
target,
buffer_type,
data_type,
attrib_name,
attrib_size,
):
if self.log_buffer_updates:
self.app.log('update_buffer: %s, %s, %s, %s, %s, %s, %s' % (buffer_index, array, target, buffer_type, data_type, attrib_name, attrib_size))
self.app.log(
f"update_buffer: {buffer_index}, {array}, {target}, {buffer_type}, {data_type}, {attrib_name}, {attrib_size}"
)
GL.glBindBuffer(target, buffer_index)
GL.glBufferData(target, array.nbytes, array, buffer_type)
if attrib_name:
attrib = self.shader.get_attrib_location(attrib_name)
GL.glEnableVertexAttribArray(attrib)
GL.glVertexAttribPointer(attrib, attrib_size, data_type,
GL.GL_FALSE, 0, ctypes.c_void_p(0))
GL.glVertexAttribPointer(
attrib, attrib_size, data_type, GL.GL_FALSE, 0, ctypes.c_void_p(0)
)
# unbind each buffer before binding next
GL.glBindBuffer(target, 0)
def advance_frame(self):
"Advance to our Art's next animation frame."
self.set_frame(self.frame + 1)
def rewind_frame(self):
"Rewind to our Art's previous animation frame."
self.set_frame(self.frame - 1)
def set_frame(self, new_frame_index):
"Set us to display our Art's given animation frame."
if new_frame_index == self.frame:
@ -177,20 +266,20 @@ class TileRenderable:
self.frame = new_frame_index % self.art.frames
self.update_tile_buffers(True, True, True, True)
if self.log_animation:
self.app.log('%s animating from frames %s to %s' % (self, old_frame, self.frame))
self.app.log(f"{self} animating from frames {old_frame} to {self.frame}")
def start_animating(self):
"Start animation playback."
self.animating = True
self.anim_timer = 0
def stop_animating(self):
"Pause animation playback on current frame (in game mode)."
self.animating = False
# restore to active frame if stopping
if not self.app.game_mode:
self.set_frame(self.art.active_frame)
def set_art(self, new_art):
"Display and bind to given Art."
if self.art:
@ -202,12 +291,12 @@ class TileRenderable:
self.frame %= self.art.frames
self.update_geo_buffers()
self.update_tile_buffers(True, True, True, True)
#print('%s now uses Art %s' % (self, self.art.filename))
# print('%s now uses Art %s' % (self, self.art.filename))
def reset_size(self):
self.width = self.art.width * self.art.quad_width * abs(self.scale_x)
self.height = self.art.height * self.art.quad_height * self.scale_y
def move_to(self, x, y, z, travel_time=None):
"""
Start simple linear interpolation to given destination over given time.
@ -220,20 +309,22 @@ class TileRenderable:
dx = x - self.x
dy = y - self.y
dz = z - self.z
dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
dist = math.sqrt(dx**2 + dy**2 + dz**2)
self.move_rate = dist / frames
else:
self.move_rate = self.default_move_rate
self.ui_moving = True
self.goal_x, self.goal_y, self.goal_z = x, y, z
if self.log_animation:
self.app.log('%s will move to %s,%s' % (self.art.filename, self.goal_x, self.goal_y))
self.app.log(
f"{self.art.filename} will move to {self.goal_x},{self.goal_y}"
)
def snap_to(self, x, y, z):
self.x, self.y, self.z = x, y, z
self.goal_x, self.goal_y, self.goal_z = x, y, z
self.ui_moving = False
def update_transform_from_object(self, obj):
"Update our position & scale based on that of given game object."
self.z = obj.z
@ -250,14 +341,14 @@ class TileRenderable:
if obj.flip_x:
self.scale_x *= -1
self.scale_z = obj.scale_z
def update_loc(self):
# TODO: probably time to bust out the ol' vector module for this stuff
# get delta
dx = self.goal_x - self.x
dy = self.goal_y - self.y
dz = self.goal_z - self.z
dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
dist = math.sqrt(dx**2 + dy**2 + dz**2)
# close enough?
if dist <= self.move_rate:
self.x = self.goal_x
@ -273,8 +364,8 @@ class TileRenderable:
self.x += self.move_rate * dir_x
self.y += self.move_rate * dir_y
self.z += self.move_rate * dir_z
#self.app.log('%s moved to %s,%s' % (self, self.x, self.y))
# self.app.log('%s moved to %s,%s' % (self, self.x, self.y))
def update(self):
if self.go:
self.update_transform_from_object(self.go)
@ -297,31 +388,41 @@ class TileRenderable:
# TODO: if new_frame < self.frame, count anim loop?
self.set_frame(new_frame)
self.last_frame_time = self.app.get_elapsed_time()
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(6, [self.vert_buffer, self.elem_buffer, self.char_buffer, self.uv_buffer, self.fg_buffer, self.bg_buffer])
GL.glDeleteBuffers(
6,
[
self.vert_buffer,
self.elem_buffer,
self.char_buffer,
self.uv_buffer,
self.fg_buffer,
self.bg_buffer,
],
)
if self.art and self in self.art.renderables:
self.art.renderables.remove(self)
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log(f"destroyed: {self}")
def get_projection_matrix(self):
"""
UIRenderable overrides this so it doesn't have to override
Renderable.render and duplicate lots of code.
"""
return np.eye(4, 4) if self.exporting else self.camera.projection_matrix
def get_view_matrix(self):
return np.eye(4, 4) if self.exporting else self.camera.view_matrix
def get_loc(self):
"Returns world space location as (x, y, z) tuple."
export_loc = (-1, 1, 0)
return export_loc if self.exporting else (self.x, self.y, self.z)
def get_scale(self):
"Returns world space scale as (x, y, z) tuple."
if not self.exporting:
@ -329,7 +430,7 @@ class TileRenderable:
x = 2 / (self.art.width * self.art.quad_width)
y = 2 / (self.art.height * self.art.quad_height)
return (x, y, 1)
def render_frame_for_export(self, frame):
self.exporting = True
self.set_frame(frame)
@ -344,7 +445,7 @@ class TileRenderable:
self.render()
self.art.app.inactive_layer_visibility = ilv
self.exporting = False
def render(self, layers=None, z_override=None, brightness=1.0):
"""
Render given list of layers at given Z depth.
@ -374,10 +475,12 @@ class TileRenderable:
GL.glUniform1f(self.palette_width_uniform, MAX_COLORS)
GL.glUniform1f(self.grain_strength_uniform, self.grain_strength)
# camera uniforms
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE,
self.get_projection_matrix())
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE,
self.get_view_matrix())
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()
)
# TODO: determine if cost of setting all above uniforms for each
# Renderable is significant enough to warrant opti where they're set once
GL.glUniform1f(self.bg_alpha_uniform, self.bg_alpha)
@ -387,38 +490,45 @@ class TileRenderable:
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
vp = ctypes.c_void_p(0)
# bind each buffer and set its attrib:
# verts
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(attrib('vertPosition'), VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('vertPosition'))
GL.glVertexAttribPointer(
self.attrib_vert_position, VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(self.attrib_vert_position)
# chars
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.char_buffer)
GL.glVertexAttribPointer(attrib('charIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('charIndex'))
GL.glVertexAttribPointer(
self.attrib_char_index, 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(self.attrib_char_index)
# uvs
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.uv_buffer)
GL.glVertexAttribPointer(attrib('uvMod'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('uvMod'))
GL.glVertexAttribPointer(
self.attrib_uv_mod, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(self.attrib_uv_mod)
# fg colors
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.fg_buffer)
GL.glVertexAttribPointer(attrib('fgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('fgColorIndex'))
GL.glVertexAttribPointer(
self.attrib_fg_color_index, 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp
)
GL.glEnableVertexAttribArray(self.attrib_fg_color_index)
# bg colors
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.bg_buffer)
GL.glVertexAttribPointer(attrib('bgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp)
GL.glEnableVertexAttribArray(attrib('bgColorIndex'))
GL.glVertexAttribPointer(
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
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glEnable(GL.GL_BLEND)
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
# draw all specified layers if no list given
if layers is None:
# sort layers in Z depth
layers = list(range(self.art.layers))
layers.sort(key=lambda i: self.art.layers_z[i], reverse=False)
layers = self.art.get_sorted_layers()
# handle a single int param
elif type(layers) is int:
layers = [layers]
@ -428,10 +538,15 @@ class TileRenderable:
if not self.app.show_hidden_layers and not self.art.layers_visibility[i]:
continue
layer_start = i * layer_size
layer_end = layer_start + layer_size
# for active art, dim all but active layer based on UI setting
if not self.app.game_mode and self.art is self.app.ui.active_art and i != self.art.active_layer:
GL.glUniform1f(self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility)
if (
not self.app.game_mode
and self.art is self.app.ui.active_art
and i != self.art.active_layer
):
GL.glUniform1f(
self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility
)
else:
GL.glUniform1f(self.alpha_uniform, self.alpha)
# use position offset instead of baked-in Z for layers - this
@ -442,8 +557,12 @@ class TileRenderable:
z += self.art.layers_z[i]
z = z_override if z_override else z
GL.glUniform3f(self.position_uniform, x, y, z)
GL.glDrawElements(GL.GL_TRIANGLES, layer_size, GL.GL_UNSIGNED_INT,
ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint)))
GL.glDrawElements(
GL.GL_TRIANGLES,
layer_size,
GL.GL_UNSIGNED_INT,
ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint)),
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
@ -452,23 +571,21 @@ class TileRenderable:
class OnionTileRenderable(TileRenderable):
"TileRenderable subclass used for onion skin display in Art Mode animation."
# never animate
def start_animating(self):
pass
def stop_animating(self):
pass
class GameObjectRenderable(TileRenderable):
"""
TileRenderable subclass used by GameObjects. Almost no custom logic for now.
"""
def get_loc(self):
"""
Returns world space location as (x, y, z) tuple, offset by our

View file

@ -1,15 +1,20 @@
import math, time, ctypes, platform
import ctypes
import math
import platform
import time
import numpy as np
from OpenGL import GL
from renderable import TileRenderable
class LineRenderable():
from .renderable import TileRenderable
class LineRenderable:
"Renderable comprised of GL_LINES"
vert_shader_source = 'lines_v.glsl'
vert_shader_source_3d = 'lines_3d_v.glsl'
frag_shader_source = 'lines_f.glsl'
vert_shader_source = "lines_v.glsl"
vert_shader_source_3d = "lines_3d_v.glsl"
frag_shader_source = "lines_f.glsl"
log_create_destroy = False
line_width = 1
# items in vert array: 2 for XY-only renderables, 3 for ones that include Z
@ -17,12 +22,12 @@ class LineRenderable():
# use game object's art_off_pct values
use_art_offset = True
visible = True
def __init__(self, app, quad_size_ref=None, game_object=None):
self.app = app
# we may be attached to a game object
self.go = game_object
self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.unique_name = f"{int(time.time())}_{self.__class__.__name__}"
self.quad_size_ref = quad_size_ref
self.x, self.y, self.z = 0, 0, 0
self.scale_x, self.scale_y = 1, 1
@ -36,123 +41,155 @@ class LineRenderable():
GL.glBindVertexArray(self.vao)
if self.vert_items == 3:
self.vert_shader_source = self.vert_shader_source_3d
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.color_uniform = self.shader.get_uniform_location('objectColor')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.color_uniform = self.shader.get_uniform_location("objectColor")
# vert buffers
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, self.vert_items,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, self.vert_items, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
# vert colors
self.color_buffer = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
self.color_attrib = self.shader.get_attrib_location('vertColor')
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
self.color_attrib = self.shader.get_attrib_location("vertColor")
GL.glEnableVertexAttribArray(self.color_attrib)
GL.glVertexAttribPointer(self.color_attrib, 4,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
if self.log_create_destroy:
self.app.log('created: %s' % self)
self.app.log(f"created: {self}")
def __str__(self):
"for debug purposes, return a unique name"
return self.unique_name
def build_geo(self):
"""
create self.vert_array, self.elem_array, self.color_array
"""
pass
def reset_loc(self):
pass
def update(self):
if self.go:
self.update_transform_from_object(self.go)
def reset_size(self):
self.width, self.height = self.get_size()
def update_transform_from_object(self, obj):
TileRenderable.update_transform_from_object(self, obj)
def rebind_buffers(self):
# resend verts
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
# resend color
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
def get_projection_matrix(self):
return np.eye(4, 4)
def get_view_matrix(self):
return np.eye(4, 4)
def get_loc(self):
return self.x, self.y, self.z
def get_size(self):
# overriden in subclasses that need specific width/height data
return 1, 1
def get_quad_size(self):
if self.quad_size_ref:
return self.quad_size_ref.quad_width, self.quad_size_ref.quad_height
else:
return 1, 1
def get_color(self):
return (1, 1, 1, 1)
def get_line_width(self):
return self.line_width
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(3, [self.vert_buffer, self.elem_buffer, self.color_buffer])
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log(f"destroyed: {self}")
def render(self):
if not self.visible:
return
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix())
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix())
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()
)
GL.glUniform3f(self.position_uniform, *self.get_loc())
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform2f(self.quad_size_uniform, *self.get_quad_size())
@ -165,22 +202,28 @@ class LineRenderable():
# attribs:
# pos
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glVertexAttribPointer(self.pos_attrib, self.vert_items,
GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0))
GL.glVertexAttribPointer(
self.pos_attrib,
self.vert_items,
GL.GL_FLOAT,
GL.GL_FALSE,
0,
ctypes.c_void_p(0),
)
GL.glEnableVertexAttribArray(self.pos_attrib)
# color
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glVertexAttribPointer(self.color_attrib, 4,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glEnableVertexAttribArray(self.color_attrib)
# bind elem array - see similar behavior in Cursor.render
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glEnable(GL.GL_BLEND)
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
if platform.system() != 'Darwin':
if platform.system() != "Darwin":
GL.glLineWidth(self.get_line_width())
GL.glDrawElements(GL.GL_LINES, self.vert_count,
GL.GL_UNSIGNED_INT, None)
GL.glDrawElements(GL.GL_LINES, self.vert_count, GL.GL_UNSIGNED_INT, None)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
@ -191,6 +234,7 @@ class LineRenderable():
# common data/code used by various boxes
BOX_VERTS = [(0, 0), (1, 0), (1, -1), (0, -1)]
def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)):
verts = np.array(vert_list or BOX_VERTS, dtype=np.float32)
elems = np.array([0, 1, 1, 2, 2, 3, 3, 0], dtype=np.uint32)
@ -199,11 +243,11 @@ def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)):
class UIRenderableX(LineRenderable):
"Red X used to denote transparent color in various places"
color = (1, 0, 0, 1)
line_width = 2
def build_geo(self):
self.vert_array = np.array([(0, 0), (1, 1), (1, 0), (0, 1)], dtype=np.float32)
self.elem_array = np.array([0, 1, 2, 3], dtype=np.uint32)
@ -211,55 +255,56 @@ class UIRenderableX(LineRenderable):
class SwatchSelectionBoxRenderable(LineRenderable):
"used for UI selection boxes etc"
color = (0.5, 0.5, 0.5, 1)
line_width = 2
def __init__(self, app, quad_size_ref):
LineRenderable.__init__(self, app, quad_size_ref)
# track tile X and Y for cursor movement
self.tile_x, self.tile_y = 0,0
self.tile_x, self.tile_y = 0, 0
def get_color(self):
return self.color
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class ToolSelectionBoxRenderable(LineRenderable):
line_width = 2
def get_color(self):
return (1.0, 1.0, 1.0, 1.0)
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None)
class WorldLineRenderable(LineRenderable):
"any LineRenderable that draws in world, ie in 3D perspective"
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
class DebugLineRenderable(WorldLineRenderable):
"""
renderable for drawing debug lines in the world.
use set_lines and add_lines to replace and add to, respectively, the list
of 3D vertex locations (and, optionally, colors).
"""
color = (0.5, 0, 0, 1)
vert_items = 3
line_width = 3
def set_lines(self, new_verts, new_colors=None):
"replace current debug lines with new given lines"
self.vert_array = np.array(new_verts, dtype=np.float32)
@ -268,28 +313,28 @@ class DebugLineRenderable(WorldLineRenderable):
for i in range(1, len(new_verts)):
elements += [i - 1, i]
self.elem_array = np.array(elements, dtype=np.uint32)
self.color_array = np.array(new_colors or self.color * len(new_verts),
dtype=np.float32)
self.color_array = np.array(
new_colors or self.color * len(new_verts), dtype=np.float32
)
self.rebind_buffers()
def set_color(self, new_color):
"changes all debug lines to given color"
self.color = new_color
lines = int(len(self.vert_array) / self.vert_items)
self.color_array = np.array(self.color * lines, dtype=np.float32)
self.rebind_buffers()
def get_quad_size(self):
return 1, 1
def add_lines(self, new_verts, new_colors=None):
"add lines to the current ones"
line_items = len(self.vert_array)
lines = int(line_items / self.vert_items)
# if new_verts is a list of tuples, unpack into flat list
if type(new_verts[0]) is tuple:
new_verts_unpacked = []
for (x, y, z) in new_verts:
for x, y, z in new_verts:
new_verts_unpacked += [x, y, z]
new_verts = new_verts_unpacked
new_size = int(line_items + len(new_verts))
@ -300,24 +345,27 @@ class DebugLineRenderable(WorldLineRenderable):
new_elem_size = int(old_elem_size + len(new_verts) / self.vert_items)
# TODO: "contiguous" parameter that joins new lines with previous
self.elem_array.resize(new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(old_elem_size,
new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(
old_elem_size, new_elem_size
)
# grow color buffer
old_color_size = len(self.color_array)
new_color_size = int(old_color_size + len(new_verts) / self.vert_items * 4)
self.color_array.resize(new_color_size)
self.color_array[old_color_size:new_color_size] = new_colors or self.color * int(len(new_verts) / self.vert_items)
self.color_array[old_color_size:new_color_size] = (
new_colors or self.color * int(len(new_verts) / self.vert_items)
)
self.rebind_buffers()
def reset_lines(self):
self.build_geo()
def build_geo(self):
# start empty
self.vert_array = np.array([], dtype=np.float32)
self.elem_array = np.array([], dtype=np.uint32)
self.color_array = np.array([], dtype=np.float32)
def render(self):
# only render if we have any data
if len(self.vert_array) == 0:
@ -326,64 +374,73 @@ class DebugLineRenderable(WorldLineRenderable):
class OriginIndicatorRenderable(WorldLineRenderable):
"classic 3-axis thingy showing location/rotation/scale"
red = (1.0, 0.1, 0.1, 1.0)
red = (1.0, 0.1, 0.1, 1.0)
green = (0.1, 1.0, 0.1, 1.0)
blue = (0.1, 0.1, 1.0, 1.0)
blue = (0.1, 0.1, 1.0, 1.0)
origin = (0, 0, 0)
x_axis = (1, 0, 0)
y_axis = (0, 1, 0)
z_axis = (0, 0, 1)
vert_items = 3
vert_items = 3
line_width = 3
use_art_offset = False
def __init__(self, app, game_object):
LineRenderable.__init__(self, app, None, game_object)
def get_quad_size(self):
return 1, 1
def get_size(self):
return self.go.scale_x, self.go.scale_y
def update_transform_from_object(self, obj):
self.x, self.y, self.z = obj.x, obj.y, obj.z
self.scale_x, self.scale_y = obj.scale_x, obj.scale_y
if obj.flip_x:
self.scale_x *= -1
self.scale_z = obj.scale_z
def build_geo(self):
self.vert_array = np.array([self.origin, self.x_axis,
self.origin, self.y_axis,
self.origin, self.z_axis],
dtype=np.float32)
self.vert_array = np.array(
[
self.origin,
self.x_axis,
self.origin,
self.y_axis,
self.origin,
self.z_axis,
],
dtype=np.float32,
)
self.elem_array = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint32)
self.color_array = np.array([self.red, self.red, self.green, self.green,
self.blue, self.blue], dtype=np.float32)
self.color_array = np.array(
[self.red, self.red, self.green, self.green, self.blue, self.blue],
dtype=np.float32,
)
class BoundsIndicatorRenderable(WorldLineRenderable):
color = (1, 1, 1, 0.5)
line_width_active = 2
line_width_inactive = 1
def __init__(self, app, game_object):
self.art = game_object.renderable.art
LineRenderable.__init__(self, app, None, game_object)
def set_art(self, new_art):
self.art = new_art
self.reset_size()
def get_size(self):
art = self.go.art
w = (art.width * art.quad_width) * self.go.scale_x
h = (art.height * art.quad_height) * self.go.scale_y
return w, h
def get_color(self):
# pulse if selected
if self.go in self.app.gw.selected_objects:
@ -391,33 +448,41 @@ class BoundsIndicatorRenderable(WorldLineRenderable):
return (color, color, color, 1)
else:
return (1, 1, 1, 1)
def get_line_width(self):
return self.line_width_active if self.go in self.app.gw.selected_objects else self.line_width_inactive
return (
self.line_width_active
if self.go in self.app.gw.selected_objects
else self.line_width_inactive
)
def get_quad_size(self):
if not self.go:
return 1, 1
return self.art.width * self.art.quad_width, self.art.height * self.art.quad_height
return (
self.art.width * self.art.quad_width,
self.art.height * self.art.quad_height,
)
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class CollisionRenderable(WorldLineRenderable):
# green = dynamic, blue = static
dynamic_color = (0, 1, 0, 1)
static_color = (0, 0, 1, 1)
def __init__(self, shape):
self.color = self.dynamic_color if shape.go.is_dynamic() else self.static_color
self.shape = shape
WorldLineRenderable.__init__(self, shape.go.app, None, shape.go)
def update(self):
self.update_transform_from_object(self.shape)
def update_transform_from_object(self, obj):
self.x = obj.x
self.y = obj.y
@ -426,7 +491,7 @@ class CollisionRenderable(WorldLineRenderable):
def get_circle_points(radius, steps=24):
angle = 0
points = [(radius, 0)]
for i in range(steps):
for _ in range(steps):
angle += math.radians(360 / steps)
x = math.cos(angle) * radius
y = math.sin(angle) * radius
@ -435,19 +500,18 @@ def get_circle_points(radius, steps=24):
class CircleCollisionRenderable(CollisionRenderable):
line_width = 2
segments = 24
def get_quad_size(self):
return self.shape.radius, self.shape.radius
def get_size(self):
w = h = self.shape.radius * 2
w *= self.go.scale_x
h *= self.go.scale_y
return w, h
def build_geo(self):
verts, elements, colors = [], [], []
angle = 0
@ -460,7 +524,7 @@ class CircleCollisionRenderable(CollisionRenderable):
y = math.sin(angle)
verts.append((x, y))
last_x, last_y = x, y
elements.append((i, i+1))
elements.append((i, i + 1))
i += 2
colors.append([self.color * 2])
self.vert_array = np.array(verts, dtype=np.float32)
@ -469,26 +533,29 @@ class CircleCollisionRenderable(CollisionRenderable):
class BoxCollisionRenderable(CollisionRenderable):
line_width = 2
def get_quad_size(self):
return self.shape.halfwidth * 2, self.shape.halfheight * 2
def get_size(self):
w, h = self.shape.halfwidth * 2, self.shape.halfheight * 2
w *= self.go.scale_x
h *= self.go.scale_y
return w, h
def build_geo(self):
verts = [(-0.5, 0.5), (0.5, 0.5), (0.5, -0.5), (-0.5, -0.5)]
self.vert_array, self.elem_array, self.color_array = get_box_arrays(verts, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
verts, self.color
)
class TileBoxCollisionRenderable(BoxCollisionRenderable):
"box for each tile in a CST_TILE object"
line_width = 1
def get_loc(self):
# draw at Z level of collision layer
return self.x, self.y, self.go.get_layer_z(self.go.col_layer_name)

View file

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

View file

@ -1,24 +1,25 @@
import math
import numpy as np
from renderable_line import LineRenderable
from .renderable_line import LineRenderable
class SelectionRenderable(LineRenderable):
color = (0.8, 0.8, 0.8, 1)
line_width = 2
x, y, z = 0, 0, 0
def build_geo(self):
# init empty arrays; geo is rebuilt every time selection changes
self.vert_array = np.array([], dtype=np.float32)
self.elem_array = np.array([], dtype=np.uint32)
self.color_array = np.array([], dtype=np.float32)
def get_adjacent_tile(self, tiles, x, y, dir_x, dir_y):
"returns True or False based on tile dict lookup relative to given tile"
return tiles.get((x + dir_x, y + dir_y), False)
def rebuild_geo(self, tiles):
# array source lists of verts, elements, colors
v, e, c = [], [], []
@ -31,14 +32,16 @@ class SelectionRenderable(LineRenderable):
below = self.get_adjacent_tile(tiles, x, y, 0, 1)
left = self.get_adjacent_tile(tiles, x, y, -1, 0)
right = self.get_adjacent_tile(tiles, x, y, 1, 0)
top_left = ( x, -y)
top_right = (x+1, -y)
bottom_right = (x+1, -y-1)
bottom_left = ( x, -y-1)
top_left = (x, -y)
top_right = (x + 1, -y)
bottom_right = (x + 1, -y - 1)
bottom_left = (x, -y - 1)
def add_line(vert_a, vert_b, verts, elems, colors, element_index):
verts += [vert_a, vert_b]
elems += [element_index, element_index+1]
elems += [element_index, element_index + 1]
colors += self.color * 2
# verts = corners
if not above:
# top edge
@ -60,16 +63,16 @@ class SelectionRenderable(LineRenderable):
self.vert_array = np.array(v, dtype=np.float32)
self.elem_array = np.array(e, dtype=np.uint32)
self.color_array = np.array(c, dtype=np.float32)
def reset_loc(self):
pass
def get_projection_matrix(self):
return self.app.camera.projection_matrix
def get_view_matrix(self):
return self.app.camera.view_matrix
def get_color(self):
# pulse for visibility
a = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)

View file

@ -1,31 +1,40 @@
import os.path, time, platform
import os.path
import platform
import time
from OpenGL import GL
from OpenGL.GL import shaders
SHADER_PATH = 'shaders/'
SHADER_PATH = "shaders/"
class ShaderLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
"AWAKENS THE SHADERLORD"
self.app = app
self.shaders = []
def new_shader(self, vert_source_file, frag_source_file):
self.last_check = 0
for shader in self.shaders:
if shader.vert_source_file == vert_source_file and shader.frag_source_file == frag_source_file:
#self.app.log('%s already uses same source' % shader)
if (
shader.vert_source_file == vert_source_file
and shader.frag_source_file == frag_source_file
):
# self.app.log('%s already uses same source' % shader)
return shader
s = Shader(self, vert_source_file, frag_source_file)
self.shaders.append(s)
return s
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
for shader in self.shaders:
@ -34,14 +43,13 @@ class ShaderLord:
shader.recompile(GL.GL_VERTEX_SHADER)
if frag_shader_updated:
shader.recompile(GL.GL_FRAGMENT_SHADER)
def destroy(self):
for shader in self.shaders:
shader.destroy()
class Shader:
log_compile = False
"If True, log shader compilation"
# per-platform shader versions, declared here for easier CFG fiddling
@ -49,7 +57,7 @@ class Shader:
glsl_version_unix = 130
glsl_version_macos = 150
glsl_version_es = 100
def __init__(self, shader_lord, vert_source_file, frag_source_file):
self.sl = shader_lord
# vertex shader
@ -57,52 +65,60 @@ class Shader:
self.last_vert_change = time.time()
vert_source = self.get_shader_source(self.vert_source_file)
if self.log_compile:
self.sl.app.log('Compiling vertex shader %s...' % self.vert_source_file)
self.vert_shader = self.try_compile_shader(vert_source, GL.GL_VERTEX_SHADER, self.vert_source_file)
self.sl.app.log(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
)
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
self.frag_source_file = frag_source_file
self.last_frag_change = time.time()
frag_source = self.get_shader_source(self.frag_source_file)
if self.log_compile:
self.sl.app.log('Compiling fragment shader %s...' % self.frag_source_file)
self.frag_shader = self.try_compile_shader(frag_source, GL.GL_FRAGMENT_SHADER, self.frag_source_file)
self.sl.app.log(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
)
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
if self.vert_shader and self.frag_shader:
self.program = shaders.compileProgram(self.vert_shader, self.frag_shader)
def get_shader_source(self, source_file):
src = open(SHADER_PATH + source_file, 'rb').read()
src = open(SHADER_PATH + source_file, "rb").read()
# prepend shader version for different platforms
if self.sl.app.context_es:
shader_version = self.glsl_version_es
elif platform.system() == 'Windows':
elif platform.system() == "Windows":
shader_version = self.glsl_version_windows
elif platform.system() == 'Darwin':
elif platform.system() == "Darwin":
shader_version = self.glsl_version_macos
else:
shader_version = self.glsl_version_unix
version_string = '#version %s\n' % shader_version
src = bytes(version_string, 'utf-8') + src
version_string = f"#version {shader_version}\n"
src = bytes(version_string, "utf-8") + src
return src
def try_compile_shader(self, source, shader_type, source_filename):
"Catch and print shader compilation exceptions"
try:
shader = shaders.compileShader(source, shader_type)
except Exception as e:
self.sl.app.log('%s: ' % source_filename)
lines = e.args[0].split('\\n')
self.sl.app.log(f"{source_filename}: ")
lines = e.args[0].split("\\n")
# salvage block after "shader compile failure" enclosed in b""
pre = lines.pop(0).split('b"')
for line in pre + lines[:-1]:
self.sl.app.log(' ' + line)
self.sl.app.log(" " + line)
return
return shader
def has_updated(self):
vert_mod_time = os.path.getmtime(SHADER_PATH + self.vert_source_file)
frag_mod_time = os.path.getmtime(SHADER_PATH + self.frag_source_file)
@ -115,7 +131,7 @@ class Shader:
if frag_changed:
self.last_frag_change = time.time()
return vert_changed, frag_changed
def recompile(self, shader_type):
file_to_reload = self.vert_source_file
if shader_type == GL.GL_FRAGMENT_SHADER:
@ -124,9 +140,9 @@ class Shader:
try:
new_shader = shaders.compileShader(new_shader_source, shader_type)
# TODO: use try_compile_shader instead here, make sure exception passes thru ok
self.sl.app.log('ShaderLord: success reloading %s' % file_to_reload)
except:
self.sl.app.log('ShaderLord: failed reloading %s' % file_to_reload)
self.sl.app.log(f"ShaderLord: success reloading {file_to_reload}")
except Exception:
self.sl.app.log(f"ShaderLord: failed reloading {file_to_reload}")
return
# recompile program with new shader
if shader_type == GL.GL_VERTEX_SHADER:
@ -134,13 +150,13 @@ class Shader:
else:
self.frag_shader = new_shader
self.program = shaders.compileProgram(self.vert_shader, self.frag_shader)
def get_uniform_location(self, uniform_name):
return GL.glGetUniformLocation(self.program, uniform_name)
def get_attrib_location(self, attrib_name):
return GL.glGetAttribLocation(self.program, attrib_name)
def destroy(self):
GL.glDeleteProgram(self.program)

View file

@ -1,15 +1,15 @@
import numpy as np
from OpenGL import GL
class Texture:
# TODO: move texture data init to a set method to make hot reload trivial(?)
mag_filter = GL.GL_NEAREST
min_filter = GL.GL_NEAREST
#min_filter = GL.GL_NEAREST_MIPMAP_NEAREST
# min_filter = GL.GL_NEAREST_MIPMAP_NEAREST
packing = GL.GL_UNPACK_ALIGNMENT
def __init__(self, data, width, height):
self.width, self.height = width, height
img_data = np.frombuffer(data, dtype=np.uint8)
@ -18,23 +18,32 @@ class Texture:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
self.set_filter(self.mag_filter, self.min_filter, False)
self.set_wrap(False, False)
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, width, height, 0,
GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, img_data)
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
GL.GL_RGBA,
width,
height,
0,
GL.GL_RGBA,
GL.GL_UNSIGNED_BYTE,
img_data,
)
if bool(GL.glGenerateMipmap):
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)
def set_filter(self, new_mag_filter, new_min_filter, bind_first=True):
if bind_first:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, new_mag_filter)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, new_min_filter)
def set_wrap(self, new_wrap, bind_first=True):
if bind_first:
GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex)
wrap = GL.GL_REPEAT if new_wrap else GL.GL_CLAMP_TO_EDGE
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, wrap)
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, wrap)
def destroy(self):
GL.glDeleteTextures([self.gltex])

View file

@ -1,24 +1,45 @@
import sdl2
import numpy as np
from PIL import Image
import sdl2
from OpenGL import GL
from PIL import Image
from texture import Texture
from ui_element import UIArt, FPSCounterUI, MessageLineUI, DebugTextUI, GameSelectionLabel, GameHoverLabel, ToolTip
from ui_console import ConsoleUI
from ui_status_bar import StatusBarUI
from ui_popup import ToolPopup
from ui_menu_bar import ArtMenuBar, GameMenuBar
from ui_menu_pulldown import PulldownMenu
from ui_edit_panel import EditListPanel
from ui_object_panel import EditObjectPanel
from ui_colors import UIColors
from ui_tool import PencilTool, EraseTool, GrabTool, RotateTool, TextTool, SelectTool, PasteTool, FillTool
from ui_toolbar import ArtToolBar
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270, uv_names
from edit_command import EditCommand, EditCommandTile, EntireArtCommand
from .art import (
UV_FLIP270,
UV_NORMAL,
uv_names,
)
from .edit_command import EditCommand, EditCommandTile, EntireArtCommand
from .texture import Texture
from .ui_colors import UIColors
from .ui_console import ConsoleUI
from .ui_edit_panel import EditListPanel
from .ui_element import (
DebugTextUI,
FPSCounterUI,
GameHoverLabel,
GameSelectionLabel,
MessageLineUI,
ToolTip,
UIArt,
)
from .ui_menu_bar import ArtMenuBar, GameMenuBar
from .ui_menu_pulldown import PulldownMenu
from .ui_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
# spacing factor of each non-active document's scale from active document
MDI_MARGIN = 1.1
@ -30,40 +51,47 @@ OIS_FILL = 2
class UI:
# user-configured UI scale factor
scale = 1.0
max_onion_alpha = 0.5
charset_name = 'ui'
palette_name = 'c64_original'
charset_name = "ui"
palette_name = "c64_original"
# red color for warnings
error_color_index = UIColors.brightred
# low-contrast background texture that distinguishes UI from flat color
grain_texture_path = UI_ASSET_DIR + 'bgnoise_alpha.png'
grain_texture_path = UI_ASSET_DIR + "bgnoise_alpha.png"
# expose to classes that don't want to import this module
asset_dir = UI_ASSET_DIR
visible = True
logg = False
popup_hold_to_show = False
flip_affects_xforms = True
tool_classes = [ PencilTool, EraseTool, GrabTool, RotateTool, TextTool,
SelectTool, PasteTool, FillTool ]
tool_selected_log = 'tool selected'
art_selected_log = 'Now editing'
frame_selected_log = 'Now editing frame %s (hold time %ss)'
layer_selected_log = 'Now editing layer: %s'
swap_color_log = 'Swapped FG/BG colors'
affects_char_on_log = 'will affect characters'
affects_char_off_log = 'will not affect characters'
affects_fg_on_log = 'will affect foreground colors'
affects_fg_off_log = 'will not affect foreground colors'
affects_bg_on_log = 'will affect background colors'
affects_bg_off_log = 'will not affect background colors'
affects_xform_on_log = 'will affect character rotation/flip'
affects_xform_off_log = 'will not affect character rotation/flip'
xform_selected_log = 'Selected character transform:'
show_edit_ui_log = 'Edit UI hidden, press %s to unhide.'
tool_classes = [
PencilTool,
EraseTool,
GrabTool,
RotateTool,
TextTool,
SelectTool,
PasteTool,
FillTool,
]
tool_selected_log = "tool selected"
art_selected_log = "Now editing"
frame_selected_log = "Now editing frame %s (hold time %ss)"
layer_selected_log = "Now editing layer: %s"
swap_color_log = "Swapped FG/BG colors"
affects_char_on_log = "will affect characters"
affects_char_off_log = "will not affect characters"
affects_fg_on_log = "will affect foreground colors"
affects_fg_off_log = "will not affect foreground colors"
affects_bg_on_log = "will affect background colors"
affects_bg_off_log = "will not affect background colors"
affects_xform_on_log = "will affect character rotation/flip"
affects_xform_off_log = "will not affect character rotation/flip"
xform_selected_log = "Selected character transform:"
show_edit_ui_log = "Edit UI hidden, press %s to unhide."
def __init__(self, app, active_art):
self.app = app
# the current art being edited
@ -92,7 +120,7 @@ class UI:
# create tools
for t in self.tool_classes:
new_tool = t(self)
tool_name = '%s_tool' % new_tool.name
tool_name = f"{new_tool.name}_tool"
setattr(self, tool_name, new_tool)
# stick in a list for popup tool tab
self.tools.append(new_tool)
@ -125,17 +153,27 @@ class UI:
self.edit_object_panel = EditObjectPanel(self)
self.game_selection_label = GameSelectionLabel(self)
self.game_hover_label = GameHoverLabel(self)
self.elements += [self.fps_counter, self.status_bar, self.popup,
self.message_line, self.debug_text, self.pulldown,
self.art_menu_bar, self.game_menu_bar, self.tooltip,
self.art_toolbar,
self.edit_list_panel, self.edit_object_panel,
self.game_hover_label, self.game_selection_label]
self.elements += [
self.fps_counter,
self.status_bar,
self.popup,
self.message_line,
self.debug_text,
self.pulldown,
self.art_menu_bar,
self.game_menu_bar,
self.tooltip,
self.art_toolbar,
self.edit_list_panel,
self.edit_object_panel,
self.game_hover_label,
self.game_selection_label,
]
# add console last so it draws last
self.elements.append(self.console)
# grain texture
img = Image.open(self.grain_texture_path)
img = img.convert('RGBA')
img = img.convert("RGBA")
width, height = img.size
self.grain_texture = Texture(img.tobytes(), width, height)
self.grain_texture.set_wrap(True)
@ -145,7 +183,7 @@ class UI:
# if editing is disallowed, hide game mode UI
if not self.app.can_edit:
self.set_game_edit_ui_visibility(False)
def set_scale(self, new_scale):
old_scale = self.scale
self.scale = new_scale
@ -155,8 +193,12 @@ class UI:
aspect = float(self.app.window_width) / self.app.window_height
inv_aspect = float(self.app.window_height) / self.app.window_width
# MAYBE-TODO: this math is correct but hard to follow, rewrite for clarity
width = self.app.window_width / (self.charset.char_width * self.scale * inv_aspect)
height = self.app.window_height / (self.charset.char_height * self.scale * inv_aspect)
width = self.app.window_width / (
self.charset.char_width * self.scale * inv_aspect
)
height = self.app.window_height / (
self.charset.char_height * self.scale * inv_aspect
)
# any new UI elements created should use new scale
UIArt.quad_width = 2 / width * aspect
UIArt.quad_height = 2 / height * aspect
@ -165,8 +207,10 @@ class UI:
# tell elements to refresh
self.set_elements_scale()
if self.scale != old_scale:
self.message_line.post_line('UI scale is now %s (%.3f x %.3f)' % (self.scale, self.width_tiles, self.height_tiles))
self.message_line.post_line(
f"UI scale is now {self.scale} ({self.width_tiles:.3f} x {self.height_tiles:.3f})"
)
def set_elements_scale(self):
for e in self.elements:
e.art.quad_width, e.art.quad_height = UIArt.quad_width, UIArt.quad_height
@ -174,11 +218,11 @@ class UI:
e.reset_art()
e.reset_loc()
e.art.geo_changed = True
def window_resized(self):
# recalc renderables' quad size (same scale, different aspect)
self.set_scale(self.scale)
def size_and_position_overlay_image(self):
# called any time active art changes, or active art changes size
r = self.app.overlay_renderable
@ -197,7 +241,7 @@ class UI:
r.scale_y = self.active_art.height * self.active_art.quad_height
r.y = -r.scale_y
r.z = self.active_art.layers_z[self.active_art.active_layer]
def set_active_art(self, new_art):
self.active_art = new_art
new_charset = self.active_art.charset
@ -231,24 +275,28 @@ class UI:
# rescale/reposition overlay image
self.size_and_position_overlay_image()
# tell select tool renderables
for r in [self.select_tool.select_renderable,
self.select_tool.drag_renderable]:
for r in [self.select_tool.select_renderable, self.select_tool.drag_renderable]:
r.quad_size_ref = new_art
r.rebuild_geo(self.select_tool.selected_tiles)
self.app.update_window_title()
if self.app.can_edit:
self.message_line.post_line('%s %s' % (self.art_selected_log, self.active_art.filename))
self.message_line.post_line(
f"{self.art_selected_log} {self.active_art.filename}"
)
def set_active_art_by_filename(self, art_filename):
for i,art in enumerate(self.app.art_loaded_for_edit):
for idx, art in enumerate(self.app.art_loaded_for_edit):
if art_filename == art.filename:
i = idx
break
else:
i = 0
new_active_art = self.app.art_loaded_for_edit.pop(i)
self.app.art_loaded_for_edit.insert(0, new_active_art)
new_active_renderable = self.app.edit_renderables.pop(i)
self.app.edit_renderables.insert(0, new_active_renderable)
self.set_active_art(new_active_art)
def previous_active_art(self):
"cycles to next art in app.art_loaded_for_edit"
if len(self.app.art_loaded_for_edit) == 1:
@ -258,7 +306,7 @@ class UI:
next_active_renderable = self.app.edit_renderables.pop(-1)
self.app.edit_renderables.insert(0, next_active_renderable)
self.set_active_art(self.app.art_loaded_for_edit[0])
def next_active_art(self):
if len(self.app.art_loaded_for_edit) == 1:
return
@ -267,12 +315,12 @@ class UI:
last_active_renderable = self.app.edit_renderables.pop(0)
self.app.edit_renderables.append(last_active_renderable)
self.set_active_art(self.app.art_loaded_for_edit[0])
def set_selected_tool(self, new_tool):
if self.app.game_mode:
return
# don't re-select same tool, except to cycle fill tool (see below)
if new_tool == self.selected_tool and not type(new_tool) is FillTool:
if new_tool == self.selected_tool and type(new_tool) is not FillTool:
return
# bail out of text entry if active
if self.selected_tool is self.text_tool:
@ -285,20 +333,26 @@ class UI:
# if we're selecting the fill tool and it's already selected,
# cycle through its 3 modes (char/fg/bg boundary)
cycled_fill = False
if type(self.selected_tool) is FillTool and \
type(self.previous_tool) is FillTool:
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[self.selected_tool.boundary_mode]
if (
type(self.selected_tool) is FillTool
and type(self.previous_tool) is FillTool
):
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[
self.selected_tool.boundary_mode
]
# TODO: do we need a message line message for this?
#self.app.log(self.selected_tool.boundary_mode)
# self.app.log(self.selected_tool.boundary_mode)
cycled_fill = True
# close menu if we selected tool from it
if self.menu_bar.active_menu_name and not cycled_fill:
self.menu_bar.close_active_menu()
self.message_line.post_line('%s %s' % (self.selected_tool.get_button_caption(), self.tool_selected_log))
self.message_line.post_line(
f"{self.selected_tool.get_button_caption()} {self.tool_selected_log}"
)
def cycle_fill_tool_mode(self):
self.set_selected_tool(self.fill_tool)
def get_longest_tool_name_length(self):
"VERY specific function to help status bar draw its buttons"
longest = 0
@ -306,7 +360,7 @@ class UI:
if len(tool.button_caption) > longest:
longest = len(tool.button_caption)
return longest
def cycle_selected_tool(self, back=False):
if not self.active_art:
return
@ -317,16 +371,17 @@ class UI:
tool_index += 1
tool_index %= len(self.tools)
self.set_selected_tool(self.tools[tool_index])
def set_selected_xform(self, new_xform):
self.selected_xform = new_xform
self.popup.set_xform(new_xform)
self.tool_settings_changed = True
line = '%s %s' % (self.xform_selected_log, uv_names[self.selected_xform])
line = f"{self.xform_selected_log} {uv_names[self.selected_xform]}"
self.message_line.post_line(line)
def cycle_selected_xform(self, back=False):
if self.app.game_mode: return
if self.app.game_mode:
return
xform = self.selected_xform
if back:
xform -= 1
@ -334,40 +389,42 @@ class UI:
xform += 1
xform %= UV_FLIP270 + 1
self.set_selected_xform(xform)
def reset_onion_frames(self, new_art=None):
"set correct visibility, frame, and alpha for all onion renderables"
new_art = new_art or self.active_art
alpha = self.max_onion_alpha
total_onion_frames = 0
def set_onion(r, new_frame, alpha):
# scale back if fewer than MAX_ONION_FRAMES in either direction
if total_onion_frames >= new_art.frames:
r.visible = False
return
r.visible = True
if not new_art is r.art:
if new_art is not r.art:
r.set_art(new_art)
r.set_frame(new_frame)
r.alpha = alpha
# make BG dimmer so it's easier to see
r.bg_alpha = alpha / 2
# populate "next" frames first
for i,r in enumerate(self.app.onion_renderables_next):
for i, r in enumerate(self.app.onion_renderables_next):
total_onion_frames += 1
new_frame = new_art.active_frame + i + 1
set_onion(r, new_frame, alpha)
alpha /= 2
#print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
# print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
alpha = self.max_onion_alpha
for i,r in enumerate(self.app.onion_renderables_prev):
for i, r in enumerate(self.app.onion_renderables_prev):
total_onion_frames += 1
new_frame = new_art.active_frame - (i + 1)
set_onion(r, new_frame, alpha)
# each successive onion layer is dimmer
alpha /= 2
#print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
# print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha))
def set_active_frame(self, new_frame):
if not self.active_art.set_active_frame(new_frame):
return
@ -377,7 +434,7 @@ class UI:
delay = self.active_art.frame_delays[frame]
if self.app.can_edit:
self.message_line.post_line(self.frame_selected_log % (frame + 1, delay))
def set_active_layer(self, new_layer):
self.active_art.set_active_layer(new_layer)
z = self.active_art.layers_z[self.active_art.active_layer]
@ -391,20 +448,22 @@ class UI:
if self.app.can_edit:
self.message_line.post_line(self.layer_selected_log % layer_name)
self.size_and_position_overlay_image()
def select_char(self, new_char_index):
if not self.active_art:
return
# wrap at last valid index
self.selected_char = new_char_index % self.active_art.charset.last_index
# only update char tooltip if it was already up; avoid stomping others
char_cycle_button = self.status_bar.button_map['char_cycle']
char_toggle_button = self.status_bar.button_map['char_toggle']
if char_cycle_button in self.status_bar.hovered_buttons or \
char_toggle_button in self.status_bar.hovered_buttons:
char_cycle_button = self.status_bar.button_map["char_cycle"]
char_toggle_button = self.status_bar.button_map["char_toggle"]
if (
char_cycle_button in self.status_bar.hovered_buttons
or char_toggle_button in self.status_bar.hovered_buttons
):
char_toggle_button.update_tooltip()
self.tool_settings_changed = True
def select_color(self, new_color_index, fg):
"common code for select_fg/bg"
if not self.active_art:
@ -415,19 +474,29 @@ class UI:
else:
self.selected_bg_color = new_color_index
# same don't-stomp-another-tooltip check as above
toggle_button = self.status_bar.button_map['fg_toggle'] if fg else self.status_bar.button_map['bg_toggle']
cycle_button = self.status_bar.button_map['fg_cycle'] if fg else self.status_bar.button_map['bg_cycle']
if toggle_button in self.status_bar.hovered_buttons or \
cycle_button in self.status_bar.hovered_buttons:
toggle_button = (
self.status_bar.button_map["fg_toggle"]
if fg
else self.status_bar.button_map["bg_toggle"]
)
cycle_button = (
self.status_bar.button_map["fg_cycle"]
if fg
else self.status_bar.button_map["bg_cycle"]
)
if (
toggle_button in self.status_bar.hovered_buttons
or cycle_button in self.status_bar.hovered_buttons
):
toggle_button.update_tooltip()
self.tool_settings_changed = True
def select_fg(self, new_fg_index):
self.select_color(new_fg_index, True)
def select_bg(self, new_bg_index):
self.select_color(new_bg_index, False)
def swap_fg_bg_colors(self):
if self.app.game_mode:
return
@ -435,11 +504,11 @@ class UI:
self.selected_fg_color, self.selected_bg_color = bg, fg
self.tool_settings_changed = True
self.message_line.post_line(self.swap_color_log)
def cut_selection(self):
self.copy_selection()
self.erase_tiles_in_selection()
def erase_selection_or_art(self):
if len(self.select_tool.selected_tiles) > 0:
self.erase_tiles_in_selection()
@ -447,7 +516,7 @@ class UI:
self.select_all()
self.erase_tiles_in_selection()
self.select_none()
def erase_tiles_in_selection(self):
# create and commit command group to clear all tiles in selection
frame, layer = self.active_art.active_frame, self.active_art.active_layer
@ -455,7 +524,9 @@ class UI:
for tile in self.select_tool.selected_tiles:
new_tile_command = EditCommandTile(self.active_art)
new_tile_command.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at(
frame, layer, *tile
)
new_tile_command.set_before(b_char, b_fg, b_bg, b_xform)
a_char = a_fg = 0
a_xform = UV_NORMAL
@ -466,7 +537,7 @@ class UI:
new_command.apply()
self.active_art.command_stack.commit_commands([new_command])
self.active_art.set_unsaved_changes(True)
def copy_selection(self):
# convert current selection tiles (active frame+layer) into
# EditCommandTiles for Cursor.preview_edits
@ -499,7 +570,7 @@ class UI:
tile_command.set_tile(frame, layer, x, y)
self.clipboard_width = max_x - min_x
self.clipboard_height = max_y - min_y
def crop_to_selection(self, art):
# ignore non-rectangular selection features, use top left and bottom
# right corners
@ -523,7 +594,7 @@ class UI:
command = EntireArtCommand(art, min_x, min_y)
command.save_tiles(before=True)
art.resize(w, h, min_x, min_y)
self.app.log('Resized %s to %s x %s' % (art.filename, w, h))
self.app.log(f"Resized {art.filename} to {w} x {h}")
art.set_unsaved_changes(True)
# clear selection to avoid having tiles we know are OoB selected
self.select_tool.selected_tiles = {}
@ -531,11 +602,11 @@ class UI:
# commit command
command.save_tiles(before=False)
art.command_stack.commit_commands([command])
def reset_edit_renderables(self):
# reposition all art renderables and change their opacity
x, y = 0, 0
for i,r in enumerate(self.app.edit_renderables):
for i, r in enumerate(self.app.edit_renderables):
# always put active art at 0,0
if r in self.active_art.renderables:
r.alpha = 1
@ -553,7 +624,7 @@ class UI:
r.move_to(x * MDI_MARGIN, 0, -i, 0.2)
x += r.art.width * r.art.quad_width
y -= r.art.height * r.art.quad_height
def adjust_for_art_resize(self, art):
if art is not self.active_art:
return
@ -568,7 +639,7 @@ class UI:
if self.app.cursor.y > art.height:
self.app.cursor.y = art.height
self.app.cursor.moved = True
def resize_art(self, art, new_width, new_height, origin_x, origin_y, bg_fill):
# create command for undo/redo
command = EntireArtCommand(art, origin_x, origin_y)
@ -580,16 +651,16 @@ class UI:
command.save_tiles(before=False)
art.command_stack.commit_commands([command])
art.set_unsaved_changes(True)
def select_none(self):
self.select_tool.selected_tiles = {}
def select_all(self):
self.select_tool.selected_tiles = {}
for y in range(self.active_art.height):
for x in range(self.active_art.width):
self.select_tool.selected_tiles[(x, y)] = True
def invert_selection(self):
old_selection = self.select_tool.selected_tiles.copy()
self.select_tool.selected_tiles = {}
@ -597,12 +668,12 @@ class UI:
for x in range(self.active_art.width):
if not old_selection.get((x, y), False):
self.select_tool.selected_tiles[(x, y)] = True
def get_screen_coords(self, window_x, window_y):
x = (2 * window_x) / self.app.window_width - 1
y = (-2 * window_y) / self.app.window_height + 1
return x, y
def update(self):
self.select_tool.update()
# window coordinates -> OpenGL coordinates
@ -615,14 +686,19 @@ class UI:
if self.console.visible:
continue
# only check visible elements
if self.app.has_mouse_focus and e.is_visible() and e.can_hover and e.is_inside(mx, my):
if (
self.app.has_mouse_focus
and e.is_visible()
and e.can_hover
and e.is_inside(mx, my)
):
self.hovered_elements.append(e)
# only hover if we weren't last update
if not e in was_hovering:
if e not in was_hovering:
e.hovered()
for e in was_hovering:
# unhover if app window loses mouse focus
if not self.app.has_mouse_focus or not e in self.hovered_elements:
if not self.app.has_mouse_focus or e not in self.hovered_elements:
e.unhovered()
# update all elements, regardless of whether they're being hovered etc
for e in self.elements:
@ -632,7 +708,7 @@ class UI:
# art update: tell renderables to refresh buffers
e.art.update()
self.tool_settings_changed = False
def clicked(self, mouse_button):
handled = False
# return True if any button handled the input
@ -642,17 +718,21 @@ class UI:
if e.clicked(mouse_button):
handled = True
# close pulldown if clicking outside it / the menu bar
if self.pulldown.visible and not self.pulldown in self.hovered_elements and not self.menu_bar in self.hovered_elements:
if (
self.pulldown.visible
and self.pulldown not in self.hovered_elements
and self.menu_bar not in self.hovered_elements
):
self.menu_bar.close_active_menu()
return handled
def unclicked(self, mouse_button):
handled = False
for e in self.hovered_elements:
if e.unclicked(mouse_button):
handled = True
return handled
def wheel_moved(self, wheel_y):
handled = False
# use wheel to scroll chooser dialogs
@ -660,17 +740,19 @@ class UI:
# an SDL keycode from that?
if self.active_dialog:
keycode = sdl2.SDLK_UP if wheel_y > 0 else sdl2.SDLK_DOWN
self.active_dialog.handle_input(keycode,
self.app.il.shift_pressed,
self.app.il.alt_pressed,
self.app.il.ctrl_pressed)
self.active_dialog.handle_input(
keycode,
self.app.il.shift_pressed,
self.app.il.alt_pressed,
self.app.il.ctrl_pressed,
)
handled = True
elif len(self.hovered_elements) > 0:
for e in self.hovered_elements:
if e.wheel_moved(wheel_y):
handled = True
return handled
def quick_grab(self):
if self.app.game_mode:
return
@ -678,17 +760,17 @@ class UI:
return
self.grab_tool.grab()
self.tool_settings_changed = True
def undo(self):
# if still painting, finish
if self.app.cursor.current_command:
self.app.cursor.finish_paint()
self.active_art.command_stack.undo()
self.active_art.set_unsaved_changes(True)
def redo(self):
self.active_art.command_stack.redo()
def open_dialog(self, dialog_class, options={}):
if self.app.game_mode and not dialog_class.game_mode_visible:
return
@ -696,14 +778,14 @@ class UI:
self.active_dialog = dialog
self.keyboard_focus_element = self.active_dialog
# insert dialog at index 0 so it draws first instead of last
#self.elements.insert(0, dialog)
# self.elements.insert(0, dialog)
self.elements.remove(self.console)
self.elements.append(dialog)
self.elements.append(self.console)
def is_game_edit_ui_visible(self):
return self.game_menu_bar.visible
def set_game_edit_ui_visibility(self, visible, show_message=True):
self.game_menu_bar.visible = visible
self.edit_list_panel.visible = visible
@ -712,24 +794,26 @@ class UI:
# relinquish keyboard focus in play mode
self.keyboard_focus_element = None
if show_message and self.app.il:
bind = self.app.il.get_command_shortcut('toggle_game_edit_ui')
bind = self.app.il.get_command_shortcut("toggle_game_edit_ui")
bind = bind.title()
self.message_line.post_line(self.show_edit_ui_log % bind, 10)
else:
self.message_line.post_line('')
self.message_line.post_line("")
self.app.update_window_title()
def object_selection_changed(self):
if len(self.app.gw.selected_objects) == 0:
self.keyboard_focus_element = None
self.refocus_keyboard()
def switch_edit_panel_focus(self, reverse=False):
# only allow tabbing away if list panel is in allowed mode
lp = self.edit_list_panel
if self.keyboard_focus_element is lp and \
lp.list_operation in lp.list_operations_allow_kb_focus and \
self.active_dialog:
if (
self.keyboard_focus_element is lp
and lp.list_operation in lp.list_operations_allow_kb_focus
and self.active_dialog
):
self.keyboard_focus_element = self.active_dialog
# prevent any other tabbing away from active dialog
if self.active_dialog:
@ -746,14 +830,14 @@ class UI:
# handle shift-tab
if reverse:
focus_elements.reverse()
for i,element in enumerate(focus_elements[:-1]):
for i, element in enumerate(focus_elements[:-1]):
if self.keyboard_focus_element is element:
self.keyboard_focus_element = focus_elements[i+1]
self.keyboard_focus_element = focus_elements[i + 1]
break
# update keyboard hover for both
self.edit_object_panel.update_keyboard_hover()
self.edit_list_panel.update_keyboard_hover()
def refocus_keyboard(self):
"called when an element closes, sets new keyboard_focus_element"
if self.active_dialog:
@ -764,27 +848,31 @@ class UI:
self.keyboard_focus_element = self.popup
elif self.pulldown.visible:
self.keyboard_focus_element = self.pulldown
elif self.edit_list_panel.is_visible() and not self.edit_object_panel.is_visible():
elif (
self.edit_list_panel.is_visible()
and not self.edit_object_panel.is_visible()
):
self.keyboard_focus_element = self.edit_list_panel
elif self.edit_object_panel.is_visible() and not self.edit_list_panel.is_visible():
elif (
self.edit_object_panel.is_visible()
and not self.edit_list_panel.is_visible()
):
self.keyboard_focus_element = self.edit_object_panel
def keyboard_navigate(self, move_x, move_y):
self.keyboard_focus_element.keyboard_navigate(move_x, move_y)
def toggle_game_edit_ui(self):
# if editing is disallowed, only run this once to disable UI
if not self.app.can_edit:
return
elif not self.app.game_mode:
if not self.app.can_edit or not self.app.game_mode:
return
self.set_game_edit_ui_visibility(not self.game_menu_bar.visible)
def destroy(self):
for e in self.elements:
e.destroy()
self.grain_texture.destroy()
def render(self):
for e in self.elements:
if e.is_visible():

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,31 +1,29 @@
# coding=utf-8
import os
import sdl2
from renderable_sprite import UISpriteRenderable
from ui_dialog import UIDialog, Field
from ui_button import UIButton
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY
from ui_colors import UIColors
from .art import UV_FLIPY, UV_NORMAL
from .renderable_sprite import UISpriteRenderable
from .ui_button import UIButton
from .ui_colors import UIColors
from .ui_dialog import Field, UIDialog
class ChooserItemButton(UIButton):
"button representing a ChooserItem"
item = None
width = 20
big_width = 30
clear_before_caption_draw = True
def __init__(self, element):
# more room for list items if screen is wide enough
if element.ui.width_tiles - 20 > element.big_width:
self.width = self.big_width
UIButton.__init__(self, element)
self.callback = self.pick_item
def pick_item(self):
if not self.item:
return
@ -33,20 +31,20 @@ class ChooserItemButton(UIButton):
class ScrollArrowButton(UIButton):
"button that scrolls up or down in a chooser item view"
arrow_char = 129
up = True
normal_bg_color = UIDialog.bg_color
dimmed_fg_color = UIColors.medgrey
dimmed_bg_color = UIDialog.bg_color
def draw_caption(self):
xform = [UV_FLIPY, UV_NORMAL][self.up]
self.element.art.set_tile_at(0, 0, self.x, self.y + self.caption_y,
self.arrow_char, None, None, xform)
self.element.art.set_tile_at(
0, 0, self.x, self.y + self.caption_y, self.arrow_char, None, None, xform
)
def callback(self):
if self.up and self.element.scroll_index > 0:
self.element.scroll_index -= 1
@ -59,9 +57,8 @@ class ScrollArrowButton(UIButton):
class ChooserItem:
label = 'Chooser item'
label = "Chooser item"
def __init__(self, index, name):
self.index = index
# item's unique name, eg a filename
@ -69,43 +66,44 @@ class ChooserItem:
self.label = self.get_label()
# validity flag lets ChooserItem subclasses exclude themselves
self.valid = True
def get_label(self): return self.name
def get_description_lines(self): return []
def get_preview_texture(self): return None
def load(self, app): pass
def get_label(self):
return self.name
def get_description_lines(self):
return []
def get_preview_texture(self):
return None
def load(self, app):
pass
def picked(self, element):
# set item selected and refresh preview
element.set_selected_item_index(self.index)
class ChooserDialog(UIDialog):
title = 'Chooser'
confirm_caption = 'Set'
cancel_caption = 'Close'
message = ''
title = "Chooser"
confirm_caption = "Set"
cancel_caption = "Close"
message = ""
# if True, chooser shows files; show filename on first line of description
show_filenames = False
directory_aware = False
tile_width, tile_height = 60, 20
# use these if screen is big enough
big_width, big_height = 80, 30
fields = [
Field(label='', type=str, width=tile_width - 4, oneline=True)
]
fields = [Field(label="", type=str, width=tile_width - 4, oneline=True)]
item_start_x, item_start_y = 2, 4
no_preview_label = 'No preview available!'
no_preview_label = "No preview available!"
show_preview_image = True
item_button_class = ChooserItemButton
chooser_item_class = ChooserItem
scrollbar_shade_char = 54
flip_preview_y = True
def __init__(self, ui, options):
self.ui = ui
# semikludge: track whether user has selected anything in a new dir,
@ -113,12 +111,13 @@ class ChooserDialog(UIDialog):
self.first_selection_made = False
if self.ui.width_tiles - 20 > self.big_width:
self.tile_width = self.big_width
self.fields[0] = Field(label='', type=str,
width=self.tile_width - 4, oneline=True)
self.fields[0] = Field(
label="", type=str, width=self.tile_width - 4, oneline=True
)
if self.ui.height_tiles - 30 > self.big_height:
self.tile_height = self.big_height
self.items_in_view = self.tile_height - self.item_start_y - 3
self.field_texts = ['']
self.field_texts = [""]
# set active field earlier than UIDialog.init so set_initial_dir
# can change its text
self.active_field = 0
@ -149,7 +148,7 @@ class ChooserDialog(UIDialog):
self.preview_renderable.blend = False
# offset into items list view provided by buttons starts from
self.position_preview()
def init_buttons(self):
for i in range(self.items_in_view):
button = self.item_button_class(self)
@ -167,24 +166,24 @@ class ChooserDialog(UIDialog):
self.down_arrow_button.y = self.item_start_y + self.items_in_view - 1
self.down_arrow_button.up = False
self.buttons += [self.up_arrow_button, self.down_arrow_button]
def set_initial_dir(self):
# for directory-aware dialogs, subclasses specify here where to start
self.current_dir = '.'
self.current_dir = "."
def change_current_dir(self, new_dir):
# check permissions:
# os.access(new_dir, os.R_OK) seems to always return True,
# so try/catch listdir instead
try:
l = os.listdir(new_dir)
except PermissionError as e:
line = 'No permission to access %s!' % os.path.abspath(new_dir)
os.listdir(new_dir)
except PermissionError:
line = f"No permission to access {os.path.abspath(new_dir)}!"
self.ui.message_line.post_line(line, error=True)
return False
self.current_dir = new_dir
if not self.current_dir.endswith('/'):
self.current_dir += '/'
if not self.current_dir.endswith("/"):
self.current_dir += "/"
# redo items and redraw
self.selected_item_index = 0
self.scroll_index = 0
@ -192,9 +191,8 @@ class ChooserDialog(UIDialog):
self.items = self.get_items()
self.reset_art(False)
return True
def set_selected_item_index(self, new_index, set_field_text=True,
update_view=True):
def set_selected_item_index(self, new_index, set_field_text=True, update_view=True):
"""
set the view's selected item to specified index
perform usually-necessary refresh functions for convenience
@ -202,17 +200,25 @@ class ChooserDialog(UIDialog):
move_dir = new_index - self.selected_item_index
self.selected_item_index = new_index
can_scroll = len(self.items) > self.items_in_view
should_scroll = self.selected_item_index >= self.scroll_index + self.items_in_view or self.selected_item_index < self.scroll_index
should_scroll = (
self.selected_item_index >= self.scroll_index + self.items_in_view
or self.selected_item_index < self.scroll_index
)
if not can_scroll:
self.scroll_index = 0
elif should_scroll:
# keep selection in bounds
self.selected_item_index = min(self.selected_item_index, len(self.items)-1)
self.selected_item_index = min(
self.selected_item_index, len(self.items) - 1
)
# scrolling up
if move_dir <= 0:
self.scroll_index = self.selected_item_index
# scrolling down
elif move_dir > 0 and self.selected_item_index - self.scroll_index == self.items_in_view:
elif (
move_dir > 0
and self.selected_item_index - self.scroll_index == self.items_in_view
):
self.scroll_index = self.selected_item_index - self.items_in_view + 1
# keep scroll in bounds
self.scroll_index = min(self.scroll_index, self.get_max_scroll())
@ -225,31 +231,35 @@ class ChooserDialog(UIDialog):
self.load_selected_item()
self.reset_art(False)
self.position_preview()
def get_max_scroll(self):
return len(self.items) - self.items_in_view
def get_selected_item(self):
# return None if out of bounds
return self.items[self.selected_item_index] if self.selected_item_index < len(self.items) else None
return (
self.items[self.selected_item_index]
if self.selected_item_index < len(self.items)
else None
)
def load_selected_item(self):
item = self.get_selected_item()
item.load(self.ui.app)
def get_initial_selection(self):
# subclasses return index of initial selection
return 0
def set_preview(self):
item = self.get_selected_item()
if self.show_preview_image:
self.preview_renderable.texture = item.get_preview_texture(self.ui.app)
def get_items(self):
# subclasses generate lists of items here
return []
def position_preview(self, reset=True):
if reset:
self.set_preview()
@ -261,29 +271,38 @@ class ChooserDialog(UIDialog):
self.preview_renderable.x = self.x + x
self.preview_renderable.scale_x = (self.tile_width - 2) * qw - x
# determine height based on width, then y position
img_inv_aspect = self.preview_renderable.texture.height / self.preview_renderable.texture.width
img_inv_aspect = (
self.preview_renderable.texture.height
/ self.preview_renderable.texture.width
)
screen_aspect = self.ui.app.window_width / self.ui.app.window_height
self.preview_renderable.scale_y = self.preview_renderable.scale_x * img_inv_aspect * screen_aspect
self.preview_renderable.scale_y = (
self.preview_renderable.scale_x * img_inv_aspect * screen_aspect
)
y = (self.description_end_y + 1) * qh
# if preview height is above max allotted size, set height to fill size
# and scale down width
max_y = (self.tile_height - 3) * qh
if self.preview_renderable.scale_y > max_y - y:
self.preview_renderable.scale_y = max_y - y
self.preview_renderable.scale_x = self.preview_renderable.scale_y * (1 / img_inv_aspect) * (1 / screen_aspect)
self.preview_renderable.scale_x = (
self.preview_renderable.scale_y
* (1 / img_inv_aspect)
* (1 / screen_aspect)
)
# flip in Y for some (palettes) but not for others (charsets)
if self.flip_preview_y:
self.preview_renderable.scale_y = -self.preview_renderable.scale_y
else:
y += self.preview_renderable.scale_y
self.preview_renderable.y = self.y - y
def get_height(self, msg_lines):
return self.tile_height
def reset_buttons(self):
# (re)generate buttons from contents of self.items
for i,button in enumerate(self.item_buttons):
for i, button in enumerate(self.item_buttons):
# ??? each button's callback loads charset/palette/whatev
if i >= len(self.items):
button.never_draw = True
@ -310,23 +329,23 @@ class ChooserDialog(UIDialog):
if not self.up_arrow_button:
return
# dim scroll buttons if we don't have enough items to scroll
state, hover = 'normal', True
state, hover = "normal", True
if len(self.items) <= self.items_in_view:
state = 'dimmed'
state = "dimmed"
hover = False
for button in [self.up_arrow_button, self.down_arrow_button]:
button.set_state(state)
button.can_hover = hover
def get_description_filename(self, item):
"returns a description-appropriate filename for given item"
# truncate from start to fit in description area if needed
max_width = self.tile_width
max_width -= self.item_start_x + self.item_button_width + 5
if len(item.name) > max_width - 1:
return '' + item.name[-max_width:]
return "" + item.name[-max_width:]
return item.name
def get_selected_description_lines(self):
item = self.get_selected_item()
lines = []
@ -334,7 +353,7 @@ class ChooserDialog(UIDialog):
lines += [self.get_description_filename(item)]
lines += item.get_description_lines() or []
return lines
def draw_selected_description(self):
x = self.tile_width - 2
y = self.item_start_y
@ -343,11 +362,10 @@ class ChooserDialog(UIDialog):
# trim line if it's too long
max_width = self.tile_width - self.item_button_width - 7
line = line[:max_width]
self.art.write_string(0, 0, x, y, line, None, None,
right_justify=True)
self.art.write_string(0, 0, x, y, line, None, None, right_justify=True)
y += 1
self.description_end_y = y
def reset_art(self, resize=True):
self.reset_buttons()
# UIDialog does: clear window, draw titlebar and confirm/cancel buttons
@ -363,33 +381,34 @@ class ChooserDialog(UIDialog):
if len(self.items) <= self.items_in_view:
fg = self.up_arrow_button.dimmed_fg_color
for y in range(self.up_arrow_button.y + 1, self.down_arrow_button.y):
self.art.set_tile_at(0, 0, self.up_arrow_button.x, y,
self.scrollbar_shade_char, fg)
self.art.set_tile_at(
0, 0, self.up_arrow_button.x, y, self.scrollbar_shade_char, fg
)
def update_drag(self, mouse_dx, mouse_dy):
UIDialog.update_drag(self, mouse_dx, mouse_dy)
# update thumbnail renderable's position too
self.position_preview(False)
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
# up/down keys navigate list
new_index = self.selected_item_index
navigated = False
if keystr == 'Return':
if keystr == "Return":
# if handle_enter returns True, bail before rest of input handling -
# make sure any changes to handle_enter are safe for this!
if self.handle_enter(shift_pressed, alt_pressed, ctrl_pressed):
return
elif keystr == 'Up':
elif keystr == "Up":
navigated = True
if self.selected_item_index > 0:
new_index -= 1
elif keystr == 'Down':
elif keystr == "Down":
navigated = True
if self.selected_item_index < len(self.items) - 1:
new_index += 1
elif keystr == 'PageUp':
elif keystr == "PageUp":
navigated = True
page_size = int(self.items_in_view / 2)
new_index -= page_size
@ -397,7 +416,7 @@ class ChooserDialog(UIDialog):
# scroll follows selection jumps
self.scroll_index -= page_size
self.scroll_index = max(0, self.scroll_index)
elif keystr == 'PageDown':
elif keystr == "PageDown":
navigated = True
page_size = int(self.items_in_view / 2)
new_index += page_size
@ -405,11 +424,11 @@ class ChooserDialog(UIDialog):
self.scroll_index += page_size
self.scroll_index = min(self.scroll_index, self.get_max_scroll())
# home/end: beginning/end of list, respectively
elif keystr == 'Home':
elif keystr == "Home":
navigated = True
new_index = 0
self.scroll_index = 0
elif keystr == 'End':
elif keystr == "End":
navigated = True
new_index = len(self.items) - 1
self.scroll_index = len(self.items) - self.items_in_view
@ -419,33 +438,33 @@ class ChooserDialog(UIDialog):
# if we didn't navigate, seek based on new alphanumeric input
if not navigated:
self.text_input_seek()
def text_input_seek(self):
field_text = self.field_texts[self.active_field]
if field_text.strip() == '':
if field_text.strip() == "":
return
# seek should be case-insensitive
field_text = field_text.lower()
# field text may be a full path; only care about the base
field_text = os.path.basename(field_text)
for i,item in enumerate(self.items):
for i, item in enumerate(self.items):
# match to base item name within dir
# (if it's a dir, snip last / for match)
item_base = item.name.lower()
if item_base.endswith('/'):
if item_base.endswith("/"):
item_base = item_base[:-1]
item_base = os.path.basename(item_base)
item_base = os.path.splitext(item_base)[0]
if item_base.startswith(field_text):
self.set_selected_item_index(i, set_field_text=False)
break
def handle_enter(self, shift_pressed, alt_pressed, ctrl_pressed):
"handle Enter key, return False if rest of handle_input should continue"
# if selected item is already in text field, pick it
field_text = self.field_texts[self.active_field]
selected_item = self.get_selected_item()
if field_text.strip() == '':
if field_text.strip() == "":
self.field_texts[self.active_field] = field_text = selected_item.name
return True
if field_text == selected_item.name:
@ -459,9 +478,13 @@ class ChooserDialog(UIDialog):
self.field_texts[self.active_field] = selected_item.name
return True
# special case for parent dir ..
if self.directory_aware and field_text == self.current_dir and selected_item.name == '..':
if (
self.directory_aware
and field_text == self.current_dir
and selected_item.name == ".."
):
self.first_selection_made = True
return self.change_current_dir('..')
return self.change_current_dir("..")
if self.directory_aware and os.path.isdir(field_text):
self.first_selection_made = True
return self.change_current_dir(field_text)
@ -470,13 +493,13 @@ class ChooserDialog(UIDialog):
# if a file, change to its dir and select it
if self.directory_aware and file_dir_name != self.current_dir:
if self.change_current_dir(file_dir_name):
for i,item in enumerate(self.items):
for i, item in enumerate(self.items):
if item.name == field_text:
self.set_selected_item_index(i)
item.picked(self)
return True
return False
def render(self):
UIDialog.render(self)
if self.show_preview_image and self.preview_renderable.texture:

View file

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

View file

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

View file

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

View file

@ -1,17 +1,28 @@
import os
from ui_element import UIElement
from ui_button import UIButton
from ui_game_dialog import LoadGameStateDialog, SaveGameStateDialog
from ui_chooser_dialog import ScrollArrowButton
from ui_colors import UIColors
from game_world import TOP_GAME_DIR, STATE_FILE_EXTENSION
from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, LO_SET_ROOM_CAMERA
from .game_world import STATE_FILE_EXTENSION, TOP_GAME_DIR
from .ui_button import UIButton
from .ui_chooser_dialog import ScrollArrowButton
from .ui_colors import UIColors
from .ui_element import UIElement
from .ui_list_operations import (
LO_LOAD_STATE,
LO_NONE,
LO_OPEN_GAME_DIR,
LO_SELECT_OBJECTS,
LO_SET_OBJECT_ROOMS,
LO_SET_ROOM,
LO_SET_ROOM_CAMERA,
LO_SET_ROOM_EDGE_OBJ,
LO_SET_ROOM_EDGE_WARP,
LO_SET_ROOM_OBJECTS,
LO_SET_SPAWN_CLASS,
)
class GamePanel(UIElement):
"base class of game edit UI panels"
tile_y = 5
game_mode_visible = True
fg_color = UIColors.black
@ -22,7 +33,7 @@ class GamePanel(UIElement):
support_keyboard_navigation = True
support_scrolling = True
keyboard_nav_offset = -2
def __init__(self, ui):
self.ui = ui
self.world = self.ui.app.gw
@ -30,60 +41,76 @@ class GamePanel(UIElement):
self.buttons = []
self.create_buttons()
self.keyboard_nav_index = 0
def create_buttons(self): pass
def create_buttons(self):
pass
# label and main item draw functions - overridden in subclasses
def get_label(self): pass
def refresh_items(self): pass
def get_label(self):
pass
def refresh_items(self):
pass
# reset all buttons to default state
def clear_buttons(self, button_list=None):
buttons = button_list or self.buttons
for button in buttons:
self.reset_button(button)
def reset_button(self, button):
button.normal_fg_color = UIButton.normal_fg_color
button.normal_bg_color = UIButton.normal_bg_color
button.hovered_fg_color = UIButton.hovered_fg_color
button.hovered_bg_color = UIButton.hovered_bg_color
button.can_hover = True
def highlight_button(self, button):
button.normal_fg_color = UIButton.clicked_fg_color
button.normal_bg_color = UIButton.clicked_bg_color
button.hovered_fg_color = UIButton.clicked_fg_color
button.hovered_bg_color = UIButton.clicked_bg_color
button.can_hover = True
def draw_titlebar(self):
# only shade titlebar if panel has keyboard focus
fg = self.titlebar_fg if self is self.ui.keyboard_focus_element else self.fg_color
bg = self.titlebar_bg if self is self.ui.keyboard_focus_element else self.bg_color
fg = (
self.titlebar_fg
if self is self.ui.keyboard_focus_element
else self.fg_color
)
bg = (
self.titlebar_bg
if self is self.ui.keyboard_focus_element
else self.bg_color
)
self.art.clear_line(0, 0, 0, fg, bg)
label = self.get_label()
if len(label) > self.tile_width:
label = label[:self.tile_width]
label = label[: self.tile_width]
if self.text_left:
self.art.write_string(0, 0, 0, 0, label)
else:
self.art.write_string(0, 0, -1, 0, label, None, None, True)
def reset_art(self):
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
self.draw_titlebar()
self.refresh_items()
UIElement.reset_art(self)
def clicked(self, mouse_button):
# always handle input, even if we didn't hit a button
UIElement.clicked(self, mouse_button)
return True
def hovered(self):
# mouse hover on focus
if self.ui.app.mouse_dx or self.ui.app.mouse_dy and \
not self is self.ui.keyboard_focus_element:
if (
self.ui.app.mouse_dx
or self.ui.app.mouse_dy
and self is not self.ui.keyboard_focus_element
):
self.ui.keyboard_focus_element = self
if self.ui.active_dialog:
self.ui.active_dialog.reset_art()
@ -93,16 +120,20 @@ class ListButton(UIButton):
width = 28
clear_before_caption_draw = True
class ListScrollArrowButton(ScrollArrowButton):
x = ListButton.width
normal_bg_color = UIButton.normal_bg_color
class ListScrollUpArrowButton(ListScrollArrowButton):
y = 1
class ListScrollDownArrowButton(ListScrollArrowButton):
up = False
class EditListPanel(GamePanel):
tile_width = ListButton.width + 1
tile_y = 5
@ -110,35 +141,38 @@ class EditListPanel(GamePanel):
# height will change based on how many items in list
tile_height = 30
snap_left = True
spawn_msg = 'Click anywhere in the world view to spawn a %s'
spawn_msg = "Click anywhere in the world view to spawn a %s"
# transient state
titlebar = 'List titlebar'
titlebar = "List titlebar"
items = []
# text helping user know how to bail
cancel_tip = 'ESC cancels'
cancel_tip = "ESC cancels"
list_operation_labels = {
LO_NONE: 'Stuff:',
LO_SELECT_OBJECTS: 'Select objects:',
LO_SET_SPAWN_CLASS: 'Class to spawn:',
LO_LOAD_STATE: 'State to load:',
LO_SET_ROOM: 'Change room:',
LO_NONE: "Stuff:",
LO_SELECT_OBJECTS: "Select objects:",
LO_SET_SPAWN_CLASS: "Class to spawn:",
LO_LOAD_STATE: "State to load:",
LO_SET_ROOM: "Change room:",
LO_SET_ROOM_OBJECTS: "Set objects for %s:",
LO_SET_OBJECT_ROOMS: "Set rooms for %s:",
LO_OPEN_GAME_DIR: 'Open game:',
LO_SET_ROOM_EDGE_WARP: 'Set edge warp room/object:',
LO_SET_ROOM_EDGE_OBJ: 'Set edge bounds object:',
LO_SET_ROOM_CAMERA: 'Set room camera marker:'
LO_OPEN_GAME_DIR: "Open game:",
LO_SET_ROOM_EDGE_WARP: "Set edge warp room/object:",
LO_SET_ROOM_EDGE_OBJ: "Set edge bounds object:",
LO_SET_ROOM_CAMERA: "Set room camera marker:",
}
list_operations_allow_kb_focus = [
LO_SET_ROOM_EDGE_WARP,
LO_SET_ROOM_EDGE_OBJ,
LO_SET_ROOM_CAMERA
LO_SET_ROOM_CAMERA,
]
class ListItem:
def __init__(self, name, obj): self.name, self.obj = name, obj
def __str__(self): return self.name
def __init__(self, name, obj):
self.name, self.obj = name, obj
def __str__(self):
return self.name
def __init__(self, ui):
# topmost index of items to show in view
self.list_scroll_index = 0
@ -149,41 +183,45 @@ class EditListPanel(GamePanel):
for list_op in self.list_operation_labels:
self.scroll_indices[list_op] = 0
# map list operations to list builder functions
self.list_functions = {LO_NONE: self.list_none,
LO_SELECT_OBJECTS: self.list_objects,
LO_SET_SPAWN_CLASS: self.list_classes,
LO_LOAD_STATE: self.list_states,
LO_SET_ROOM: self.list_rooms,
LO_SET_ROOM_OBJECTS: self.list_objects,
LO_SET_OBJECT_ROOMS: self.list_rooms,
LO_OPEN_GAME_DIR: self.list_games,
LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects,
LO_SET_ROOM_EDGE_OBJ: self.list_objects,
LO_SET_ROOM_CAMERA: self.list_objects
self.list_functions = {
LO_NONE: self.list_none,
LO_SELECT_OBJECTS: self.list_objects,
LO_SET_SPAWN_CLASS: self.list_classes,
LO_LOAD_STATE: self.list_states,
LO_SET_ROOM: self.list_rooms,
LO_SET_ROOM_OBJECTS: self.list_objects,
LO_SET_OBJECT_ROOMS: self.list_rooms,
LO_OPEN_GAME_DIR: self.list_games,
LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects,
LO_SET_ROOM_EDGE_OBJ: self.list_objects,
LO_SET_ROOM_CAMERA: self.list_objects,
}
# map list operations to "item clicked" functions
self.click_functions = {LO_SELECT_OBJECTS: self.select_object,
LO_SET_SPAWN_CLASS: self.set_spawn_class,
LO_LOAD_STATE: self.load_state,
LO_SET_ROOM: self.set_room,
LO_SET_ROOM_OBJECTS: self.set_room_object,
LO_SET_OBJECT_ROOMS: self.set_object_room,
LO_OPEN_GAME_DIR: self.open_game_dir,
LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp,
LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj,
LO_SET_ROOM_CAMERA: self.set_room_camera
self.click_functions = {
LO_SELECT_OBJECTS: self.select_object,
LO_SET_SPAWN_CLASS: self.set_spawn_class,
LO_LOAD_STATE: self.load_state,
LO_SET_ROOM: self.set_room,
LO_SET_ROOM_OBJECTS: self.set_room_object,
LO_SET_OBJECT_ROOMS: self.set_object_room,
LO_OPEN_GAME_DIR: self.open_game_dir,
LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp,
LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj,
LO_SET_ROOM_CAMERA: self.set_room_camera,
}
# separate lists for item buttons vs other controls
self.list_buttons = []
# set when game resets
self.should_reset_list = False
GamePanel.__init__(self, ui)
def create_buttons(self):
def list_callback(item=None):
if not item: return
if not item:
return
self.clicked_item(item)
for y in range(self.tile_height-1):
for y in range(self.tile_height - 1):
button = ListButton(self)
button.y = y + 1
button.callback = list_callback
@ -198,32 +236,33 @@ class EditListPanel(GamePanel):
# TODO: adjust height according to screen tile height
self.down_button.y = self.tile_height - 1
self.buttons.append(self.down_button)
def reset_art(self):
GamePanel.reset_art(self)
x = self.tile_width - 1
for y in range(1, self.tile_height):
self.art.set_tile_at(0, 0, x, y, self.scrollbar_shade_char,
UIColors.medgrey)
self.art.set_tile_at(
0, 0, x, y, self.scrollbar_shade_char, UIColors.medgrey
)
def cancel(self):
self.set_list_operation(LO_NONE)
self.world.classname_to_spawn = None
def scroll_list_up(self):
if self.list_scroll_index > 0:
self.list_scroll_index -= 1
def scroll_list_down(self):
max_scroll = len(self.items) - self.tile_height
#max_scroll = len(self.element.items) - self.element.items_in_view
# max_scroll = len(self.element.items) - self.element.items_in_view
if self.list_scroll_index <= max_scroll:
self.list_scroll_index += 1
def clicked_item(self, item):
# do thing appropriate to current list operation
self.click_functions[self.list_operation](item)
def wheel_moved(self, wheel_y):
if wheel_y > 0:
self.scroll_list_up()
@ -231,7 +270,7 @@ class EditListPanel(GamePanel):
if wheel_y < 0:
self.scroll_list_down()
return True
def set_list_operation(self, new_op):
"changes list type and sets new items"
if new_op == LO_LOAD_STATE and not self.world.game_dir:
@ -255,11 +294,11 @@ class EditListPanel(GamePanel):
self.list_scroll_index = self.scroll_indices[self.list_operation]
# keep in bounds if list size changed since last view
self.list_scroll_index = min(self.list_scroll_index, len(self.items))
def get_label(self):
label = '%s (%s)' % (self.list_operation_labels[self.list_operation], self.cancel_tip)
label = f"{self.list_operation_labels[self.list_operation]} ({self.cancel_tip})"
# some labels contain variables
if '%s' in label:
if "%s" in label:
if self.list_operation == LO_SET_ROOM_OBJECTS:
if self.world.current_room:
label %= self.world.current_room.name
@ -267,7 +306,7 @@ class EditListPanel(GamePanel):
if len(self.world.selected_objects) == 1:
label %= self.world.selected_objects[0].name
return label
def should_highlight(self, item):
if self.list_operation == LO_SELECT_OBJECTS:
return item.obj in self.world.selected_objects
@ -280,25 +319,30 @@ class EditListPanel(GamePanel):
elif self.list_operation == LO_SET_ROOM:
return self.world.current_room and item.name == self.world.current_room.name
elif self.list_operation == LO_SET_ROOM_OBJECTS:
return self.world.current_room and item.name in self.world.current_room.objects
return (
self.world.current_room and item.name in self.world.current_room.objects
)
elif self.list_operation == LO_SET_OBJECT_ROOMS:
return len(self.world.selected_objects) == 1 and item.name in self.world.selected_objects[0].rooms
return (
len(self.world.selected_objects) == 1
and item.name in self.world.selected_objects[0].rooms
)
return False
def game_reset(self):
self.should_reset_list = True
def items_changed(self):
"called by anything that changes the items list, eg object add/delete"
self.items = self.list_functions[self.list_operation]()
# change selected item index if it's OOB
if self.keyboard_nav_index >= len(self.items):
self.keyboard_nav_index = len(self.items) - 1
def refresh_items(self):
for i,b in enumerate(self.list_buttons):
for i, b in enumerate(self.list_buttons):
if i >= len(self.items):
b.caption = ''
b.caption = ""
b.cb_arg = None
self.reset_button(b)
b.can_hover = False
@ -306,7 +350,7 @@ class EditListPanel(GamePanel):
index = self.list_scroll_index + i
item = self.items[index]
b.cb_arg = item
b.caption = item.name[:self.tile_width - 1]
b.caption = item.name[: self.tile_width - 1]
b.can_hover = True
# change button appearance if this item should remain
# highlighted/selected
@ -315,7 +359,7 @@ class EditListPanel(GamePanel):
else:
self.reset_button(b)
self.draw_buttons()
def post_keyboard_navigate(self):
# check for scrolling
if len(self.items) <= len(self.list_buttons):
@ -336,7 +380,7 @@ class EditListPanel(GamePanel):
elif self.keyboard_nav_index < 0:
self.scroll_list_up()
self.keyboard_nav_index += 1
def update(self):
if self.should_reset_list:
self.set_list_operation(self.list_operation)
@ -346,18 +390,18 @@ class EditListPanel(GamePanel):
self.refresh_items()
GamePanel.update(self)
self.renderable.alpha = 1 if self is self.ui.keyboard_focus_element else 0.5
def is_visible(self):
return GamePanel.is_visible(self) and self.list_operation != LO_NONE
#
# list functions
#
def list_classes(self):
items = []
base_class = self.world.modules['game_object'].GameObject
base_class = self.world.modules["game_object"].GameObject
# get list of available classes from GameWorld
for classname,classdef in self.world._get_all_loaded_classes().items():
for classname, classdef in self.world._get_all_loaded_classes().items():
# ignore non-GO classes, eg GameRoom, GameHUD
if not issubclass(classdef, base_class):
continue
@ -368,7 +412,7 @@ class EditListPanel(GamePanel):
# sort classes alphabetically
items.sort(key=lambda i: i.name)
return items
def list_objects(self):
items = []
# include just-spawned objects too
@ -377,24 +421,27 @@ class EditListPanel(GamePanel):
for obj in all_objects.values():
if obj.exclude_from_object_list:
continue
if self.world.list_only_current_room_objects and not self.world.current_room.name in obj.rooms:
if (
self.world.list_only_current_room_objects
and self.world.current_room.name not in obj.rooms
):
continue
li = self.ListItem(obj.name, obj)
items.append(li)
# sort object names alphabetically
items.sort(key=lambda i: i.name)
return items
def list_states(self):
items = []
# list state files in current game dir
for filename in os.listdir(self.world.game_dir):
if filename.endswith('.' + STATE_FILE_EXTENSION):
if filename.endswith("." + STATE_FILE_EXTENSION):
li = self.ListItem(filename[:-3], None)
items.append(li)
items.sort(key=lambda i: i.name)
return items
def list_rooms(self):
items = []
for room in self.world.rooms.values():
@ -402,7 +449,7 @@ class EditListPanel(GamePanel):
items.append(li)
items.sort(key=lambda i: i.name)
return items
def list_games(self):
def get_dirs(dirname):
dirs = []
@ -410,6 +457,7 @@ class EditListPanel(GamePanel):
if os.path.isdir(dirname + filename):
dirs.append(filename)
return dirs
# get list of both app dir games and user dir games
docs_game_dir = self.ui.app.documents_dir + TOP_GAME_DIR
items = []
@ -419,18 +467,18 @@ class EditListPanel(GamePanel):
li = self.ListItem(game, None)
items.append(li)
return items
def list_rooms_and_objects(self):
items = self.list_rooms()
# prefix room names with "ROOM:"
for i,item in enumerate(items):
item.name = 'ROOM: %s' % item.name
for item in items:
item.name = f"ROOM: {item.name}"
items += self.list_objects()
return items
def list_none(self):
return []
#
# "clicked list item" functions
#
@ -443,25 +491,27 @@ class EditListPanel(GamePanel):
else:
self.world.deselect_all()
self.world.select_object(item.obj, force=True)
def set_spawn_class(self, item):
# set this class to be the one spawned when GameWorld is clicked
self.world.classname_to_spawn = item.name
self.ui.message_line.post_line(self.spawn_msg % self.world.classname_to_spawn, 5)
self.ui.message_line.post_line(
self.spawn_msg % self.world.classname_to_spawn, 5
)
def load_state(self, item):
self.world.load_game_state(item.name)
def set_room(self, item):
self.world.change_room(item.name)
def set_room_object(self, item):
# add/remove object from current room
if item.name in self.world.current_room.objects:
self.world.current_room.remove_object_by_name(item.name)
else:
self.world.current_room.add_object_by_name(item.name)
def set_object_room(self, item):
# UI can only show a single object's rooms, do nothing if many selected
if len(self.world.selected_objects) != 1:
@ -473,20 +523,20 @@ class EditListPanel(GamePanel):
room.remove_object(obj)
else:
room.add_object(obj)
def open_game_dir(self, item):
self.world.set_game_dir(item.name, True)
def set_room_edge_warp(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.name
self.ui.keyboard_focus_element = dialog
def set_room_bounds_obj(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.name
self.ui.keyboard_focus_element = dialog
def set_room_camera(self, item):
dialog = self.ui.active_dialog
dialog.field_texts[dialog.active_field] = item.obj.name

View file

@ -1,15 +1,12 @@
import time
import numpy as np
from math import ceil
import vector
from art import Art
from renderable import TileRenderable
from renderable_line import LineRenderable
from ui_button import UIButton
from . import vector
from .art import Art
from .renderable import TileRenderable
class UIElement:
# size, in tiles
tile_width, tile_height = 1, 1
snap_top, snap_bottom, snap_left, snap_right = False, False, False, False
@ -35,13 +32,20 @@ class UIElement:
game_mode_visible = False
all_modes_visible = False
keyboard_nav_offset = 0
def __init__(self, ui):
self.ui = ui
self.hovered_buttons = []
# generate a unique name
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.art = UIArt(art_name, self.ui.app, self.ui.charset, self.ui.palette, self.tile_width, self.tile_height)
art_name = 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.renderable = UIRenderable(self.ui.app, self.art)
self.renderable.ui = self.ui
# some elements add their own renderables before calling this
@ -53,13 +57,13 @@ class UIElement:
self.reset_loc()
if self.support_keyboard_navigation:
self.keyboard_nav_index = 0
def is_inside(self, x, y):
"returns True if given point is inside this element's bounds"
w = self.tile_width * self.art.quad_width
h = self.tile_height * self.art.quad_height
return self.x <= x <= self.x+w and self.y >= y >= self.y-h
return self.x <= x <= self.x + w and self.y >= y >= self.y - h
def is_inside_button(self, x, y, button):
"returns True if given point is inside the given button's bounds"
aqw, aqh = self.art.quad_width, self.art.quad_height
@ -69,30 +73,30 @@ class UIElement:
bxmin, bymin = self.x + bx, self.y - by
bxmax, bymax = bxmin + bw, bymin - bh
return bxmin <= x <= bxmax and bymin >= y >= bymax
def reset_art(self):
"""
runs on init and resize, restores state.
"""
self.draw_buttons()
def draw_buttons(self):
for button in self.buttons:
if button.visible:
button.draw()
def hovered(self):
self.log_event('hovered')
self.log_event("hovered")
def unhovered(self):
self.log_event('unhovered')
self.log_event("unhovered")
def wheel_moved(self, wheel_y):
handled = False
return handled
def clicked(self, mouse_button):
self.log_event('clicked', mouse_button)
self.log_event("clicked", mouse_button)
# return if a button did something
handled = False
# tell any hovered buttons they've been clicked
@ -116,9 +120,9 @@ class UIElement:
if self.always_consume_input:
return True
return handled
def unclicked(self, mouse_button):
self.log_event('unclicked', mouse_button)
self.log_event("unclicked", mouse_button)
handled = False
for b in self.hovered_buttons:
b.unclick()
@ -126,21 +130,26 @@ class UIElement:
if self.always_consume_input:
return True
return handled
def log_event(self, event_type, mouse_button=None):
mouse_button = mouse_button or '[n/a]'
mouse_button = mouse_button or "[n/a]"
if self.ui.logg:
self.ui.app.log('UIElement: %s %s with mouse button %s' % (self.__class__.__name__, event_type, mouse_button))
self.ui.app.log(
f"UIElement: {self.__class__.__name__} {event_type} with mouse button {mouse_button}"
)
def is_visible(self):
if self.all_modes_visible:
return self.visible
elif not self.ui.app.game_mode and self.game_mode_visible:
return False
elif self.ui.app.game_mode and not self.game_mode_visible:
elif (
not self.ui.app.game_mode
and self.game_mode_visible
or self.ui.app.game_mode
and not self.game_mode_visible
):
return False
return self.visible
def reset_loc(self):
if self.snap_top:
self.y = 1
@ -155,7 +164,7 @@ class UIElement:
elif self.tile_x:
self.x = -1 + (self.tile_x * self.art.quad_width)
self.renderable.x, self.renderable.y = self.x, self.y
def keyboard_navigate(self, move_x, move_y):
if not self.support_keyboard_navigation:
return
@ -166,15 +175,16 @@ class UIElement:
elif move_x > 0:
self.ui.menu_bar.next_menu()
return
old_idx = self.keyboard_nav_index
new_idx = self.keyboard_nav_index + move_y
self.keyboard_nav_index += move_y
if not self.support_scrolling:
# if button list starts at >0 Y, use an offset
self.keyboard_nav_index %= len(self.buttons) + self.keyboard_nav_offset
tries = 0
# recognize two different kinds of inactive items: empty caption and dim state
while tries < len(self.buttons) and (self.buttons[self.keyboard_nav_index].caption == '' or self.buttons[self.keyboard_nav_index].state == 'dimmed'):
while tries < len(self.buttons) and (
self.buttons[self.keyboard_nav_index].caption == ""
or self.buttons[self.keyboard_nav_index].state == "dimmed"
):
# move_y might be zero, give it a direction to avoid infinite loop
# if menu item 0 is dimmed
self.keyboard_nav_index += move_y or 1
@ -184,23 +194,23 @@ class UIElement:
return
self.post_keyboard_navigate()
self.update_keyboard_hover()
def update_keyboard_hover(self):
if not self.support_keyboard_navigation:
return
for i,button in enumerate(self.buttons):
for i, button in enumerate(self.buttons):
# don't higlhight if this panel doesn't have focus
if self.keyboard_nav_index == i and self is self.ui.keyboard_focus_element:
button.set_state('hovered')
elif button.state != 'dimmed':
button.set_state('normal')
button.set_state("hovered")
elif button.state != "dimmed":
button.set_state("normal")
def keyboard_select_item(self):
if not self.support_keyboard_navigation:
return
button = self.buttons[self.keyboard_nav_index]
# don't allow selecting dimmed buttons
if button.state == 'dimmed':
if button.state == "dimmed":
return
# check for None; cb_arg could be 0
if button.cb_arg is not None:
@ -208,11 +218,11 @@ class UIElement:
else:
button.callback()
return button
def post_keyboard_navigate(self):
# subclasses can put stuff here to check scrolling etc
pass
def update(self):
"runs every frame, checks button states"
# this is very similar to UI.update, implying an alternative structure
@ -225,16 +235,20 @@ class UIElement:
for b in self.buttons:
# element.clicked might have been set it non-hoverable, acknowledge
# its hoveredness here so it can unhover correctly
if b.visible and (b.can_hover or b.state == 'clicked') and self.is_inside_button(mx, my, b):
if (
b.visible
and (b.can_hover or b.state == "clicked")
and self.is_inside_button(mx, my, b)
):
self.hovered_buttons.append(b)
if not b in was_hovering:
if b not in was_hovering:
b.hover()
for b in was_hovering:
if not b in self.hovered_buttons:
if b not in self.hovered_buttons:
b.unhover()
# tiles might have just changed
self.art.update()
def render(self):
# ("is visible" check happens in UI.render, calls our is_visible)
# render drop shadow first
@ -246,7 +260,7 @@ class UIElement:
self.renderable.render(brightness=0.1)
self.renderable.x, self.renderable.y = orig_x, orig_y
self.renderable.render()
def destroy(self):
for r in self.renderables:
r.destroy()
@ -258,27 +272,25 @@ class UIArt(Art):
class UIRenderable(TileRenderable):
grain_strength = 0.2
def get_projection_matrix(self):
# don't use projection matrix, ie identity[0][0]=aspect;
# rather do all aspect correction in UI.set_scale when determining quad size
return self.ui.view_matrix
def get_view_matrix(self):
return self.ui.view_matrix
class FPSCounterUI(UIElement):
tile_y = 1
tile_width, tile_height = 12, 2
snap_right = True
game_mode_visible = True
all_modes_visible = True
visible = False
def update(self):
bg = 0
self.art.clear_frame_layer(0, 0, bg)
@ -288,13 +300,13 @@ class FPSCounterUI(UIElement):
color = self.ui.colors.yellow
if self.ui.app.fps < 10:
color = self.ui.colors.red
text = '%.1f fps' % self.ui.app.fps
text = f"{self.ui.app.fps:.1f} fps"
x = self.tile_width - 1
self.art.write_string(0, 0, x, 0, text, color, None, True)
# display last tick time; frame_time includes delay, is useless
text = '%.1f ms ' % self.ui.app.frame_time
text = f"{self.ui.app.frame_time:.1f} ms "
self.art.write_string(0, 0, x, 1, text, color, None, True)
def render(self):
# always show FPS if low
if self.visible or self.ui.app.fps < 30:
@ -302,9 +314,8 @@ class FPSCounterUI(UIElement):
class MessageLineUI(UIElement):
"when console outputs something new, show last line here before fading out"
tile_y = 2
snap_left = True
# just info, don't bother with hover, click etc
@ -314,21 +325,21 @@ class MessageLineUI(UIElement):
game_mode_visible = True
all_modes_visible = True
drop_shadow = True
def __init__(self, ui):
UIElement.__init__(self, ui)
# line we're currently displaying (even after fading out)
self.line = ''
self.line = ""
self.last_post = self.ui.app.get_elapsed_time()
self.hold_time = self.default_hold_time
self.alpha = 1
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_line(self, new_line, hold_time=None, error=False):
"write a line to this element (ie so as not to spam console log)"
self.hold_time = hold_time or self.default_hold_time
@ -336,12 +347,12 @@ class MessageLineUI(UIElement):
color = self.ui.error_color_index if error else self.ui.colors.white
start_x = 1
# trim to screen width
self.line = str(new_line)[:self.tile_width-start_x-1]
self.line = str(new_line)[: self.tile_width - start_x - 1]
self.art.clear_frame_layer(0, 0, 0, color)
self.art.write_string(0, 0, start_x, 0, self.line)
self.alpha = 1
self.last_post = self.ui.app.get_elapsed_time()
def update(self):
if self.ui.app.get_elapsed_time() > self.last_post + (self.hold_time * 1000):
if self.alpha >= self.fade_rate:
@ -349,70 +360,71 @@ class MessageLineUI(UIElement):
if self.alpha <= self.fade_rate:
self.alpha = 0
self.renderable.alpha = self.alpha
def render(self):
# TODO: draw if popup is visible but not obscuring message line?
if not self.ui.popup in self.ui.hovered_elements and not self.ui.console.visible:
if (
self.ui.popup not in self.ui.hovered_elements
and not self.ui.console.visible
):
UIElement.render(self)
class DebugTextUI(UIElement):
"simple UI element for posting debug text"
tile_x, tile_y = 1, 4
tile_height = 20
clear_lines_after_render = True
game_mode_visible = True
visible = False
def __init__(self, ui):
UIElement.__init__(self, ui)
self.lines = []
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles)
self.art.resize(self.tile_width, self.tile_height)
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
UIElement.reset_loc(self)
def post_lines(self, lines):
if type(lines) is list:
self.lines += lines
else:
self.lines += [lines]
def update(self):
self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
for y,line in enumerate(self.lines):
for y, line in enumerate(self.lines):
self.art.write_string(0, 0, 0, y, line)
def render(self):
UIElement.render(self)
if self.clear_lines_after_render:
self.lines = []
#self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
# self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white)
class ToolTip(UIElement):
"popup text label that is invoked and controlled by a UIButton hover"
visible = False
tile_width, tile_height = 30, 1
tile_x, tile_y = 10, 5
def set_text(self, text):
self.art.write_string(0, 0, 0, 0, text,
self.ui.colors.black, self.ui.colors.white)
self.art.write_string(
0, 0, 0, 0, text, self.ui.colors.black, self.ui.colors.white
)
# clear tiles past end of text
for x in range(len(text), self.tile_width):
self.art.set_color_at(0, 0, x, 0, 0, 0)
def reset_art(self):
UIElement.reset_art(self)
self.art.clear_frame_layer(0, 0,
self.ui.colors.white, self.ui.colors.black)
self.art.clear_frame_layer(0, 0, self.ui.colors.white, self.ui.colors.black)
class GameLabel(UIElement):
@ -423,9 +435,8 @@ class GameLabel(UIElement):
class GameSelectionLabel(GameLabel):
multi_select_label = '[%s selected]'
multi_select_label = "[%s selected]"
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
@ -435,7 +446,7 @@ class GameSelectionLabel(GameLabel):
self.visible = True
if len(self.ui.app.gw.selected_objects) == 1:
obj = self.ui.app.gw.selected_objects[0]
text = obj.name[:self.tile_width-1]
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
else:
# draw "[N selected]" at avg of selected object locations
@ -453,10 +464,10 @@ class GameSelectionLabel(GameLabel):
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
class GameHoverLabel(GameLabel):
alpha = 0.75
def update(self):
self.visible = False
if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible():
@ -465,7 +476,7 @@ class GameHoverLabel(GameLabel):
return
self.visible = True
obj = self.ui.app.gw.hovered_focus_object
text = obj.name[:self.tile_width-1]
text = obj.name[: self.tile_width - 1]
x, y, z = obj.x, obj.y, obj.z
self.art.clear_line(0, 0, 0, self.ui.colors.white, -1)
self.art.write_string(0, 0, 0, 0, text)

View file

@ -1,27 +1,33 @@
import os, time, json
import json
import os
import time
from PIL import Image
from texture import Texture
from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton
from ui_console import OpenCommand, LoadCharSetCommand, LoadPaletteCommand
from ui_art_dialog import PaletteFromFileDialog, ImportOptionsDialog
from art import ART_DIR, ART_FILE_EXTENSION, THUMBNAIL_CACHE_DIR, SCRIPT_FILE_EXTENSION, ART_SCRIPT_DIR
from palette import Palette, PALETTE_DIR, PALETTE_EXTENSIONS
from charset import CharacterSet, CHARSET_DIR, CHARSET_FILE_EXTENSION
from image_export import write_thumbnail
from .art import (
ART_DIR,
ART_FILE_EXTENSION,
ART_SCRIPT_DIR,
SCRIPT_FILE_EXTENSION,
THUMBNAIL_CACHE_DIR,
)
from .charset import CHARSET_DIR, CHARSET_FILE_EXTENSION
from .image_export import write_thumbnail
from .palette import PALETTE_DIR, PALETTE_EXTENSIONS
from .texture import Texture
from .ui_art_dialog import ImportOptionsDialog, PaletteFromFileDialog
from .ui_chooser_dialog import ChooserDialog, ChooserItem
from .ui_console import OpenCommand
class BaseFileChooserItem(ChooserItem):
hide_file_extension = False
def get_short_dir_name(self):
# name should end in / but don't assume
dir_name = self.name[:-1] if self.name.endswith('/') else self.name
return os.path.basename(dir_name) + '/'
dir_name = self.name[:-1] if self.name.endswith("/") else self.name
return os.path.basename(dir_name) + "/"
def get_label(self):
if os.path.isdir(self.name):
return self.get_short_dir_name()
@ -31,15 +37,15 @@ class BaseFileChooserItem(ChooserItem):
return os.path.splitext(label)[0]
else:
return label
def get_description_lines(self):
if os.path.isdir(self.name):
if self.name == '..':
return ['[parent folder]']
if self.name == "..":
return ["[parent folder]"]
# TODO: # of items in dir?
return []
return None
def picked(self, element):
# if this is different from the last clicked item, pick it
if element.selected_item_index != self.index:
@ -52,8 +58,8 @@ class BaseFileChooserItem(ChooserItem):
if not element.first_selection_made:
element.first_selection_made = True
return
if self.name == '..' and self.name != '/':
new_dir = os.path.abspath(os.path.abspath(element.current_dir) + '/..')
if self.name == ".." and self.name != "/":
new_dir = os.path.abspath(os.path.abspath(element.current_dir) + "/..")
element.change_current_dir(new_dir)
elif os.path.isdir(self.name):
new_dir = element.current_dir + self.get_short_dir_name()
@ -62,39 +68,40 @@ class BaseFileChooserItem(ChooserItem):
element.confirm_pressed()
element.first_selection_made = False
class BaseFileChooserDialog(ChooserDialog):
"base class for choosers whose items correspond with files"
chooser_item_class = BaseFileChooserItem
show_filenames = True
file_extensions = []
def set_initial_dir(self):
self.current_dir = self.ui.app.documents_dir
self.field_texts[self.active_field] = self.current_dir
def get_filenames(self):
"subclasses override: get list of desired filenames"
return self.get_sorted_dir_list()
def get_sorted_dir_list(self):
"common code for getting sorted directory + file lists"
# list parent, then dirs, then filenames with extension(s)
parent = [] if self.current_dir == '/' else ['..']
parent = [] if self.current_dir == "/" else [".."]
if not os.path.exists(self.current_dir):
return parent
dirs, files = [], []
for filename in os.listdir(self.current_dir):
# skip unix-hidden files
if filename.startswith('.'):
if filename.startswith("."):
continue
full_filename = self.current_dir + filename
# if no extensions specified, take any file
if len(self.file_extensions) == 0:
self.file_extensions = ['']
self.file_extensions = [""]
for ext in self.file_extensions:
if os.path.isdir(full_filename):
dirs += [full_filename + '/']
dirs += [full_filename + "/"]
break
elif filename.lower().endswith(ext.lower()):
files += [full_filename]
@ -102,7 +109,7 @@ class BaseFileChooserDialog(ChooserDialog):
dirs.sort(key=lambda x: x.lower())
files.sort(key=lambda x: x.lower())
return parent + dirs + files
def get_items(self):
"populate and return items from list of files, loading as needed"
items = []
@ -123,12 +130,12 @@ class BaseFileChooserDialog(ChooserDialog):
# art chooser
#
class ArtChooserItem(BaseFileChooserItem):
# set in load()
art_width = None
hide_file_extension = True
def get_description_lines(self):
lines = BaseFileChooserItem.get_description_lines(self)
if lines is not None:
@ -136,31 +143,33 @@ class ArtChooserItem(BaseFileChooserItem):
if not self.art_width:
return []
mod_time = time.gmtime(self.art_mod_time)
mod_time = time.strftime('%Y-%m-%d %H:%M:%S', mod_time)
lines = ['last change: %s' % mod_time]
line = '%s x %s, ' % (self.art_width, self.art_height)
line += '%s frame' % self.art_frames
mod_time = time.strftime("%Y-%m-%d %H:%M:%S", mod_time)
lines = [f"last change: {mod_time}"]
line = f"{self.art_width} x {self.art_height}, "
line += f"{self.art_frames} frame"
# pluralize properly
line += 's' if self.art_frames > 1 else ''
line += ', %s layer' % self.art_layers
line += 's' if self.art_layers > 1 else ''
line += "s" if self.art_frames > 1 else ""
line += f", {self.art_layers} layer"
line += "s" if self.art_layers > 1 else ""
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
def get_preview_texture(self, app):
if os.path.isdir(self.name):
return
thumbnail_filename = app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + '.png'
thumbnail_filename = (
app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + ".png"
)
# create thumbnail if it doesn't exist
if not os.path.exists(thumbnail_filename):
write_thumbnail(app, self.name, thumbnail_filename)
# read thumbnail
img = Image.open(thumbnail_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
def load(self, app):
if os.path.isdir(self.name):
return
@ -172,33 +181,36 @@ class ArtChooserItem(BaseFileChooserItem):
self.art_hash = app.get_file_hash(self.name)
# rather than load the entire art, just get some high level stats
d = json.load(open(self.name))
self.art_width, self.art_height = d['width'], d['height']
self.art_frames = len(d['frames'])
self.art_layers = len(d['frames'][0]['layers'])
self.art_charset = d['charset']
self.art_palette = d['palette']
self.art_width, self.art_height = d["width"], d["height"]
self.art_frames = len(d["frames"])
self.art_layers = len(d["frames"][0]["layers"])
self.art_charset = d["charset"]
self.art_palette = d["palette"]
class ArtChooserDialog(BaseFileChooserDialog):
title = 'Open art'
confirm_caption = 'Open'
cancel_caption = 'Cancel'
title = "Open art"
confirm_caption = "Open"
cancel_caption = "Cancel"
chooser_item_class = ArtChooserItem
flip_preview_y = False
directory_aware = True
file_extensions = [ART_FILE_EXTENSION]
def set_initial_dir(self):
# TODO: IF no art in Documents dir yet, start in app/art/ for examples?
# get last opened dir, else start in docs/game art dir
if self.ui.app.last_art_dir:
self.current_dir = self.ui.app.last_art_dir
else:
self.current_dir = self.ui.app.gw.game_dir if self.ui.app.gw.game_dir else self.ui.app.documents_dir
self.current_dir = (
self.ui.app.gw.game_dir
if self.ui.app.gw.game_dir
else self.ui.app.documents_dir
)
self.current_dir += ART_DIR
self.field_texts[self.active_field] = self.current_dir
def confirm_pressed(self):
if not os.path.exists(self.field_texts[0]):
return
@ -211,27 +223,26 @@ class ArtChooserDialog(BaseFileChooserDialog):
# generic file chooser for importers
#
class GenericImportChooserDialog(BaseFileChooserDialog):
title = 'Import %s'
confirm_caption = 'Import'
cancel_caption = 'Cancel'
# allowed extensions set by invoking
title = "Import %s"
confirm_caption = "Import"
cancel_caption = "Cancel"
# allowed extensions set by invoking
file_extensions = []
show_preview_image = False
directory_aware = True
def __init__(self, ui, options):
self.title %= ui.app.importer.format_name
self.file_extensions = ui.app.importer.allowed_file_extensions
BaseFileChooserDialog.__init__(self, ui, options)
def set_initial_dir(self):
if self.ui.app.last_import_dir:
self.current_dir = self.ui.app.last_import_dir
else:
self.current_dir = self.ui.app.documents_dir
self.field_texts[self.active_field] = self.current_dir
def confirm_pressed(self):
filename = self.field_texts[0]
if not os.path.exists(filename):
@ -240,44 +251,42 @@ class GenericImportChooserDialog(BaseFileChooserDialog):
self.dismiss()
# importer might offer a dialog for options
if self.ui.app.importer.options_dialog_class:
options = {'filename': filename}
self.ui.open_dialog(self.ui.app.importer.options_dialog_class,
options)
options = {"filename": filename}
self.ui.open_dialog(self.ui.app.importer.options_dialog_class, options)
else:
ImportOptionsDialog.do_import(self.ui.app, filename, {})
class ImageChooserItem(BaseFileChooserItem):
def get_preview_texture(self, app):
if os.path.isdir(self.name):
return
# may not be a valid image file
try:
img = Image.open(self.name)
except:
except Exception:
return
try:
img = img.convert('RGBA')
except:
img = img.convert("RGBA")
except Exception:
# (probably) PIL bug: some images just crash! return None
return
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
class ImageFileChooserDialog(BaseFileChooserDialog):
cancel_caption = 'Cancel'
cancel_caption = "Cancel"
chooser_item_class = ImageChooserItem
flip_preview_y = False
directory_aware = True
file_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'gif']
file_extensions = ["png", "jpg", "jpeg", "bmp", "gif"]
class PaletteFromImageChooserDialog(ImageFileChooserDialog):
title = 'Palette from image'
confirm_caption = 'Choose'
title = "Palette from image"
confirm_caption = "Choose"
def confirm_pressed(self):
if not os.path.exists(self.field_texts[0]):
return
@ -291,31 +300,31 @@ class PaletteFromImageChooserDialog(ImageFileChooserDialog):
palette_filename = os.path.splitext(palette_filename)[0]
self.ui.active_dialog.field_texts[1] = palette_filename
#
# palette chooser
#
class PaletteChooserItem(BaseFileChooserItem):
def get_label(self):
return os.path.splitext(self.name)[0]
def get_description_lines(self):
colors = len(self.palette.colors)
return ['Unique colors: %s' % str(colors - 1)]
return [f"Unique colors: {str(colors - 1)}"]
def get_preview_texture(self, app):
return self.palette.src_texture
def load(self, app):
self.palette = app.load_palette(self.name)
class PaletteChooserDialog(BaseFileChooserDialog):
title = 'Choose palette'
title = "Choose palette"
chooser_item_class = PaletteChooserItem
def get_initial_selection(self):
if not self.ui.active_art:
return 0
@ -324,9 +333,9 @@ class PaletteChooserDialog(BaseFileChooserDialog):
# eg filename minus extension
if item.label == self.ui.active_art.palette.name:
return item.index
#print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
# print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
return 0
def get_filenames(self):
filenames = []
# search all files in dirs with appropriate extensions
@ -337,54 +346,54 @@ class PaletteChooserDialog(BaseFileChooserDialog):
filenames.append(filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.active_art.set_palette(item.palette, log=True)
self.ui.popup.set_active_palette(item.palette)
#
# charset chooser
#
class CharsetChooserItem(BaseFileChooserItem):
def get_label(self):
return os.path.splitext(self.name)[0]
def get_description_lines(self):
# first comment in file = description
lines = []
for line in open(self.charset.filename, encoding='utf-8').readlines():
for line in open(self.charset.filename, encoding="utf-8").readlines():
line = line.strip()
if line.startswith('//'):
if line.startswith("//"):
lines.append(line[2:])
break
lines.append('Characters: %s' % str(self.charset.last_index))
lines.append(f"Characters: {str(self.charset.last_index)}")
return lines
def get_preview_texture(self, app):
return self.charset.texture
def load(self, app):
self.charset = app.load_charset(self.name)
class CharSetChooserDialog(BaseFileChooserDialog):
title = 'Choose character set'
title = "Choose character set"
flip_preview_y = False
chooser_item_class = CharsetChooserItem
def get_initial_selection(self):
if not self.ui.active_art:
return 0
for item in self.items:
if item.label == self.ui.active_art.charset.name:
return item.index
#print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
# print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__)
return 0
def get_filenames(self):
filenames = []
# search all files in dirs with appropriate extensions
@ -394,7 +403,7 @@ class CharSetChooserDialog(BaseFileChooserDialog):
filenames.append(filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.active_art.set_charset(item.charset, log=True)
@ -405,11 +414,10 @@ class CharSetChooserDialog(BaseFileChooserDialog):
class ArtScriptChooserItem(BaseFileChooserItem):
def get_label(self):
label = os.path.splitext(self.name)[0]
return os.path.basename(label)
def get_description_lines(self):
lines = []
# read every comment line until a non-comment line is encountered
@ -417,25 +425,24 @@ class ArtScriptChooserItem(BaseFileChooserItem):
line = line.strip()
if not line:
continue
if not line.startswith('#'):
if not line.startswith("#"):
break
# snip #
line = line[line.index('#')+1:]
line = line[line.index("#") + 1 :]
lines.append(line)
return lines
def load(self, app):
self.script = open(self.name)
class RunArtScriptDialog(BaseFileChooserDialog):
title = 'Run Artscript'
title = "Run Artscript"
tile_width, big_width = 70, 90
tile_height, big_height = 15, 25
chooser_item_class = ArtScriptChooserItem
show_preview_image = False
def get_filenames(self):
filenames = []
# search all files in dirs with appropriate extensions
@ -445,7 +452,7 @@ class RunArtScriptDialog(BaseFileChooserDialog):
filenames.append(dirname + filename)
filenames.sort(key=lambda x: x.lower())
return filenames
def confirm_pressed(self):
item = self.get_selected_item()
self.ui.app.last_art_script = item.name
@ -454,10 +461,9 @@ class RunArtScriptDialog(BaseFileChooserDialog):
class OverlayImageFileChooserDialog(ImageFileChooserDialog):
title = 'Choose overlay image'
confirm_caption = 'Choose'
title = "Choose overlay image"
confirm_caption = "Choose"
def confirm_pressed(self):
filename = self.field_texts[0]
self.ui.app.set_overlay_image(filename)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,33 @@
from math import ceil
from ui_element import UIElement
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_menu_pulldown_item import FileMenuData, EditMenuData, ToolMenuData, ViewMenuData, ArtMenuData, FrameMenuData, LayerMenuData, CharColorMenuData, HelpMenuData
from ui_game_menu_pulldown_item import GameMenuData, GameStateMenuData, GameViewMenuData, GameWorldMenuData, GameRoomMenuData, GameObjectMenuData
from ui_info_dialog import AboutDialog
from ui_colors import UIColors
from renderable_sprite import UISpriteRenderable
from .renderable_sprite import UISpriteRenderable
from .ui_button import TEXT_CENTER, UIButton
from .ui_colors import UIColors
from .ui_element import UIElement
from .ui_game_menu_pulldown_item import (
GameMenuData,
GameObjectMenuData,
GameRoomMenuData,
GameStateMenuData,
GameViewMenuData,
GameWorldMenuData,
)
from .ui_info_dialog import AboutDialog
from .ui_menu_pulldown_item import (
ArtMenuData,
CharColorMenuData,
EditMenuData,
FileMenuData,
FrameMenuData,
HelpMenuData,
LayerMenuData,
ToolMenuData,
ViewMenuData,
)
class MenuButton(UIButton):
caption = 'Base Class Menu Button'
caption = "Base Class Menu Button"
caption_justify = TEXT_CENTER
# menu data is just a class w/ little more than a list of items, partly
# so we don't have to list all the items here in a different module
@ -18,11 +36,11 @@ class MenuButton(UIButton):
normal_bg_color = UIColors.white
hovered_bg_color = UIColors.lightgrey
dimmed_bg_color = UIColors.lightgrey
def __init__(self, element):
UIButton.__init__(self, element)
self.callback = self.open_pulldown
def open_pulldown(self):
# don't open menus if a dialog is up
if self.element.ui.active_dialog:
@ -44,118 +62,137 @@ class MenuButton(UIButton):
# playscii logo button = normal UIButton, opens About screen directly
class PlaysciiMenuButton(UIButton):
name = 'playscii'
caption = ' '
name = "playscii"
caption = " "
caption_justify = TEXT_CENTER
width = len(caption) + 2
normal_bg_color = MenuButton.normal_bg_color
hovered_bg_color = MenuButton.hovered_bg_color
dimmed_bg_color = MenuButton.dimmed_bg_color
#
# art mode menu buttons
#
class FileMenuButton(MenuButton):
name = 'file'
caption = 'File'
name = "file"
caption = "File"
menu_data = FileMenuData
class EditMenuButton(MenuButton):
name = 'edit'
caption = 'Edit'
name = "edit"
caption = "Edit"
menu_data = EditMenuData
class ToolMenuButton(MenuButton):
name = 'tool'
caption = 'Tool'
name = "tool"
caption = "Tool"
menu_data = ToolMenuData
class ViewMenuButton(MenuButton):
name = 'view'
caption = 'View'
name = "view"
caption = "View"
menu_data = ViewMenuData
class ArtMenuButton(MenuButton):
name = 'art'
caption = 'Art'
name = "art"
caption = "Art"
menu_data = ArtMenuData
class FrameMenuButton(MenuButton):
name = 'frame'
caption = 'Frame'
name = "frame"
caption = "Frame"
menu_data = FrameMenuData
class LayerMenuButton(MenuButton):
name = 'layer'
caption = 'Layer'
name = "layer"
caption = "Layer"
menu_data = LayerMenuData
class CharColorMenuButton(MenuButton):
name = 'char_color'
caption = 'Char/Color'
name = "char_color"
caption = "Char/Color"
menu_data = CharColorMenuData
# (appears in both art and game mode menus)
class HelpMenuButton(MenuButton):
name = 'help'
caption = 'Help'
name = "help"
caption = "Help"
menu_data = HelpMenuData
#
# game mode menu buttons
#
class GameMenuButton(MenuButton):
name = 'game'
caption = 'Game'
name = "game"
caption = "Game"
menu_data = GameMenuData
class StateMenuButton(MenuButton):
name = 'state'
caption = 'State'
name = "state"
caption = "State"
menu_data = GameStateMenuData
class GameViewMenuButton(MenuButton):
name = 'view'
caption = 'View'
name = "view"
caption = "View"
menu_data = GameViewMenuData
class WorldMenuButton(MenuButton):
name = 'world'
caption = 'World'
name = "world"
caption = "World"
menu_data = GameWorldMenuData
class RoomMenuButton(MenuButton):
name = 'room'
caption = 'Room'
name = "room"
caption = "Room"
menu_data = GameRoomMenuData
class ObjectMenuButton(MenuButton):
name = 'object'
caption = 'Object'
name = "object"
caption = "Object"
menu_data = GameObjectMenuData
class ModeMenuButton(UIButton):
caption_justify = TEXT_CENTER
normal_bg_color = UIColors.black
normal_fg_color = UIColors.white
#hovered_bg_color = UIColors.lightgrey
#dimmed_bg_color = UIColors.lightgrey
# hovered_bg_color = UIColors.lightgrey
# dimmed_bg_color = UIColors.lightgrey
class ArtModeMenuButton(ModeMenuButton):
caption = 'Game Mode'
caption = "Game Mode"
width = len(caption) + 2
class GameModeMenuButton(ModeMenuButton):
caption = 'Art Mode'
caption = "Art Mode"
width = len(caption) + 2
class MenuBar(UIElement):
"main menu bar element, has lots of buttons which control the pulldown"
snap_top = True
snap_left = True
always_consume_input = True
@ -165,7 +202,7 @@ class MenuBar(UIElement):
mode_button_class = None
# empty tiles between each button
button_padding = 1
def __init__(self, ui):
# bitmap icon for about menu button
self.playscii_sprite = UISpriteRenderable(ui.app)
@ -180,7 +217,7 @@ class MenuBar(UIElement):
button.width = len(button.caption) + 2
button.x = x
x += button.width + self.button_padding
setattr(self, '%s_button' % button.name, button)
setattr(self, f"{button.name}_button", button)
# NOTE: callback already defined in MenuButton class,
# menu data for pulldown with set in MenuButton subclass
button.pulldown = self.ui.pulldown
@ -197,22 +234,24 @@ class MenuBar(UIElement):
if not self.mode_button_class:
return
self.mode_button = self.mode_button_class(self)
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
self.mode_button.callback = self.toggle_game_mode
self.buttons.append(self.mode_button)
def reset_icon(self):
inv_aspect = self.ui.app.window_height / self.ui.app.window_width
self.playscii_sprite.scale_x = self.art.quad_height * inv_aspect
self.playscii_sprite.scale_y = self.art.quad_height
self.playscii_sprite.x = -1 + self.art.quad_width
self.playscii_sprite.y = 1 - self.art.quad_height
def open_about(self):
if self.ui.active_dialog:
return
self.ui.open_dialog(AboutDialog)
def toggle_game_mode(self):
if self.ui.active_dialog:
return
@ -221,18 +260,18 @@ class MenuBar(UIElement):
else:
self.ui.app.exit_game_mode()
self.ui.app.update_window_title()
def close_active_menu(self):
# un-dim active menu button
for button in self.menu_buttons:
if button.name == self.active_menu_name:
button.dimmed = False
button.set_state('normal')
button.set_state("normal")
self.active_menu_name = None
self.ui.pulldown.visible = False
self.ui.keyboard_focus_element = None
self.ui.refocus_keyboard()
def refresh_active_menu(self):
if not self.ui.pulldown.visible:
return
@ -240,36 +279,36 @@ class MenuBar(UIElement):
if button.name == self.active_menu_name:
# don't reset keyboard nav index
self.ui.pulldown.open_at(button, False)
def open_menu_by_name(self, menu_name):
if not self.ui.app.can_edit:
return
for button in self.menu_buttons:
if button.name == menu_name:
button.callback()
def open_menu_by_index(self, index):
if index > len(self.menu_buttons) - 1:
return
# don't navigate to the about menu
# (does this mean it's not accessible via kb-only? probably, that's fine)
if self.menu_buttons[index].name == 'playscii':
if self.menu_buttons[index].name == "playscii":
return
self.menu_buttons[index].callback()
def get_menu_index(self, menu_name):
for i,button in enumerate(self.menu_buttons):
for i, button in enumerate(self.menu_buttons):
if button.name == self.active_menu_name:
return i
def next_menu(self):
i = self.get_menu_index(self.active_menu_name)
self.open_menu_by_index(i + 1)
def previous_menu(self):
i = self.get_menu_index(self.active_menu_name)
self.open_menu_by_index(i - 1)
def reset_art(self):
self.tile_width = ceil(self.ui.width_tiles * self.ui.scale)
# must resize here, as window width will vary
@ -280,28 +319,46 @@ class MenuBar(UIElement):
self.art.clear_frame_layer(0, 0, bg, fg)
# reposition right-justified mode switch button
if self.mode_button:
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
# draw buttons, etc
UIElement.reset_art(self)
self.reset_icon()
def render(self):
UIElement.render(self)
self.playscii_sprite.render()
def destroy(self):
UIElement.destroy(self)
self.playscii_sprite.destroy()
class ArtMenuBar(MenuBar):
button_classes = [FileMenuButton, EditMenuButton, ToolMenuButton,
ViewMenuButton, ArtMenuButton, FrameMenuButton,
LayerMenuButton, CharColorMenuButton, HelpMenuButton]
button_classes = [
FileMenuButton,
EditMenuButton,
ToolMenuButton,
ViewMenuButton,
ArtMenuButton,
FrameMenuButton,
LayerMenuButton,
CharColorMenuButton,
HelpMenuButton,
]
mode_button_class = GameModeMenuButton
class GameMenuBar(MenuBar):
button_classes = [GameMenuButton, StateMenuButton, GameViewMenuButton,
WorldMenuButton, RoomMenuButton, ObjectMenuButton,
HelpMenuButton]
button_classes = [
GameMenuButton,
StateMenuButton,
GameViewMenuButton,
WorldMenuButton,
RoomMenuButton,
ObjectMenuButton,
HelpMenuButton,
]
game_mode_visible = True
mode_button_class = ArtModeMenuButton

View file

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

View file

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

View file

@ -1,103 +1,122 @@
from ui_element import UIElement, UIArt, UIRenderable
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_swatch import CharacterSetSwatch, PaletteSwatch, MIN_CHARSET_WIDTH
from ui_colors import UIColors
from ui_tool import FillTool, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FILL_BOUND_BG_COLOR
from renderable_line import LineRenderable, SwatchSelectionBoxRenderable
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY
from ui_file_chooser_dialog import CharSetChooserDialog, PaletteChooserDialog
from .art import UV_FLIPX, UV_FLIPY, UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270
from .renderable_line import SwatchSelectionBoxRenderable
from .ui_button import TEXT_CENTER, UIButton
from .ui_colors import UIColors
from .ui_element import UIArt, UIElement
from .ui_file_chooser_dialog import CharSetChooserDialog, PaletteChooserDialog
from .ui_swatch import MIN_CHARSET_WIDTH, CharacterSetSwatch, PaletteSwatch
from .ui_tool import FILL_BOUND_BG_COLOR, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FillTool
TOOL_PANE_WIDTH = 10
class ToolTabButton(UIButton):
x, y = 0, 0
caption_y = 1
# width is set on the fly by popup size in reset_art
height = 3
caption_justify = TEXT_CENTER
caption = 'Tools'
caption = "Tools"
class CharColorTabButton(UIButton):
caption_y = 1
height = ToolTabButton.height
caption_justify = TEXT_CENTER
caption = 'Chars/Colors'
caption = "Chars/Colors"
# charset view scale up/down buttons
class CharSetScaleUpButton(UIButton):
width, height = 3, 1
x, y = -width, ToolTabButton.height + 1
caption = '+'
caption = "+"
caption_justify = TEXT_CENTER
class CharSetScaleDownButton(CharSetScaleUpButton):
x = -CharSetScaleUpButton.width + CharSetScaleUpButton.x
caption = '-'
caption = "-"
# charset flip / rotate buttons
class CharXformButton(UIButton):
hovered_fg_color = UIColors.white
hovered_bg_color = UIColors.medgrey
class CharFlipNoButton(CharXformButton):
x = 3 + len('Flip:') + 1
x = 3 + len("Flip:") + 1
y = CharSetScaleUpButton.y + 1
caption = 'None'
caption = "None"
width = len(caption) + 2
caption_justify = TEXT_CENTER
class CharFlipXButton(CharFlipNoButton):
x = CharFlipNoButton.x + CharFlipNoButton.width + 1
width = 3
caption = 'X'
caption = "X"
class CharFlipYButton(CharFlipXButton):
x = CharFlipXButton.x + CharFlipXButton.width + 1
caption = 'Y'
caption = "Y"
class CharRot0Button(CharXformButton):
x = 3 + len('Rotation:') + 1
x = 3 + len("Rotation:") + 1
y = CharFlipNoButton.y + 1
width = 3
caption = '0'
caption = "0"
caption_justify = TEXT_CENTER
class CharRot90Button(CharRot0Button):
x = CharRot0Button.x + CharRot0Button.width + 1
width = 4
caption = '90'
caption = "90"
class CharRot180Button(CharRot0Button):
x = CharRot90Button.x + CharRot90Button.width + 1
width = 5
caption = '180'
caption = "180"
class CharRot270Button(CharRot0Button):
x = CharRot180Button.x + CharRot180Button.width + 1
width = 5
caption = '270'
caption = "270"
# tool and tool settings buttons
class ToolButton(UIButton):
"a tool entry in the tool tab's left hand pane. populated from UI.tools"
width = TOOL_PANE_WIDTH
caption = 'TOOLZ'
caption = "TOOLZ"
y = ToolTabButton.height + 2
class BrushSizeUpButton(UIButton):
width = 3
y = ToolTabButton.height + 3
caption = '+'
caption = "+"
caption_justify = TEXT_CENTER
normal_fg_color = UIColors.white
normal_bg_color = UIColors.medgrey
class BrushSizeDownButton(BrushSizeUpButton):
caption = '-'
caption = "-"
class AffectCharToggleButton(UIButton):
width = 3
@ -108,38 +127,46 @@ class AffectCharToggleButton(UIButton):
normal_fg_color = UIColors.white
normal_bg_color = UIColors.medgrey
class AffectFgToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 1
class AffectBgToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 2
class AffectXformToggleButton(AffectCharToggleButton):
y = AffectCharToggleButton.y + 3
# fill boundary mode items
class FillBoundaryModeCharButton(AffectCharToggleButton):
y = AffectXformToggleButton.y + 3
class FillBoundaryModeFGButton(AffectCharToggleButton):
y = FillBoundaryModeCharButton.y + 1
class FillBoundaryModeBGButton(AffectCharToggleButton):
y = FillBoundaryModeCharButton.y + 2
# charset / palette chooser buttons
class CharSetChooserButton(UIButton):
caption = 'Set:'
caption = "Set:"
x = 1
normal_fg_color = UIColors.black
normal_bg_color = UIColors.white
hovered_fg_color = UIColors.white
hovered_bg_color = UIColors.medgrey
class PaletteChooserButton(CharSetChooserButton):
caption = 'Palette:'
caption = "Palette:"
TAB_TOOLS = 0
@ -147,7 +174,6 @@ TAB_CHAR_COLOR = 1
class ToolPopup(UIElement):
visible = False
# actual width will be based on character set + palette size and scale
tile_width, tile_height = 20, 15
@ -156,19 +182,19 @@ class ToolPopup(UIElement):
fg_color = UIColors.black
bg_color = UIColors.lightgrey
highlight_color = UIColors.white
tool_settings_label = 'Tool Settings:'
brush_size_label = 'Brush size:'
affects_heading_label = 'Affects:'
affects_char_label = 'Character'
affects_fg_label = 'Foreground Color'
affects_bg_label = 'Background Color'
affects_xform_label = 'Rotation/Flip'
fill_boundary_modes_label = 'Fill boundary mode:'
tool_settings_label = "Tool Settings:"
brush_size_label = "Brush size:"
affects_heading_label = "Affects:"
affects_char_label = "Character"
affects_fg_label = "Foreground Color"
affects_bg_label = "Background Color"
affects_xform_label = "Rotation/Flip"
fill_boundary_modes_label = "Fill boundary mode:"
fill_boundary_char_label = affects_char_label
fill_boundary_fg_label = affects_fg_label
fill_boundary_bg_label = affects_bg_label
flip_label = 'Flip:'
rotation_label = 'Rotation:'
flip_label = "Flip:"
rotation_label = "Rotation:"
# index of check mark character in UI charset
check_char_index = 131
# index of off and on radio button characters in UI charset
@ -176,36 +202,36 @@ class ToolPopup(UIElement):
radio_char_1_index = 127
# map classes to member names / callbacks
button_names = {
ToolTabButton: 'tool_tab',
CharColorTabButton: 'char_color_tab',
ToolTabButton: "tool_tab",
CharColorTabButton: "char_color_tab",
}
char_color_tab_button_names = {
CharSetScaleUpButton: 'scale_charset_up',
CharSetScaleDownButton: 'scale_charset_down',
CharSetChooserButton: 'choose_charset',
CharFlipNoButton: 'xform_normal',
CharFlipXButton: 'xform_flipX',
CharFlipYButton: 'xform_flipY',
CharRot0Button: 'xform_0',
CharRot90Button: 'xform_90',
CharRot180Button: 'xform_180',
CharRot270Button: 'xform_270',
PaletteChooserButton: 'choose_palette',
CharSetScaleUpButton: "scale_charset_up",
CharSetScaleDownButton: "scale_charset_down",
CharSetChooserButton: "choose_charset",
CharFlipNoButton: "xform_normal",
CharFlipXButton: "xform_flipX",
CharFlipYButton: "xform_flipY",
CharRot0Button: "xform_0",
CharRot90Button: "xform_90",
CharRot180Button: "xform_180",
CharRot270Button: "xform_270",
PaletteChooserButton: "choose_palette",
}
tool_tab_button_names = {
BrushSizeUpButton: 'brush_size_up',
BrushSizeDownButton: 'brush_size_down',
AffectCharToggleButton: 'toggle_affect_char',
AffectFgToggleButton: 'toggle_affect_fg',
AffectBgToggleButton: 'toggle_affect_bg',
AffectXformToggleButton: 'toggle_affect_xform',
BrushSizeUpButton: "brush_size_up",
BrushSizeDownButton: "brush_size_down",
AffectCharToggleButton: "toggle_affect_char",
AffectFgToggleButton: "toggle_affect_fg",
AffectBgToggleButton: "toggle_affect_bg",
AffectXformToggleButton: "toggle_affect_xform",
}
fill_boundary_mode_button_names = {
FillBoundaryModeCharButton: 'set_fill_boundary_char',
FillBoundaryModeFGButton: 'set_fill_boundary_fg',
FillBoundaryModeBGButton: 'set_fill_boundary_bg'
FillBoundaryModeCharButton: "set_fill_boundary_char",
FillBoundaryModeFGButton: "set_fill_boundary_fg",
FillBoundaryModeBGButton: "set_fill_boundary_bg",
}
def __init__(self, ui):
self.ui = ui
self.charset_swatch = CharacterSetSwatch(ui, self)
@ -219,19 +245,26 @@ class ToolPopup(UIElement):
# create buttons from button:name map, button & callback names generated
# group these into lists that can be combined into self.buttons
self.common_buttons = self.create_buttons_from_map(self.button_names)
self.char_color_tab_buttons = self.create_buttons_from_map(self.char_color_tab_button_names)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(self.fill_boundary_mode_button_names)
self.tool_tab_buttons = self.create_buttons_from_map(self.tool_tab_button_names) + self.fill_boundary_mode_buttons
self.char_color_tab_buttons = self.create_buttons_from_map(
self.char_color_tab_button_names
)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(
self.fill_boundary_mode_button_names
)
self.tool_tab_buttons = (
self.create_buttons_from_map(self.tool_tab_button_names)
+ self.fill_boundary_mode_buttons
)
# populate more tool tab buttons from UI's list of tools
# similar to create_buttons_from_map, but class name isn't known
# MAYBE-TODO: is there a way to unify this?
for tool in self.ui.tools:
tool_button = ToolButton(self)
# caption: 1-space padding from left
tool_button.caption = ' %s' % tool.button_caption
tool_button_name = '%s_tool_button' % tool.name
tool_button.caption = f" {tool.button_caption}"
tool_button_name = f"{tool.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)
# set a special property UI can refer to
tool_button.tool_name = tool.name
@ -239,19 +272,21 @@ class ToolPopup(UIElement):
UIElement.__init__(self, ui)
# set initial tab state
self.char_color_tab_button_pressed()
self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color = self.highlight_color
self.xform_0_button.normal_bg_color = (
self.xform_normal_button.normal_bg_color
) = self.highlight_color
def create_buttons_from_map(self, button_dict):
buttons = []
for button_class in button_dict:
button = button_class(self)
button_name = '%s_button' % button_dict[button_class]
button_name = f"{button_dict[button_class]}_button"
setattr(self, button_name, button)
cb_name = '%s_pressed' % button_name
cb_name = f"{button_name}_pressed"
button.callback = getattr(self, cb_name)
buttons.append(button)
return buttons
def tool_tab_button_pressed(self):
self.active_tab = TAB_TOOLS
self.char_color_tab_button.can_hover = True
@ -261,7 +296,7 @@ class ToolPopup(UIElement):
self.buttons = self.common_buttons + self.tool_tab_buttons
self.draw_tool_tab()
self.draw_buttons()
def char_color_tab_button_pressed(self):
self.active_tab = TAB_CHAR_COLOR
self.tool_tab_button.can_hover = True
@ -271,79 +306,79 @@ class ToolPopup(UIElement):
self.buttons = self.common_buttons + self.char_color_tab_buttons
self.draw_char_color_tab()
self.draw_buttons()
def scale_charset_up_button_pressed(self):
self.charset_swatch.increase_scale()
self.reset_art()
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def scale_charset_down_button_pressed(self):
self.charset_swatch.decrease_scale()
self.reset_art()
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def brush_size_up_button_pressed(self):
# any changes to tool's setting will force redraw of settings tab
self.ui.selected_tool.increase_brush_size()
def brush_size_down_button_pressed(self):
self.ui.selected_tool.decrease_brush_size()
def toggle_affect_char_button_pressed(self):
self.ui.selected_tool.toggle_affects_char()
def toggle_affect_fg_button_pressed(self):
self.ui.selected_tool.toggle_affects_fg()
def toggle_affect_bg_button_pressed(self):
self.ui.selected_tool.toggle_affects_bg()
def toggle_affect_xform_button_pressed(self):
self.ui.selected_tool.toggle_affects_xform()
def set_fill_boundary_char_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_CHAR
self.ui.tool_settings_changed = True
def set_fill_boundary_fg_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_FG_COLOR
self.ui.tool_settings_changed = True
def set_fill_boundary_bg_button_pressed(self):
self.ui.fill_tool.boundary_mode = FILL_BOUND_BG_COLOR
self.ui.tool_settings_changed = True
def pencil_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.pencil_tool)
def erase_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.erase_tool)
def grab_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.grab_tool)
def rotate_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.rotate_tool)
def text_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.text_tool)
def select_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.select_tool)
def paste_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.paste_tool)
def fill_tool_button_pressed(self):
self.ui.set_selected_tool(self.ui.fill_tool)
def set_xform(self, new_xform):
"tells UI elements to respect new xform"
self.charset_swatch.set_xform(new_xform)
self.update_xform_buttons()
def update_xform_buttons(self):
# light up button for current selected option
button_map = {
@ -352,7 +387,7 @@ class ToolPopup(UIElement):
UV_ROTATE180: self.xform_180_button,
UV_ROTATE270: self.xform_270_button,
UV_FLIPX: self.xform_flipX_button,
UV_FLIPY: self.xform_flipY_button
UV_FLIPY: self.xform_flipY_button,
}
for b in button_map:
if b == self.ui.selected_xform:
@ -361,50 +396,55 @@ class ToolPopup(UIElement):
button_map[b].normal_bg_color = self.bg_color
self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color
self.draw_buttons()
def xform_normal_button_pressed(self):
self.ui.set_selected_xform(UV_NORMAL)
def xform_flipX_button_pressed(self):
self.ui.set_selected_xform(UV_FLIPX)
def xform_flipY_button_pressed(self):
self.ui.set_selected_xform(UV_FLIPY)
def xform_0_button_pressed(self):
self.ui.set_selected_xform(UV_NORMAL)
def xform_90_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE90)
def xform_180_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE180)
def xform_270_button_pressed(self):
self.ui.set_selected_xform(UV_ROTATE270)
def choose_charset_button_pressed(self):
self.hide()
self.ui.open_dialog(CharSetChooserDialog)
def choose_palette_button_pressed(self):
self.hide()
self.ui.open_dialog(PaletteChooserDialog)
def draw_char_color_tab(self):
"draw non-button bits of this tab"
# charset renderable location will be set in update()
charset = self.ui.active_art.charset
palette = self.ui.active_art.palette
cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height
_cqw, cqh = (
self.charset_swatch.art.quad_width,
self.charset_swatch.art.quad_height,
)
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
# position & caption charset button
y = self.tab_height + 1
self.choose_charset_button.y = y
self.choose_charset_button.caption = ' %s %s ' % (CharSetChooserButton.caption, charset.name)
self.choose_charset_button.caption = (
f" {CharSetChooserButton.caption} {charset.name} "
)
self.choose_charset_button.width = len(self.choose_charset_button.caption)
# charset scale
charset_scale = '%.2fx' % self.charset_swatch.char_scale
charset_scale = f"{self.charset_swatch.char_scale:.2f}x"
x = -self.scale_charset_up_button.width * 2
self.art.write_string(0, 0, x, y, charset_scale, None, None, True)
# transform labels and buttons, eg
@ -419,14 +459,16 @@ class ToolPopup(UIElement):
pal_caption_y = (cqh * charset.map_height) / self.art.quad_height
pal_caption_y += self.tab_height + 5
self.choose_palette_button.y = int(pal_caption_y)
self.choose_palette_button.caption = ' %s %s ' % (PaletteChooserButton.caption, palette.name)
self.choose_palette_button.caption = (
f" {PaletteChooserButton.caption} {palette.name} "
)
self.choose_palette_button.width = len(self.choose_palette_button.caption)
# set button states so captions draw properly
tab_width = int(self.tile_width / 2)
self.tool_tab_button.width = tab_width
self.char_color_tab_button.width = int(self.tile_width) - tab_width
self.char_color_tab_button.x = tab_width
def draw_tool_tab(self):
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
# fill tool bar with dimmer color, highlight selected tool
@ -435,15 +477,15 @@ class ToolPopup(UIElement):
self.art.set_color_at(0, 0, x, y, self.ui.colors.medgrey, False)
# set selected tool BG lighter
y = self.tab_height + 1
for i,tool in enumerate(self.ui.tools):
for i, tool in enumerate(self.ui.tools):
tool_button = None
for button in self.tool_tab_buttons:
try:
if button.tool_name == tool.name:
tool_button = button
except:
except Exception:
pass
tool_button.y = y+i
tool_button.y = y + i
if tool == self.ui.selected_tool:
tool_button.normal_bg_color = self.ui.colors.lightgrey
else:
@ -451,7 +493,7 @@ class ToolPopup(UIElement):
# draw current tool settings
x = TOOL_PANE_WIDTH + 1
y = self.tab_height + 1
label = '%s %s' % (self.ui.selected_tool.button_caption, self.tool_settings_label)
label = f"{self.ui.selected_tool.button_caption} {self.tool_settings_label}"
self.art.write_string(0, 0, x, y, label)
x += 1
y += 2
@ -462,8 +504,8 @@ class ToolPopup(UIElement):
label = self.brush_size_label
# calculate X of + and - buttons based on size string
self.brush_size_down_button.x = TOOL_PANE_WIDTH + len(label) + 2
label += ' ' * (self.brush_size_down_button.width + 1)
label += '%s' % self.ui.selected_tool.brush_size
label += " " * (self.brush_size_down_button.width + 1)
label += f"{self.ui.selected_tool.brush_size}"
self.brush_size_up_button.x = TOOL_PANE_WIDTH + len(label) + 3
self.art.write_string(0, 0, x, y, label)
else:
@ -479,19 +521,29 @@ class ToolPopup(UIElement):
y += 2
self.art.write_string(0, 0, x, y, self.affects_heading_label)
y += 1
# set affects-* button labels AND captions
def get_affects_char(affects):
return [0, self.check_char_index][affects]
w = self.toggle_affect_char_button.width
label_toggle_pairs = []
label_toggle_pairs += [(self.affects_char_label, self.ui.selected_tool.affects_char)]
label_toggle_pairs += [(self.affects_fg_label, self.ui.selected_tool.affects_fg_color)]
label_toggle_pairs += [(self.affects_bg_label, self.ui.selected_tool.affects_bg_color)]
label_toggle_pairs += [(self.affects_xform_label, self.ui.selected_tool.affects_xform)]
for label,toggle in label_toggle_pairs:
self.art.write_string(0, 0, x+w+1, y, '%s' % label)
#self.art.set_tile_at(0, 0, x, y, get_affects_char(toggle), 4, 2)
self.art.set_char_index_at(0, 0, x+1, y, get_affects_char(toggle))
label_toggle_pairs += [
(self.affects_char_label, self.ui.selected_tool.affects_char)
]
label_toggle_pairs += [
(self.affects_fg_label, self.ui.selected_tool.affects_fg_color)
]
label_toggle_pairs += [
(self.affects_bg_label, self.ui.selected_tool.affects_bg_color)
]
label_toggle_pairs += [
(self.affects_xform_label, self.ui.selected_tool.affects_xform)
]
for label, toggle in label_toggle_pairs:
self.art.write_string(0, 0, x + w + 1, y, 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
else:
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)
y += 1
# boundary mode buttons + labels
#x +=
labels = [self.fill_boundary_char_label,
self.fill_boundary_fg_label,
self.fill_boundary_bg_label]
for i,button in enumerate(self.fill_boundary_mode_buttons):
# x +=
labels = [
self.fill_boundary_char_label,
self.fill_boundary_fg_label,
self.fill_boundary_bg_label,
]
for i, button in enumerate(self.fill_boundary_mode_buttons):
button.visible = True
char = [self.radio_char_0_index, self.radio_char_1_index][i == self.ui.fill_tool.boundary_mode]
#self.ui.app.log(char)
self.art.set_char_index_at(0, 0, x+1, y, char)
self.art.write_string(0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i])
char = [self.radio_char_0_index, self.radio_char_1_index][
i == self.ui.fill_tool.boundary_mode
]
# self.ui.app.log(char)
self.art.set_char_index_at(0, 0, x + 1, y, char)
self.art.write_string(
0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i]
)
y += 1
else:
for button in self.fill_boundary_mode_buttons:
button.visible = False
def reset_art(self):
if not self.ui.active_art:
return
@ -527,7 +585,10 @@ class ToolPopup(UIElement):
# set panel size based on charset size
margin = self.swatch_margin * 2
charset = self.ui.active_art.charset
cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height
cqw, cqh = (
self.charset_swatch.art.quad_width,
self.charset_swatch.art.quad_height,
)
old_width, old_height = self.tile_width, self.tile_height
# min width in case of tiny charsets
charset_tile_width = max(charset.map_width, MIN_CHARSET_WIDTH)
@ -537,7 +598,10 @@ class ToolPopup(UIElement):
# account for popup info lines etc: charset name + palette name + 1 padding each
extra_lines = 7
# account for size of palette + bottom margin
palette_height = ((self.palette_swatch.art.height * self.palette_swatch.art.quad_height) + self.swatch_margin) / UIArt.quad_height
palette_height = (
(self.palette_swatch.art.height * self.palette_swatch.art.quad_height)
+ self.swatch_margin
) / UIArt.quad_height
self.tile_height += self.tab_height + palette_height + extra_lines
if old_width != self.tile_width or old_height != self.tile_height:
self.art.resize(int(self.tile_width), int(self.tile_height))
@ -549,7 +613,7 @@ class ToolPopup(UIElement):
self.update_xform_buttons()
# draw button captions
UIElement.reset_art(self)
def show(self):
# if already visible, bail - key repeat probably triggered this
if self.visible:
@ -564,19 +628,22 @@ class ToolPopup(UIElement):
if self.ui.pulldown.visible:
self.ui.menu_bar.close_active_menu()
self.reset_loc()
def toggle(self):
if self.visible:
self.hide()
else:
self.show()
def reset_loc(self):
if not self.ui.active_art:
return
x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
# center on mouse
w, h = self.tile_width * self.art.quad_width, self.tile_height * self.art.quad_height
w, h = (
self.tile_width * self.art.quad_width,
self.tile_height * self.art.quad_height,
)
x -= w / 2
y += h / 2
# clamp to edges of screen
@ -586,12 +653,12 @@ class ToolPopup(UIElement):
self.renderable.x, self.renderable.y = self.x, self.y
self.charset_swatch.reset_loc()
self.palette_swatch.reset_loc()
def hide(self):
self.visible = False
self.ui.keyboard_focus_element = None
self.ui.refocus_keyboard()
def set_active_charset(self, new_charset):
self.charset_swatch.art.charset = new_charset
self.palette_swatch.art.charset = new_charset
@ -602,7 +669,7 @@ class ToolPopup(UIElement):
# charset width drives palette swatch width
self.palette_swatch.reset()
self.reset_art()
def set_active_palette(self, new_palette):
self.charset_swatch.art.palette = new_palette
self.palette_swatch.art.palette = new_palette
@ -612,7 +679,7 @@ class ToolPopup(UIElement):
self.ui.status_bar.set_active_palette(new_palette)
self.palette_swatch.reset()
self.reset_art()
def update(self):
UIElement.update(self)
if not self.ui.active_art:
@ -624,7 +691,9 @@ class ToolPopup(UIElement):
self.cursor_box.visible = True
elif mouse_moved and self in self.ui.hovered_elements:
self.cursor_box.visible = False
x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
x, y = self.ui.get_screen_coords(
self.ui.app.mouse_x, self.ui.app.mouse_y
)
for e in [self.charset_swatch, self.palette_swatch]:
if e.is_inside(x, y):
self.cursor_box.visible = True
@ -636,23 +705,25 @@ class ToolPopup(UIElement):
elif self.active_tab == TAB_TOOLS and self.ui.tool_settings_changed:
self.draw_tool_tab()
self.draw_buttons()
def keyboard_navigate(self, dx, dy):
active_swatch = self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
active_swatch = (
self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
)
# TODO: can't handle cross-swatch navigation properly, restrict to chars
active_swatch = self.charset_swatch
# reverse up/down direction
active_swatch.move_cursor(self.cursor_box, dx, -dy)
def keyboard_select_item(self):
# called as ui.keyboard_focus_element
# simulate left/right click in popup to select stuff
self.select_key_pressed(self.ui.app.il.shift_pressed)
def select_key_pressed(self, mod_pressed):
mouse_button = [1, 3][mod_pressed]
self.clicked(mouse_button)
def clicked(self, mouse_button):
handled = UIElement.clicked(self, mouse_button)
if handled:
@ -669,7 +740,7 @@ class ToolPopup(UIElement):
elif mouse_button == 3:
self.ui.selected_bg_color = self.cursor_color
return True
def render(self):
if not self.visible:
return

View file

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

View file

@ -1,25 +1,34 @@
import math, time
import math
import time
import numpy as np
from ui_element import UIElement, UIArt, UIRenderable
from renderable_line import LineRenderable, SwatchSelectionBoxRenderable, UIRenderableX
from .renderable_line import LineRenderable, SwatchSelectionBoxRenderable, UIRenderableX
from .ui_element import UIArt, UIElement, UIRenderable
# min width for charset; if charset is tiny adjust to this
MIN_CHARSET_WIDTH = 16
class UISwatch(UIElement):
def __init__(self, ui, popup):
self.ui = ui
self.popup = popup
self.reset()
def reset(self):
self.tile_width, self.tile_height = self.get_size()
art = self.ui.active_art
# generate a unique name for debug purposes
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.art = UIArt(art_name, self.ui.app, art.charset, art.palette, self.tile_width, self.tile_height)
art_name = 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,
)
# tear down existing renderables if any
if not self.renderables:
self.renderables = []
@ -32,20 +41,20 @@ class UISwatch(UIElement):
self.renderable.grain_strength = 0
self.renderables.append(self.renderable)
self.reset_art()
def reset_art(self):
pass
def get_size(self):
return 1, 1
def set_cursor_loc_from_mouse(self, cursor, mouse_x, mouse_y):
# get location within char map
w, h = self.art.quad_width, self.art.quad_height
tile_x = (mouse_x - self.x) / w
tile_y = (mouse_y - self.y) / h
self.set_cursor_loc(cursor, tile_x, tile_y)
def set_cursor_loc(self, cursor, tile_x, tile_y):
"""
common, generalized code for both character and palette swatches:
@ -68,64 +77,69 @@ class UISwatch(UIElement):
cursor.quad_size_ref = self.art
cursor.tile_x, cursor.tile_y = tile_x, tile_y
cursor.x, cursor.y = x, y
def is_selection_index_valid(self, index):
"returns True if given index is valid for choices this swatch offers"
return False
def set_cursor_selection_index(self, index):
"another set_cursor_loc support method, overriden by subclasses"
self.popup.blah = index
def render(self):
self.renderable.render()
class CharacterSetSwatch(UISwatch):
# scale the character set will be drawn at
char_scale = 2
min_scale = 1
max_scale = 5
scale_increment = 0.25
def increase_scale(self):
if self.char_scale <= self.max_scale - self.scale_increment:
self.char_scale += self.scale_increment
def decrease_scale(self):
if self.char_scale >= self.min_scale + self.scale_increment:
self.char_scale -= self.scale_increment
def reset(self):
UISwatch.reset(self)
self.selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
self.grid = CharacterGridRenderable(self.ui.app, self.art)
self.create_shade()
self.renderables = [self.renderable, self.selection_box, self.grid,
self.shade]
self.renderables = [self.renderable, self.selection_box, self.grid, self.shade]
def create_shade(self):
# shaded box neath chars in case selected colors make em hard to see
self.shade_art = UIArt('charset_shade', self.ui.app,
self.ui.active_art.charset, self.ui.palette,
self.tile_width, self.tile_height)
self.shade_art = UIArt(
"charset_shade",
self.ui.app,
self.ui.active_art.charset,
self.ui.palette,
self.tile_width,
self.tile_height,
)
self.shade_art.clear_frame_layer(0, 0, self.ui.colors.black)
self.shade = UIRenderable(self.ui.app, self.shade_art)
self.shade.ui = self.ui
self.shade.alpha = 0.2
def get_size(self):
art = self.ui.active_art
return art.charset.map_width, art.charset.map_height
def reset_art(self):
# MAYBE-TODO: using screen resolution, try to set quad size to an even
# multiple of screen so the sampling doesn't get chunky
aspect = self.ui.app.window_width / self.ui.app.window_height
charset = self.art.charset
self.art.quad_width = UIArt.quad_width * self.char_scale
self.art.quad_height = self.art.quad_width * (charset.char_height / charset.char_width) * aspect
self.art.quad_height = (
self.art.quad_width * (charset.char_height / charset.char_width) * aspect
)
# only need to populate characters on reset_art, but update
# colors every update()
self.art.clear_frame_layer(0, 0, 0)
@ -135,7 +149,7 @@ class CharacterSetSwatch(UISwatch):
self.art.set_char_index_at(0, 0, x, y, i)
i += 1
self.art.geo_changed = True
def reset_loc(self):
self.x = self.popup.x + self.popup.swatch_margin
self.y = self.popup.y
@ -144,38 +158,36 @@ class CharacterSetSwatch(UISwatch):
self.grid.x, self.grid.y = self.x, self.y
self.grid.y -= self.art.quad_height
self.shade.x, self.shade.y = self.x, self.y
def set_xform(self, new_xform):
for y in range(self.art.height):
for x in range(self.art.width):
self.art.set_char_transform_at(0, 0, x, y, new_xform)
def is_selection_index_valid(self, index):
return index < self.art.charset.last_index
def set_cursor_selection_index(self, index):
self.popup.cursor_char = index
self.popup.cursor_color = -1
def move_cursor(self, cursor, dx, dy):
"moves cursor by specified amount in selection grid"
# determine new cursor tile X/Y
tile_x = cursor.tile_x + dx
tile_y = cursor.tile_y + dy
tile_index = (abs(tile_y) * self.art.width) + tile_x
if tile_x < 0 or tile_x >= self.art.width:
return
elif tile_y > 0:
if tile_x < 0 or tile_x >= self.art.width or tile_y > 0:
return
elif tile_y <= -self.art.height:
# TODO: handle "jump" to palette swatch, and back
#cursor.tile_y = 0
#self.popup.palette_swatch.move_cursor(cursor, 0, 0)
# cursor.tile_y = 0
# self.popup.palette_swatch.move_cursor(cursor, 0, 0)
return
elif tile_index >= self.art.charset.last_index:
return
self.set_cursor_loc(cursor, tile_x, tile_y)
def update(self):
charset = self.ui.active_art.charset
fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color
@ -185,7 +197,10 @@ class CharacterSetSwatch(UISwatch):
for x in range(charset.map_width):
self.art.set_tile_at(0, 0, x, y, None, fg, bg, xform)
self.art.update()
if self.shade_art.quad_width != self.art.quad_width or self.shade_art.quad_height != self.art.quad_height:
if (
self.shade_art.quad_width != self.art.quad_width
or self.shade_art.quad_height != self.art.quad_height
):
self.shade_art.quad_width = self.art.quad_width
self.shade_art.quad_height = self.art.quad_height
self.shade_art.geo_changed = True
@ -203,17 +218,18 @@ class CharacterSetSwatch(UISwatch):
self.selection_box.y = self.renderable.y
selection_y = (self.ui.selected_char - selection_x) / charset.map_width
self.selection_box.y -= selection_y * self.art.quad_height
def render_bg(self):
# draw shaded box beneath swatch if selected color(s) too similar to BG
def is_hard_to_see(other_color_index):
return self.ui.palette.are_colors_similar(self.popup.bg_color,
self.art.palette,
other_color_index)
return self.ui.palette.are_colors_similar(
self.popup.bg_color, self.art.palette, other_color_index
)
fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color
if is_hard_to_see(fg) or is_hard_to_see(bg):
self.shade.render()
def render(self):
if not self.popup.visible:
return
@ -224,27 +240,34 @@ class CharacterSetSwatch(UISwatch):
class PaletteSwatch(UISwatch):
def reset(self):
UISwatch.reset(self)
self.transparent_x = UIRenderableX(self.ui.app, self.art)
self.fg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
self.bg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art)
# F label for FG color selection
self.f_art = ColorSelectionLabelArt(self.ui, 'F')
self.f_art = ColorSelectionLabelArt(self.ui, "F")
# make character dark
self.f_art.set_color_at(0, 0, 0, 0, self.f_art.palette.darkest_index, True)
self.f_renderable = ColorSelectionLabelRenderable(self.ui.app, self.f_art)
self.f_renderable.ui = self.ui
# B label for BG color seletion
self.b_art = ColorSelectionLabelArt(self.ui, 'B')
self.b_art = ColorSelectionLabelArt(self.ui, "B")
self.b_renderable = ColorSelectionLabelRenderable(self.ui.app, self.b_art)
self.b_renderable.ui = self.ui
self.renderables += self.transparent_x, self.fg_selection_box, self.bg_selection_box, self.f_renderable, self.b_renderable
self.renderables += (
self.transparent_x,
self.fg_selection_box,
self.bg_selection_box,
self.f_renderable,
self.b_renderable,
)
def get_size(self):
# balance rows/columns according to character set swatch width
charmap_width = max(self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH)
charmap_width = max(
self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH
)
colors = len(self.popup.charset_swatch.art.palette.colors)
rows = math.ceil(colors / charmap_width)
columns = math.ceil(colors / rows)
@ -252,10 +275,13 @@ class PaletteSwatch(UISwatch):
if colors == 129 and columns == 15:
columns = 16
return columns, rows
def reset_art(self):
# base our quad size on charset's
cqw, cqh = self.popup.charset_swatch.art.quad_width, self.popup.charset_swatch.art.quad_height
cqw, cqh = (
self.popup.charset_swatch.art.quad_width,
self.popup.charset_swatch.art.quad_height,
)
# maximize item size based on row/column determined in get_size()
charmap_width = max(self.art.charset.map_width, MIN_CHARSET_WIDTH)
self.art.quad_width = (charmap_width / self.art.width) * cqw
@ -271,12 +297,15 @@ class PaletteSwatch(UISwatch):
self.art.set_color_at(0, 0, x, y, i, False)
i += 1
self.art.geo_changed = True
def reset_loc(self):
self.x = self.popup.x + self.popup.swatch_margin
self.y = self.popup.charset_swatch.renderable.y
# adjust Y for charset
self.y -= self.popup.charset_swatch.art.quad_height * self.ui.active_art.charset.map_height
self.y -= (
self.popup.charset_swatch.art.quad_height
* self.ui.active_art.charset.map_height
)
# adjust Y for palette caption and character scale
self.y -= self.popup.art.quad_height * 2
self.renderable.x, self.renderable.y = self.x, self.y
@ -294,23 +323,26 @@ class PaletteSwatch(UISwatch):
self.transparent_x.y = self.renderable.y - self.art.quad_height
self.transparent_x.y -= (h - 1) * self.art.quad_height
# set f/b_art's quad size
self.f_art.quad_width, self.f_art.quad_height = self.b_art.quad_width, self.b_art.quad_height = self.popup.art.quad_width, self.popup.art.quad_height
self.f_art.quad_width, self.f_art.quad_height = (
self.b_art.quad_width,
self.b_art.quad_height,
) = self.popup.art.quad_width, self.popup.art.quad_height
self.f_art.geo_changed = True
self.b_art.geo_changed = True
def is_selection_index_valid(self, index):
return index < len(self.art.palette.colors)
def set_cursor_selection_index(self, index):
# modulo wrap if selecting last color
self.popup.cursor_color = (index + 1) % len(self.art.palette.colors)
self.popup.cursor_char = -1
def move_cursor(self, cursor, dx, dy):
# similar enough to charset swatch's move_cursor, different enough to
# merit this small bit of duplicate code
pass
def update(self):
self.art.update()
self.f_art.update()
@ -358,7 +390,7 @@ class PaletteSwatch(UISwatch):
self.b_renderable.y = self.bg_selection_box.y
self.b_renderable.x += x_offset
self.b_renderable.y -= y_offset
def render(self):
if not self.popup.visible:
return
@ -373,7 +405,7 @@ class PaletteSwatch(UISwatch):
class ColorSelectionLabelArt(UIArt):
def __init__(self, ui, letter):
letter_index = ui.charset.get_char_index(letter)
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
art_name = f"{int(time.time())}_{self.__class__.__name__}"
UIArt.__init__(self, art_name, ui.app, ui.charset, ui.palette, 1, 1)
label_color = ui.colors.white
label_bg_color = 0
@ -386,9 +418,8 @@ class ColorSelectionLabelRenderable(UIRenderable):
class CharacterGridRenderable(LineRenderable):
color = (0.5, 0.5, 0.5, 0.25)
def build_geo(self):
w, h = self.quad_size_ref.width, self.quad_size_ref.height
v = []
@ -396,12 +427,12 @@ class CharacterGridRenderable(LineRenderable):
c = self.color * 4 * w * h
index = 0
for x in range(1, w):
v += [(x, -h+1), (x, 1)]
e += [index, index+1]
v += [(x, -h + 1), (x, 1)]
e += [index, index + 1]
index += 2
for y in range(h-1):
for y in range(h - 1):
v += [(w, -y), (0, -y)]
e += [index, index+1]
e += [index, index + 1]
index += 2
self.vert_array = np.array(v, dtype=np.float32)
self.elem_array = np.array(e, dtype=np.uint32)

View file

@ -1,18 +1,26 @@
import math
import sdl2
from PIL import Image
from texture import Texture
from edit_command import EditCommandTile
from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270
from key_shifts import SHIFT_MAP
from selection import SelectionRenderable
from .art import (
UV_FLIP90,
UV_FLIP270,
UV_FLIPX,
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:
name = 'DEBUGTESTTOOL'
name = "DEBUGTESTTOOL"
# name visible in popup's tool tab
button_caption = 'Debug Tool'
button_caption = "Debug Tool"
# paint continuously, ie every time mouse enters a new tile
paint_while_dragging = True
# show preview of paint result under cursor
@ -25,8 +33,8 @@ class UITool:
# (false for eg Selection tool)
affects_masks = True
# filename of icon in UI_ASSET_DIR, shown on cursor
icon_filename = 'icon.png'
icon_filename = "icon.png"
def __init__(self, ui):
self.ui = ui
self.affects_char = True
@ -36,69 +44,89 @@ class UITool:
# load icon, cursor's sprite renderable will reference this texture
icon_filename = self.ui.asset_dir + self.icon_filename
self.icon_texture = self.load_icon_texture(icon_filename)
def load_icon_texture(self, img_filename):
img = Image.open(img_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
def get_icon_texture(self):
"""
Returns icon texture that should display for tool's current state.
(override to eg choose from multiples for mod keys)
"""
return self.icon_texture
def get_button_caption(self):
# normally just returns button_caption, but can be overridden to
# provide custom behavior (eg fill tool)
return self.button_caption
def toggle_affects_char(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_char = not self.affects_char
self.ui.tool_settings_changed = True
line = self.button_caption + ' '
line = '%s %s' % (self.button_caption, [self.ui.affects_char_off_log, self.ui.affects_char_on_log][self.affects_char])
line = self.button_caption + " "
line = "{} {}".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)
def toggle_affects_fg(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_fg_color = not self.affects_fg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][self.affects_fg_color])
line = "{} {}".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)
def toggle_affects_bg(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_bg_color = not self.affects_bg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][self.affects_bg_color])
line = "{} {}".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)
def toggle_affects_xform(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_xform = not self.affects_xform
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][self.affects_xform])
line = "{} {}".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)
def get_paint_commands(self):
"returns a list of EditCommandTiles for a given paint operation"
return []
def increase_brush_size(self):
if not self.brush_size:
return
self.brush_size += 1
self.ui.app.cursor.set_scale(self.brush_size)
self.ui.tool_settings_changed = True
def decrease_brush_size(self):
if not self.brush_size:
return
@ -109,12 +137,11 @@ class UITool:
class PencilTool(UITool):
name = 'pencil'
name = "pencil"
# "Paint" not Pencil so the A mnemonic works :/
button_caption = 'Paint'
icon_filename = 'tool_paint.png'
button_caption = "Paint"
icon_filename = "tool_paint.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
"""
return the tile value changes this tool would perform on a tile -
@ -123,12 +150,12 @@ class PencilTool(UITool):
a_char = self.ui.selected_char if self.affects_char else None
# don't paint fg color for blank characters
# (disabled, see BB issue #86)
#a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None
# a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None
a_fg = self.ui.selected_fg_color if self.affects_fg_color else None
a_bg = self.ui.selected_bg_color if self.affects_bg_color else None
a_xform = self.ui.selected_xform if self.affects_xform else None
return a_char, a_fg, a_bg, a_xform
def get_paint_commands(self):
commands = []
art = self.ui.active_art
@ -137,8 +164,8 @@ class PencilTool(UITool):
cur = self.ui.app.cursor
# handle dragging while painting (cursor does the heavy lifting here)
# !!TODO!! finish this, work in progress
if cur.moved_this_frame() and cur.current_command and False: #DEBUG
#print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG
if cur.moved_this_frame() and cur.current_command and False: # DEBUG
# print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG
tiles = cur.get_tiles_under_drag()
else:
tiles = cur.get_tiles_under_brush()
@ -154,7 +181,9 @@ class PencilTool(UITool):
new_tc.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, *tile)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(
b_char, b_fg, b_bg, b_xform
)
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# Note: even if command has same result as another in command_tiles,
# add it anyway as it may be a tool for which subsequent edits to
@ -165,11 +194,10 @@ class PencilTool(UITool):
class EraseTool(PencilTool):
name = 'erase'
button_caption = 'Erase'
icon_filename = 'tool_erase.png'
name = "erase"
button_caption = "Erase"
icon_filename = "tool_erase.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
char = 0 if self.affects_char else None
fg = 0 if self.affects_fg_color else None
@ -180,9 +208,8 @@ class EraseTool(PencilTool):
class RotateTool(PencilTool):
name = 'rotate'
button_caption = 'Rotate'
name = "rotate"
button_caption = "Rotate"
update_preview_after_paint = True
rotation_shifts = {
UV_NORMAL: UV_ROTATE90,
@ -193,22 +220,21 @@ class RotateTool(PencilTool):
UV_FLIPX: UV_FLIP270,
UV_FLIP270: UV_FLIPY,
UV_FLIPY: UV_ROTATE270,
UV_FLIP90: UV_FLIPX
UV_FLIP90: UV_FLIPX,
}
icon_filename = 'tool_rotate.png'
icon_filename = "tool_rotate.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
return b_char, b_fg, b_bg, self.rotation_shifts[b_xform]
class GrabTool(UITool):
name = 'grab'
button_caption = 'Grab'
name = "grab"
button_caption = "Grab"
brush_size = None
show_preview = False
icon_filename = 'tool_grab.png'
icon_filename = "tool_grab.png"
def grab(self):
x, y = self.ui.app.cursor.get_tile()
art = self.ui.active_art
@ -233,44 +259,48 @@ class GrabTool(UITool):
class TextTool(UITool):
name = 'text'
button_caption = 'Text'
name = "text"
button_caption = "Text"
brush_size = None
show_preview = False
icon_filename = 'tool_text.png'
icon_filename = "tool_text.png"
def __init__(self, ui):
UITool.__init__(self, ui)
self.input_active = False
self.cursor = None
def start_entry(self):
self.cursor = self.ui.app.cursor
# popup gobbles keyboard input, so always dismiss it if it's up
if self.ui.popup.visible:
self.ui.popup.hide()
if self.cursor.x < 0 or self.cursor.x > self.ui.active_art.width or \
-self.cursor.y < 0 or -self.cursor.y > self.ui.active_art.height:
if (
self.cursor.x < 0
or self.cursor.x > self.ui.active_art.width
or -self.cursor.y < 0
or -self.cursor.y > self.ui.active_art.height
):
return
self.input_active = True
self.reset_cursor_start(self.cursor.x, -self.cursor.y)
self.cursor.start_paint()
#self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line('Started text entry, press Escape to stop entering text.', 5)
# self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line(
"Started text entry, press Escape to stop entering text.", 5
)
def finish_entry(self):
self.input_active = False
self.ui.tool_settings_changed = True
if self.cursor:
x, y = int(self.cursor.x) + 1, int(-self.cursor.y) + 1
self.cursor.finish_paint()
#self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y))
self.ui.message_line.post_line('Finished text entry.')
# self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y))
self.ui.message_line.post_line("Finished text entry.")
def reset_cursor_start(self, new_x, new_y):
self.start_x, self.start_y = int(new_x), int(new_y)
def handle_keyboard_input(self, key, shift_pressed, ctrl_pressed, alt_pressed):
# for now, do nothing on ctrl/alt
if ctrl_pressed or alt_pressed:
@ -284,30 +314,32 @@ class TextTool(UITool):
x, y = int(self.cursor.x), int(-self.cursor.y)
char_w, char_h = art.quad_width, art.quad_height
# TODO: if cursor isn't inside selection, bail early
if keystr == 'Return':
if keystr == "Return":
if self.cursor.y < art.width:
self.cursor.x = self.start_x
self.cursor.y -= 1
elif keystr == 'Backspace':
elif keystr == "Backspace":
if self.cursor.x > self.start_x:
self.cursor.x -= char_w
# undo command on previous tile
self.cursor.current_command.undo_commands_for_tile(frame, layer, x-1, y)
elif keystr == 'Space':
keystr = ' '
elif keystr == 'Up':
self.cursor.current_command.undo_commands_for_tile(
frame, layer, x - 1, y
)
elif keystr == "Space":
keystr = " "
elif keystr == "Up":
if -self.cursor.y > 0:
self.cursor.y += 1
elif keystr == 'Down':
elif keystr == "Down":
if -self.cursor.y < art.height - 1:
self.cursor.y -= 1
elif keystr == 'Left':
elif keystr == "Left":
if self.cursor.x > 0:
self.cursor.x -= char_w
elif keystr == 'Right':
elif keystr == "Right":
if self.cursor.x < art.width - 1:
self.cursor.x += char_w
elif keystr == 'Escape':
elif keystr == "Escape":
self.finish_entry()
return
# ignore any other non-character keys
@ -322,9 +354,9 @@ class TextTool(UITool):
if keystr.isalpha() and not shift_pressed and not self.ui.app.il.capslock_on:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, ' ')
keystr = SHIFT_MAP.get(keystr, " ")
# if cursor got out of bounds, don't input
if 0 > x or x >= art.width or 0 > y or y >= art.height:
if x < 0 or x >= art.width or y < 0 or y >= art.height:
return
# create tile command
new_tc = EditCommandTile(art)
@ -340,7 +372,7 @@ class TextTool(UITool):
if self.cursor.current_command:
self.cursor.current_command.add_command_tiles([new_tc])
else:
self.ui.app.log('DEV WARNING: Cursor current command was expected')
self.ui.app.log("DEV WARNING: Cursor current command was expected")
new_tc.apply()
self.cursor.x += char_w
if self.cursor.x >= self.ui.active_art.width:
@ -351,17 +383,16 @@ class TextTool(UITool):
class SelectTool(UITool):
name = 'select'
button_caption = 'Select'
name = "select"
button_caption = "Select"
brush_size = None
affects_masks = False
show_preview = False
icon_filename = 'tool_select_add.png' # used only for toolbar
icon_filename_normal = 'tool_select.png'
icon_filename_add = 'tool_select_add.png'
icon_filename_sub = 'tool_select_sub.png'
icon_filename = "tool_select_add.png" # used only for toolbar
icon_filename_normal = "tool_select.png"
icon_filename_add = "tool_select_add.png"
icon_filename_sub = "tool_select_sub.png"
def __init__(self, ui):
UITool.__init__(self, ui)
self.selection_in_progress = False
@ -380,7 +411,7 @@ class SelectTool(UITool):
self.icon_texture_add = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_sub
self.icon_texture_sub = self.load_icon_texture(icon)
def get_icon_texture(self):
# show different icons based on mod key status
if self.ui.app.il.shift_pressed:
@ -389,14 +420,14 @@ class SelectTool(UITool):
return self.icon_texture_sub
else:
return self.icon_texture
def start_select(self):
self.selection_in_progress = True
self.current_drag = {}
x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
self.drag_start_x, self.drag_start_y = x, y
#print('started select drag at %s,%s' % (x, y))
# print('started select drag at %s,%s' % (x, y))
def finish_select(self, add_to_selection, subtract_from_selection):
self.selection_in_progress = False
# selection boolean operations:
@ -410,9 +441,9 @@ class SelectTool(UITool):
for tile in self.current_drag:
self.selected_tiles.pop(tile, None)
self.current_drag = {}
#x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
#print('finished select drag at %s,%s' % (x, y))
# x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
# print('finished select drag at %s,%s' % (x, y))
def update(self):
if not self.ui.active_art:
return
@ -423,9 +454,15 @@ class SelectTool(UITool):
start_x, start_y = int(self.drag_start_x), int(self.drag_start_y)
end_x, end_y = int(self.ui.app.cursor.x), int(-self.ui.app.cursor.y)
if start_x > end_x:
start_x, end_x, = end_x, start_x
(
start_x,
end_x,
) = end_x, start_x
if start_y > end_y:
start_y, end_y, = end_y, start_y
(
start_y,
end_y,
) = end_y, start_y
# always grow to include cursor's tile
end_x += 1
end_y += 1
@ -445,7 +482,7 @@ class SelectTool(UITool):
self.drag_renderable.rebind_buffers()
self.last_selection = self.selected_tiles.copy()
self.last_drag = self.current_drag.copy()
def render_selections(self):
if len(self.selected_tiles) > 0:
self.select_renderable.render()
@ -454,12 +491,11 @@ class SelectTool(UITool):
class PasteTool(UITool):
name = 'paste'
button_caption = 'Paste'
name = "paste"
button_caption = "Paste"
brush_size = None
icon_filename = 'tool_paste.png'
icon_filename = "tool_paste.png"
# TODO!: dragging large pastes around seems heck of slow, investigate
# why this function might be to blame and see if there's a fix!
def get_paint_commands(self):
@ -488,7 +524,9 @@ class PasteTool(UITool):
if len(self.ui.select_tool.selected_tiles) > 0:
if not self.ui.select_tool.selected_tiles.get((x, y), False):
continue
b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(frame, layer, x, y)
b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(
frame, layer, x, y
)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
new_tc.set_tile(frame, layer, x, y)
# respect affects masks like other tools
@ -502,35 +540,36 @@ class PasteTool(UITool):
commands.append(new_tc)
return commands
# "fill boundary" modes: character, fg color, bg color
FILL_BOUND_CHAR = 0
FILL_BOUND_FG_COLOR = 1
FILL_BOUND_BG_COLOR = 2
class FillTool(UITool):
name = 'fill'
button_caption = 'Fill'
name = "fill"
button_caption = "Fill"
brush_size = None
icon_filename = 'tool_fill_char.png' # used only for toolbar
icon_filename = "tool_fill_char.png" # used only for toolbar
# icons and strings for different boundary modes
icon_filename_char = 'tool_fill_char.png'
icon_filename_fg = 'tool_fill_fg.png'
icon_filename_bg = 'tool_fill_bg.png'
icon_filename_char = "tool_fill_char.png"
icon_filename_fg = "tool_fill_fg.png"
icon_filename_bg = "tool_fill_bg.png"
boundary_mode = FILL_BOUND_CHAR
# user-facing names for the boundary modes
boundary_mode_names = {
FILL_BOUND_CHAR : 'character',
FILL_BOUND_FG_COLOR : 'fg color',
FILL_BOUND_BG_COLOR : 'bg color'
FILL_BOUND_CHAR: "character",
FILL_BOUND_FG_COLOR: "fg color",
FILL_BOUND_BG_COLOR: "bg color",
}
# determine cycling order
next_boundary_modes = {
FILL_BOUND_CHAR : FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR : FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR : FILL_BOUND_CHAR
FILL_BOUND_CHAR: FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR: FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR: FILL_BOUND_CHAR,
}
def __init__(self, ui):
UITool.__init__(self, ui)
icon = self.ui.asset_dir + self.icon_filename_char
@ -539,11 +578,12 @@ class FillTool(UITool):
self.icon_texture_fg = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_bg
self.icon_texture_bg = self.load_icon_texture(icon)
def get_icon_texture(self):
# show different icon based on boundary type
return [self.icon_texture_char, self.icon_texture_fg,
self.icon_texture_bg][self.boundary_mode]
return [self.icon_texture_char, self.icon_texture_fg, self.icon_texture_bg][
self.boundary_mode
]
def get_button_caption(self):
return '%s (%s bounded)' % (self.button_caption, self.boundary_mode_names[self.boundary_mode])
return f"{self.button_caption} ({self.boundary_mode_names[self.boundary_mode]} bounded)"

View file

@ -1,24 +1,21 @@
from ui_element import UIElement
from ui_button import UIButton
from renderable_sprite import UISpriteRenderable
from renderable_line import ToolSelectionBoxRenderable
from .renderable_line import ToolSelectionBoxRenderable
from .renderable_sprite import UISpriteRenderable
from .ui_button import UIButton
from .ui_element import UIElement
class ToolBar(UIElement):
tile_width, tile_height = 4, 1 # real size will be set based on buttons
tile_width, tile_height = 4, 1 # real size will be set based on buttons
icon_scale_factor = 4
snap_left = True
def __init__(self, ui):
self.ui = ui
self.icon_renderables = []
self.create_toolbar_buttons()
UIElement.__init__(self, ui)
self.selection_box = ToolSelectionBoxRenderable(ui.app, self.art)
def reset_art(self):
# by default, a 1D vertical bar
self.tile_width = ToolBarButton.width
@ -26,7 +23,7 @@ class ToolBar(UIElement):
self.tile_height = ToolBarButton.height * len(self.buttons)
self.art.resize(self.tile_width, self.tile_height)
UIElement.reset_art(self)
def reset_loc(self):
UIElement.reset_loc(self)
# by default, a vertical bar centered along left edge of the screen
@ -36,19 +33,19 @@ class ToolBar(UIElement):
self.renderable.x, self.renderable.y = self.x, self.y
# scale and position button icons only now that we're positioned
self.reset_button_icons()
def create_toolbar_buttons(self):
# (override in subclass)
pass
def update_selection_box(self):
# (override in subclass)
pass
def update(self):
UIElement.update(self)
self.update_selection_box()
def render(self):
UIElement.render(self)
for r in self.icon_renderables:
@ -58,42 +55,47 @@ class ToolBar(UIElement):
class ToolBarButton(UIButton):
width, height = 4, 2
caption = ''
caption = ""
tooltip_on_hover = True
def get_tooltip_text(self):
return self.cb_arg.button_caption
def get_tooltip_location(self):
x = self.width
window_height_chars = self.element.ui.app.window_height / (self.element.ui.charset.char_height * self.element.ui.scale)
window_height_chars = self.element.ui.app.window_height / (
self.element.ui.charset.char_height * self.element.ui.scale
)
cursor_y = self.element.ui.app.mouse_y / self.element.ui.app.window_height
y = int(cursor_y * window_height_chars)
return x, y
class ArtToolBar(ToolBar):
def create_toolbar_buttons(self):
for i,tool in enumerate(self.ui.tools):
for i, tool in enumerate(self.ui.tools):
button = ToolBarButton(self)
# button.caption = tool.button_caption # DEBUG
button.x = 0
button.y = i * button.height
# alternate colors
button.normal_bg_color = self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey
button.normal_bg_color = (
self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey
)
button.hovered_bg_color = self.ui.colors.medgrey
# callback: tell ui to set this tool as selected
button.callback = self.ui.set_selected_tool
button.cb_arg = tool
self.buttons.append(button)
# create button icon
sprite = UISpriteRenderable(self.ui.app, self.ui.asset_dir + tool.icon_filename)
sprite = UISpriteRenderable(
self.ui.app, self.ui.asset_dir + tool.icon_filename
)
self.icon_renderables.append(sprite)
def reset_button_icons(self):
button_height = self.art.quad_height * ToolBarButton.height
for i,icon in enumerate(self.icon_renderables):
for i, icon in enumerate(self.icon_renderables):
# scale: same screen size as cursor icon
scale_x = icon.texture.width / self.ui.app.window_width
scale_x *= self.icon_scale_factor * self.ui.scale
@ -104,12 +106,12 @@ class ArtToolBar(ToolBar):
# position
# remember that in renderable space, (0, 0) = center of screen
icon.x = self.x
icon.x += (icon.scale_x / 8)
icon.x += icon.scale_x / 8
icon.y = self.y
icon.y -= button_height * i
icon.y -= icon.scale_y
icon.y -= (icon.scale_y / 8)
icon.y -= icon.scale_y / 8
def update_selection_box(self):
# scale and position box around currently selected tool
self.selection_box.scale_x = ToolBarButton.width

View file

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

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
*.default
README.md
license.txt
code_of_conduct.txt
*.dll
version
*.default
README.md
license.txt
code_of_conduct.txt
*.dll

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