Compare commits

...

24 commits

Author SHA1 Message Date
e1521327e8
Simplify the readme 2026-02-12 22:46:00 -05:00
fd16a68991
Add uv lock 2026-02-12 22:44:35 -05:00
0ca8fa269e
Remove unneeded requirements 2026-02-12 22:44:35 -05:00
b0051bdd17
Add ty as dev dep 2026-02-12 22:44:35 -05:00
e924e8eb75
Use dict lookup for charset and palette loading 2026-02-12 21:32:30 -05:00
3c3cae682f
Pre-allocate vertex and element lists in build_geo 2026-02-12 21:32:30 -05:00
536dce6a98
Use set for O(1) palette color deduplication 2026-02-12 21:32:30 -05:00
4619444ed1
Cache sorted layer indices on Art 2026-02-12 21:32:30 -05:00
7abbb6bffb
Vectorize UV initialization with numpy broadcasting 2026-02-12 21:08:33 -05:00
09a9059b27
Cache shader attribute locations in TileRenderable 2026-02-12 21:08:00 -05:00
d84d1d809b
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-12 21:01:13 -05:00
9b7e7becc5
Fix cursor offset when tiling WM resizes window on first tick 2026-02-12 20:21:26 -05:00
ac2154d935
Run dos2unix on project 2026-02-12 20:16:27 -05:00
6a37f9c65d
Add agent config with project guide 2026-02-12 20:15:39 -05:00
b2bea441a0
Disable lint rules inappropriate for legacy codebase 2026-02-12 20:15:39 -05:00
86e6382adc
Fix minor lint issues 2026-02-12 20:15:39 -05:00
45d72bf8ed
Convert format calls to f-strings 2026-02-12 20:15:39 -05:00
1d6cd6138f
Fix unused variables and loop variables 2026-02-12 20:15:39 -05:00
addfe0c2e5
Replace bare except with except Exception 2026-02-12 20:15:39 -05:00
6bf4dd60d4
Convert percent formatting to f-strings 2026-02-12 20:15:39 -05:00
1e4d31121b
Apply ruff auto-fixes and formatting 2026-02-12 20:15:39 -05:00
0bdd700350
Add justfile for project commands 2026-02-12 20:15:39 -05:00
cec9df2fc3
Add pyproject.toml with dependencies and ruff config 2026-02-12 20:15:18 -05:00
80b0cc0aa8
fixup! Add initial source download 2026-02-12 20:15:17 -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
Playscii (pronounced play-skee) is an art, animation, and game creation tool.
The latest version will always be available here:
ascii art and animation program
* [http://jp.itch.io/playscii](http://jp.itch.io/playscii)
* [https://heptapod.host/jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
## forked from jp labreton
Playscii's main website is here:
- [jp.itch.io/playscii](https://jp.itch.io/playscii)
- [jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
- [cheesetalks.net/jplebreton.php](https://cheesetalks.net/jplebreton.php)
* [https://jplebreton.com/playscii/](https://jplebreton.com/playscii/)
## setup
## Offline documentation
requires libSDL2 and libSDL2_mixer installed on the system:
Playscii now includes its own HTML documentation, which you can find in the
docs/html/ subfolder of the folder where this README resides.
```sh
# fedora
sudo dnf install SDL2 SDL2_mixer
## Online documentation
# debian/ubuntu
sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0
The latest version of the HTML documentation resides here:
# mac
brew install sdl2 sdl2_mixer
```
[https://jplebreton.com/playscii/howto_main.html](https://jplebreton.com/playscii/howto_main.html)
then, with [uv](https://docs.astral.sh/uv/#installation) installed:
## Bugs
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)
```sh
uv sync
just run
```

View file

@ -1,17 +1,17 @@
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"
@ -26,24 +26,24 @@ Assumes 80 columns, DOS character set and EGA palette.
def get_commands_from_sequence(self, seq):
"returns command type & commands (separated by semicolon) from sequence"
cmds = []
new_cmd = ''
new_cmd = ""
for k in seq[:-1]:
if k != 59:
new_cmd += chr(k)
else:
cmds.append(new_cmd)
new_cmd = ''
new_cmd = ""
# include last command
cmds.append(new_cmd)
return chr(seq[-1]), cmds
def run_import(self, in_filename, options={}):
self.set_art_charset('dos')
self.set_art_palette('ansi')
self.set_art_charset("dos")
self.set_art_palette("ansi")
# resize to arbitrary height, crop once we know final line count
self.resize(WIDTH, MAX_LINES)
self.art.clear_frame_layer(0, 0, DEFAULT_BG + 1)
data = open(in_filename, 'rb').read()
data = open(in_filename, "rb").read()
x, y = 0, 0
# cursor save/restore codes position
saved_x, saved_y = 0, 0
@ -57,7 +57,8 @@ 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
@ -68,7 +69,7 @@ Assumes 80 columns, DOS character set and EGA palette.
# 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
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,10 +151,10 @@ 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)
increment += len(seq)
@ -158,12 +163,14 @@ Assumes 80 columns, DOS character set and EGA palette.
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
# most ATAs are 40 columns, but some are a couple chars longer and a few are 80!
WIDTH, HEIGHT = 80, 40
class ATAImporter(ArtImporter):
format_name = 'ATASCII'
format_name = "ATASCII"
format_description = """
ATARI 8-bit computer version of ASCII.
Imports with ATASCII character set and Atari palette.
"""
allowed_file_extensions = ['ata']
allowed_file_extensions = ["ata"]
def run_import(self, in_filename, options={}):
self.set_art_charset('atari')
self.set_art_palette('atari')
self.set_art_charset("atari")
self.set_art_palette("atari")
self.resize(WIDTH, HEIGHT)
self.art.clear_frame_layer(0, 0, DEFAULT_BG)
# iterate over the bytes
data = open(in_filename, 'rb').read()
data = open(in_filename, "rb").read()
i = 0
x, y = 0, 0
while i < len(data):

View file

@ -1,24 +1,23 @@
# 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'
class ConvertImageChooserDialog(ImageFileChooserDialog):
title = "Convert image"
confirm_caption = "Choose"
def confirm_pressed(self):
filename = self.field_texts[0]
@ -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'
class ConvertImageOptionsDialog(ImportOptionsDialog):
title = "Convert bitmap image options"
field0_label = "Color palette:"
field1_label = "Current palette (%s)"
field2_label = "From source image; # of colors:"
field3_label = " "
field5_label = "Converted art size:"
field6_label = "Best fit to current size (%s)"
field7_label = "%% of source image: (%s)"
field8_label = " "
field10_label = "Smooth (bicubic) scale source image"
radio_groups = [(1, 2), (6, 7)]
field_width = UIDialog.default_short_field_width
# to get the layout we want, we must specify 0 padding lines and
@ -62,17 +57,17 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
Field(label=field1_label, type=bool, width=0, oneline=True),
Field(label=field2_label, type=bool, width=0, oneline=True),
Field(label=field3_label, type=int, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field5_label, type=None, width=0, oneline=True),
Field(label=field6_label, type=bool, width=0, oneline=True),
Field(label=field7_label, type=bool, width=0, oneline=True),
Field(label=field8_label, type=float, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field10_label, type=bool, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
invalid_color_error = 'Palettes must be between 2 and 256 colors.'
invalid_scale_error = 'Scale must be greater than 0.0'
invalid_color_error = "Palettes must be between 2 and 256 colors."
invalid_scale_error = "Scale must be greater than 0.0"
# redraw dynamic labels
always_redraw_labels = True
@ -81,38 +76,42 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
return UIDialog.true_field_text
elif field_number == 3:
# # of colors from source image
return '64'
return "64"
elif field_number == 6:
return UIDialog.true_field_text
elif field_number == 8:
# % of source image size
return '50.0'
return "50.0"
elif field_number == 10:
return ' '
return ''
return " "
return ""
def get_field_label(self, field_index):
label = self.fields[field_index].label
# custom label replacements to show palette, possible convert sizes
if field_index == 1:
label %= self.ui.active_art.palette.name if self.ui.active_art else DEFAULT_PALETTE
label %= (
self.ui.active_art.palette.name
if self.ui.active_art
else DEFAULT_PALETTE
)
elif field_index == 6:
# can't assume any art is open, use defaults if needed
w = self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH
h = self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT
label %= '%s x %s' % (w, h)
label %= f"{w} x {h}"
elif field_index == 7:
# scale # might not be 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
@ -130,48 +129,62 @@ class ConvertImageOptionsDialog(ImportOptionsDialog):
def is_input_valid(self):
# colors: int between 2 and 256
try: int(self.field_texts[3])
except: return False, self.invalid_color_error
try:
int(self.field_texts[3])
except Exception:
return False, self.invalid_color_error
colors = int(self.field_texts[3])
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.
"""
@ -181,11 +194,11 @@ Bitmap image in PNG, JPEG, or BMP format.
def run_import(self, in_filename, options={}):
# modify self.app.ui.active_art based on options
palette = self.app.load_palette(options['palette'])
palette = self.app.load_palette(options["palette"])
self.art.set_palette(palette)
width, height = options['art_width'], options['art_height']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
bicubic_scale = options["bicubic_scale"]
# let ImageConverter do the actual heavy lifting
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,18 +18,18 @@ class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog):
field10_label = ConvertImageOptionsDialog.field10_label
field_width = ConvertImageOptionsDialog.field_width
fields = [
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label='', type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label="", type=SkipFieldType, width=0, oneline=True),
Field(label=field5_label, type=None, width=0, oneline=True),
Field(label=field6_label, type=bool, width=0, oneline=True),
Field(label=field7_label, type=bool, width=0, oneline=True),
Field(label=field8_label, type=float, width=field_width, oneline=True),
Field(label='', type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field10_label, type=bool, width=0, oneline=True),
Field(label='', type=None, width=0, oneline=True)
Field(label="", type=None, width=0, oneline=True),
]
def __init__(self, ui, options):
@ -37,18 +39,17 @@ class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog):
def get_initial_field_text(self, field_number):
# alternate defaults - use 1:1 scaling
if field_number == 6:
return ' '
return " "
elif field_number == 7:
return UIDialog.true_field_text
elif field_number == 8:
# % of source image size - alternate default
return '100.0'
return "100.0"
else:
return ConvertImageOptionsDialog.get_initial_field_text(self, field_number)
class TwoColorImageConverter(ImageConverter):
def get_color_combos_for_block(self, src_block):
colors, counts = np.unique(src_block, False, False, return_counts=True)
if len(colors) > 0:
@ -60,7 +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
@ -72,12 +73,12 @@ fg/bg color swaps. Suitable for plaintext export.
def run_import(self, in_filename, options={}):
# force palette to 1-bit black and white
palette = self.app.load_palette('bw')
palette = self.app.load_palette("bw")
self.art.set_palette(palette)
width, height = options['art_width'], options['art_height']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
bicubic_scale = options["bicubic_scale"]
ic = TwoColorImageConverter(self.app, in_filename, self.art, bicubic_scale)
# 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
@ -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:
@ -51,7 +50,7 @@ class ImageSequenceConverter:
self.app.update_window_title()
def fail(self):
self.app.log('Bad frame %s' % self.image_filenames[0], error=True)
self.app.log(f"Bad frame {self.image_filenames[0]}", error=True)
self.finish(True)
def update(self):
@ -64,19 +63,22 @@ class ImageSequenceConverter:
def finish(self, cancelled=False):
time_taken = time.time() - self.start_time
(verb, error) = ('cancelled', True) if cancelled else ('finished', False)
self.app.log('Conversion of image sequence %s %s after %.3f seconds' % (self.image_name, verb, time_taken), error)
(verb, error) = ("cancelled", True) if cancelled else ("finished", False)
self.app.log(
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
@ -86,28 +88,28 @@ image chosen.
# 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']
width, height = options["art_width"], options["art_height"]
self.art.resize(width, height) # Importer.init will adjust UI
bicubic_scale = options['bicubic_scale']
bicubic_scale = options["bicubic_scale"]
# get dir listing with full pathname
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) :]
# remove files from end of list if they don't end in a number
while not os.path.splitext(in_files[-1])[0][-1].isdecimal() and \
len(in_files) > 0:
while (
not os.path.splitext(in_files[-1])[0][-1].isdecimal() and len(in_files) > 0
):
in_files.pop()
# add frames to art as needed
while self.art.frames < len(in_files):
self.art.add_frame_to_end(log=False)
self.art.set_active_frame(0)
# create converter
isc = ImageSequenceConverter(self.app, in_files, self.art,
bicubic_scale)
isc = ImageSequenceConverter(self.app, in_files, self.art, bicubic_scale)
# bail on early failure
if not isc.init_success:
return False

View file

@ -1,45 +1,44 @@
from art_import import ArtImporter
from ui_dialog import UIDialog, Field
from ui_art_dialog import ImportOptionsDialog
from 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.
@ -48,28 +47,30 @@ 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]
# 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'):
if line[-2] == ord("\r") and line[-1] == ord("\n"):
# self.app.log('EDSCIIImporter: windows-style line breaks detected')
lb_length = 2
break

View file

@ -1,8 +1,8 @@
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.
@ -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):
if i % 2 != 0:
continue
color_byte = data[i + 1]
bits = bin(color_byte)[2:]
bits = bits.rjust(7, '0')
bits = bits.rjust(7, "0")
bg_bits = bits[:3]
fg_bits = bits[3:]
offset = 1

View file

@ -1,9 +1,8 @@
from 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.

View file

@ -1,33 +1,33 @@
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):
@ -35,7 +35,7 @@ Exports active layer of active frame.
def run_export(self, out_filename, options):
# binary file; encoding into ANSI bytes happens just before write
self.outfile = open(out_filename, 'wb')
self.outfile = open(out_filename, "wb")
layer = self.art.active_layer
frame = self.art.active_frame
for y in range(self.art.height):
@ -57,6 +57,6 @@ Exports active layer of active frame.
# special (top row) chars won't display in terminal anyway
self.write(chr(0))
# carriage return + line feed
self.outfile.write(b'\r\n')
self.outfile.write(b"\r\n")
self.outfile.close()
return True

View file

@ -1,19 +1,19 @@
from 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()
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,
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))
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'
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'
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()
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,43 +91,51 @@ class PNGSetExportOptionsDialog(ExportOptionsDialog):
elif export_layers:
label %= str(art.layers)
else:
label %= '1'
label %= "1"
# preview frame + layer filename mutations based on current settings
elif field_index == 5:
export_frames = bool(self.field_texts[2].strip())
export_layers = bool(self.field_texts[3].strip())
art = self.ui.active_art
fn = get_full_filename(self.filename, 0, art.layer_names[0],
export_frames, export_layers,
self.ui.app.forbidden_filename_chars)
fn = get_full_filename(
self.filename,
0,
art.layer_names[0],
export_frames,
export_layers,
self.ui.app.forbidden_filename_chars,
)
fn = os.path.basename(fn)
label %= fn
return label
def is_input_valid(self):
# scale factor: >0 int
try: int(self.field_texts[0])
except: return False, self.invalid_scale_error
try:
int(self.field_texts[0])
except 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.
"""
@ -134,8 +143,8 @@ PNG image set for each frame and/or layer.
options_dialog_class = PNGSetExportOptionsDialog
def run_export(self, out_filename, options):
export_frames = options['frames']
export_layers = options['layers']
export_frames = options["frames"]
export_layers = options["layers"]
art = self.app.ui.active_art
# remember user's active frame/layer/viz settings so we
# can set em back when done
@ -145,7 +154,9 @@ PNG image set for each frame and/or layer.
start_layer_viz = self.app.inactive_layer_visibility
self.app.onion_frames_visible = False
# if multi-player, only show active layer
self.app.inactive_layer_visibility = LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL
self.app.inactive_layer_visibility = (
LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL
)
success = True
for frame in range(art.frames):
# if exporting layers but not frames, only export active frame
@ -154,13 +165,21 @@ PNG image set for each frame and/or layer.
art.set_active_frame(frame)
for layer in range(art.layers):
art.set_active_layer(layer)
full_filename = get_full_filename(out_filename, frame,
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)):
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,17 +1,19 @@
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)
@ -23,7 +25,7 @@ any extended characters you want translated.
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)
@ -26,8 +24,8 @@ class LineTester(GameObject):
generate_art = True
def pre_first_update(self):
self.mark_a = self.world.spawn_object_of_class('DebugMarker', -3, 33)
self.mark_b = self.world.spawn_object_of_class('DebugMarker', -10, 40)
self.mark_a = self.world.spawn_object_of_class("DebugMarker", -3, 33)
self.mark_b = self.world.spawn_object_of_class("DebugMarker", -10, 40)
self.z = -0.01
self.world.grid.visible = True
self.line = DebugLineRenderable(self.app, self.art)
@ -37,14 +35,14 @@ class LineTester(GameObject):
def update(self):
GameObject.update(self)
# debug line
self.line.set_lines([(self.mark_a.x, self.mark_a.y, 0.0),
(self.mark_b.x, self.mark_b.y, 0.0)])
self.line.set_lines(
[(self.mark_a.x, self.mark_a.y, 0.0), (self.mark_b.x, self.mark_b.y, 0.0)]
)
# paint tiles under line
self.art.clear_frame_layer(0, 0, 7)
line_func = vector.get_tiles_along_line
line_func = vector.get_tiles_along_integer_line
tiles = line_func(self.mark_a.x, self.mark_a.y,
self.mark_b.x, self.mark_b.y)
tiles = line_func(self.mark_a.x, self.mark_a.y, self.mark_b.x, self.mark_b.y)
for tile in tiles:
x, y = self.get_tile_at_point(tile[0], tile[1])
char, fg = 1, 6
@ -53,5 +51,5 @@ class LineTester(GameObject):
def render(self, layer, z_override=None):
GameObject.render(self, layer, z_override)
# TODO not sure why this is necessary, pre_first_update should run before first render(), right? blech
if hasattr(self, 'line') and self.line:
if hasattr(self, "line") and self.line:
self.line.render()

View file

@ -1,15 +1,21 @@
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
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
@ -17,11 +23,7 @@ class CrawlPlayer(Player):
view_range_tiles = 8
fg_color = 8 # yellow
dir_chars = { DIR_NORTH: 147,
DIR_SOUTH: 163,
DIR_EAST: 181,
DIR_WEST: 180
}
dir_chars = {DIR_NORTH: 147, DIR_SOUTH: 163, DIR_EAST: 181, DIR_WEST: 180}
def pre_first_update(self):
Player.pre_first_update(self)
@ -32,14 +34,14 @@ class CrawlPlayer(Player):
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
# turning?
if key == 'left':
if key == "left":
self.direction = LEFT_TURN_DIRS[self.direction]
elif key == 'right':
elif key == "right":
self.direction = RIGHT_TURN_DIRS[self.direction]
# moving?
elif key == 'up' or key == 'down':
elif key == "up" or key == "down":
x, y = self.maze.get_tile_at_point(self.x, self.y)
if key == 'up':
if key == "up":
new_x = x + self.direction[0]
new_y = y + self.direction[1]
else:
@ -48,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,16 +1,16 @@
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
@ -25,18 +25,23 @@ class CrawlTopDownView(GameObject):
# 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
@ -80,11 +85,15 @@ class CrawlTopDownView(GameObject):
for tile in hit_tiles:
tile_x, tile_y = tile[0], tile[1]
# skip out-of-bounds tiles
if 0 > tile_x or tile_x >= self.art.width or \
0 > tile_y or tile_y >= self.art.height:
if (
tile_x < 0
or tile_x >= self.art.width
or tile_y < 0
or tile_y >= self.art.height
):
continue
# whether this tile is solid or not, we have seen it
if not tile in tiles:
if tile not in tiles:
tiles.append((tile_x, tile_y))
if not see_thru_walls and self.is_tile_solid(*tile):
break
@ -99,16 +108,19 @@ 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)
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:
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)
@ -118,6 +130,5 @@ class CrawlTopDownView(GameObject):
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,10 +1,13 @@
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
@ -16,16 +19,19 @@ class CronoPlayer(TopDownPlayer):
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,29 +28,28 @@ SPAWN_MARGIN_X = 8
# each particle's character "decays" towards 0 in random jumps
CHAR_DECAY_RATE_MAX = 16
# music is just an OGG file, modders feel free to provide your own in sounds/
MUSIC_FILENAME = 'music.ogg'
MUSIC_URL = 'http://brotherandroid.com'
MUSIC_FILENAME = "music.ogg"
MUSIC_URL = "http://brotherandroid.com"
# random ranges for time in seconds til next message pops up
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX = 300, 600
MESSAGES = [
'Happy Holidays',
'Merry Christmas',
'Happy New Year',
'Happy Hanukkah',
'Happy Kwanzaa',
'Feliz Navidad',
'Joyeux Noel'
"Happy Holidays",
"Merry Christmas",
"Happy New Year",
"Happy Hanukkah",
"Happy Kwanzaa",
"Feliz Navidad",
"Joyeux Noel",
]
class Fireplace(GameObject):
"The main game object, manages particles, handles input, draws the fire."
generate_art = True
art_charset = 'c64_petscii'
art_charset = "c64_petscii"
art_width, art_height = 54, 30 # approximately 16x9 aspect
art_palette = 'fireplace'
art_palette = "fireplace"
handle_key_events = True
def pre_first_update(self):
@ -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
@ -80,7 +79,7 @@ class Fireplace(GameObject):
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,7 +88,7 @@ class Fireplace(GameObject):
self.credit_screen.z = 1.1
self.credit_screen.set_scale(0.75, 0.75, 1)
else:
self.app.log('No %s found in %s' % (MUSIC_FILENAME, self.world.sounds_dir))
self.app.log(f"No {MUSIC_FILENAME} found in {self.world.sounds_dir}")
self.set_new_message_time()
def update(self):
@ -147,7 +146,9 @@ class Fireplace(GameObject):
# (looks nicer if we don't clear between frames, actually)
# self.art.clear_frame_layer(0, 0)
for p in self.particles:
self.art.set_tile_at(0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg)
self.art.set_tile_at(
0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg
)
# spawn new particles to maintain target count
while len(self.particles) < self.target_particles:
p = FireParticle(self)
@ -155,7 +156,9 @@ class Fireplace(GameObject):
GameObject.update(self)
def set_new_message_time(self):
self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX)
self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX
)
def post_new_message(self):
msg_text = choice(MESSAGES)
@ -168,34 +171,33 @@ class Fireplace(GameObject):
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
# in many Playscii games all input goes through the Player object;
# here input is handled by this object.
if key == 'escape' and not self.world.app.can_edit:
if key == "escape" and not self.world.app.can_edit:
self.world.app.should_quit = True
elif key == 'h':
elif key == "h":
self.help_screen.visible = not self.help_screen.visible
if self.credit_screen:
self.credit_screen.visible = not self.credit_screen.visible
elif key == 'm' and self.music_exists:
elif key == "m" and self.music_exists:
if self.music_paused:
self.world.resume_music()
self.music_paused = False
else:
self.world.pause_music()
self.music_paused = True
elif key == 'c':
elif key == "c":
if not self.app.fb.disable_crt:
self.app.fb.toggle_crt()
elif key == '=' or key == '+':
elif key == "=" or key == "+":
self.target_particles += 10
self.art.write_string(0, 0, 0, 0, 'Embers: %s' % self.target_particles, 15, 1)
elif key == '-':
self.art.write_string(0, 0, 0, 0, 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):
@ -239,15 +241,14 @@ class FireParticle:
class HelpScreen(GameObject):
art_src = 'help'
art_src = "help"
alpha = 0.7
class CreditScreen(GameObject):
"Separate object for the clickable area of the help screen."
art_src = 'credit'
art_src = "credit"
alpha = 0.7
handle_mouse_events = True

View file

@ -1,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,8 +20,8 @@ GS_LOST = 2
class Board(GameObject):
generate_art = True
art_width, art_height = BOARD_WIDTH, BOARD_HEIGHT
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
handle_key_events = True
def __init__(self, world, obj_data):
@ -58,7 +57,7 @@ class Board(GameObject):
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:
@ -80,8 +79,8 @@ class Board(GameObject):
self.reset()
return
# get list of valid keys from length of tile_colors
valid_keys = ['%s' % str(i + 1) for i in range(len(TILE_COLORS))]
if not key in valid_keys:
valid_keys = [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,8 +89,8 @@ class Board(GameObject):
class ColorBar(GameObject):
generate_art = True
art_width, art_height = len(TILE_COLORS), 1
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
def __init__(self, world, obj_data):
GameObject.__init__(self, world, obj_data)
@ -103,18 +102,18 @@ class ColorBar(GameObject):
class TurnsBar(GameObject):
text = 'turns: %s'
text = "turns: %s"
generate_art = True
art_width, art_height = len(text) + 3, 1
art_charset = 'jpetscii'
art_palette = 'c64_original'
art_charset = "jpetscii"
art_palette = "c64_original"
def __init__(self, world, obj_data):
GameObject.__init__(self, world, obj_data)
self.board = None
def pre_first_update(self):
self.board = self.world.get_all_objects_of_type('Board')[0]
self.board = self.world.get_all_objects_of_type("Board")[0]
def draw_text(self):
if not self.board:
@ -122,9 +121,9 @@ class TurnsBar(GameObject):
self.art.clear_frame_layer(0, 0)
new_text = self.text % self.board.turns
if self.board.game_state == GS_WON:
new_text = 'won!!'
new_text = "won!!"
elif self.board.game_state == GS_LOST:
new_text = 'lost :('
new_text = "lost :("
color = TILE_COLORS[self.board.turns % len(TILE_COLORS)]
self.art.write_string(0, 0, 0, 0, new_text, color, 0)

View file

@ -1,14 +1,14 @@
from 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,9 +17,9 @@ class MazeHUD(GameHUD):
aspect = self.world.app.window_height / self.world.app.window_width
self.msg.scale_x = 0.075 * aspect
self.msg.scale_y = 0.05
self.current_msg = ''
self.current_msg = ""
self.msg_art.clear_frame_layer(0, 0, 0, self.message_color)
self.post_msg('Welcome to MAZE, the amazing example game!')
self.post_msg("Welcome to MAZE, the amazing example game!")
def post_msg(self, msg_text):
self.current_msg = msg_text

View file

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

View file

@ -1,14 +1,15 @@
import math
from game_util_objects import Player, BlobShadow
from 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,12 +17,12 @@ class PlayerBlobShadow(BlobShadow):
class MazePlayer(Player):
art_src = 'player'
move_state = 'stand'
art_src = "player"
move_state = "stand"
col_radius = 0.5
# TODO: setting this to 2 fixes tunneling, but shouldn't slow down the player!
fast_move_steps = 2
attachment_classes = { 'shadow': 'PlayerBlobShadow' }
attachment_classes = {"shadow": "PlayerBlobShadow"}
def __init__(self, world, obj_data=None):
Player.__init__(self, world, obj_data)

View file

@ -1,18 +1,15 @@
from game_room import GameRoom
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):

View file

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

View file

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

View file

@ -1,14 +1,12 @@
import random
import time
import time, random
from 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,7 +14,6 @@ FLOWER_WIDTH, FLOWER_HEIGHT = 16, 16
class FlowerObject(GameObject):
generate_art = True
should_save = False
physics_move = False
@ -42,7 +39,6 @@ class FlowerObject(GameObject):
# set random seed based on date, a different flower each day
t = time.localtime()
year, month, day = t.tm_year, t.tm_mon, t.tm_mday
weekday = t.tm_wday # 0 = monday
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,11 +50,13 @@ 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
@ -91,11 +89,16 @@ class FlowerObject(GameObject):
# 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.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)
self.art.palette.name,
)
# re-set art's filename to be in documents dir rather than game dir :/
self.exportable_art.set_filename(self.export_filename)
# image export process needs a renderable
@ -111,7 +114,7 @@ class FlowerObject(GameObject):
def update_growth(self):
if self.debug_log:
print('update growth:')
print("update growth:")
grew = False
for p in self.petals:
if not p.finished_growing:
@ -136,7 +139,7 @@ class FlowerObject(GameObject):
self.finished_growing = True
self.exportable_art.set_active_frame(self.exportable_art.frames - 1)
if self.debug_log:
print('flower finished')
print("flower finished")
def paint_mirrored(self, layer, x, y, char, fg, bg=None):
# only paint if in top left quadrant
@ -148,12 +151,11 @@ class FlowerObject(GameObject):
top_right = (self.art_width - 1 - x, y)
bottom_left = (x, self.art_height - 1 - y)
bottom_right = (self.art_width - 1 - x, self.art_height - 1 - y)
self.art.set_tile_at(0, layer, *top_right,
char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left,
char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(0, layer, *bottom_right,
char, fg, bg, transform=UV_ROTATE180)
self.art.set_tile_at(0, layer, *top_right, char, fg, bg, transform=UV_FLIPX)
self.art.set_tile_at(0, layer, *bottom_left, char, fg, bg, transform=UV_FLIPY)
self.art.set_tile_at(
0, layer, *bottom_right, char, fg, bg, transform=UV_ROTATE180
)
def copy_new_frame(self):
# add new frame to art for export

View file

@ -1,9 +1,7 @@
import random
from games.wildflowers.scripts.ramps import RampIterator
# growth direction consts
NONE = (0, 0)
LEFT = (-1, 0)
@ -18,18 +16,23 @@ DIRS = [LEFT, LEFT_UP, UP, RIGHT_UP, RIGHT, RIGHT_DOWN, DOWN, LEFT_DOWN]
FROND_CHARS = [
# thick and skinny \
151, 166,
151,
166,
# thick and skinny /
150, 167,
150,
167,
# thick and skinny X
183, 182,
183,
182,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Frond:
min_life, max_life = 3, 16
random_char_chance = 0.5
mutate_char_chance = 0.2
@ -42,8 +45,11 @@ class Frond:
self.index = index
self.finished_growing = False
# choose growth function
self.growth_functions = [self.grow_straight_line, self.grow_curl,
self.grow_wander_outward]
self.growth_functions = [
self.grow_straight_line,
self.grow_curl,
self.grow_wander_outward,
]
self.get_grow_dir = random.choice(self.growth_functions)
# self.get_grow_dir = self.grow_curl # DEBUG
# for straight line growers, set a consistent direction
@ -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

View file

@ -1,27 +1,35 @@
import random, math
import math
import random
from games.wildflowers.scripts.ramps import RampIterator
PETAL_CHARS = [
# solid block
255,
# shaded boxes
254, 253,
254,
253,
# solid circle
122,
# curved corner lines, NW NE SE SW
105, 107, 139, 137,
105,
107,
139,
137,
# mostly-solid curved corners, NW NE SE SW
144, 146, 178, 176,
144,
146,
178,
176,
# solid inward wedges, NW NE SE SW
148, 149, 164, 165
148,
149,
164,
165,
]
class Petal:
min_radius = 3
mutate_char_chance = 0.2
# layer all petals should paint on
@ -36,8 +44,12 @@ class Petal:
max_radius = int(self.flower.art_width / 2)
self.goal_radius = random.randint(self.min_radius, max_radius)
self.radius = 0
ring_styles = [self.get_ring_tiles_box, self.get_ring_tiles_wings,
self.get_ring_tiles_diamond, self.get_ring_tiles_circle]
ring_styles = [
self.get_ring_tiles_box,
self.get_ring_tiles_wings,
self.get_ring_tiles_diamond,
self.get_ring_tiles_circle,
]
self.get_ring_tiles = random.choice(ring_styles)
# pick a starting point near center
w, h = self.flower.art_width, self.flower.art_height
@ -55,7 +67,9 @@ class Petal:
self.finished_growing = True
return
if self.debug:
print(' petal %i at (%i, %i) at radius %i using %s' % (self.index, self.x, self.y, self.radius, self.get_ring_tiles.__name__))
print(
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
@ -70,10 +84,11 @@ class Petal:
x = self.x - t[0]
y = self.y - t[1]
# don't paint out of bounds
if 0 <= x < self.flower.art_width - 1 and \
0 <= y < self.flower.art_height - 1:
self.flower.paint_mirrored(self.layer, x, y,
self.char, self.color)
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):
@ -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):
@ -116,10 +130,10 @@ class Petal:
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,11 +1,10 @@
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
@ -21,9 +20,9 @@ PALETTE_RAMPS = {
(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
(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
@ -37,9 +36,9 @@ PALETTE_RAMPS = {
(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
@ -57,9 +56,9 @@ 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
@ -74,9 +73,9 @@ PALETTE_RAMPS = {
(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
(247, 7, -1), # red to yellow
],
'atari': [
"atari": [
(113, 8, -16), # white to black
(114, 8, -16), # yellow to muddy brown
(115, 8, -16), # dull gold to brown
@ -92,13 +91,12 @@ PALETTE_RAMPS = {
(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
]
(128, 8, -16), # gold to golden brown
],
}
class RampIterator:
def __init__(self, flower):
ramp_def = random.choice(PALETTE_RAMPS[flower.art.palette.name])
self.start, self.length, self.stride = ramp_def

View file

@ -1,8 +1,5 @@
from game_util_objects import WorldGlobalsObject, GameObject
from image_export import export_animation, export_still_image
from games.wildflowers.scripts.flower import FlowerObject
from playscii.game_util_objects import GameObject, WorldGlobalsObject
from playscii.image_export import export_still_image
"""
overall approach:
@ -22,7 +19,6 @@ character ramps based on direction changes, visual density, something else?
class FlowerGlobals(WorldGlobalsObject):
# if True, generate a 4x4 grid instead of just one
test_gen = False
handle_key_events = True
@ -33,21 +29,21 @@ class FlowerGlobals(WorldGlobalsObject):
def pre_first_update(self):
# 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
@ -65,19 +61,22 @@ class FlowerGlobals(WorldGlobalsObject):
# 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)

14
justfile Normal file
View file

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

1
playscii/__init__.py Normal file
View file

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

9
playscii/__main__.py Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,21 +1,20 @@
import traceback
from art import ART_DIR
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."
@ -24,8 +23,8 @@ class ArtExporter:
self.app = app
self.art = self.app.ui.active_art
# add file extension to output filename if not present
if self.file_extension and not out_filename.endswith('.%s' % self.file_extension):
out_filename += '.%s' % self.file_extension
if self.file_extension and not out_filename.endswith(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,11 +39,11 @@ 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

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)

View file

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

View file

@ -1,12 +1,15 @@
import math
import numpy as np
import vector
from . import vector
def clamp(val, lowest, highest):
return min(highest, max(lowest, val))
class Camera:
class Camera:
# good starting values
start_x, start_y = 0, 0
start_zoom = 2.5
@ -65,10 +68,12 @@ class Camera:
forward = (target - eye).normalize()
side = forward.cross(up).normalize()
upward = side.cross(forward)
m = [[side.x, upward.x, -forward.x, 0],
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]]
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1],
]
self.view_matrix = np.array(m, dtype=np.float32)
self.look_x, self.look_y, self.look_z = side, upward, forward
@ -77,10 +82,7 @@ class Camera:
ymul = 1 / math.tan(self.fov * math.pi / 360)
aspect = self.app.window_width / self.app.window_height
xmul = ymul / aspect
m = [[xmul, 0, 0, 0],
[ 0, ymul, 0, 0],
[ 0, 0, -1, -1],
[ 0, 0, zmul, 0]]
m = [[xmul, 0, 0, 0], [0, ymul, 0, 0], [0, 0, -1, -1], [0, 0, zmul, 0]]
return np.array(m, dtype=np.float32)
def get_ortho_matrix(self, width=None, height=None):
@ -88,17 +90,13 @@ class Camera:
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):
@ -181,8 +179,8 @@ class Camera:
# add 1 tile of UI chars to top and bottom margins
top_margin = 1 - self.app.ui.menu_bar.art.quad_height
bot_margin = -1 + self.app.ui.status_bar.art.quad_height
return left >= -1 and top <= top_margin and \
right <= 1 and bot >= bot_margin
return left >= -1 and top <= top_margin and right <= 1 and bot >= bot_margin
# zoom out from minimum until all corners are visible
self.z = self.min_zoom
# recalc view matrix each move so projection stays correct
@ -199,9 +197,17 @@ class Camera:
art.camera_zoomed_extents = not override
if art.camera_zoomed_extents:
# restore cached position
self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z
self.x, self.y, self.z = (
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
)
else:
art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z = self.x, self.y, self.z
(
art.non_extents_camera_x,
art.non_extents_camera_y,
art.non_extents_camera_z,
) = self.x, self.y, self.z
# center camera on art
self.x = (art.width * art.quad_width) / 2
self.y = -(art.height * art.quad_height) / 2
@ -244,8 +250,9 @@ class Camera:
def update(self):
# zoom-proportional pan scale is based on art
if self.app.ui.active_art:
speed_scale = clamp(self.get_current_zoom_pct(),
self.pan_min_pct, self.pan_max_pct)
speed_scale = clamp(
self.get_current_zoom_pct(), self.pan_min_pct, self.pan_max_pct
)
self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
else:
self.max_pan_speed = self.base_max_pan_speed
@ -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,14 +1,16 @@
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
@ -17,35 +19,44 @@ class CharacterSetLord:
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
changed = None
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
@ -125,7 +138,7 @@ class CharacterSet:
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
@ -152,16 +165,23 @@ class CharacterSet:
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(' source texture %s is %s x %s pixels' % (self.image_filename, self.image_width, self.image_height))
self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height))
self.app.log(' char map width/height is %s x %s' % (self.map_width, self.map_height))
self.app.log(' last character index: %s' % self.last_index)
self.app.log(
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

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 = []
@ -118,15 +122,26 @@ class CollisionShape:
else:
# skip if even bounds don't overlap
obj_left, obj_top, obj_right, obj_bottom = obj.get_edges()
if not boxes_overlap(shape_left, shape_top, shape_right, shape_bottom,
obj_left, obj_top, obj_right, obj_bottom):
if not boxes_overlap(
shape_left,
shape_top,
shape_right,
shape_bottom,
obj_left,
obj_top,
obj_right,
obj_bottom,
):
continue
overlapping_shapes += obj.collision.get_shapes_overlapping_box(shape_left, shape_top, shape_right, shape_bottom)
overlapping_shapes += obj.collision.get_shapes_overlapping_box(
shape_left, shape_top, shape_right, shape_bottom
)
return overlapping_shapes
class CircleCollisionShape(CollisionShape):
"CollisionShape using a circle area."
def __init__(self, loc_x, loc_y, radius, game_object):
self.x, self.y = loc_x, loc_y
self.radius = radius
@ -134,7 +149,12 @@ class CircleCollisionShape(CollisionShape):
def get_box(self):
"Return world coordinates of our bounds (left, top, right, bottom)"
return self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius
return (
self.x - self.radius,
self.y - self.radius,
self.x + self.radius,
self.y + self.radius,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
@ -147,20 +167,26 @@ class CircleCollisionShape(CollisionShape):
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = point_circle_penetration(self.x, self.y,
other.x, other.y,
self.radius + other.radius)
px, py, pdist1, pdist2 = point_circle_penetration(
self.x, self.y, other.x, other.y, self.radius + other.radius
)
elif type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(self.x, self.y,
other.x, other.y,
self.radius, other.halfwidth,
other.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
self.x,
self.y,
other.x,
other.y,
self.radius,
other.halfwidth,
other.halfheight,
)
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
class AABBCollisionShape(CollisionShape):
"CollisionShape using an axis-aligned bounding box area."
def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object):
self.x, self.y = loc_x, loc_y
self.halfwidth, self.halfheight = halfwidth, halfheight
@ -169,7 +195,12 @@ class AABBCollisionShape(CollisionShape):
self.tiles = []
def get_box(self):
return self.x - self.halfwidth, self.y - self.halfheight, self.x + self.halfwidth, self.y + self.halfheight
return (
self.x - self.halfwidth,
self.y - self.halfheight,
self.x + self.halfwidth,
self.y + self.halfheight,
)
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
@ -183,15 +214,26 @@ class AABBCollisionShape(CollisionShape):
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = box_penetration(self.x, self.y,
other.x, other.y,
self.halfwidth, self.halfheight,
other.halfwidth, other.halfheight)
px, py, pdist1, pdist2 = box_penetration(
self.x,
self.y,
other.x,
other.y,
self.halfwidth,
self.halfheight,
other.halfwidth,
other.halfheight,
)
elif type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = circle_box_penetration(other.x, other.y,
self.x, self.y,
other.radius, self.halfwidth,
self.halfheight)
px, py, pdist1, pdist2 = circle_box_penetration(
other.x,
other.y,
self.x,
self.y,
other.radius,
self.halfwidth,
self.halfheight,
)
# reverse result if we're shape B
px, py = -px, -py
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
@ -200,8 +242,10 @@ class AABBCollisionShape(CollisionShape):
class Collideable:
"Collision component for GameObjects. Contains a list of shapes."
use_art_offset = False
"use game object's art_off_pct values"
def __init__(self, obj):
"Create new Collideable for given GameObject."
self.go = obj
@ -251,10 +295,9 @@ class Collideable:
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)
shape = self.cl._add_box_shape(
x, y, self.go.col_width / 2, self.go.col_height / 2, self.go
)
self.shapes = [shape]
self.renderables = [BoxCollisionRenderable(shape)]
@ -262,19 +305,27 @@ class Collideable:
"Create AABB shapes for a CST_TILE object"
# generate fewer, larger boxes!
frame = self.go.renderable.frame
if not self.go.col_layer_name in self.go.art.layer_names:
self.go.app.dev_log("%s: Couldn't find collision layer with name '%s'" % (self.go.name, self.go.col_layer_name))
if self.go.col_layer_name not in self.go.art.layer_names:
self.go.app.dev_log(
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):
@ -322,9 +374,9 @@ class Collideable:
"Return a list of our shapes that overlap given box."
shapes = []
tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom)
for (x, y) in tiles:
for x, y in tiles:
shape = self.tile_shapes.get((x, y), None)
if shape and not shape in shapes:
if shape and shape not in shapes:
shapes.append(shape)
return shapes
@ -373,11 +425,13 @@ class CollisionLord:
Collision manager object, tracks Collideables, detects overlaps and
resolves collisions.
"""
iterations = 7
"""
Number of times to resolve collisions per update. Lower at own risk;
multi-object collisions require multiple iterations to settle correctly.
"""
def __init__(self, world):
self.world = world
self.ticks = 0
@ -386,9 +440,9 @@ class CollisionLord:
self.reset()
def report(self):
print('%s: %s dynamic shapes, %s static shapes' % (self,
len(self.dynamic_shapes),
len(self.static_shapes)))
print(
f"{self}: {len(self.dynamic_shapes)} dynamic shapes, {len(self.static_shapes)} static shapes"
)
def reset(self):
self.dynamic_shapes, self.static_shapes = [], []
@ -417,7 +471,7 @@ class CollisionLord:
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
@ -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
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,6 +587,7 @@ 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
@ -529,6 +598,7 @@ def point_circle_penetration(point_x, point_y, circle_x, circle_y, 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,15 +623,25 @@ 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))
@ -572,6 +652,5 @@ def circle_box_penetration(circle_x, circle_y, box_x, box_y, circle_radius,
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,10 +56,10 @@ BASE_COLOR = (0.8, 0.8, 0.8, 1)
# because a static vertex list wouldn't be able to adjust to different
# character set aspect ratios.
class Cursor:
vert_shader_source = 'cursor_v.glsl'
frag_shader_source = 'cursor_f.glsl'
class Cursor:
vert_shader_source = "cursor_v.glsl"
frag_shader_source = "cursor_f.glsl"
alpha = 1
icon_scale_factor = 4
logg = False
@ -92,29 +85,40 @@ class Cursor:
self.elem_array = np.array(corner_elems, dtype=np.uint32)
self.vert_count = int(len(self.elem_array))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
# shader, attributes
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# vert positions
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, 2,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.color_uniform = self.shader.get_uniform_location('baseColor')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.xform_uniform = self.shader.get_uniform_location('vertTransform')
self.offset_uniform = self.shader.get_uniform_location('vertOffset')
self.alpha_uniform = self.shader.get_uniform_location('baseAlpha')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.color_uniform = self.shader.get_uniform_location("baseColor")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.xform_uniform = self.shader.get_uniform_location("vertTransform")
self.offset_uniform = self.shader.get_uniform_location("vertOffset")
self.alpha_uniform = self.shader.get_uniform_location("baseAlpha")
# finish
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
@ -136,7 +140,9 @@ 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
@ -178,7 +184,7 @@ class Cursor:
x0, y0 = self.x, -self.y
x1, y1 = self.last_x, -self.last_y
tiles = vector.get_tiles_along_line(x0, y0, x1, y1)
print('drag from %s,%s to %s,%s:' % (x0, y0, x1, y1))
print(f"drag from {x0},{y0} to {x1},{y1}:")
print(tiles)
return tiles
@ -209,7 +215,10 @@ class Cursor:
self.preview_edits = []
def start_paint(self):
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
if (
self.app.ui.console.visible
or self.app.ui.popup in self.app.ui.hovered_elements
):
return
if self.app.ui.selected_tool is self.app.ui.grab_tool:
self.app.ui.grab_tool.grab()
@ -223,7 +232,10 @@ class Cursor:
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:
@ -237,14 +249,16 @@ class Cursor:
# 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
@ -270,7 +284,9 @@ class Cursor:
# self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
mouse_moved = self.app.mouse_dx != 0 or self.app.mouse_dy != 0
# update cursor from mouse if: mouse moved, camera moved w/o keyboard
if mouse_moved or (not self.app.keyboard_editing and self.app.camera.moved_this_frame):
if mouse_moved or (
not self.app.keyboard_editing and self.app.camera.moved_this_frame
):
# don't let mouse move cursor if text tool input is happening
if not self.app.ui.text_tool.input_active:
self.reposition_from_mouse()
@ -311,12 +327,20 @@ class Cursor:
def render(self):
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix)
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix)
GL.glUniformMatrix4fv(
self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix
)
GL.glUniformMatrix4fv(
self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix
)
GL.glUniform3f(self.position_uniform, self.x, self.y, self.z)
GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z)
GL.glUniform4fv(self.color_uniform, 1, self.color)
GL.glUniform2f(self.quad_size_uniform, self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height)
GL.glUniform2f(
self.quad_size_uniform,
self.app.ui.active_art.quad_width,
self.app.ui.active_art.quad_height,
)
GL.glUniform1f(self.alpha_uniform, self.alpha)
# VAO vs non-VAO paths
if self.app.use_vao:
@ -324,9 +348,15 @@ class Cursor:
else:
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!
@ -339,8 +369,9 @@ class Cursor:
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,7 +1,4 @@
import time
class EditCommand:
"undo/redo-able representation of an art edit (eg paint, erase) operation"
def __init__(self, art):
@ -18,32 +15,30 @@ class EditCommand:
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):
@ -51,8 +46,10 @@ class EditCommand:
if len(self.tile_commands) == 0:
return
# tile might not have undo commands, eg text entry beyond start region
if not y in self.tile_commands[frame][layer] or \
not x in self.tile_commands[frame][layer][y]:
if (
y not in self.tile_commands[frame][layer]
or x not in self.tile_commands[frame][layer][y]
):
return
self.tile_commands[frame][layer][y][x].undo()
@ -72,14 +69,13 @@ class EditCommand:
class EntireArtCommand:
"""
undo/redo-able representation of a whole-art operation, eg:
resize/crop, run art script, add/remove layer, etc
"""
# art arrays to grab
array_types = ['chars', 'fg_colors', 'bg_colors', 'uv_mods']
array_types = ["chars", "fg_colors", "bg_colors", "uv_mods"]
def __init__(self, art, origin_x=0, origin_y=0):
self.art = art
@ -91,11 +87,11 @@ class EntireArtCommand:
def save_tiles(self, before=True):
# save copies of tile data lists
prefix = 'b' if before else 'a'
prefix = "b" if before else "a"
for atype in self.array_types:
# save list as eg "b_chars" for "character data before operation"
src_data = getattr(self.art, atype)
var_name = '%s_%s' % (prefix, atype)
var_name = f"{prefix}_{atype}"
# deep copy each frame's data, else before == after
new_data = []
for frame in src_data:
@ -114,7 +110,7 @@ class EntireArtCommand:
x, y = self.before_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'b_' + atype)
new_data = getattr(self, "b_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
# Art.resize will set geo_changed and mark all frames changed
@ -126,7 +122,7 @@ class EntireArtCommand:
x, y = self.after_size
self.art.resize(x, y, self.origin_x, self.origin_y)
for atype in self.array_types:
new_data = getattr(self, 'a_' + atype)
new_data = getattr(self, "a_" + atype)
setattr(self.art, atype, new_data[:])
if self.before_size != self.after_size:
self.art.app.ui.adjust_for_art_resize(self.art)
@ -134,7 +130,6 @@ class EntireArtCommand:
class EditCommandTile:
def __init__(self, art):
self.art = art
self.creation_time = self.art.app.get_elapsed_time()
@ -146,18 +141,32 @@ class EditCommandTile:
self.a_char = self.a_fg = self.a_bg = self.a_xform = None
def __str__(self):
s = 'F%s L%s %s,%s @ %.2f: ' % (self.frame, self.layer, str(self.x).rjust(2, '0'), str(self.y).rjust(2, '0'), self.creation_time)
s += 'c%s f%s b%s x%s -> ' % (self.b_char, self.b_fg, self.b_bg, self.b_xform)
s += 'c%s f%s b%s x%s' % (self.a_char, self.a_fg, self.a_bg, self.a_xform)
s = "F{} 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"
@ -187,7 +196,12 @@ class EditCommandTile:
self.a_fg, self.a_bg = fg, bg
def is_null(self):
return self.a_char == self.b_char and self.a_fg == self.b_fg and self.a_bg == self.b_bg and self.a_xform == self.b_xform
return (
self.a_char == self.b_char
and self.a_fg == self.b_fg
and self.a_bg == self.b_bg
and self.a_xform == self.b_xform
)
def undo(self):
# tile's frame or layer may have been deleted
@ -196,31 +210,58 @@ class EditCommandTile:
if self.x >= self.art.width or self.y >= self.art.height:
return
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.b_char, self.b_fg, self.b_bg, self.b_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.b_char,
self.b_fg,
self.b_bg,
self.b_xform,
set_all,
)
def apply(self):
tool = self.art.app.ui.selected_tool
set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform
self.art.set_tile_at(self.frame, self.layer, self.x, self.y,
self.a_char, self.a_fg, self.a_bg, self.a_xform, set_all)
set_all = (
tool.affects_char
and tool.affects_fg_color
and tool.affects_fg_color
and tool.affects_xform
)
self.art.set_tile_at(
self.frame,
self.layer,
self.x,
self.y,
self.a_char,
self.a_fg,
self.a_bg,
self.a_xform,
set_all,
)
class CommandStack:
def __init__(self, art):
self.art = art
self.undo_commands, self.redo_commands = [], []
def __str__(self):
s = 'stack for %s:\n' % self.art.filename
s += '===\nundo:\n'
s = 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):

View file

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

View file

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

View file

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

View file

@ -1,27 +1,35 @@
from .game_object import GameObject
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,7 +57,7 @@ class GameRoom:
else:
setattr(self, v, room_data[v])
# find objects by name and add them
for obj_name in room_data.get('objects', []):
for obj_name in room_data.get("objects", []):
self.add_object_by_name(obj_name)
def pre_first_update(self):
@ -58,7 +68,8 @@ class GameRoom:
# no warping if we don't know our bounds
if not self.edge_obj:
return
edge_dest_name_suffix = '_name'
edge_dest_name_suffix = "_name"
def set_edge_dest(dest_property):
# property name to destination name
dest_name = getattr(self, dest_property)
@ -66,22 +77,27 @@ 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)
@ -89,7 +105,7 @@ class GameRoom:
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()
@ -104,7 +120,7 @@ class GameRoom:
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)
@ -113,7 +129,7 @@ class GameRoom:
"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)
@ -126,7 +142,7 @@ class GameRoom:
"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)
@ -135,16 +151,20 @@ class GameRoom:
if obj.name in self.objects:
self.objects.pop(obj.name)
else:
self.world.app.log("GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name))
self.world.app.log(
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):
@ -155,7 +175,12 @@ class GameRoom:
# bail if no bounds or edge warp destinations set
if not self.edge_obj:
return
if not self.left_edge_warp_dest and not self.right_edge_warp_dest and not self.top_edge_warp_dest and not self.bottom_edge_warp_dest:
if (
not self.left_edge_warp_dest
and not self.right_edge_warp_dest
and not self.top_edge_warp_dest
and not self.bottom_edge_warp_dest
):
return
if game_object.warped_recently():
return

View file

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

View file

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

View file

@ -1,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,8 +8,8 @@ AXIS_COLOR = (0.8, 0.8, 0.8, 0.5)
BASE_COLOR = (0.5, 0.5, 0.5, 0.25)
EXTENTS_COLOR = (0, 0, 0, 1)
class Grid(LineRenderable):
class Grid(LineRenderable):
visible = True
draw_axes = False
@ -74,7 +74,6 @@ class Grid(LineRenderable):
class ArtGrid(Grid):
def reset_loc(self):
self.x, self.y = 0, 0
self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer]
@ -88,7 +87,6 @@ class ArtGrid(Grid):
class GameGrid(Grid):
draw_axes = True
base_size = 800

View file

@ -1,11 +1,12 @@
import math
import os.path
import time
import math, os.path, time
import numpy as np
from PIL import Image
from PIL import Image, ImageChops, ImageStat
from 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:
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)
@ -113,7 +119,11 @@ class ImageConverter:
unique_colors = len(colors)
color_diffs = np.zeros((unique_colors, unique_colors), dtype=np.float32)
# option: L*a*b color space conversion for greater accuracy
get_color_diff = self.get_lab_color_diff if self.lab_color_comparison else self.get_rgb_color_diff
get_color_diff = (
self.get_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):
@ -138,12 +148,14 @@ class ImageConverter:
r = color1[0] - color2[0]
g = color1[1] - color2[1]
b = color1[2] - color2[2]
return math.sqrt((((512+rmean)*r*r)>>8) + 4*g*g + (((767-rmean)*b*b)>>8))
return math.sqrt(
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8)
)
def update(self):
if time.time() < self.start_time + self.start_delay:
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,8 +164,15 @@ 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)
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:
@ -179,8 +198,8 @@ class ImageConverter:
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
@ -209,7 +228,7 @@ class ImageConverter:
# 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
@ -231,22 +250,24 @@ class ImageConverter:
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),
art.palette.colors[0] = (
round(bg_color[0] * 255),
round(bg_color[1] * 255),
round(bg_color[2] * 255),
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')
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,8 +111,9 @@ 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
@ -104,13 +121,12 @@ def export_animation(app, art, out_filename, bg_color=None, loop=True):
# 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))
@ -124,19 +140,17 @@ 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'
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()

View file

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

View file

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

View file

@ -3,6 +3,7 @@
import math
def rgb_to_xyz(r, g, b):
r /= 255.0
g /= 255.0
@ -28,6 +29,7 @@ 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
@ -50,10 +52,12 @@ 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

View file

@ -1,16 +1,19 @@
import os.path, math, time
import math
import os.path
import time
from random import randint
from PIL import Image
from 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:
class PaletteLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
@ -19,29 +22,33 @@ class PaletteLord:
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
changed = None
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,7 +83,8 @@ 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
@ -86,7 +95,7 @@ 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)
@ -106,7 +115,7 @@ class Palette:
width = min(16, len(self.colors) - 1)
height = math.floor((len(self.colors) - 1) / width)
# new PIL image, blank (0 alpha) pixels
img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
# set each pixel from color list (minus first, transparent color)
color_index = 1
for y in range(height):
@ -122,10 +131,11 @@ class Palette:
block_size = 8
# scale up
width, height = img.size
img = img.resize((width * block_size, height * block_size),
resample=Image.NEAREST)
img = img.resize(
(width * block_size, height * block_size), resample=Image.NEAREST
)
# write to file
img_filename = self.app.documents_dir + PALETTE_DIR + self.name + '.png'
img_filename = self.app.documents_dir + PALETTE_DIR + self.name + ".png"
img.save(img_filename)
def all_colors_opaque(self):
@ -137,23 +147,26 @@ class Palette:
def get_random_non_palette_color(self):
"returns random color not in this palette, eg for 8-bit transparency"
def rand_byte():
return randint(0, 255)
# assume full alpha
r, g, b, a = rand_byte(), rand_byte(), rand_byte(), 255
while (r, g, b, a) in self.colors:
r, g, b = rand_byte(), rand_byte(), rand_byte()
return r, g, b, a
def get_palettized_image(self, src_img, transparent_color=(0, 0, 0),
force_no_transparency=False):
def get_palettized_image(
self, src_img, transparent_color=(0, 0, 0), force_no_transparency=False
):
"returns a copy of source image quantized to this palette"
pal_img = Image.new('P', (1, 1))
pal_img = Image.new("P", (1, 1))
# source must be in RGB (no alpha) format
out_img = src_img.convert('RGB')
out_img = src_img.convert("RGB")
# Image.putpalette needs a flat tuple :/
colors = []
for i,color in enumerate(self.colors):
for 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]
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.
@ -202,14 +214,13 @@ class Palette:
class PaletteFromList(Palette):
"palette created from list of 3/4-tuple base-255 colors instead of image"
def __init__(self, app, src_color_list, log):
self.init_success = False
self.app = app
# generate a unique non-user-facing palette name
name = 'PaletteFromList_%s' % time.time()
name = f"PaletteFromList_{time.time()}"
self.filename = self.name = self.base_filename = name
colors = []
for color in src_color_list:
@ -230,17 +241,17 @@ 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"
@ -248,18 +259,19 @@ class PaletteFromList(Palette):
class PaletteFromFile(Palette):
def __init__(self, app, src_filename, palette_filename, colors=MAX_COLORS):
self.init_success = False
src_filename = app.find_filename_path(src_filename)
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,17 +19,18 @@ class TileRenderable:
rectangular OpenGL triangle-pairs. Animation frames are uploaded into our
buffers from source Art's numpy arrays.
"""
vert_shader_source = 'renderable_v.glsl'
vert_shader_source = "renderable_v.glsl"
"vertex shader: includes view projection matrix, XYZ camera uniforms."
frag_shader_source = 'renderable_f.glsl'
frag_shader_source = "renderable_f.glsl"
"Pixel shader: handles FG/BG colors."
log_create_destroy = False
log_animation = False
log_buffer_updates = False
grain_strength = 0.
alpha = 1.
grain_strength = 0.0
alpha = 1.0
"Alpha (0 to 1) for entire Renderable."
bg_alpha = 1.
bg_alpha = 1.0
"Alpha (0 to 1) *only* for tile background colors."
default_move_rate = 1
use_art_offset = True
@ -69,65 +73,133 @@ 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))
@ -143,21 +215,38 @@ class TileRenderable:
if update_bg:
updates[self.bg_buffer] = self.art.bg_colors
for update in updates:
self.update_buffer(update, updates[update][self.frame],
GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT, None, None)
self.update_buffer(
update,
updates[update][self.frame],
GL.GL_ARRAY_BUFFER,
GL.GL_DYNAMIC_DRAW,
GL.GL_FLOAT,
None,
None,
)
def update_buffer(self, buffer_index, array, target, buffer_type, data_type,
attrib_name, attrib_size):
def update_buffer(
self,
buffer_index,
array,
target,
buffer_type,
data_type,
attrib_name,
attrib_size,
):
if self.log_buffer_updates:
self.app.log('update_buffer: %s, %s, %s, %s, %s, %s, %s' % (buffer_index, array, target, buffer_type, data_type, attrib_name, attrib_size))
self.app.log(
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)
@ -177,7 +266,7 @@ 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."
@ -227,7 +316,9 @@ class TileRenderable:
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
@ -301,11 +392,21 @@ class TileRenderable:
def destroy(self):
if self.app.use_vao:
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(6, [self.vert_buffer, self.elem_buffer, self.char_buffer, self.uv_buffer, self.fg_buffer, self.bg_buffer])
GL.glDeleteBuffers(
6,
[
self.vert_buffer,
self.elem_buffer,
self.char_buffer,
self.uv_buffer,
self.fg_buffer,
self.bg_buffer,
],
)
if self.art and self in self.art.renderables:
self.art.renderables.remove(self)
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log(f"destroyed: {self}")
def get_projection_matrix(self):
"""
@ -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,7 +571,6 @@ class TileRenderable:
class OnionTileRenderable(TileRenderable):
"TileRenderable subclass used for onion skin display in Art Mode animation."
# never animate
@ -464,7 +582,6 @@ class OnionTileRenderable(TileRenderable):
class GameObjectRenderable(TileRenderable):
"""
TileRenderable subclass used by GameObjects. Almost no custom logic for now.
"""

View file

@ -1,15 +1,20 @@
import math, time, ctypes, platform
import ctypes
import math
import platform
import time
import numpy as np
from OpenGL import GL
from renderable import TileRenderable
class LineRenderable():
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
@ -22,7 +27,7 @@ class LineRenderable():
self.app = app
# we may be attached to a game object
self.go = game_object
self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.unique_name = 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,44 +41,60 @@ class LineRenderable():
GL.glBindVertexArray(self.vao)
if self.vert_items == 3:
self.vert_shader_source = self.vert_shader_source_3d
self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source)
self.shader = self.app.sl.new_shader(
self.vert_shader_source, self.frag_shader_source
)
# uniforms
self.proj_matrix_uniform = self.shader.get_uniform_location('projection')
self.view_matrix_uniform = self.shader.get_uniform_location('view')
self.position_uniform = self.shader.get_uniform_location('objectPosition')
self.scale_uniform = self.shader.get_uniform_location('objectScale')
self.quad_size_uniform = self.shader.get_uniform_location('quadSize')
self.color_uniform = self.shader.get_uniform_location('objectColor')
self.proj_matrix_uniform = self.shader.get_uniform_location("projection")
self.view_matrix_uniform = self.shader.get_uniform_location("view")
self.position_uniform = self.shader.get_uniform_location("objectPosition")
self.scale_uniform = self.shader.get_uniform_location("objectScale")
self.quad_size_uniform = self.shader.get_uniform_location("quadSize")
self.color_uniform = self.shader.get_uniform_location("objectColor")
# vert buffers
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
self.pos_attrib = self.shader.get_attrib_location('vertPosition')
self.pos_attrib = self.shader.get_attrib_location("vertPosition")
GL.glEnableVertexAttribArray(self.pos_attrib)
offset = ctypes.c_void_p(0)
GL.glVertexAttribPointer(self.pos_attrib, self.vert_items,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.pos_attrib, self.vert_items, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
# vert colors
self.color_buffer = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
self.color_attrib = self.shader.get_attrib_location('vertColor')
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
self.color_attrib = self.shader.get_attrib_location("vertColor")
GL.glEnableVertexAttribArray(self.color_attrib)
GL.glVertexAttribPointer(self.color_attrib, 4,
GL.GL_FLOAT, GL.GL_FALSE, 0, offset)
GL.glVertexAttribPointer(
self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
if self.log_create_destroy:
self.app.log('created: %s' % self)
self.app.log(f"created: {self}")
def __str__(self):
"for debug purposes, return a unique name"
@ -101,18 +122,30 @@ class LineRenderable():
def rebind_buffers(self):
# resend verts
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes,
self.vert_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.vert_array.nbytes,
self.vert_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer)
GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes,
self.elem_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER,
self.elem_array.nbytes,
self.elem_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self.vert_count = int(len(self.elem_array))
# resend color
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes,
self.color_array, GL.GL_STATIC_DRAW)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
self.color_array.nbytes,
self.color_array,
GL.GL_STATIC_DRAW,
)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
def get_projection_matrix(self):
@ -145,14 +178,18 @@ class LineRenderable():
GL.glDeleteVertexArrays(1, [self.vao])
GL.glDeleteBuffers(3, [self.vert_buffer, self.elem_buffer, self.color_buffer])
if self.log_create_destroy:
self.app.log('destroyed: %s' % self)
self.app.log(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,8 +243,8 @@ def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)):
class UIRenderableX(LineRenderable):
"Red X used to denote transparent color in various places"
color = (1, 0, 0, 1)
line_width = 2
@ -211,7 +255,6 @@ class UIRenderableX(LineRenderable):
class SwatchSelectionBoxRenderable(LineRenderable):
"used for UI selection boxes etc"
color = (0.5, 0.5, 0.5, 1)
@ -226,7 +269,9 @@ class SwatchSelectionBoxRenderable(LineRenderable):
return self.color
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class ToolSelectionBoxRenderable(LineRenderable):
@ -241,6 +286,7 @@ class ToolSelectionBoxRenderable(LineRenderable):
class WorldLineRenderable(LineRenderable):
"any LineRenderable that draws in world, ie in 3D perspective"
def get_projection_matrix(self):
return self.app.camera.projection_matrix
@ -249,7 +295,6 @@ class WorldLineRenderable(LineRenderable):
class DebugLineRenderable(WorldLineRenderable):
"""
renderable for drawing debug lines in the world.
use set_lines and add_lines to replace and add to, respectively, the list
@ -268,8 +313,9 @@ class DebugLineRenderable(WorldLineRenderable):
for i in range(1, len(new_verts)):
elements += [i - 1, i]
self.elem_array = np.array(elements, dtype=np.uint32)
self.color_array = np.array(new_colors or self.color * len(new_verts),
dtype=np.float32)
self.color_array = np.array(
new_colors or self.color * len(new_verts), dtype=np.float32
)
self.rebind_buffers()
def set_color(self, new_color):
@ -285,11 +331,10 @@ class DebugLineRenderable(WorldLineRenderable):
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,13 +345,16 @@ class DebugLineRenderable(WorldLineRenderable):
new_elem_size = int(old_elem_size + len(new_verts) / self.vert_items)
# TODO: "contiguous" parameter that joins new lines with previous
self.elem_array.resize(new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(old_elem_size,
new_elem_size)
self.elem_array[old_elem_size:new_elem_size] = range(
old_elem_size, new_elem_size
)
# grow color buffer
old_color_size = len(self.color_array)
new_color_size = int(old_color_size + len(new_verts) / self.vert_items * 4)
self.color_array.resize(new_color_size)
self.color_array[old_color_size:new_color_size] = new_colors or self.color * int(len(new_verts) / self.vert_items)
self.color_array[old_color_size:new_color_size] = (
new_colors or self.color * int(len(new_verts) / self.vert_items)
)
self.rebind_buffers()
def reset_lines(self):
@ -326,7 +374,6 @@ class DebugLineRenderable(WorldLineRenderable):
class OriginIndicatorRenderable(WorldLineRenderable):
"classic 3-axis thingy showing location/rotation/scale"
red = (1.0, 0.1, 0.1, 1.0)
@ -357,13 +404,23 @@ class OriginIndicatorRenderable(WorldLineRenderable):
self.scale_z = obj.scale_z
def build_geo(self):
self.vert_array = np.array([self.origin, self.x_axis,
self.origin, self.y_axis,
self.origin, self.z_axis],
dtype=np.float32)
self.vert_array = np.array(
[
self.origin,
self.x_axis,
self.origin,
self.y_axis,
self.origin,
self.z_axis,
],
dtype=np.float32,
)
self.elem_array = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint32)
self.color_array = np.array([self.red, self.red, self.green, self.green,
self.blue, self.blue], dtype=np.float32)
self.color_array = np.array(
[self.red, self.red, self.green, self.green, self.blue, self.blue],
dtype=np.float32,
)
class BoundsIndicatorRenderable(WorldLineRenderable):
color = (1, 1, 1, 0.5)
@ -393,19 +450,27 @@ class BoundsIndicatorRenderable(WorldLineRenderable):
return (1, 1, 1, 1)
def get_line_width(self):
return self.line_width_active if self.go in self.app.gw.selected_objects else self.line_width_inactive
return (
self.line_width_active
if self.go in self.app.gw.selected_objects
else self.line_width_inactive
)
def get_quad_size(self):
if not self.go:
return 1, 1
return self.art.width * self.art.quad_width, self.art.height * self.art.quad_height
return (
self.art.width * self.art.quad_width,
self.art.height * self.art.quad_height,
)
def build_geo(self):
self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
None, self.color
)
class CollisionRenderable(WorldLineRenderable):
# green = dynamic, blue = static
dynamic_color = (0, 1, 0, 1)
static_color = (0, 0, 1, 1)
@ -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,7 +500,6 @@ def get_circle_points(radius, steps=24):
class CircleCollisionRenderable(CollisionRenderable):
line_width = 2
segments = 24
@ -469,7 +533,6 @@ class CircleCollisionRenderable(CollisionRenderable):
class BoxCollisionRenderable(CollisionRenderable):
line_width = 2
def get_quad_size(self):
@ -483,12 +546,16 @@ class BoxCollisionRenderable(CollisionRenderable):
def build_geo(self):
verts = [(-0.5, 0.5), (0.5, 0.5), (0.5, -0.5), (-0.5, -0.5)]
self.vert_array, self.elem_array, self.color_array = get_box_arrays(verts, self.color)
self.vert_array, self.elem_array, self.color_array = get_box_arrays(
verts, self.color
)
class TileBoxCollisionRenderable(BoxCollisionRenderable):
"box for each tile in a CST_TILE object"
line_width = 1
def get_loc(self):
# draw at Z level of collision layer
return self.x, self.y, self.go.get_layer_z(self.go.col_layer_name)

View file

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

View file

@ -1,10 +1,11 @@
import math
import numpy as np
from renderable_line import LineRenderable
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
@ -35,10 +36,12 @@ class SelectionRenderable(LineRenderable):
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]
colors += self.color * 2
# verts = corners
if not above:
# top edge

View file

@ -1,11 +1,14 @@
import os.path, time, platform
import os.path
import platform
import time
from OpenGL import GL
from OpenGL.GL import shaders
SHADER_PATH = 'shaders/'
SHADER_PATH = "shaders/"
class ShaderLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
@ -17,7 +20,10 @@ class ShaderLord:
def new_shader(self, vert_source_file, frag_source_file):
self.last_check = 0
for shader in self.shaders:
if shader.vert_source_file == vert_source_file and shader.frag_source_file == frag_source_file:
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)
@ -25,7 +31,10 @@ class ShaderLord:
return s
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
for shader in self.shaders:
@ -41,7 +50,6 @@ class ShaderLord:
class Shader:
log_compile = False
"If True, log shader compilation"
# per-platform shader versions, declared here for easier CFG fiddling
@ -57,36 +65,44 @@ 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):
@ -94,12 +110,12 @@ class Shader:
try:
shader = shaders.compileShader(source, shader_type)
except Exception as e:
self.sl.app.log('%s: ' % source_filename)
lines = e.args[0].split('\\n')
self.sl.app.log(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
@ -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:

View file

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

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,39 +51,46 @@ OIS_FILL = 2
class UI:
# user-configured UI scale factor
scale = 1.0
max_onion_alpha = 0.5
charset_name = 'ui'
palette_name = 'c64_original'
charset_name = "ui"
palette_name = "c64_original"
# red color for warnings
error_color_index = UIColors.brightred
# low-contrast background texture that distinguishes UI from flat color
grain_texture_path = UI_ASSET_DIR + 'bgnoise_alpha.png'
grain_texture_path = UI_ASSET_DIR + "bgnoise_alpha.png"
# expose to classes that don't want to import this module
asset_dir = UI_ASSET_DIR
visible = True
logg = False
popup_hold_to_show = False
flip_affects_xforms = True
tool_classes = [ PencilTool, EraseTool, GrabTool, RotateTool, TextTool,
SelectTool, PasteTool, FillTool ]
tool_selected_log = 'tool selected'
art_selected_log = 'Now editing'
frame_selected_log = 'Now editing frame %s (hold time %ss)'
layer_selected_log = 'Now editing layer: %s'
swap_color_log = 'Swapped FG/BG colors'
affects_char_on_log = 'will affect characters'
affects_char_off_log = 'will not affect characters'
affects_fg_on_log = 'will affect foreground colors'
affects_fg_off_log = 'will not affect foreground colors'
affects_bg_on_log = 'will affect background colors'
affects_bg_off_log = 'will not affect background colors'
affects_xform_on_log = 'will affect character rotation/flip'
affects_xform_off_log = 'will not affect character rotation/flip'
xform_selected_log = 'Selected character transform:'
show_edit_ui_log = 'Edit UI hidden, press %s to unhide.'
tool_classes = [
PencilTool,
EraseTool,
GrabTool,
RotateTool,
TextTool,
SelectTool,
PasteTool,
FillTool,
]
tool_selected_log = "tool selected"
art_selected_log = "Now editing"
frame_selected_log = "Now editing frame %s (hold time %ss)"
layer_selected_log = "Now editing layer: %s"
swap_color_log = "Swapped FG/BG colors"
affects_char_on_log = "will affect characters"
affects_char_off_log = "will not affect characters"
affects_fg_on_log = "will affect foreground colors"
affects_fg_off_log = "will not affect foreground colors"
affects_bg_on_log = "will affect background colors"
affects_bg_off_log = "will not affect background colors"
affects_xform_on_log = "will affect character rotation/flip"
affects_xform_off_log = "will not affect character rotation/flip"
xform_selected_log = "Selected character transform:"
show_edit_ui_log = "Edit UI hidden, press %s to unhide."
def __init__(self, app, active_art):
self.app = app
@ -92,7 +120,7 @@ class UI:
# create tools
for t in self.tool_classes:
new_tool = t(self)
tool_name = '%s_tool' % new_tool.name
tool_name = 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.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.edit_list_panel,
self.edit_object_panel,
self.game_hover_label,
self.game_selection_label,
]
# add console last so it draws last
self.elements.append(self.console)
# grain texture
img = Image.open(self.grain_texture_path)
img = img.convert('RGBA')
img = img.convert("RGBA")
width, height = img.size
self.grain_texture = Texture(img.tobytes(), width, height)
self.grain_texture.set_wrap(True)
@ -155,8 +193,12 @@ class UI:
aspect = float(self.app.window_width) / self.app.window_height
inv_aspect = float(self.app.window_height) / self.app.window_width
# MAYBE-TODO: this math is correct but hard to follow, rewrite for clarity
width = self.app.window_width / (self.charset.char_width * self.scale * inv_aspect)
height = self.app.window_height / (self.charset.char_height * self.scale * inv_aspect)
width = self.app.window_width / (
self.charset.char_width * self.scale * inv_aspect
)
height = self.app.window_height / (
self.charset.char_height * self.scale * inv_aspect
)
# any new UI elements created should use new scale
UIArt.quad_width = 2 / width * aspect
UIArt.quad_height = 2 / height * aspect
@ -165,7 +207,9 @@ 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:
@ -231,18 +275,22 @@ 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)
@ -272,7 +320,7 @@ class UI:
if self.app.game_mode:
return
# don't re-select same tool, except to cycle fill tool (see below)
if new_tool == self.selected_tool and not type(new_tool) is FillTool:
if new_tool == self.selected_tool and type(new_tool) is not FillTool:
return
# bail out of text entry if active
if self.selected_tool is self.text_tool:
@ -285,16 +333,22 @@ class UI:
# if we're selecting the fill tool and it's already selected,
# cycle through its 3 modes (char/fg/bg boundary)
cycled_fill = False
if type(self.selected_tool) is FillTool and \
type(self.previous_tool) is FillTool:
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[self.selected_tool.boundary_mode]
if (
type(self.selected_tool) is FillTool
and type(self.previous_tool) is FillTool
):
self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[
self.selected_tool.boundary_mode
]
# TODO: do we need a message line message for this?
# self.app.log(self.selected_tool.boundary_mode)
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)
@ -322,11 +376,12 @@ class UI:
self.selected_xform = new_xform
self.popup.set_xform(new_xform)
self.tool_settings_changed = True
line = '%s %s' % (self.xform_selected_log, uv_names[self.selected_xform])
line = 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
@ -340,18 +395,20 @@ class UI:
new_art = new_art or self.active_art
alpha = self.max_onion_alpha
total_onion_frames = 0
def set_onion(r, new_frame, alpha):
# scale back if fewer than MAX_ONION_FRAMES in either direction
if total_onion_frames >= new_art.frames:
r.visible = False
return
r.visible = True
if not new_art is r.art:
if new_art is not r.art:
r.set_art(new_art)
r.set_frame(new_frame)
r.alpha = alpha
# make BG dimmer so it's easier to see
r.bg_alpha = alpha / 2
# populate "next" frames first
for i, r in enumerate(self.app.onion_renderables_next):
total_onion_frames += 1
@ -398,10 +455,12 @@ class UI:
# wrap at last valid index
self.selected_char = new_char_index % self.active_art.charset.last_index
# only update char tooltip if it was already up; avoid stomping others
char_cycle_button = self.status_bar.button_map['char_cycle']
char_toggle_button = self.status_bar.button_map['char_toggle']
if char_cycle_button in self.status_bar.hovered_buttons or \
char_toggle_button in self.status_bar.hovered_buttons:
char_cycle_button = self.status_bar.button_map["char_cycle"]
char_toggle_button = self.status_bar.button_map["char_toggle"]
if (
char_cycle_button in self.status_bar.hovered_buttons
or char_toggle_button in self.status_bar.hovered_buttons
):
char_toggle_button.update_tooltip()
self.tool_settings_changed = True
@ -415,10 +474,20 @@ class UI:
else:
self.selected_bg_color = new_color_index
# same don't-stomp-another-tooltip check as above
toggle_button = self.status_bar.button_map['fg_toggle'] if fg else self.status_bar.button_map['bg_toggle']
cycle_button = self.status_bar.button_map['fg_cycle'] if fg else self.status_bar.button_map['bg_cycle']
if toggle_button in self.status_bar.hovered_buttons or \
cycle_button in self.status_bar.hovered_buttons:
toggle_button = (
self.status_bar.button_map["fg_toggle"]
if fg
else self.status_bar.button_map["bg_toggle"]
)
cycle_button = (
self.status_bar.button_map["fg_cycle"]
if fg
else self.status_bar.button_map["bg_cycle"]
)
if (
toggle_button in self.status_bar.hovered_buttons
or cycle_button in self.status_bar.hovered_buttons
):
toggle_button.update_tooltip()
self.tool_settings_changed = True
@ -455,7 +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
@ -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 = {}
@ -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:
@ -642,7 +718,11 @@ class UI:
if e.clicked(mouse_button):
handled = True
# close pulldown if clicking outside it / the menu bar
if self.pulldown.visible and not self.pulldown in self.hovered_elements and not self.menu_bar in self.hovered_elements:
if (
self.pulldown.visible
and self.pulldown not in self.hovered_elements
and self.menu_bar not in self.hovered_elements
):
self.menu_bar.close_active_menu()
return handled
@ -660,10 +740,12 @@ class UI:
# an SDL keycode from that?
if self.active_dialog:
keycode = sdl2.SDLK_UP if wheel_y > 0 else sdl2.SDLK_DOWN
self.active_dialog.handle_input(keycode,
self.active_dialog.handle_input(
keycode,
self.app.il.shift_pressed,
self.app.il.alt_pressed,
self.app.il.ctrl_pressed)
self.app.il.ctrl_pressed,
)
handled = True
elif len(self.hovered_elements) > 0:
for e in self.hovered_elements:
@ -712,11 +794,11 @@ class UI:
# relinquish keyboard focus in play mode
self.keyboard_focus_element = None
if show_message and self.app.il:
bind = self.app.il.get_command_shortcut('toggle_game_edit_ui')
bind = self.app.il.get_command_shortcut("toggle_game_edit_ui")
bind = bind.title()
self.message_line.post_line(self.show_edit_ui_log % bind, 10)
else:
self.message_line.post_line('')
self.message_line.post_line("")
self.app.update_window_title()
def object_selection_changed(self):
@ -727,9 +809,11 @@ class UI:
def switch_edit_panel_focus(self, reverse=False):
# only allow tabbing away if list panel is in allowed mode
lp = self.edit_list_panel
if self.keyboard_focus_element is lp and \
lp.list_operation in lp.list_operations_allow_kb_focus and \
self.active_dialog:
if (
self.keyboard_focus_element is lp
and lp.list_operation in lp.list_operations_allow_kb_focus
and self.active_dialog
):
self.keyboard_focus_element = self.active_dialog
# prevent any other tabbing away from active dialog
if self.active_dialog:
@ -764,9 +848,15 @@ class UI:
self.keyboard_focus_element = self.popup
elif self.pulldown.visible:
self.keyboard_focus_element = self.pulldown
elif self.edit_list_panel.is_visible() and not self.edit_object_panel.is_visible():
elif (
self.edit_list_panel.is_visible()
and not self.edit_object_panel.is_visible()
):
self.keyboard_focus_element = self.edit_list_panel
elif self.edit_object_panel.is_visible() and not self.edit_list_panel.is_visible():
elif (
self.edit_object_panel.is_visible()
and not self.edit_list_panel.is_visible()
):
self.keyboard_focus_element = self.edit_object_panel
def keyboard_navigate(self, move_x, move_y):
@ -774,9 +864,7 @@ class UI:
def toggle_game_edit_ui(self):
# if editing is disallowed, only run this once to disable UI
if not self.app.can_edit:
return
elif not self.app.game_mode:
if not self.app.can_edit or not self.app.game_mode:
return
self.set_game_edit_ui_visibility(not self.game_menu_bar.visible)

View file

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

View file

@ -1,20 +1,19 @@
from ui_colors import UIColors
from .ui_colors import UIColors
TEXT_LEFT = 0
TEXT_CENTER = 1
TEXT_RIGHT = 2
BUTTON_STATES = ['normal', 'hovered', 'clicked', 'dimmed']
BUTTON_STATES = ["normal", "hovered", "clicked", "dimmed"]
class UIButton:
"clickable button that does something in a UIElement"
# x/y/width/height given in tile scale
x, y = 0, 0
width, height = 1, 1
caption = 'TEST'
caption = "TEST"
caption_justify = TEXT_LEFT
# paint caption from string, or not
should_draw_caption = True
@ -47,36 +46,41 @@ class UIButton:
def __init__(self, element, starting_state=None):
self.element = element
self.state = starting_state or 'normal'
self.state = starting_state or "normal"
def log_event(self, event_type):
"common code for button event logging"
if self.element.ui.logg:
self.element.ui.app.log("UIButton: %s's %s %s" % (self.element.__class__.__name__, self.__class__.__name__, event_type))
self.element.ui.app.log(
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):
@ -91,18 +95,18 @@ class UIButton:
tt.reset_loc()
def hover(self):
self.log_event('hovered')
self.set_state('hovered')
self.log_event("hovered")
self.set_state("hovered")
if self.tooltip_on_hover:
self.element.ui.tooltip.visible = True
self.update_tooltip()
def unhover(self):
self.log_event('unhovered')
self.log_event("unhovered")
if self.dimmed:
self.set_state('dimmed')
self.set_state("dimmed")
else:
self.set_state('normal')
self.set_state("normal")
if self.tooltip_on_hover:
# if two buttons are adjacent, we might be unhovering this one
# right after hovering the other in the same frame. if so,
@ -117,11 +121,11 @@ class UIButton:
self.element.ui.tooltip.visible = False
def click(self):
self.log_event('clicked')
self.set_state('clicked')
self.log_event("clicked")
self.set_state("clicked")
def unclick(self):
self.log_event('unclicked')
self.log_event("unclicked")
if self in self.element.hovered_buttons:
self.hover()
else:
@ -129,7 +133,7 @@ class UIButton:
def get_tooltip_text(self):
"override in a subclass to define this button's tooltip text"
return 'ERROR'
return "ERROR"
def get_tooltip_location(self):
"override in a subclass to define this button's tooltip screen location"

View file

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

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,15 +157,17 @@ 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
@ -156,102 +176,118 @@ class PaletteFromImageCommand(ConsoleCommand):
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,12 +296,12 @@ 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
@ -283,18 +319,18 @@ class ConsoleUI(UIElement):
self.last_lines = []
self.history_filename = self.ui.app.config_dir + CONSOLE_HISTORY_FILENAME
if os.path.exists(self.history_filename):
self.history_file = open(self.history_filename, 'r')
self.history_file = open(self.history_filename)
try:
self.command_history = self.history_file.readlines()
except:
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
@ -302,7 +338,9 @@ class ConsoleUI(UIElement):
self.width = ceil(self.ui.width_tiles * self.ui.scale)
# % of screen must take aspect into account
inv_aspect = self.ui.app.window_height / self.ui.app.window_width
self.height = int(self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale)
self.height = int(
self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale
)
# dim background
self.renderable.bg_alpha = self.bg_alpha
# must resize here, as window width will vary
@ -314,7 +352,7 @@ class ConsoleUI(UIElement):
self.current_line = self.current_line[: self.max_line_length]
# self.update_user_line()
# empty log lines so they refresh from app
self.last_user_line = 'XXtestXX'
self.last_user_line = "XXtestXX"
self.last_lines = []
def toggle(self):
@ -364,25 +402,34 @@ class ConsoleUI(UIElement):
self.art.clear_frame_layer(0, 0, self.bg_color_index)
# line -1 is always a line of ____________
for x in range(self.width):
self.art.set_tile_at(0, 0, x, -1, self.bottom_line_char_index, self.text_color, None, UV_FLIPY)
self.art.set_tile_at(
0,
0,
x,
-1,
self.bottom_line_char_index,
self.text_color,
None,
UV_FLIPY,
)
def update_user_line(self):
"draw current user input on second to last line, with >_ prompt"
# clear entire user line first
self.art.write_string(0, 0, 0, -2, ' ' * self.width, self.text_color)
self.art.write_string(0, 0, 0, -2, '%s ' % self.prompt, self.text_color)
self.art.write_string(0, 0, 0, -2, " " * self.width, self.text_color)
self.art.write_string(0, 0, 0, -2, 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):
@ -436,31 +483,34 @@ class ConsoleUI(UIElement):
keystr = sdl2.SDL_GetKeyName(key).decode()
# TODO: get console bound key from InputLord, detect that instead of
# hard-coded backquote
if keystr == '`':
if keystr == "`":
self.toggle()
return
elif keystr == 'Return':
line = '%s %s' % (self.prompt, self.current_line)
elif keystr == "Return":
line = 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,15 +531,15 @@ class ConsoleUI(UIElement):
if len(self.current_line) == 0:
# same as above: reset position in cmd history
self.history_index = 0
elif keystr == 'Space':
keystr = ' '
elif keystr == "Space":
keystr = " "
# ignore any other non-character keys
if len(keystr) > 1:
return
if keystr.isalpha() and not shift_pressed:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, '')
keystr = SHIFT_MAP.get(keystr, "")
if len(self.current_line) < self.max_line_length:
self.current_line += keystr
@ -508,26 +558,30 @@ 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):
@ -535,4 +589,4 @@ class ConsoleUI(UIElement):
# delimiters - alt-backspace deletes to most recent one of these
delimiters = [' ', '.', ')', ']', ',', '_']
delimiters = [" ", ".", ")", "]", ",", "_"]

View file

@ -1,49 +1,57 @@
import platform
import sdl2
from collections import namedtuple
from ui_element import UIElement
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_colors import UIColors
import sdl2
from key_shifts import SHIFT_MAP
from .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,7 +80,7 @@ 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
@ -84,11 +92,13 @@ class UIDialog(UIElement):
self.confirm_button = ConfirmButton(self)
self.other_button = OtherButton(self)
self.cancel_button = CancelButton(self)
# handle caption overrides
def caption_override(button, alt_caption):
if alt_caption and button.caption != alt_caption:
button.caption = alt_caption
button.width = len(alt_caption) + 2
caption_override(self.confirm_button, self.confirm_caption)
caption_override(self.other_button, self.other_caption)
caption_override(self.cancel_button, self.cancel_caption)
@ -98,7 +108,7 @@ class UIDialog(UIElement):
self.buttons = [self.confirm_button, self.other_button, self.cancel_button]
# populate fields with text
self.field_texts = []
for i,field in enumerate(self.fields):
for i in range(len(self.fields)):
self.field_texts.append(self.get_initial_field_text(i))
# field cursor starts on
self.active_field = 0
@ -108,7 +118,7 @@ class UIDialog(UIElement):
def get_initial_field_text(self, field_number):
"subclasses specify a given field's initial text here"
return ''
return ""
def get_height(self, msg_lines):
"determine size based on contents (subclasses can use custom logic)"
@ -138,12 +148,11 @@ 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)
@ -175,7 +184,9 @@ class UIDialog(UIElement):
field_button.field_number = i
# field settings mean button can be in a variety of places
field_button.width = 1 if field.type is bool else field.width
field_button.x = 2 if not field.oneline or field.type is bool else len(field.label) + 1
field_button.x = (
2 if not field.oneline or field.type is bool else len(field.label) + 1
)
field_button.y = self.get_field_y(i)
if not field.oneline:
field_button.y += 1
@ -191,8 +202,9 @@ class UIDialog(UIElement):
def hovered(self):
# mouse hover on focus
if (self.ui.app.mouse_dx or self.ui.app.mouse_dy) and \
not self is self.ui.keyboard_focus_element:
if (
self.ui.app.mouse_dx or self.ui.app.mouse_dy
) and self is not self.ui.keyboard_focus_element:
self.ui.keyboard_focus_element = self
self.reset_art()
@ -206,21 +218,21 @@ class UIDialog(UIElement):
bottom_y = self.tile_height - 1
# first clear any previous warnings
self.art.clear_line(0, 0, bottom_y)
self.confirm_button.set_state('normal')
self.confirm_button.set_state("normal")
# some dialogs use reason for warning + valid input
if reason:
fg = self.ui.error_color_index
self.art.write_string(0, 0, 1, bottom_y, reason, fg)
if not valid:
self.confirm_button.set_state('dimmed')
self.confirm_button.set_state("dimmed")
UIElement.update(self)
def get_message(self):
# if a triple quoted string, split line breaks
msg = self.message.rstrip().split('\n')
msg = self.message.rstrip().split("\n")
msg_lines = []
for line in msg:
if line != '':
if line != "":
msg_lines.append(line)
# TODO: split over multiple lines if too long
return msg_lines
@ -256,7 +268,11 @@ class UIDialog(UIElement):
# true/false ~ field text is 'x'
field_true = self.field_texts[i] == self.true_field_text
if is_radio:
char = self.radio_true_char_index if field_true else self.radio_false_char_index
char = (
self.radio_true_char_index
if field_true
else self.radio_false_char_index
)
else:
char = self.checkbox_char_index if field_true else 0
fg, bg = self.get_field_colors(i)
@ -275,14 +291,14 @@ class UIDialog(UIElement):
else:
y += 1
# draw field contents
if not field.type in [bool, None]:
if field.type not in [bool, None]:
fg, bg = self.get_field_colors(i)
text = self.field_texts[i]
# caret for active field (if kb focus)
if i == self.active_field and self is self.ui.keyboard_focus_element:
blink_on = int(self.ui.app.get_elapsed_time() / 250) % 2
if blink_on:
text += '_'
text += "_"
# pad with spaces to full width of field
text = text.ljust(field.width)
self.art.write_string(0, 0, x, y, text, fg, bg)
@ -312,11 +328,11 @@ class UIDialog(UIElement):
if not on:
for i in group:
if i != field_index:
self.field_texts[i] = ' '
self.field_texts[i] = " "
break
# toggle checkbox
if not radio_button:
return ' ' if on else self.true_field_text
return " " if on else self.true_field_text
# only toggle radio button on; selecting others toggles it off
elif on:
return field_text
@ -326,86 +342,96 @@ class UIDialog(UIElement):
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
field = None
field_text = ''
field_text = ""
if self.active_field < len(self.fields):
field = self.fields[self.active_field]
field_text = self.field_texts[self.active_field]
# special case: shortcut 'D' for 3rd button if no field input
if len(self.fields) == 0 and keystr.lower() == 'd':
if len(self.fields) == 0 and keystr.lower() == "d":
self.other_pressed()
return
if keystr == '`' and not shift_pressed:
if keystr == "`" and not shift_pressed:
self.ui.console.toggle()
return
# if list panel is up don't let user tab away
lp = self.ui.edit_list_panel
# only allow tab to focus shift IF list panel accepts it
if keystr == 'Tab' and lp.is_visible() and \
lp.list_operation in lp.list_operations_allow_kb_focus:
if (
keystr == "Tab"
and lp.is_visible()
and lp.list_operation in lp.list_operations_allow_kb_focus
):
self.ui.keyboard_focus_element = self.ui.edit_list_panel
return
elif keystr == 'Return':
elif keystr == "Return":
self.confirm_pressed()
elif keystr == 'Escape':
elif keystr == "Escape":
self.cancel_pressed()
# cycle through fields with up/down
elif keystr == 'Up' or (keystr == 'Tab' and shift_pressed):
elif keystr == "Up" or (keystr == "Tab" and shift_pressed):
if len(self.fields) > 1:
self.active_field -= 1
self.active_field %= len(self.fields)
# skip over None-type fields aka dead labels
while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType:
while (
self.fields[self.active_field].type is None
or self.fields[self.active_field].type is SkipFieldType
):
self.active_field -= 1
self.active_field %= len(self.fields)
return
elif keystr == 'Down' or keystr == 'Tab':
elif keystr == "Down" or keystr == "Tab":
if len(self.fields) > 1:
self.active_field += 1
self.active_field %= len(self.fields)
while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType:
while (
self.fields[self.active_field].type is None
or self.fields[self.active_field].type is SkipFieldType
):
self.active_field += 1
self.active_field %= len(self.fields)
return
elif keystr == 'Backspace':
if len(field_text) == 0:
pass
# don't let user clear a bool value
# TODO: allow for checkboxes but not radio buttons
elif field and field.type is bool:
elif keystr == "Backspace":
if len(field_text) == 0 or field and field.type is bool:
pass
elif alt_pressed:
# for file dialogs, delete back to last slash
last_slash = field_text[:-1].rfind('/')
last_slash = field_text[:-1].rfind("/")
# on windows, recognize backslash as well
if platform.system() == 'Windows':
last_backslash = field_text[:-1].rfind('\\')
if platform.system() == "Windows":
last_backslash = field_text[:-1].rfind("\\")
if last_backslash != -1 and last_slash != -1:
last_slash = min(last_backslash, last_slash)
if last_slash == -1:
field_text = ''
field_text = ""
else:
field_text = field_text[: last_slash + 1]
else:
field_text = field_text[:-1]
elif keystr == 'Space':
elif keystr == "Space":
# if field.type is bool, toggle value
if field.type is bool:
field_text = self.get_toggled_bool_field(self.active_field)
else:
field_text += ' '
field_text += " "
elif len(keystr) > 1:
return
# alphanumeric text input
elif field and not field.type is bool:
elif field and field.type is not bool:
if field.type is str:
if not shift_pressed:
keystr = keystr.lower()
if not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, '')
elif field.type is int and not keystr.isdigit() and keystr != '-':
return
# this doesn't guard against things like 0.00.001
elif field.type is float and not keystr.isdigit() and keystr != '.' and keystr != '-':
keystr = SHIFT_MAP.get(keystr, "")
elif (
field.type is int
and not keystr.isdigit()
and keystr != "-"
or field.type is float
and not keystr.isdigit()
and keystr != "."
and keystr != "-"
):
return
field_text += keystr
# apply new field text and redraw
@ -437,10 +463,9 @@ class UIDialog(UIElement):
class DialogFieldButton(UIButton):
"invisible button that provides clickability for input fields"
caption = ''
caption = ""
# re-set by dialog constructor
field_number = 0
never_draw = True
@ -450,6 +475,8 @@ class DialogFieldButton(UIButton):
self.element.active_field = self.field_number
# toggle if a bool field
if self.element.fields[self.field_number].type is bool:
self.element.field_texts[self.field_number] = self.element.get_toggled_bool_field(self.field_number)
self.element.field_texts[self.field_number] = (
self.element.get_toggled_bool_field(self.field_number)
)
# redraw fields & labels
self.element.draw_fields(self.element.always_redraw_labels)

View file

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

View file

@ -1,15 +1,12 @@
import time
import numpy as np
from math import ceil
import vector
from art import Art
from renderable import TileRenderable
from renderable_line import LineRenderable
from ui_button import UIButton
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
@ -40,8 +37,15 @@ class UIElement:
self.ui = ui
self.hovered_buttons = []
# generate a unique name
art_name = '%s_%s' % (int(time.time()), self.__class__.__name__)
self.art = UIArt(art_name, self.ui.app, self.ui.charset, self.ui.palette, self.tile_width, self.tile_height)
art_name = 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
@ -82,17 +86,17 @@ class UIElement:
button.draw()
def hovered(self):
self.log_event('hovered')
self.log_event("hovered")
def unhovered(self):
self.log_event('unhovered')
self.log_event("unhovered")
def wheel_moved(self, wheel_y):
handled = False
return handled
def clicked(self, mouse_button):
self.log_event('clicked', mouse_button)
self.log_event("clicked", mouse_button)
# return if a button did something
handled = False
# tell any hovered buttons they've been clicked
@ -118,7 +122,7 @@ class UIElement:
return handled
def unclicked(self, mouse_button):
self.log_event('unclicked', mouse_button)
self.log_event("unclicked", mouse_button)
handled = False
for b in self.hovered_buttons:
b.unclick()
@ -128,16 +132,21 @@ class UIElement:
return handled
def log_event(self, event_type, mouse_button=None):
mouse_button = mouse_button or '[n/a]'
mouse_button = mouse_button or "[n/a]"
if self.ui.logg:
self.ui.app.log('UIElement: %s %s with mouse button %s' % (self.__class__.__name__, event_type, mouse_button))
self.ui.app.log(
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
@ -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
@ -191,16 +201,16 @@ class UIElement:
for i, button in enumerate(self.buttons):
# don't higlhight if this panel doesn't have focus
if self.keyboard_nav_index == i and self is self.ui.keyboard_focus_element:
button.set_state('hovered')
elif button.state != 'dimmed':
button.set_state('normal')
button.set_state("hovered")
elif button.state != "dimmed":
button.set_state("normal")
def keyboard_select_item(self):
if not self.support_keyboard_navigation:
return
button = self.buttons[self.keyboard_nav_index]
# don't allow selecting dimmed buttons
if button.state == 'dimmed':
if button.state == "dimmed":
return
# check for None; cb_arg could be 0
if button.cb_arg is not None:
@ -225,12 +235,16 @@ class UIElement:
for b in self.buttons:
# element.clicked might have been set it non-hoverable, acknowledge
# its hoveredness here so it can unhover correctly
if b.visible and (b.can_hover or b.state == 'clicked') and self.is_inside_button(mx, my, b):
if (
b.visible
and (b.can_hover or b.state == "clicked")
and self.is_inside_button(mx, my, b)
):
self.hovered_buttons.append(b)
if not b in was_hovering:
if b not in was_hovering:
b.hover()
for b in was_hovering:
if not b in self.hovered_buttons:
if b not in self.hovered_buttons:
b.unhover()
# tiles might have just changed
self.art.update()
@ -258,7 +272,6 @@ class UIArt(Art):
class UIRenderable(TileRenderable):
grain_strength = 0.2
def get_projection_matrix(self):
@ -271,7 +284,6 @@ class UIRenderable(TileRenderable):
class FPSCounterUI(UIElement):
tile_y = 1
tile_width, tile_height = 12, 2
snap_right = True
@ -288,11 +300,11 @@ class FPSCounterUI(UIElement):
color = self.ui.colors.yellow
if self.ui.app.fps < 10:
color = self.ui.colors.red
text = '%.1f fps' % self.ui.app.fps
text = 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):
@ -302,7 +314,6 @@ class FPSCounterUI(UIElement):
class MessageLineUI(UIElement):
"when console outputs something new, show last line here before fading out"
tile_y = 2
@ -318,7 +329,7 @@ class MessageLineUI(UIElement):
def __init__(self, ui):
UIElement.__init__(self, ui)
# line we're currently displaying (even after fading out)
self.line = ''
self.line = ""
self.last_post = self.ui.app.get_elapsed_time()
self.hold_time = self.default_hold_time
self.alpha = 1
@ -352,12 +363,14 @@ class MessageLineUI(UIElement):
def render(self):
# TODO: draw if popup is visible but not obscuring message line?
if not self.ui.popup in self.ui.hovered_elements and not self.ui.console.visible:
if (
self.ui.popup not in self.ui.hovered_elements
and not self.ui.console.visible
):
UIElement.render(self)
class DebugTextUI(UIElement):
"simple UI element for posting debug text"
tile_x, tile_y = 1, 4
@ -395,7 +408,6 @@ class DebugTextUI(UIElement):
class ToolTip(UIElement):
"popup text label that is invoked and controlled by a UIButton hover"
visible = False
@ -403,16 +415,16 @@ class ToolTip(UIElement):
tile_x, tile_y = 10, 5
def set_text(self, text):
self.art.write_string(0, 0, 0, 0, text,
self.ui.colors.black, self.ui.colors.white)
self.art.write_string(
0, 0, 0, 0, text, self.ui.colors.black, self.ui.colors.white
)
# clear tiles past end of text
for x in range(len(text), self.tile_width):
self.art.set_color_at(0, 0, x, 0, 0, 0)
def reset_art(self):
UIElement.reset_art(self)
self.art.clear_frame_layer(0, 0,
self.ui.colors.white, self.ui.colors.black)
self.art.clear_frame_layer(0, 0, self.ui.colors.white, self.ui.colors.black)
class GameLabel(UIElement):
@ -423,8 +435,7 @@ class GameLabel(UIElement):
class GameSelectionLabel(GameLabel):
multi_select_label = '[%s selected]'
multi_select_label = "[%s selected]"
def update(self):
self.visible = False
@ -453,8 +464,8 @@ class GameSelectionLabel(GameLabel):
self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z)
self.reset_loc()
class GameHoverLabel(GameLabel):
class GameHoverLabel(GameLabel):
alpha = 0.75
def update(self):

View file

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

View file

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

View file

@ -1,6 +1,14 @@
# coding=utf-8
from .ui_menu_pulldown_item import (
FileQuitItem,
PulldownMenuData,
PulldownMenuItem,
SeparatorItem,
ViewSetZoomItem,
ViewToggleCameraTiltItem,
ViewToggleCRTItem,
ViewToggleGridItem,
)
from ui_menu_pulldown_item import PulldownMenuItem, SeparatorItem, PulldownMenuData, FileQuitItem, ViewToggleCRTItem, ViewToggleCameraTiltItem, ViewSetZoomItem, ViewToggleGridItem
class GameModePulldownMenuItem(PulldownMenuItem):
# unless overridden, game mode items not allowed in art mode
@ -11,297 +19,405 @@ class GameModePulldownMenuItem(PulldownMenuItem):
# game menu
#
class HideEditUIItem(GameModePulldownMenuItem):
label = 'Hide edit UI'
command = 'toggle_game_edit_ui'
label = "Hide edit UI"
command = "toggle_game_edit_ui"
close_on_select = True
always_active = True
class NewGameDirItem(GameModePulldownMenuItem):
label = 'New game…'
command = 'new_game_dir'
label = "New game…"
command = "new_game_dir"
always_active = True
class SetGameDirItem(GameModePulldownMenuItem):
label = 'Open game…'
command = 'set_game_dir'
label = "Open game…"
command = "set_game_dir"
close_on_select = True
always_active = True
class PauseGameItem(GameModePulldownMenuItem):
label = 'blah'
command = 'toggle_anim_playback'
label = "blah"
command = "toggle_anim_playback"
always_active = True
def get_label(app):
return ['Pause game', 'Unpause game'][app.gw.paused]
return ["Pause game", "Unpause game"][app.gw.paused]
class OpenConsoleItem(GameModePulldownMenuItem):
label = 'Open dev console'
command = 'toggle_console'
label = "Open dev console"
command = "toggle_console"
close_on_select = True
always_active = True
art_mode_allowed = True
#
# state menu
#
class ResetStateItem(GameModePulldownMenuItem):
label = 'Reset to last state'
command = 'reset_game'
label = "Reset to last state"
command = "reset_game"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class LoadStateItem(GameModePulldownMenuItem):
label = 'Load state…'
command = 'load_game_state'
label = "Load state…"
command = "load_game_state"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class SaveStateItem(GameModePulldownMenuItem):
label = 'Save current state'
command = 'save_current'
label = "Save current state"
command = "save_current"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class SaveNewStateItem(GameModePulldownMenuItem):
label = 'Save new state…'
command = 'save_game_state'
label = "Save new state…"
command = "save_game_state"
def should_dim(app):
return not app.gw.game_dir
#
# view menu
#
class ObjectsToCameraItem(GameModePulldownMenuItem):
label = 'Move selected object(s) to camera'
command = 'objects_to_camera'
label = "Move selected object(s) to camera"
command = "objects_to_camera"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class CameraToObjectsItem(GameModePulldownMenuItem):
label = 'Move camera to selected object'
command = 'camera_to_objects'
label = "Move camera to selected object"
command = "camera_to_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) != 1
class ToggleDebugObjectsItem(GameModePulldownMenuItem):
label = ' Draw debug objects'
command = 'toggle_debug_objects'
label = " Draw debug objects"
command = "toggle_debug_objects"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.properties and ui.app.gw.properties.draw_debug_objects
class ToggleOriginVizItem(GameModePulldownMenuItem):
label = ' Show all object origins'
command = 'toggle_all_origin_viz'
label = " Show all object origins"
command = "toggle_all_origin_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_origin_all
class ToggleBoundsVizItem(GameModePulldownMenuItem):
label = ' Show all object bounds'
command = 'toggle_all_bounds_viz'
label = " Show all object bounds"
command = "toggle_all_bounds_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_bounds_all
class ToggleCollisionVizItem(GameModePulldownMenuItem):
label = ' Show all object collision'
command = 'toggle_all_collision_viz'
label = " Show all object collision"
command = "toggle_all_collision_viz"
def should_dim(app):
return not app.gw.game_dir
def should_mark(ui):
return ui.app.gw.show_collision_all
#
# world menu
#
class EditWorldPropertiesItem(GameModePulldownMenuItem):
label = 'Edit world properties…'
command = 'edit_world_properties'
label = "Edit world properties…"
command = "edit_world_properties"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
#
# room menu
#
class ChangeRoomItem(GameModePulldownMenuItem):
label = 'Change current room…'
command = 'change_current_room'
label = "Change current room…"
command = "change_current_room"
close_on_select = True
def should_dim(app):
return len(app.gw.rooms) == 0
class AddRoomItem(GameModePulldownMenuItem):
label = 'Add room…'
command = 'add_room'
label = "Add room…"
command = "add_room"
def should_dim(app):
return not app.gw.game_dir
class SetRoomObjectsItem(GameModePulldownMenuItem):
label = 'Add/remove objects from room…'
command = 'set_room_objects'
label = "Add/remove objects from room…"
command = "set_room_objects"
close_on_select = True
def should_dim(app):
return app.gw.current_room is None
class RemoveRoomItem(GameModePulldownMenuItem):
label = 'Remove this room'
command = 'remove_current_room'
label = "Remove this room"
command = "remove_current_room"
close_on_select = True
def should_dim(app):
return app.gw.current_room is None
class RenameRoomItem(GameModePulldownMenuItem):
label = 'Rename this room…'
command = 'rename_current_room'
label = "Rename this room…"
command = "rename_current_room"
def should_dim(app):
return app.gw.current_room is None
class ToggleAllRoomsVizItem(GameModePulldownMenuItem):
label = 'blah'
command = 'toggle_all_rooms_visible'
label = "blah"
command = "toggle_all_rooms_visible"
def should_dim(app):
return len(app.gw.rooms) == 0
def get_label(app):
return ['Show all rooms', 'Show only current room'][app.gw.show_all_rooms]
return ["Show all rooms", "Show only current room"][app.gw.show_all_rooms]
class ToggleListOnlyRoomObjectItem(GameModePulldownMenuItem):
label = ' List only objects in this room'
command = 'toggle_list_only_room_objects'
label = " List only objects in this room"
command = "toggle_list_only_room_objects"
def should_dim(app):
return len(app.gw.rooms) == 0
def should_mark(ui):
return ui.app.gw.list_only_current_room_objects
class ToggleRoomCamerasItem(GameModePulldownMenuItem):
label = ' Camera changes with room'
command = 'toggle_room_camera_changes'
label = " Camera changes with room"
command = "toggle_room_camera_changes"
def should_dim(app):
return len(app.gw.rooms) == 0
def should_mark(ui):
return ui.app.gw.room_camera_changes_enabled
class SetRoomCameraItem(GameModePulldownMenuItem):
label = "Set this room's camera marker…"
command = 'set_room_camera_marker'
command = "set_room_camera_marker"
def should_dim(app):
return app.gw.current_room is None
class SetRoomEdgeDestinationsItem(GameModePulldownMenuItem):
label = "Set this room's edge warps…"
command = 'set_room_edge_warps'
command = "set_room_edge_warps"
def should_dim(app):
return app.gw.current_room is None
class SetRoomBoundsObject(GameModePulldownMenuItem):
label = "Set this room's edge object…"
command = 'set_room_bounds_obj'
command = "set_room_bounds_obj"
def should_dim(app):
return app.gw.current_room is None
class AddSelectedToCurrentRoomItem(GameModePulldownMenuItem):
label = 'Add selected objects to this room'
command = 'add_selected_to_room'
label = "Add selected objects to this room"
command = "add_selected_to_room"
def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0
class RemoveSelectedFromCurrentRoomItem(GameModePulldownMenuItem):
label = 'Remove selected objects from this room'
command = 'remove_selected_from_room'
label = "Remove selected objects from this room"
command = "remove_selected_from_room"
def should_dim(app):
return app.gw.current_room is None or len(app.gw.selected_objects) == 0
#
# object menu
#
class SpawnObjectItem(GameModePulldownMenuItem):
label = 'Spawn object…'
command = 'choose_spawn_object_class'
label = "Spawn object…"
command = "choose_spawn_object_class"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class DuplicateObjectsItem(GameModePulldownMenuItem):
label = 'Duplicate selected objects'
command = 'duplicate_selected_objects'
label = "Duplicate selected objects"
command = "duplicate_selected_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class SelectObjectsItem(GameModePulldownMenuItem):
label = 'Select objects…'
command = 'select_objects'
label = "Select objects…"
command = "select_objects"
close_on_select = True
def should_dim(app):
return not app.gw.game_dir
class EditArtForObjectsItem(GameModePulldownMenuItem):
label = 'Edit art for selected…'
command = 'edit_art_for_selected_objects'
label = "Edit art for selected…"
command = "edit_art_for_selected_objects"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class SetObjectRoomsItem(GameModePulldownMenuItem):
label = 'Add/remove this object from rooms…'
command = 'set_object_rooms'
label = "Add/remove this object from rooms…"
command = "set_object_rooms"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) != 1
class DeleteSelectedObjectsItem(GameModePulldownMenuItem):
label = 'Delete selected object(s)'
command = 'erase_selection_or_art'
label = "Delete selected object(s)"
command = "erase_selection_or_art"
close_on_select = True
def should_dim(app):
return len(app.gw.selected_objects) == 0
class GameMenuData(PulldownMenuData):
items = [HideEditUIItem, OpenConsoleItem, SeparatorItem,
NewGameDirItem, SetGameDirItem, PauseGameItem, SeparatorItem,
FileQuitItem]
items = [
HideEditUIItem,
OpenConsoleItem,
SeparatorItem,
NewGameDirItem,
SetGameDirItem,
PauseGameItem,
SeparatorItem,
FileQuitItem,
]
class GameStateMenuData(PulldownMenuData):
items = [ResetStateItem, LoadStateItem, SaveStateItem, SaveNewStateItem]
class GameViewMenuData(PulldownMenuData):
items = [ViewToggleCRTItem, ViewToggleGridItem, SeparatorItem,
ViewSetZoomItem, ViewToggleCameraTiltItem, SeparatorItem,
ObjectsToCameraItem, CameraToObjectsItem, ToggleDebugObjectsItem,
ToggleOriginVizItem, ToggleBoundsVizItem, ToggleCollisionVizItem]
items = [
ViewToggleCRTItem,
ViewToggleGridItem,
SeparatorItem,
ViewSetZoomItem,
ViewToggleCameraTiltItem,
SeparatorItem,
ObjectsToCameraItem,
CameraToObjectsItem,
ToggleDebugObjectsItem,
ToggleOriginVizItem,
ToggleBoundsVizItem,
ToggleCollisionVizItem,
]
def should_mark_item(item, ui):
if hasattr(item, 'should_mark'):
if hasattr(item, "should_mark"):
return item.should_mark(ui)
return False
class GameWorldMenuData(PulldownMenuData):
items = [EditWorldPropertiesItem]
class GameRoomMenuData(PulldownMenuData):
items = [ChangeRoomItem, AddRoomItem, RemoveRoomItem, RenameRoomItem,
ToggleAllRoomsVizItem, ToggleListOnlyRoomObjectItem, ToggleRoomCamerasItem, SeparatorItem,
AddSelectedToCurrentRoomItem, RemoveSelectedFromCurrentRoomItem,
SetRoomObjectsItem, SeparatorItem,
SetRoomCameraItem, SetRoomEdgeDestinationsItem, SetRoomBoundsObject,
SeparatorItem
items = [
ChangeRoomItem,
AddRoomItem,
RemoveRoomItem,
RenameRoomItem,
ToggleAllRoomsVizItem,
ToggleListOnlyRoomObjectItem,
ToggleRoomCamerasItem,
SeparatorItem,
AddSelectedToCurrentRoomItem,
RemoveSelectedFromCurrentRoomItem,
SetRoomObjectsItem,
SeparatorItem,
SetRoomCameraItem,
SetRoomEdgeDestinationsItem,
SetRoomBoundsObject,
SeparatorItem,
]
def should_mark_item(item, ui):
"show checkmark for current room"
if not ui.app.gw.current_room:
return False
if hasattr(item, 'should_mark'):
if hasattr(item, "should_mark"):
return item.should_mark(ui)
return ui.app.gw.current_room.name == item.cb_arg
@ -320,18 +436,21 @@ class GameRoomMenuData(PulldownMenuData):
if len(item.label) + 1 > longest_line:
longest_line = len(item.label) + 1
# cap at max allowed line length
for room_name,room in app.gw.rooms.items():
class TempMenuItemClass(GameModePulldownMenuItem): pass
for room_name 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,19 +1,19 @@
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
@ -26,29 +26,29 @@ class PagedInfoDialog(UIDialog):
# disable prev/next buttons if we're at either end of the page list
if self.page == 0:
self.other_button.can_hover = False
self.other_button.set_state('dimmed')
self.other_button.set_state("dimmed")
elif self.page == len(self.message) - 1:
self.confirm_button.can_hover = False
self.confirm_button.set_state('dimmed')
self.confirm_button.set_state("dimmed")
else:
for button in [self.confirm_button, self.other_button]:
button.can_hover = True
button.dimmed = False
if button.state != 'normal':
button.set_state('normal')
if button.state != "normal":
button.set_state("normal")
UIElement.update(self)
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
keystr = sdl2.SDL_GetKeyName(key).decode()
if keystr == 'Left':
if keystr == "Left":
self.other_pressed()
elif keystr == 'Right':
elif keystr == "Right":
self.confirm_pressed()
elif keystr == 'Escape':
elif keystr == "Escape":
self.cancel_pressed()
def get_message(self):
return self.message[self.page].rstrip().split('\n')
return self.message[self.page].rstrip().split("\n")
def confirm_pressed(self):
# confirm repurposed to "next page"
@ -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
@ -44,98 +62,116 @@ 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
@ -143,17 +179,18 @@ class ModeMenuButton(UIButton):
# hovered_bg_color = UIColors.lightgrey
# dimmed_bg_color = UIColors.lightgrey
class ArtModeMenuButton(ModeMenuButton):
caption = 'Game Mode'
caption = "Game Mode"
width = len(caption) + 2
class GameModeMenuButton(ModeMenuButton):
caption = 'Art Mode'
caption = "Art Mode"
width = len(caption) + 2
class MenuBar(UIElement):
"main menu bar element, has lots of buttons which control the pulldown"
snap_top = True
@ -180,7 +217,7 @@ class MenuBar(UIElement):
button.width = len(button.caption) + 2
button.x = x
x += button.width + self.button_padding
setattr(self, '%s_button' % button.name, button)
setattr(self, 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,7 +234,9 @@ class MenuBar(UIElement):
if not self.mode_button_class:
return
self.mode_button = self.mode_button_class(self)
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
self.mode_button.callback = self.toggle_game_mode
self.buttons.append(self.mode_button)
@ -227,7 +266,7 @@ class MenuBar(UIElement):
for button in self.menu_buttons:
if button.name == self.active_menu_name:
button.dimmed = False
button.set_state('normal')
button.set_state("normal")
self.active_menu_name = None
self.ui.pulldown.visible = False
self.ui.keyboard_focus_element = None
@ -253,7 +292,7 @@ class MenuBar(UIElement):
return
# don't navigate to the about menu
# (does this mean it's not accessible via kb-only? probably, that's fine)
if self.menu_buttons[index].name == 'playscii':
if self.menu_buttons[index].name == "playscii":
return
self.menu_buttons[index].callback()
@ -280,7 +319,9 @@ class MenuBar(UIElement):
self.art.clear_frame_layer(0, 0, bg, fg)
# reposition right-justified mode switch button
if self.mode_button:
self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
self.mode_button.x = (
int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width
)
# draw buttons, etc
UIElement.reset_art(self)
self.reset_icon()
@ -293,15 +334,31 @@ class MenuBar(UIElement):
UIElement.destroy(self)
self.playscii_sprite.destroy()
class ArtMenuBar(MenuBar):
button_classes = [FileMenuButton, EditMenuButton, ToolMenuButton,
ViewMenuButton, ArtMenuButton, FrameMenuButton,
LayerMenuButton, CharColorMenuButton, HelpMenuButton]
button_classes = [
FileMenuButton,
EditMenuButton,
ToolMenuButton,
ViewMenuButton,
ArtMenuButton,
FrameMenuButton,
LayerMenuButton,
CharColorMenuButton,
HelpMenuButton,
]
mode_button_class = GameModeMenuButton
class GameMenuBar(MenuBar):
button_classes = [GameMenuButton, StateMenuButton, GameViewMenuButton,
WorldMenuButton, RoomMenuButton, ObjectMenuButton,
HelpMenuButton]
button_classes = [
GameMenuButton,
StateMenuButton,
GameViewMenuButton,
WorldMenuButton,
RoomMenuButton,
ObjectMenuButton,
HelpMenuButton,
]
game_mode_visible = True
mode_button_class = ArtModeMenuButton

View file

@ -1,9 +1,8 @@
from ui_element import UIElement
from 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):
@ -26,7 +25,6 @@ class MenuItemButton(UIButton):
class PulldownMenu(UIElement):
"element that's moved and resized based on currently active pulldown"
label_shortcut_padding = 5
@ -85,7 +83,14 @@ class PulldownMenu(UIElement):
# 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
@ -105,14 +110,17 @@ class PulldownMenu(UIElement):
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:
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)
@ -157,26 +165,27 @@ class PulldownMenu(UIElement):
# return concise string for bind and the actual function it runs.
def null():
pass
# special handling of SeparatorMenuItem, no command or label
if menu_item is SeparatorItem:
return '', null
return "", null
binds = self.ui.app.il.edit_binds
for bind_tuple in binds:
command_functions = binds[bind_tuple]
for f in command_functions:
if f.__name__ == 'BIND_%s' % menu_item.command:
shortcut = ''
if f.__name__ == 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,8 +85,8 @@ class PropertyItem:
class EditObjectPanel(GamePanel):
"panel showing info for selected game object"
tile_width = 36
tile_height = 36
snap_right = True
@ -97,16 +102,19 @@ class EditObjectPanel(GamePanel):
# buttons for persistent unique commands, eg reset object
for i, button_class in enumerate(self.base_button_classes):
button = button_class(self)
button.caption += ' '
button.caption += " "
button.width = self.tile_width
button.y = i + 1
button.callback = button.selected
if button.clear_before_caption_draw:
button.refresh_caption()
self.base_buttons.append(button)
def callback(item=None):
if not item: return
if not item:
return
self.clicked_item(item)
for y in range(self.tile_height - len(self.base_buttons) - 1):
button = EditObjectPropertyButton(self)
button.y = y + len(self.base_buttons) + 1
@ -130,17 +138,26 @@ class EditObjectPanel(GamePanel):
obj.set_object_property(item.prop_name, not val)
return
# set dialog values appropriate to property being edited
EditObjectPropertyDialog.title = EditObjectPropertyDialog.base_title % item.prop_name
EditObjectPropertyDialog.title = (
EditObjectPropertyDialog.base_title % item.prop_name
)
# can't set named tuple values directly, build a new one and set it
old_field = EditObjectPropertyDialog.fields[0]
new_label = EditObjectPropertyDialog.field0_base_label % (item.prop_type.__name__, item.prop_name)
new_label = EditObjectPropertyDialog.field0_base_label % (
item.prop_type.__name__,
item.prop_name,
)
new_type = item.prop_type
# if None, assume string
if item.prop_type is type(None):
new_type = str
new_field = Field(label=new_label, type=new_type, width=old_field.width,
oneline=old_field.oneline)
new_field = Field(
label=new_label,
type=new_type,
width=old_field.width,
oneline=old_field.oneline,
)
EditObjectPropertyDialog.fields[0] = new_field
tile_x = int(self.ui.width_tiles * self.ui.scale) - self.tile_width
@ -157,11 +174,11 @@ class EditObjectPanel(GamePanel):
selected = len(self.world.selected_objects)
# panel shouldn't draw when nothing selected, fill in anyway
if selected == 0:
return '[nothing selected]'
return "[nothing selected]"
elif selected == 1 and self.world.selected_objects[0]:
return self.world.selected_objects[0].name
else:
return '[%s selected]' % selected
return f"[{selected} selected]"
def refresh_items(self):
if len(self.world.selected_objects) == 0:
@ -174,7 +191,7 @@ class EditObjectPanel(GamePanel):
if obj is None:
continue
for propname in obj.serialized + obj.editable:
if not propname in propnames:
if propname not in propnames:
propnames.append(propname)
# build list of items from properties
items = []
@ -200,19 +217,19 @@ class EditObjectPanel(GamePanel):
y = button_index + len(self.base_buttons) + 1
self.art.clear_line(0, 0, y, self.fg_color, self.bg_color)
if item is None:
button.caption = ''
button.caption = ""
button.cb_arg = None
button.can_hover = False
return
# set button caption, width, x based on value
button.caption = '%s ' % item.prop_value
button.caption = 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):

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,34 +202,34 @@ class ToolPopup(UIElement):
radio_char_1_index = 127
# map classes to member names / callbacks
button_names = {
ToolTabButton: 'tool_tab',
CharColorTabButton: 'char_color_tab',
ToolTabButton: "tool_tab",
CharColorTabButton: "char_color_tab",
}
char_color_tab_button_names = {
CharSetScaleUpButton: 'scale_charset_up',
CharSetScaleDownButton: 'scale_charset_down',
CharSetChooserButton: 'choose_charset',
CharFlipNoButton: 'xform_normal',
CharFlipXButton: 'xform_flipX',
CharFlipYButton: 'xform_flipY',
CharRot0Button: 'xform_0',
CharRot90Button: 'xform_90',
CharRot180Button: 'xform_180',
CharRot270Button: 'xform_270',
PaletteChooserButton: 'choose_palette',
CharSetScaleUpButton: "scale_charset_up",
CharSetScaleDownButton: "scale_charset_down",
CharSetChooserButton: "choose_charset",
CharFlipNoButton: "xform_normal",
CharFlipXButton: "xform_flipX",
CharFlipYButton: "xform_flipY",
CharRot0Button: "xform_0",
CharRot90Button: "xform_90",
CharRot180Button: "xform_180",
CharRot270Button: "xform_270",
PaletteChooserButton: "choose_palette",
}
tool_tab_button_names = {
BrushSizeUpButton: 'brush_size_up',
BrushSizeDownButton: 'brush_size_down',
AffectCharToggleButton: 'toggle_affect_char',
AffectFgToggleButton: 'toggle_affect_fg',
AffectBgToggleButton: 'toggle_affect_bg',
AffectXformToggleButton: 'toggle_affect_xform',
BrushSizeUpButton: "brush_size_up",
BrushSizeDownButton: "brush_size_down",
AffectCharToggleButton: "toggle_affect_char",
AffectFgToggleButton: "toggle_affect_fg",
AffectBgToggleButton: "toggle_affect_bg",
AffectXformToggleButton: "toggle_affect_xform",
}
fill_boundary_mode_button_names = {
FillBoundaryModeCharButton: 'set_fill_boundary_char',
FillBoundaryModeFGButton: 'set_fill_boundary_fg',
FillBoundaryModeBGButton: 'set_fill_boundary_bg'
FillBoundaryModeCharButton: "set_fill_boundary_char",
FillBoundaryModeFGButton: "set_fill_boundary_fg",
FillBoundaryModeBGButton: "set_fill_boundary_bg",
}
def __init__(self, ui):
@ -219,19 +245,26 @@ class ToolPopup(UIElement):
# create buttons from button:name map, button & callback names generated
# group these into lists that can be combined into self.buttons
self.common_buttons = self.create_buttons_from_map(self.button_names)
self.char_color_tab_buttons = self.create_buttons_from_map(self.char_color_tab_button_names)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(self.fill_boundary_mode_button_names)
self.tool_tab_buttons = self.create_buttons_from_map(self.tool_tab_button_names) + self.fill_boundary_mode_buttons
self.char_color_tab_buttons = self.create_buttons_from_map(
self.char_color_tab_button_names
)
self.fill_boundary_mode_buttons = self.create_buttons_from_map(
self.fill_boundary_mode_button_names
)
self.tool_tab_buttons = (
self.create_buttons_from_map(self.tool_tab_button_names)
+ self.fill_boundary_mode_buttons
)
# populate more tool tab buttons from UI's list of tools
# similar to create_buttons_from_map, but class name isn't known
# MAYBE-TODO: is there a way to unify this?
for tool in self.ui.tools:
tool_button = ToolButton(self)
# caption: 1-space padding from left
tool_button.caption = ' %s' % tool.button_caption
tool_button_name = '%s_tool_button' % tool.name
tool_button.caption = 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,15 +272,17 @@ class ToolPopup(UIElement):
UIElement.__init__(self, ui)
# set initial tab state
self.char_color_tab_button_pressed()
self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color = self.highlight_color
self.xform_0_button.normal_bg_color = (
self.xform_normal_button.normal_bg_color
) = self.highlight_color
def create_buttons_from_map(self, button_dict):
buttons = []
for button_class in button_dict:
button = button_class(self)
button_name = '%s_button' % button_dict[button_class]
button_name = 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
@ -352,7 +387,7 @@ class ToolPopup(UIElement):
UV_ROTATE180: self.xform_180_button,
UV_ROTATE270: self.xform_270_button,
UV_FLIPX: self.xform_flipX_button,
UV_FLIPY: self.xform_flipY_button
UV_FLIPY: self.xform_flipY_button,
}
for b in button_map:
if b == self.ui.selected_xform:
@ -396,15 +431,20 @@ class ToolPopup(UIElement):
# charset renderable location will be set in update()
charset = self.ui.active_art.charset
palette = self.ui.active_art.palette
cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height
_cqw, cqh = (
self.charset_swatch.art.quad_width,
self.charset_swatch.art.quad_height,
)
self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color)
# position & caption charset button
y = self.tab_height + 1
self.choose_charset_button.y = y
self.choose_charset_button.caption = ' %s %s ' % (CharSetChooserButton.caption, charset.name)
self.choose_charset_button.caption = (
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,7 +459,9 @@ 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)
@ -441,7 +483,7 @@ class ToolPopup(UIElement):
try:
if button.tool_name == tool.name:
tool_button = button
except:
except Exception:
pass
tool_button.y = y + i
if tool == self.ui.selected_tool:
@ -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,17 +521,27 @@ 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)]
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.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
@ -505,15 +557,21 @@ class ToolPopup(UIElement):
y += 1
# boundary mode buttons + labels
# x +=
labels = [self.fill_boundary_char_label,
labels = [
self.fill_boundary_char_label,
self.fill_boundary_fg_label,
self.fill_boundary_bg_label]
self.fill_boundary_bg_label,
]
for i, button in enumerate(self.fill_boundary_mode_buttons):
button.visible = True
char = [self.radio_char_0_index, self.radio_char_1_index][i == self.ui.fill_tool.boundary_mode]
char = [self.radio_char_0_index, self.radio_char_1_index][
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])
self.art.write_string(
0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i]
)
y += 1
else:
for button in self.fill_boundary_mode_buttons:
@ -527,7 +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))
@ -576,7 +640,10 @@ class ToolPopup(UIElement):
return
x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y)
# center on mouse
w, h = self.tile_width * self.art.quad_width, self.tile_height * self.art.quad_height
w, h = (
self.tile_width * self.art.quad_width,
self.tile_height * self.art.quad_height,
)
x -= w / 2
y += h / 2
# clamp to edges of screen
@ -624,7 +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
@ -638,7 +707,9 @@ class ToolPopup(UIElement):
self.draw_buttons()
def keyboard_navigate(self, dx, dy):
active_swatch = self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
active_swatch = (
self.charset_swatch if self.cursor_char != -1 else self.palette_swatch
)
# TODO: can't handle cross-swatch navigation properly, restrict to chars
active_swatch = self.charset_swatch
# reverse up/down direction

View file

@ -1,77 +1,106 @@
import os.path, time
import os.path
import time
from math import ceil
from ui_element import UIElement, UIArt, UIRenderable
from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT
from ui_colors import UIColors
from renderable_line import UIRenderableX
from art import uv_names
from .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):
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
@ -192,44 +258,52 @@ class StatusBarUI(UIElement):
# button callbacks
def char_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_char()
def char_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_char(self.ui.selected_char + 1)
elif mouse_button == 3:
self.ui.select_char(self.ui.selected_char - 1)
def fg_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_fg()
def fg_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_fg(self.ui.selected_fg_color + 1)
elif mouse_button == 3:
self.ui.select_fg(self.ui.selected_fg_color - 1)
def bg_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_bg()
def bg_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.select_bg(self.ui.selected_bg_color + 1)
elif mouse_button == 3:
self.ui.select_bg(self.ui.selected_bg_color - 1)
def xform_toggle_button_pressed(self):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
self.ui.selected_tool.toggle_affects_xform()
def xform_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.cycle_selected_xform()
elif mouse_button == 3:
@ -238,7 +312,8 @@ class StatusBarUI(UIElement):
self.xform_cycle_button.caption = uv_names[self.ui.selected_xform]
def tool_cycle_button_pressed(self, mouse_button):
if self.ui.active_dialog: return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.cycle_selected_tool()
elif mouse_button == 3:
@ -246,32 +321,40 @@ class StatusBarUI(UIElement):
self.tool_cycle_button.caption = self.ui.selected_tool.get_button_caption()
def file_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.next_active_art()
elif mouse_button == 3:
self.ui.previous_active_art()
def layer_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.set_active_layer(self.ui.active_art.active_layer + 1)
elif mouse_button == 3:
self.ui.set_active_layer(self.ui.active_art.active_layer - 1)
def frame_cycle_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.set_active_frame(self.ui.active_art.active_frame + 1)
elif mouse_button == 3:
self.ui.set_active_frame(self.ui.active_art.active_frame - 1)
def zoom_set_button_pressed(self, mouse_button):
if not self.ui.active_art: return
if self.ui.active_dialog: return
if not self.ui.active_art:
return
if self.ui.active_dialog:
return
if mouse_button == 1:
self.ui.app.camera.zoom_proportional(1)
elif mouse_button == 3:
@ -305,8 +388,9 @@ class StatusBarUI(UIElement):
if self.tile_width < self.left_items_width:
return
# draw tool label
self.art.write_string(0, 0, self.tool_label_x, 0, self.tool_label,
self.ui.palette.darkest_index)
self.art.write_string(
0, 0, self.tool_label_x, 0, self.tool_label, self.ui.palette.darkest_index
)
# only draw right side info if the window is wide enough
if self.art.width > self.left_items_width + self.right_items_width:
self.file_cycle_button.visible = True
@ -330,7 +414,13 @@ class StatusBarUI(UIElement):
def get_tile_y(self):
"returns tile coordinate Y position of bar"
return int(self.ui.app.window_height / (self.ui.charset.char_height * self.ui.scale)) - 1
return (
int(
self.ui.app.window_height
/ (self.ui.charset.char_height * self.ui.scale)
)
- 1
)
def update_button_captions(self):
"set captions for buttons that change from selections"
@ -339,20 +429,22 @@ 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
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):
@ -406,7 +498,7 @@ class StatusBarUI(UIElement):
self.zoom_set_button.x = x
x -= padding
# tile
tile = 'X/Y'
tile = "X/Y"
color = light
if self.ui.app.cursor and art:
tile_x, tile_y = self.ui.app.cursor.get_tile()
@ -420,18 +512,18 @@ class StatusBarUI(UIElement):
color = self.dim_color
tile_x = str(tile_x).rjust(3)
tile_y = str(tile_y).rjust(3)
tile = '%s,%s' % (tile_x, tile_y)
tile = 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)

View file

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

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,7 +33,7 @@ class UITool:
# (false for eg Selection tool)
affects_masks = True
# filename of icon in UI_ASSET_DIR, shown on cursor
icon_filename = 'icon.png'
icon_filename = "icon.png"
def __init__(self, ui):
self.ui = ui
@ -39,7 +47,7 @@ class UITool:
def load_icon_texture(self, img_filename):
img = Image.open(img_filename)
img = img.convert('RGBA')
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
@ -60,8 +68,13 @@ class UITool:
return
self.affects_char = not self.affects_char
self.ui.tool_settings_changed = True
line = self.button_caption + ' '
line = '%s %s' % (self.button_caption, [self.ui.affects_char_off_log, self.ui.affects_char_on_log][self.affects_char])
line = self.button_caption + " "
line = "{} {}".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):
@ -69,7 +82,12 @@ class UITool:
return
self.affects_fg_color = not self.affects_fg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][self.affects_fg_color])
line = "{} {}".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):
@ -77,7 +95,12 @@ class UITool:
return
self.affects_bg_color = not self.affects_bg_color
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][self.affects_bg_color])
line = "{} {}".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):
@ -85,7 +108,12 @@ class UITool:
return
self.affects_xform = not self.affects_xform
self.ui.tool_settings_changed = True
line = '%s %s' % (self.button_caption, [self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][self.affects_xform])
line = "{} {}".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):
@ -109,11 +137,10 @@ class UITool:
class PencilTool(UITool):
name = 'pencil'
name = "pencil"
# "Paint" not Pencil so the A mnemonic works :/
button_caption = 'Paint'
icon_filename = 'tool_paint.png'
button_caption = "Paint"
icon_filename = "tool_paint.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
"""
@ -154,7 +181,9 @@ class PencilTool(UITool):
new_tc.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, *tile)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(
b_char, b_fg, b_bg, b_xform
)
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# Note: even if command has same result as another in command_tiles,
# add it anyway as it may be a tool for which subsequent edits to
@ -165,10 +194,9 @@ class PencilTool(UITool):
class EraseTool(PencilTool):
name = 'erase'
button_caption = 'Erase'
icon_filename = 'tool_erase.png'
name = "erase"
button_caption = "Erase"
icon_filename = "tool_erase.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
char = 0 if self.affects_char else None
@ -180,9 +208,8 @@ class EraseTool(PencilTool):
class RotateTool(PencilTool):
name = 'rotate'
button_caption = 'Rotate'
name = "rotate"
button_caption = "Rotate"
update_preview_after_paint = True
rotation_shifts = {
UV_NORMAL: UV_ROTATE90,
@ -193,21 +220,20 @@ class RotateTool(PencilTool):
UV_FLIPX: UV_FLIP270,
UV_FLIP270: UV_FLIPY,
UV_FLIPY: UV_ROTATE270,
UV_FLIP90: UV_FLIPX
UV_FLIP90: UV_FLIPX,
}
icon_filename = 'tool_rotate.png'
icon_filename = "tool_rotate.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
return b_char, b_fg, b_bg, self.rotation_shifts[b_xform]
class GrabTool(UITool):
name = 'grab'
button_caption = 'Grab'
name = "grab"
button_caption = "Grab"
brush_size = None
show_preview = False
icon_filename = 'tool_grab.png'
icon_filename = "tool_grab.png"
def grab(self):
x, y = self.ui.app.cursor.get_tile()
@ -233,12 +259,11 @@ class GrabTool(UITool):
class TextTool(UITool):
name = 'text'
button_caption = 'Text'
name = "text"
button_caption = "Text"
brush_size = None
show_preview = False
icon_filename = 'tool_text.png'
icon_filename = "tool_text.png"
def __init__(self, ui):
UITool.__init__(self, ui)
@ -250,23 +275,28 @@ class TextTool(UITool):
# popup gobbles keyboard input, so always dismiss it if it's up
if self.ui.popup.visible:
self.ui.popup.hide()
if self.cursor.x < 0 or self.cursor.x > self.ui.active_art.width or \
-self.cursor.y < 0 or -self.cursor.y > self.ui.active_art.height:
if (
self.cursor.x < 0
or self.cursor.x > self.ui.active_art.width
or -self.cursor.y < 0
or -self.cursor.y > self.ui.active_art.height
):
return
self.input_active = True
self.reset_cursor_start(self.cursor.x, -self.cursor.y)
self.cursor.start_paint()
# self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line('Started text entry, press Escape to stop entering text.', 5)
self.ui.message_line.post_line(
"Started text entry, 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.")
def reset_cursor_start(self, new_x, new_y):
self.start_x, self.start_y = int(new_x), int(new_y)
@ -284,30 +314,32 @@ class TextTool(UITool):
x, y = int(self.cursor.x), int(-self.cursor.y)
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,16 +383,15 @@ class TextTool(UITool):
class SelectTool(UITool):
name = 'select'
button_caption = 'Select'
name = "select"
button_caption = "Select"
brush_size = None
affects_masks = False
show_preview = False
icon_filename = 'tool_select_add.png' # used only for toolbar
icon_filename_normal = 'tool_select.png'
icon_filename_add = 'tool_select_add.png'
icon_filename_sub = 'tool_select_sub.png'
icon_filename = "tool_select_add.png" # used only for toolbar
icon_filename_normal = "tool_select.png"
icon_filename_add = "tool_select_add.png"
icon_filename_sub = "tool_select_sub.png"
def __init__(self, ui):
UITool.__init__(self, ui)
@ -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
@ -454,11 +491,10 @@ class SelectTool(UITool):
class PasteTool(UITool):
name = 'paste'
button_caption = 'Paste'
name = "paste"
button_caption = "Paste"
brush_size = None
icon_filename = 'tool_paste.png'
icon_filename = "tool_paste.png"
# TODO!: dragging large pastes around seems heck of slow, investigate
# why this function might be to blame and see if there's a fix!
@ -488,7 +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,33 +540,34 @@ class PasteTool(UITool):
commands.append(new_tc)
return commands
# "fill boundary" modes: character, fg color, bg color
FILL_BOUND_CHAR = 0
FILL_BOUND_FG_COLOR = 1
FILL_BOUND_BG_COLOR = 2
class FillTool(UITool):
name = 'fill'
button_caption = 'Fill'
class FillTool(UITool):
name = "fill"
button_caption = "Fill"
brush_size = None
icon_filename = 'tool_fill_char.png' # used only for toolbar
icon_filename = "tool_fill_char.png" # used only for toolbar
# icons and strings for different boundary modes
icon_filename_char = 'tool_fill_char.png'
icon_filename_fg = 'tool_fill_fg.png'
icon_filename_bg = 'tool_fill_bg.png'
icon_filename_char = "tool_fill_char.png"
icon_filename_fg = "tool_fill_fg.png"
icon_filename_bg = "tool_fill_bg.png"
boundary_mode = FILL_BOUND_CHAR
# user-facing names for the boundary modes
boundary_mode_names = {
FILL_BOUND_CHAR : 'character',
FILL_BOUND_FG_COLOR : 'fg color',
FILL_BOUND_BG_COLOR : 'bg color'
FILL_BOUND_CHAR: "character",
FILL_BOUND_FG_COLOR: "fg color",
FILL_BOUND_BG_COLOR: "bg color",
}
# determine cycling order
next_boundary_modes = {
FILL_BOUND_CHAR: FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR: FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR : FILL_BOUND_CHAR
FILL_BOUND_BG_COLOR: FILL_BOUND_CHAR,
}
def __init__(self, ui):
@ -542,8 +581,9 @@ class FillTool(UITool):
def get_icon_texture(self):
# show different icon based on boundary type
return [self.icon_texture_char, self.icon_texture_fg,
self.icon_texture_bg][self.boundary_mode]
return [self.icon_texture_char, self.icon_texture_fg, self.icon_texture_bg][
self.boundary_mode
]
def get_button_caption(self):
return '%s (%s bounded)' % (self.button_caption, self.boundary_mode_names[self.boundary_mode])
return f"{self.button_caption} ({self.boundary_mode_names[self.boundary_mode]} bounded)"

View file

@ -1,13 +1,10 @@
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
icon_scale_factor = 4
snap_left = True
@ -58,7 +55,7 @@ class ToolBar(UIElement):
class ToolBarButton(UIButton):
width, height = 4, 2
caption = ''
caption = ""
tooltip_on_hover = True
def get_tooltip_text(self):
@ -66,14 +63,15 @@ class ToolBarButton(UIButton):
def get_tooltip_location(self):
x = self.width
window_height_chars = self.element.ui.app.window_height / (self.element.ui.charset.char_height * self.element.ui.scale)
window_height_chars = self.element.ui.app.window_height / (
self.element.ui.charset.char_height * self.element.ui.scale
)
cursor_y = self.element.ui.app.mouse_y / self.element.ui.app.window_height
y = int(cursor_y * window_height_chars)
return x, y
class ArtToolBar(ToolBar):
def create_toolbar_buttons(self):
for i, tool in enumerate(self.ui.tools):
button = ToolBarButton(self)
@ -81,14 +79,18 @@ class ArtToolBar(ToolBar):
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):
@ -104,11 +106,11 @@ class ArtToolBar(ToolBar):
# position
# remember that in renderable space, (0, 0) = center of screen
icon.x = self.x
icon.x += (icon.scale_x / 8)
icon.x += icon.scale_x / 8
icon.y = self.y
icon.y -= button_height * i
icon.y -= icon.scale_y
icon.y -= (icon.scale_y / 8)
icon.y -= icon.scale_y / 8
def update_selection_box(self):
# scale and position box around currently selected tool

View file

@ -1,17 +1,17 @@
import math
import numpy as np
from OpenGL import GL, GLU
import numpy as np
from OpenGL import GLU
class Vec3:
"Basic 3D vector class. Not used very much currently."
def __init__(self, x=0, y=0, z=0):
self.x, self.y, self.z = x, y, z
def __str__(self):
return 'Vec3 %.4f, %.4f, %.4f' % (self.x, self.y, self.z)
return f"Vec3 {self.x:.4f}, {self.y:.4f}, {self.z:.4f}"
def __sub__(self, b):
"Return a new vector subtracted from given other vector."
@ -51,6 +51,7 @@ class Vec3:
"Return a copy of this vector."
return Vec3(self.x, self.y, self.z)
def get_tiles_along_line(x0, y0, x1, y1):
"""
Return list of (x,y) tuples for all tiles crossing given worldspace
@ -63,7 +64,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
n = 1
if dx == 0:
x_inc = 0
error = float('inf')
error = float("inf")
elif x1 > x0:
x_inc = 1
n += math.floor(x1) - x
@ -74,7 +75,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
error = (x0 - math.floor(x0)) * dy
if dy == 0:
y_inc = 0
error -= float('inf')
error -= float("inf")
elif y1 > y0:
y_inc = 1
n += math.floor(y1) - y
@ -95,6 +96,7 @@ def get_tiles_along_line(x0, y0, x1, y1):
n -= 1
return tiles
def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True):
"""
simplified version of get_tiles_along_line using only integer math,
@ -128,6 +130,7 @@ def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True):
n -= 1
return tiles
def cut_xyz(x, y, z, threshold):
"""
Return input x,y,z with each axis clamped to 0 if it's close enough to
@ -138,10 +141,21 @@ def cut_xyz(x, y, z, threshold):
z = z if abs(z) > threshold else 0
return x, y, z
def ray_plane_intersection(plane_x, plane_y, plane_z,
plane_dir_x, plane_dir_y, plane_dir_z,
ray_x, ray_y, ray_z,
ray_dir_x, ray_dir_y, ray_dir_z):
def ray_plane_intersection(
plane_x,
plane_y,
plane_z,
plane_dir_x,
plane_dir_y,
plane_dir_z,
ray_x,
ray_y,
ray_z,
ray_dir_x,
ray_dir_y,
ray_dir_z,
):
# from http://stackoverflow.com/a/39424162
plane = np.array([plane_x, plane_y, plane_z])
plane_dir = np.array([plane_dir_x, plane_dir_y, plane_dir_z])
@ -156,6 +170,7 @@ def ray_plane_intersection(plane_x, plane_y, plane_z,
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" },
]