Add initial source download

This commit is contained in:
Jared Miller 2026-02-12 19:31:07 -05:00
parent 1fdb9eb287
commit 0cbb1ef05a
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
360 changed files with 227491 additions and 0 deletions

4
.itch.toml Normal file
View file

@ -0,0 +1,4 @@
[[actions]]
name = "play"
path = "playscii/playscii_linux.sh"

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# PLAYSCII - an ASCII art and game creation tool
Playscii (pronounced play-skee) is an art, animation, and game creation tool.
The latest version will always be available here:
* [http://jp.itch.io/playscii](http://jp.itch.io/playscii)
* [https://heptapod.host/jp-lebreton/playscii](https://heptapod.host/jp-lebreton/playscii)
Playscii's main website is here:
* [https://jplebreton.com/playscii/](https://jplebreton.com/playscii/)
## Offline documentation
Playscii now includes its own HTML documentation, which you can find in the
docs/html/ subfolder of the folder where this README resides.
## Online documentation
The latest version of the HTML documentation resides here:
[https://jplebreton.com/playscii/howto_main.html](https://jplebreton.com/playscii/howto_main.html)
## Bugs
If you run into any issues with Playscii, please report a bug here:
[https://heptapod.host/jp-lebreton/playscii/issues](https://heptapod.host/jp-lebreton/playscii/issues)
## Roadmap
For possible future features see Playscii's Trello:
[https://trello.com/b/BLQBXn5H/playscii](https://trello.com/b/BLQBXn5H/playscii)
Please don't take anything there as a promise, though. If you'd find something
on there especially valuable, feel free to vote or comment!
## Contact
If you've made something cool with Playscii and/or have any suggestions on how
to improve it, please let JP know!
[https://jplebreton.com/#contact_email](https://jplebreton.com/#contact_email)

1250
art.py Normal file

File diff suppressed because it is too large Load diff

122
art/blob_shadow.psci Normal file
View file

@ -0,0 +1,122 @@
{
"active_frame": 0,
"active_layer": 0,
"camera": [
2.0,
-2.0,
3.50242610515142
],
"charset": "jpetscii",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 102,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 145,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 145,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 104,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 134,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 177,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 177,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 136,
"fg": 1,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
}
],
"z": 0
}
]
}
],
"height": 4,
"palette": "c64_original",
"width": 4
}

View file

@ -0,0 +1,33 @@
{
"active_frame": 0,
"active_layer": 0,
"camera": [
1,
-1.0,
8.989488752238989
],
"charset": "jpetscii",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 0,
"char": 252,
"fg": 2,
"xform": 0
}
],
"visible": 1,
"z": 0
}
]
}
],
"height": 1,
"palette": "c64_original",
"width": 1
}

View file

@ -0,0 +1,694 @@
{
"active_frame": 0,
"active_layer": 0,
"camera": [
9.78410337431772,
-4.180414323343156,
11.151385425870988
],
"charset": "ui",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 1,
"char": 128,
"fg": 10,
"xform": 2
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 11,
"xform": 3
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 31,
"fg": 3,
"xform": 0
},
{
"bg": 1,
"char": 32,
"fg": 4,
"xform": 0
},
{
"bg": 1,
"char": 33,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 28,
"fg": 6,
"xform": 0
},
{
"bg": 1,
"char": 48,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 39,
"fg": 8,
"xform": 0
},
{
"bg": 1,
"char": 47,
"fg": 9,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 2,
"xform": 5
},
{
"bg": 1,
"char": 87,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 73,
"fg": 2,
"xform": 5
},
{
"bg": 1,
"char": 100,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 74,
"fg": 2,
"xform": 5
},
{
"bg": 1,
"char": 94,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 92,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 2,
"xform": 5
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 14,
"xform": 1
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 15,
"xform": 0
}
],
"visible": 1,
"z": 0
},
{
"name": "Layer 2",
"tiles": [
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 131,
"fg": 2,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
}
],
"visible": 1,
"z": 0.1
}
]
}
],
"height": 5,
"palette": "c64_original",
"width": 11
}

8265
art/hello1.psci Normal file

File diff suppressed because it is too large Load diff

81
art/loc_marker.psci Normal file
View file

@ -0,0 +1,81 @@
{
"active_frame": 0,
"active_layer": 0,
"camera": [
1.5,
-1.5,
7.369075414190879
],
"charset": "c64_petscii",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 30,
"fg": 16,
"xform": 0
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 31,
"fg": 13,
"xform": 0
},
{
"bg": 0,
"char": 75,
"fg": 2,
"xform": 0
},
{
"bg": 0,
"char": 31,
"fg": 16,
"xform": 2
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 0,
"char": 30,
"fg": 13,
"xform": 2
},
{
"bg": 0,
"char": 0,
"fg": 0,
"xform": 0
}
],
"visible": 1,
"z": 0
}
]
}
],
"height": 3,
"palette": "c64_original",
"width": 3
}

9631
art/new.psci Normal file

File diff suppressed because it is too large Load diff

BIN
art/owell.ed Normal file

Binary file not shown.

226
art/trigger_default.psci Normal file
View file

@ -0,0 +1,226 @@
{
"active_frame": 0,
"active_layer": 1,
"camera": [
2.0,
-2.0,
11.149579543758225
],
"charset": "c64_petscii",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 20,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 20,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 18,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 9,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 7,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 9,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 0,
"char": 7,
"fg": 5,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 5,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 5,
"xform": 0
}
],
"visible": 1,
"z": 0
},
{
"name": "collision",
"tiles": [
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 1,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 7,
"xform": 0
},
{
"bg": 0,
"char": 94,
"fg": 7,
"xform": 0
}
],
"visible": 1,
"z": 0.1
}
]
}
],
"height": 4,
"palette": "c64_original",
"width": 4
}

View file

@ -0,0 +1,117 @@
{
"active_frame": 0,
"active_layer": 0,
"camera": [
2.5,
-1.5,
8.762620393409348
],
"charset": "ui",
"frames": [
{
"delay": 0.1,
"layers": [
{
"name": "Layer 1",
"tiles": [
{
"bg": 1,
"char": 50,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 42,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 45,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 39,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 31,
"fg": 2,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 7,
"xform": 0
},
{
"bg": 7,
"char": 130,
"fg": 6,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 7,
"xform": 4
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
},
{
"bg": 1,
"char": 128,
"fg": 7,
"xform": 5
},
{
"bg": 7,
"char": 129,
"fg": 6,
"xform": 5
},
{
"bg": 1,
"char": 128,
"fg": 7,
"xform": 2
},
{
"bg": 1,
"char": 0,
"fg": 0,
"xform": 0
}
],
"visible": 1,
"z": 0
}
]
}
],
"height": 3,
"palette": "c64_original",
"width": 5
}

57
art_export.py Normal file
View file

@ -0,0 +1,57 @@
import traceback
from art import ART_DIR
class ArtExporter:
"""
Class for exporting an Art into a non-Playscii format.
Export logic happens in run_export; exporter authors simply extend this
class, override run_export and the class properties below.
"""
format_name = 'ERROR - ArtExporter.format_name'
"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 = ''
"Extension to give the exported file, sans dot."
options_dialog_class = None
"UIDialog subclass exposing export options to user."
def __init__(self, app, out_filename, options={}):
self.app = app
self.art = self.app.ui.active_art
# add file extension to output filename if not present
if self.file_extension and not out_filename.endswith('.%s' % self.file_extension):
out_filename += '.%s' % self.file_extension
# 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
self.success = False
"Set True on successful export."
# store final filename for log messages
self.out_filename = out_filename
# remove any cursor-hover changes to art in memory
for edit in self.app.cursor.preview_edits:
edit.undo()
try:
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)
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'):
self.app.log(line)
# store last used export options for "Export last"
self.app.last_export_options = options
def run_export(self, out_filename, options):
"""
Contains the actual export logic. Write data based on current art,
return success.
"""
return False

88
art_import.py Normal file
View file

@ -0,0 +1,88 @@
import os, traceback
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'
"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."
allowed_file_extensions = []
"List of file extensions for this format - if empty, any file is accepted."
file_chooser_dialog_class = GenericImportChooserDialog
"""
BaseFileChooserDialog subclass for picking files. Only needed for things
like custom preview images.
"""
options_dialog_class = None
"UIDialog subclass exposing import options to user."
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)
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)
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)
self.art.set_palette(palette)
self.app.set_new_art_for_edit(self.art)
self.art.clear_frame_layer(0, 0, 1)
self.success = False
"Set True on successful import."
# run_import returns success, log it separately from exceptions
try:
if self.run_import(in_filename, options):
self.success = True
except:
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)
self.app.log(line)
self.app.close_art(self.art)
# post message now after close_art sets active art back
self.app.ui.message_line.post_line(line, error=True)
return
# tidy final result, whether or not it was successful
# TODO: GROSS! figure out why this works but
# art.geo_changed=True and art.mark_all_frames_changed() don't!
self.app.ui.erase_selection_or_art()
self.app.ui.undo()
# adjust for new art size and set it active
self.app.ui.adjust_for_art_resize(self.art)
self.app.ui.set_active_art(self.art)
def set_art_charset(self, charset_name):
"Convenience function for setting charset by name from run_import."
self.art.set_charset_by_name(charset_name)
def set_art_palette(self, palette_name):
"Convenience function for setting palette by name from run_import."
self.art.set_palette_by_name(palette_name)
def resize(self, new_width, new_height):
"Convenience function for resizing art from run_import"
self.art.resize(new_width, new_height)
self.app.ui.adjust_for_art_resize(self.art)
def run_import(self, in_filename, options):
"""
Contains the actual import logic. Read input file, set Art
size/charset/palette, set tiles from data, return success.
"""
return False

View file

@ -0,0 +1,28 @@
# convert from C64 EDSCII to C64 original color palettes
color_map = {
1: 1,
2: 3,
3: 5,
4: 7,
5: 6,
6: 8,
7: 12,
8: 16,
9: 10,
10: 9,
11: 11,
12: 15,
13: 14,
14: 4,
15: 13,
16: 2
}
for frame, layer, x, y in TileIter(self):
ch, fg, bg, xf = self.get_tile_at(frame, layer, x, y)
fg = color_map.get(fg, 0)
bg = color_map.get(bg, 0)
self.set_color_at(frame, layer, x, y, fg, fg=True)
self.set_color_at(frame, layer, x, y, bg, fg=False)

28
artscripts/c64_fade.arsc Normal file
View file

@ -0,0 +1,28 @@
# palette-specific (c64 original) fade to black
color_ramps = {
2: 16,
3: 10,
4: 15,
5: 12,
6: 12,
7: 12,
8: 9,
9: 10,
10: 12,
11: 3,
12: 1,
13: 12,
14: 6,
15: 7,
16: 13
}
for frame, layer, x, y in TileIter(self):
fg = self.get_fg_color_index_at(frame, layer, x, y)
fg = color_ramps.get(fg, 1)
self.set_color_at(frame, layer, x, y, fg, fg=True)
bg = self.get_bg_color_index_at(frame, layer, x, y)
bg = color_ramps.get(bg, 1)
self.set_color_at(frame, layer, x, y, bg, fg=False)

39
artscripts/conway.arsc Normal file
View file

@ -0,0 +1,39 @@
# conway's game of life
# naive, super slow implementation for proof-of-concept
# (accessing char array would probably be way faster!)
for frame, layer, x, y in TileIter(self):
dead = self.get_char_index_at(frame, layer, x, y) == 0
# N, NE, E, SE, S, SW, W, NW
neighbor_offsets = [(0, -1), (1, -1), (1, 0), (1, 1),
(0, 1), (-1, 1), (-1, 0), (-1, -1)]
neighbors = 0
neighbor_chars = []
neighbor_colors = []
for offset in neighbor_offsets:
check_x, check_y = x + offset[0], y + offset[1]
# don't check at edges
if not (0 < check_x < self.width and 0 < check_y < self.height):
continue
neighbor_char = self.get_char_index_at(frame, layer, check_x, check_y)
if neighbor_char != 0:
neighbors += 1
# remember neighbor char in case we come alive
neighbor_chars.append(neighbor_char)
fg = self.get_fg_color_index_at(frame, layer, check_x, check_y)
neighbor_colors.append(fg)
bg = self.get_bg_color_index_at(frame, layer, check_x, check_y)
neighbor_colors.append(bg)
# rule #4: any dead cell with exactly 3 neighbors becomes alive
if dead and neighbors == 3:
# pick a random neighbord character to be
self.set_char_index_at(frame, layer, x, y, random.choice(neighbor_chars))
change_fg = random.choice([False, True])
self.set_color_at(frame, layer, x, y, random.choice(neighbor_colors), change_fg)
# rule #3: any living cell with >3 neighbors dies from overcrowding
elif neighbors > 3:
self.set_char_index_at(frame, layer, x, y, 0)
# rule #1: any living cell with <2 neighbors dies from underpopulation
elif neighbors < 2:
self.set_char_index_at(frame, layer, x, y, 0)
# rule #2: any living cell with 2 or 3 neighbors survives

23
artscripts/dissolv.arsc Normal file
View file

@ -0,0 +1,23 @@
# quickie dissolve effect
frame, layer = 0, 0
left_x = int(self.width / 2)
top_y = int(self.height / 2)
for x in range(int(self.width / 2)):
for y in range(self.height):
char = self.get_char_index_at(frame, layer, x, y)
char = max(0, char - 1)
if x > 0:
self.set_char_index_at(frame, layer, x - 1, y, char)
self.set_char_index_at(frame, layer, x, y, 0)
for x in range(self.width - 1, int(self.width / 2), -1):
for y in range(self.height):
char = self.get_char_index_at(frame, layer, x, y)
char = max(0, char - 1)
if x < self.width - 1:
self.set_char_index_at(frame, layer, x + 1, y, char)
self.set_char_index_at(frame, layer, x, y, 0)

14
artscripts/evap.arsc Normal file
View file

@ -0,0 +1,14 @@
# quickie "evaporate" effect
spesh_idx = 127
for frame, layer, x, y in TileIter(self):
char = self.get_char_index_at(frame, layer, x, y)
if char != spesh_idx and char != 0:
self.set_char_index_at(frame, layer, x, y, spesh_idx)
elif y < self.height - 1:
c,f,b,xf = self.get_tile_at(frame, layer, x, y+1)
self.set_tile_at(frame, layer, x, y, c, f, b)
elif y == self.height - 1:
self.set_char_index_at(frame, layer, x, y, 0)

202
artscripts/hello1.arsc Normal file
View file

@ -0,0 +1,202 @@
# hello1 test art generator script
# every line in this file must be a valid python expression.
# "self" is the Art that's running us,
# so we have full access to its namespace!
# sets some test data:
# c64_edscii charset & palette
# add tiles/layers if not 8x8, 3 layers
self.set_charset_by_name('c64_edscii')
self.set_palette_by_name('c64_edscii')
if self.layers < 3:
self.add_layer(0.25)
self.add_layer(0.5)
if self.width < 8 or self.height < 8:
self.resize(8, 8)
# clear 1st layer to black, 2nd and 3rd to transparent
self.clear_frame_layer(0, 0, self.palette.darkest_index)
self.clear_frame_layer(0, 1)
self.clear_frame_layer(0, 2)
# write white text onto 3 layers
color = self.palette.lightest_index
self.write_string(0, 0, 1, 1, 'Hello.', color)
self.set_char_transform_at(0, 0, 2, 1, UV_ROTATE90)
# draw snaky ring thingy
# color ramp: 2, 10, 6, 13, 14, 12, 3, back to 2
# top
self.set_tile_at(0, 1, 1, 3, 119, 2)
self.set_tile_at(0, 1, 2, 3, 102, 10)
self.set_tile_at(0, 1, 3, 3, 102, 6)
self.set_tile_at(0, 1, 4, 3, 102, 13)
self.set_tile_at(0, 1, 5, 3, 120, 14)
# sides
self.set_tile_at(0, 1, 1, 4, 145, 3)
self.set_tile_at(0, 1, 5, 4, 145, 12)
self.set_tile_at(0, 1, 1, 5, 145, 12)
self.set_tile_at(0, 1, 5, 5, 145, 3)
# bottom
self.set_tile_at(0, 1, 1, 6, 121, 14)
self.set_tile_at(0, 1, 2, 6, 102, 13)
self.set_tile_at(0, 1, 3, 6, 102, 6)
self.set_tile_at(0, 1, 4, 6, 102, 10)
self.set_tile_at(0, 1, 5, 6, 122, 2)
# :]
char = self.charset.get_char_index(':')
self.set_tile_at(0, 2, 3, 4, char, color)
char = self.charset.get_char_index(']')
self.set_tile_at(0, 2, 4, 4, char, color)
# add frames and animate 'em
self.duplicate_frame(0)
self.duplicate_frame(0)
self.duplicate_frame(0)
self.duplicate_frame(0)
self.duplicate_frame(0)
self.duplicate_frame(0)
# cycle capitals through "hello" text
h = self.charset.get_char_index('h')
char = self.charset.get_char_index('E')
self.set_char_index_at(1, 0, 2, 1, char)
self.set_char_index_at(1, 0, 1, 1, h)
char = self.charset.get_char_index('L')
self.set_char_index_at(2, 0, 3, 1, char)
self.set_char_index_at(2, 0, 1, 1, h)
self.set_char_index_at(3, 0, 4, 1, char)
self.set_char_index_at(3, 0, 1, 1, h)
char = self.charset.get_char_index('O')
self.set_char_index_at(4, 0, 5, 1, char)
self.set_char_index_at(4, 0, 1, 1, h)
char = self.charset.get_char_index('!')
self.set_char_index_at(5, 0, 6, 1, char)
self.set_char_index_at(5, 0, 1, 1, h)
self.set_char_index_at(6, 0, 1, 1, h)
# make smiley go from ;] to :D
char = self.charset.get_char_index(';')
self.set_char_index_at(3, 2, 3, 4, char)
self.set_char_index_at(4, 2, 3, 4, char)
self.set_char_index_at(5, 2, 3, 4, char)
char = self.charset.get_char_index('D')
self.set_char_index_at(3, 2, 4, 4, char)
self.set_char_index_at(4, 2, 4, 4, char)
self.set_char_index_at(5, 2, 4, 4, char)
self.set_char_transform_at(4, 2, 4, 4, UV_FLIPX)
# cycle colors for snaky thing
#
# frame 1 top
#
self.set_color_at(1, 1, 1, 3, 10)
self.set_color_at(1, 1, 2, 3, 6)
self.set_color_at(1, 1, 3, 3, 13)
self.set_color_at(1, 1, 4, 3, 14)
self.set_color_at(1, 1, 5, 3, 12)
# frame 1 sides
self.set_color_at(1, 1, 1, 4, 2)
self.set_color_at(1, 1, 5, 4, 3)
self.set_color_at(1, 1, 1, 5, 3)
self.set_color_at(1, 1, 5, 5, 2)
# frame 1 bottom
self.set_color_at(1, 1, 1, 6, 12)
self.set_color_at(1, 1, 2, 6, 14)
self.set_color_at(1, 1, 3, 6, 13)
self.set_color_at(1, 1, 4, 6, 6)
self.set_color_at(1, 1, 5, 6, 10)
#
# frame 2 top
#
self.set_color_at(2, 1, 1, 3, 6)
self.set_color_at(2, 1, 2, 3, 13)
self.set_color_at(2, 1, 3, 3, 14)
self.set_color_at(2, 1, 4, 3, 12)
self.set_color_at(2, 1, 5, 3, 3)
# frame 2 sides
self.set_color_at(2, 1, 1, 4, 10)
self.set_color_at(2, 1, 5, 4, 2)
self.set_color_at(2, 1, 1, 5, 2)
self.set_color_at(2, 1, 5, 5, 10)
# frame 2 bottom
self.set_color_at(2, 1, 1, 6, 3)
self.set_color_at(2, 1, 2, 6, 12)
self.set_color_at(2, 1, 3, 6, 14)
self.set_color_at(2, 1, 4, 6, 13)
self.set_color_at(2, 1, 5, 6, 6)
#
# frame 3 top
#
self.set_color_at(3, 1, 1, 3, 13)
self.set_color_at(3, 1, 2, 3, 14)
self.set_color_at(3, 1, 3, 3, 12)
self.set_color_at(3, 1, 4, 3, 3)
self.set_color_at(3, 1, 5, 3, 2)
# frame 3 sides
self.set_color_at(3, 1, 1, 4, 6)
self.set_color_at(3, 1, 5, 4, 10)
self.set_color_at(3, 1, 1, 5, 10)
self.set_color_at(3, 1, 5, 5, 6)
# frame 3 bottom
self.set_color_at(3, 1, 1, 6, 2)
self.set_color_at(3, 1, 2, 6, 3)
self.set_color_at(3, 1, 3, 6, 12)
self.set_color_at(3, 1, 4, 6, 14)
self.set_color_at(3, 1, 5, 6, 13)
#
# frame 4 top
#
self.set_color_at(4, 1, 1, 3, 14)
self.set_color_at(4, 1, 2, 3, 12)
self.set_color_at(4, 1, 3, 3, 3)
self.set_color_at(4, 1, 4, 3, 2)
self.set_color_at(4, 1, 5, 3, 10)
# frame 4 sides
self.set_color_at(4, 1, 1, 4, 13)
self.set_color_at(4, 1, 5, 4, 6)
self.set_color_at(4, 1, 1, 5, 6)
self.set_color_at(4, 1, 5, 5, 13)
# frame 4 bottom
self.set_color_at(4, 1, 1, 6, 10)
self.set_color_at(4, 1, 2, 6, 2)
self.set_color_at(4, 1, 3, 6, 3)
self.set_color_at(4, 1, 4, 6, 12)
self.set_color_at(4, 1, 5, 6, 14)
#
# frame 5 top
#
self.set_color_at(5, 1, 1, 3, 12)
self.set_color_at(5, 1, 2, 3, 3)
self.set_color_at(5, 1, 3, 3, 2)
self.set_color_at(5, 1, 4, 3, 10)
self.set_color_at(5, 1, 5, 3, 6)
# frame 5 sides
self.set_color_at(5, 1, 1, 4, 14)
self.set_color_at(5, 1, 5, 4, 13)
self.set_color_at(5, 1, 1, 5, 13)
self.set_color_at(5, 1, 5, 5, 14)
# frame 5 bottom
self.set_color_at(5, 1, 1, 6, 6)
self.set_color_at(5, 1, 2, 6, 10)
self.set_color_at(5, 1, 3, 6, 2)
self.set_color_at(5, 1, 4, 6, 3)
self.set_color_at(5, 1, 5, 6, 12)
#
# frame 6 top
#
self.set_color_at(6, 1, 1, 3, 3)
self.set_color_at(6, 1, 2, 3, 2)
self.set_color_at(6, 1, 3, 3, 10)
self.set_color_at(6, 1, 4, 3, 6)
self.set_color_at(6, 1, 5, 3, 13)
# frame 6 sides
self.set_color_at(6, 1, 1, 4, 12)
self.set_color_at(6, 1, 5, 4, 14)
self.set_color_at(6, 1, 1, 5, 14)
self.set_color_at(6, 1, 5, 5, 12)
# frame 6 bottom
self.set_color_at(6, 1, 1, 6, 13)
self.set_color_at(6, 1, 2, 6, 6)
self.set_color_at(6, 1, 3, 6, 10)
self.set_color_at(6, 1, 4, 6, 2)
self.set_color_at(6, 1, 5, 6, 3)

13
artscripts/mutate.arsc Normal file
View file

@ -0,0 +1,13 @@
# mutate!
# change a random tile on a random layer
x = random.randint(0, self.width-1)
y = random.randint(0, self.height-1)
layer = random.randint(0, self.layers-1)
char = random.randint(0, 128)
color_index = self.palette.get_random_color_index()
self.set_char_index_at(0, layer, x, y, char)
self.set_color_at(0, layer, x, y, color_index)
color_index = self.palette.get_random_color_index()
self.set_color_at(0, layer, x, y, color_index, False)

145
audio.py Normal file
View file

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

177
binds.cfg.default Normal file
View file

@ -0,0 +1,177 @@
# DEFAULT BIND FILE TEMPLATE, DO NOT MODIFY
# user keybinds file
# accepted modifiers: ctrl, alt, shift
# keys must be equivalent to output of sdl2.SDL_GetKeyName(),
# eg return, tab, backspace
self.edit_bind_src = {
'ctrl q' : 'quit',
'`' : 'toggle_console',
'ctrl m' : 'import_file',
'ctrl e' : ('export_file_last', 'edit_art_for_selected_objects'),
'ctrl -' : 'decrease_ui_scale',
'ctrl =' : 'increase_ui_scale',
'alt return': 'toggle_fullscreen',
'1' : 'decrease_brush_size',
'2' : 'increase_brush_size',
'3' : 'cycle_char_forward',
'shift 3' : 'cycle_char_backward',
'4' : 'cycle_fg_forward',
'shift 4' : 'cycle_fg_backward',
'5' : 'cycle_bg_forward',
'shift 5' : 'cycle_bg_backward',
'6' : 'cycle_xform_forward',
'shift 6' : 'cycle_xform_backward',
'c' : 'toggle_affects_char',
'f' : 'toggle_affects_fg',
'b' : 'toggle_affects_bg',
# bind can also be a tuple of function names
'x' : ('toggle_affects_xform', 'game_frob'),
'z' : ('toggle_zoom_extents', 'game_grab'),
'shift r' : 'toggle_crt',
'a' : 'select_pencil_tool',
'e' : 'select_erase_tool',
'r' : 'select_rotate_tool',
't' : 'select_text_tool',
's' : 'select_select_tool',
'ctrl x' : 'cut_selection',
'ctrl c' : 'copy_selection',
'v' : 'select_paste_tool',
'ctrl v' : 'select_paste_tool',
'i' : 'select_fill_tool',
'escape' : 'cancel',
'ctrl d' : 'select_none',
'ctrl a' : 'select_all',
'ctrl i' : 'select_invert',
'delete' : 'erase_selection_or_art',
'backspace': 'erase_selection_or_art',
'g' : 'toggle_game_mode',
'shift e' : 'toggle_game_edit_ui',
'ctrl shift g': 'set_game_dir',
'ctrl g' : 'load_game_state',
'f2' : 'reset_game',
'space' : 'toggle_picker',
'w' : 'swap_fg_bg_colors',
'ctrl s' : 'save_current',
'shift u' : 'toggle_ui_visibility',
'shift g' : 'toggle_grid_visibility',
',' : 'previous_frame',
'.' : 'next_frame',
'p' : 'toggle_anim_playback',
'[' : 'previous_layer',
']' : 'next_layer',
'shift ctrl tab': 'previous_art',
'ctrl tab' : 'next_art',
'ctrl z' : 'undo',
'shift ctrl z': 'redo',
'q' : 'quick_grab',
'shift t' : 'toggle_camera_tilt',
'shift i' : 'toggle_overlay_image',
'=' : 'camera_zoom_in_proportional',
'-' : 'camera_zoom_out_proportional',
'return' : 'select_or_paint',
'shift return': 'add_to_list_selection',
'ctrl return': 'remove_from_list_selection',
'f12' : 'screenshot',
'ctrl shift m' : 'run_test_mutate',
'up' : 'arrow_up',
'down' : 'arrow_down',
'left' : 'arrow_left',
'right' : 'arrow_right',
'home' : 'center_cursor_in_art',
'l' : 'cycle_inactive_layer_visibility',
'alt f' : 'open_file_menu',
'alt e' : 'open_edit_menu',
'alt t' : 'open_tool_menu',
'alt v' : 'open_view_menu',
'alt a' : 'open_art_menu',
'alt r' : 'open_frame_menu',
'alt l' : 'open_layer_menu',
'alt c' : 'open_char_color_menu',
'alt g' : 'open_game_menu',
'alt h' : 'open_help_menu',
'alt s' : 'open_state_menu',
'alt w' : 'open_world_menu',
'alt o' : 'open_object_menu',
'ctrl o' : 'open_art',
'ctrl n' : 'new_art',
'ctrl w' : 'close_art',
'f1' : 'open_help_docs',
'ctrl k' : 'crop_to_selection',
'ctrl r' : 'resize_art',
'ctrl t' : 'run_art_script_last',
'ctrl f' : 'add_frame',
'ctrl l' : ('add_layer', 'select_objects'),
'ctrl h' : 'choose_charset',
'ctrl p' : ('choose_palette', 'choose_spawn_object_class'),
'o' : 'toggle_onion_visibility',
'f5' : 'toggle_all_origin_viz',
'f6' : 'toggle_all_bounds_viz',
'f7' : 'toggle_all_collision_viz',
'f8' : 'toggle_debug_text',
'f9' : 'toggle_fps_counter',
'f3' : 'toggle_collision_on_selected',
'tab' : 'switch_edit_panel_focus',
'shift tab': 'switch_edit_panel_focus_reverse',
# commands that don't have a shortcut still need to be declared
# bind strings preceded by a _ will not be displayed
'_saveas' : 'save_art_as',
'_grab' : 'select_grab_tool',
'_switch_art' : 'art_switch_to',
'_switch_layer' : 'layer_switch_to',
'_layer_viz' : 'toggle_layer_visibility',
'_hidden_layers': 'toggle_hidden_layers_visible',
'_website' : 'open_website',
'_docs' : 'generate_docs',
'_dup_frame' : 'duplicate_frame',
'_frame_delay' : 'change_frame_delay',
'_frame_delay_all': 'change_frame_delay_all',
'_frame_index' : 'change_frame_index',
'_delete_frame' : 'delete_frame',
'_dup_layer' : 'duplicate_layer',
'_layer_name' : 'change_layer_name',
'_layer_z' : 'change_layer_z',
'_delete_layer' : 'delete_layer',
'_pal_from_file': 'palette_from_file',
'_cycle_onion_frames': 'cycle_onion_frames',
'_cycle_onion_display': 'cycle_onion_ahead_behind',
'_open_game_assets' : 'open_all_game_assets',
'_export_file' : 'export_file',
'_import_file': 'import_file',
'_revert' : 'revert_art',
'_new_game' : 'new_game_dir',
'_duplicate_objects': 'duplicate_selected_objects',
'_edit_world' : 'edit_world_properties',
'_save_game' : 'save_game_state',
'_change_room' : 'change_current_room',
'_change_room_to': 'change_current_room_to',
'_add_room' : 'add_room',
'_remove_room' : 'remove_current_room',
'_room_objects' : 'set_room_objects',
'_object_rooms' : 'set_object_rooms',
'_show_all_rooms': 'toggle_all_rooms_visible',
'_set_room_cam' : 'set_room_camera_marker',
'_obj_to_cam' : 'objects_to_camera',
'_cam_to_obj' : 'camera_to_objects',
'_add_to_room' : 'add_selected_to_room',
'_remove_from_room': 'remove_selected_from_room',
'_room_edge_warps': 'set_room_edge_warps',
'_room_bounds' : 'set_room_bounds_obj',
'_room_cameras' : 'toggle_room_camera_changes',
'_list_room_objs': 'toggle_list_only_room_objects',
'_rename_room' : 'rename_current_room',
'_toggle_debug_objects': 'toggle_debug_objects',
'_toggle_picker_hold': 'toggle_picker_hold',
'_set_camera_zoom': 'set_camera_zoom',
'_toggle_bg_texture': 'toggle_bg_texture',
'_run_art_script': 'run_art_script',
'_art_flip_horizontal': 'art_flip_horizontal',
'_art_flip_vertical': 'art_flip_vertical',
'_art_toggle_flip_affects_xforms': 'art_toggle_flip_affects_xforms',
'_edit_cfg': 'edit_cfg',
'_select_overlay_image': 'select_overlay_image',
'_set_overlay_image_opacity': 'set_overlay_image_opacity',
'_set_overlay_image_scaling': 'set_overlay_image_scaling',
'_toggle_art_toolbar': 'toggle_art_toolbar',
'_cycle_fill_boundary_mode': 'cycle_fill_boundary_mode'
}

7
build_mac.sh Executable file
View file

@ -0,0 +1,7 @@
pyinstaller playscii_mac.spec
cd dist/Playscii.app/Contents/MacOS/
ln -s libSDL2-2.0.0.dylib libSDL2.dylib
ln -s libSDL2_mixer-2.0.0.dylib libSDL2_mixer.dylib
cd ../../../../
hdiutil create -ov -size 110m -srcfolder dist/Playscii.app/ playscii_mac-`cat version`.dmg

37
build_windows.bat Normal file
View file

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

303
camera.py Normal file
View file

@ -0,0 +1,303 @@
import math
import numpy as np
import vector
def clamp(val, lowest, highest):
return min(highest, max(lowest, val))
class Camera:
# good starting values
start_x,start_y = 0,0
start_zoom = 2.5
x_tilt, y_tilt = 0, 0
# pan/zoom speed tuning
mouse_pan_rate = 10
pan_accel = 0.005
base_max_pan_speed = 0.8
pan_friction = 0.1
# min/max zoom % between which pan speed variation scales
pan_min_pct = 25.0
pan_max_pct = 200.0
# factor by which zoom level modifies pan speed
pan_zoom_increase_factor = 16
zoom_accel = 0.1
max_zoom_speed = 2.5
zoom_friction = 0.1
# kill velocity if below this
min_velocity = 0.05
# map extents
# starting values only, bounds are generated according to art size
min_x,max_x = -10, 50
min_y,max_y = -50, 10
use_bounds = True
min_zoom,max_zoom = 1, 1000
# matrices -> worldspace renderable vertex shader uniforms
fov = 90
near_z = 0.0001
far_z = 100000
def __init__(self, app):
self.app = app
self.reset()
self.max_pan_speed = self.base_max_pan_speed
def reset(self):
self.x, self.y = self.start_x, self.start_y
self.z = self.start_zoom
# store look vectors so world/screen space conversions can refer to it
self.look_x, self.look_y, self.look_z = None,None,None
self.vel_x, self.vel_y, self.vel_z = 0,0,0
self.mouse_panned, self.moved_this_frame = False, False
# GameObject to focus on
self.focus_object = None
self.calc_projection_matrix()
self.calc_view_matrix()
def calc_projection_matrix(self):
self.projection_matrix = self.get_perspective_matrix()
def calc_view_matrix(self):
eye = vector.Vec3(self.x, self.y, self.z)
up = vector.Vec3(0, 1, 0)
target = vector.Vec3(eye.x + self.x_tilt, eye.y + self.y_tilt, 0)
# view axes
forward = (target - eye).normalize()
side = forward.cross(up).normalize()
upward = side.cross(forward)
m = [[side.x, upward.x, -forward.x, 0],
[side.y, upward.y, -forward.y, 0],
[side.z, upward.z, -forward.z, 0],
[-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]]
self.view_matrix = np.array(m, dtype=np.float32)
self.look_x, self.look_y, self.look_z = side, upward, forward
def get_perspective_matrix(self):
zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
ymul = 1 / math.tan(self.fov * math.pi / 360)
aspect = self.app.window_width / self.app.window_height
xmul = ymul / aspect
m = [[xmul, 0, 0, 0],
[ 0, ymul, 0, 0],
[ 0, 0, -1, -1],
[ 0, 0, zmul, 0]]
return np.array(m, dtype=np.float32)
def get_ortho_matrix(self, width=None, height=None):
width, height = width or self.app.window_width, height or self.app.window_height
m = np.eye(4, 4, dtype=np.float32)
left, bottom = 0, 0
right, top = width, height
far_z, near_z = -1, 1
x = 2 / (right - left)
y = 2 / (top - bottom)
z = -2 / (self.far_z - self.near_z)
wx = -(right + left) / (right - left)
wy = -(top + bottom) / (top - bottom)
wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z)
m = [[ x, 0, 0, 0],
[ 0, y, 0, 0],
[ 0, 0, z, 0],
[wx, wy, wz, 0]]
return np.array(m, dtype=np.float32)
def pan(self, dx, dy, keyboard=False):
# modify pan speed based on zoom according to a factor
m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
self.vel_x += dx * self.pan_accel * m
self.vel_y += dy * self.pan_accel * m
# for brevity, app passes in whether user appears to be keyboard editing
if keyboard:
self.app.keyboard_editing = True
def zoom(self, dz, keyboard=False, towards_cursor=False):
self.vel_z += dz * self.zoom_accel
# pan towards cursor while zooming?
if towards_cursor:
dx = self.app.cursor.x - self.x
dy = self.app.cursor.y - self.y
self.pan(dx, dy, keyboard)
if keyboard:
self.app.keyboard_editing = True
def get_current_zoom_pct(self):
"returns % of base (1:1) for current camera"
return (self.get_base_zoom() / self.z) * 100
def get_base_zoom(self):
"returns camera Z needed for 1:1 pixel zoom"
wh = self.app.window_height
ch = self.app.ui.active_art.charset.char_height
# TODO: understand why this produces correct result for 8x8 charsets
if ch == 8:
ch = 16
return wh / ch
def set_to_base_zoom(self):
self.z = self.get_base_zoom()
def zoom_proportional(self, direction):
"zooms in or out via increments of 1:1 pixel scales for active art"
if not self.app.ui.active_art:
return
self.app.ui.active_art.camera_zoomed_extents = False
base_zoom = self.get_base_zoom()
# build span of all 1:1 zoom increments
zooms = []
m = 1
while base_zoom / m > self.min_zoom:
zooms.append(base_zoom / m)
m *= 2
zooms.reverse()
m = 1
while base_zoom * m < self.max_zoom:
zooms.append(base_zoom * m)
m *= 2
# set zoom to nearest increment in direction we're heading
if direction > 0:
zooms.reverse()
for zoom in zooms:
if self.z > zoom:
self.z = zoom
break
elif direction < 0:
for zoom in zooms:
if self.z < zoom:
self.z = zoom
break
# kill all Z velocity for camera so we don't drift out of 1:1
self.vel_z = 0
def find_closest_zoom_extents(self):
def corners_on_screen():
art = self.app.ui.active_art
z = art.layers_z[-1]
x1, y1 = art.renderables[0].x, art.renderables[0].y
left, top = vector.world_to_screen_normalized(self.app, x1, y1, z)
x2 = x1 + art.width * art.quad_width
y2 = y1 - art.height * art.quad_height
right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z)
#print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
# 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
# zoom out from minimum until all corners are visible
self.z = self.min_zoom
# recalc view matrix each move so projection stays correct
self.calc_view_matrix()
tries = 0
while not corners_on_screen() and tries < 30:
self.zoom_proportional(-1)
self.calc_view_matrix()
tries += 1
def toggle_zoom_extents(self, override=None):
art = self.app.ui.active_art
if override is not None:
art.camera_zoomed_extents = not override
if art.camera_zoomed_extents:
# restore cached position
self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z
else:
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
self.find_closest_zoom_extents()
# kill all camera velocity when snapping
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
art.camera_zoomed_extents = not art.camera_zoomed_extents
def window_resized(self):
self.calc_projection_matrix()
def set_zoom(self, z):
# TODO: set lerp target, clear if keyboard etc call zoom()
self.z = z
def set_loc(self, x, y, z):
self.x, self.y, self.z = x, y, (z or self.z) # z optional
def set_loc_from_obj(self, game_object):
self.set_loc(game_object.x, game_object.y, game_object.z)
def set_for_art(self, art):
# set limits
self.max_x = art.width * art.quad_width
self.min_y = -art.height * art.quad_height
# use saved pan/zoom
self.set_loc(art.camera_x, art.camera_y, art.camera_z)
def mouse_pan(self, dx, dy):
"pan view based on mouse delta"
if dx == 0 and dy == 0:
return
m = ((1 * self.pan_zoom_increase_factor) * self.z) / self.min_zoom
m /= self.max_zoom
self.x -= dx / self.mouse_pan_rate * m
self.y += dy / self.mouse_pan_rate * m
self.vel_x = self.vel_y = 0
self.mouse_panned = True
def update(self):
# zoom-proportional pan scale is based on art
if self.app.ui.active_art:
speed_scale = clamp(self.get_current_zoom_pct(),
self.pan_min_pct, self.pan_max_pct)
self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
else:
self.max_pan_speed = self.base_max_pan_speed
# remember last position to see if it changed
self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
# if focus object is set, use it for X and Y transforms
if self.focus_object:
# track towards target
# TODO: revisit this for better feel later
dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y
l = math.sqrt(dx ** 2 + dy ** 2)
if l != 0 and l > 0.1:
il = 1 / l
dx *= il
dy *= il
self.x += dx * self.pan_friction
self.y += dy * self.pan_friction
else:
# clamp velocity
self.vel_x = clamp(self.vel_x, -self.max_pan_speed, self.max_pan_speed)
self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
# apply friction
self.vel_x *= 1 - self.pan_friction
self.vel_y *= 1 - self.pan_friction
if abs(self.vel_x) < self.min_velocity:
self.vel_x = 0
if abs(self.vel_y) < self.min_velocity:
self.vel_y = 0
# if camera moves, we're not in zoom-extents state anymore
if self.app.ui.active_art and (self.vel_x or self.vel_y):
self.app.ui.active_art.camera_zoomed_extents = False
# move
self.x += self.vel_x
self.y += self.vel_y
# process Z separately
self.vel_z = clamp(self.vel_z, -self.max_zoom_speed, self.max_zoom_speed)
self.vel_z *= 1 - self.zoom_friction
if abs(self.vel_z) < self.min_velocity:
self.vel_z = 0
# as bove, if zooming turn off zoom-extents state
if self.vel_z and self.app.ui.active_art:
self.app.ui.active_art.camera_zoomed_extents = False
self.z += self.vel_z
# keep within bounds
if self.use_bounds:
self.x = clamp(self.x, self.min_x, self.max_x)
self.y = clamp(self.y, self.min_y, self.max_y)
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.mouse_panned = False
def log_loc(self):
self.app.log('camera x=%s, y=%s, z=%s' % (self.x, self.y, self.z))

191
charset.py Normal file
View file

@ -0,0 +1,191 @@
import os.path, string, time
from PIL import Image
from texture import Texture
CHARSET_DIR = 'charsets/'
CHARSET_FILE_EXTENSION = 'char'
class CharacterSetLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval:
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)
else:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
except:
self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True)
class CharacterSet:
transparent_color = (0, 0, 0)
def __init__(self, app, src_filename, log):
self.init_success = False
self.app = app
self.filename = self.app.find_filename_path(src_filename, CHARSET_DIR,
CHARSET_FILE_EXTENSION)
if not self.filename:
self.app.log("Couldn't find character set data %s" % self.filename)
return
self.name = os.path.basename(self.filename)
self.name = os.path.splitext(self.name)[0]
# image filename discovered by character data load process
self.image_filename = None
# remember last modified times for data and image files
self.last_data_change = os.path.getmtime(self.filename)
self.last_image_change = 0
# do most stuff in load_char_data so we can hot reload
if not self.load_char_data():
return
# report
if log and not self.app.game_mode:
self.app.log("loaded charmap '%s' from %s:" % (self.name, self.filename))
self.report()
self.init_success = True
def load_char_data(self):
"carries out majority of CharacterSet init, including loading image"
char_data_src = open(self.filename, encoding='utf-8').readlines()
# 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('//'):
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')
if not img_filename:
self.app.log("Couldn't find character set image %s" % 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(',')
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:
self.char_mapping[char] = index
index += 1
if index >= self.map_width * self.map_height:
break
# if no lower case included, map upper to lower & vice versa
has_upper, has_lower = False, False
for line in char_data:
for char in line:
if char.isupper():
has_upper = True
elif char.islower():
has_lower = True
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:
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:
continue
self.char_mapping[char] = self.char_mapping[char.lower()]
# last valid index a character can be
self.last_index = self.map_width * self.map_height
# load image
self.load_image_data()
self.set_char_dimensions()
# store base filename for easy comparisons with not-yet-loaded sets
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
return True
def load_image_data(self):
# load and process image
img = Image.open(self.image_filename)
img = img.convert('RGBA')
# flip for openGL
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_width, self.image_height = img.size
# any pixel that is "transparent color" will be made fully transparent
# any pixel that isn't will be opaque + tinted FG color
for y in range(self.image_height):
for x in range(self.image_width):
# TODO: PIL pixel access shows up in profiler, use numpy array
# assignment instead
color = img.getpixel((x, y))
if color[:3] == self.transparent_color[:3]:
# MAYBE-TODO: does keeping non-alpha color improve sampling?
img.putpixel((x, y), (color[0], color[1], color[2], 0))
self.texture = Texture(img.tobytes(), self.image_width, self.image_height)
# flip image data back and save it for later, eg image conversion
img = img.transpose(Image.FLIP_TOP_BOTTOM)
self.image_data = img
def set_char_dimensions(self):
# store character dimensions and UV size
self.char_width = int(self.image_width / self.map_width)
self.char_height = int(self.image_height / self.map_height)
self.u_width = self.char_width / self.image_width
self.v_height = self.char_height / self.image_height
def report(self):
self.app.log(' source texture %s is %s x %s pixels' % (self.image_filename, self.image_width, self.image_height))
self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height))
self.app.log(' char map width/height is %s x %s' % (self.map_width, self.map_height))
self.app.log(' last character index: %s' % self.last_index)
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):
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
if data_changed:
self.last_data_change = time.time()
if img_changed:
self.last_image_change = time.time()
return data_changed or img_changed
def get_char_index(self, char):
return self.char_mapping.get(char, 0)
def get_solid_pixels_in_char(self, char_index):
"Returns # of solid pixels in character at given index"
tile_x = int(char_index % self.map_width)
tile_y = int(char_index / self.map_width)
x_start = self.char_width * tile_x
x_end = x_start + self.char_width
y_start = self.char_height * tile_y
y_end = y_start + self.char_height
pixels = 0
for x in range(x_start, x_end):
for y in range(y_start, y_end):
color = self.image_data.getpixel((x, y))
if color[3] > 0:
pixels += 1
return pixels

9
charsets/agat.char Normal file
View file

@ -0,0 +1,9 @@
// Agat (Soviet Apple II clone)
agat.png
16, 6
!"#$%^'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
`abcdefghijklmno
pqrstuvwxyz{|}~

BIN
charsets/agat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

10
charsets/amiga_topaz.char Normal file
View file

@ -0,0 +1,10 @@
// Amiga "Topaz" workbench font
amiga_topaz.png
32, 6
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~

BIN
charsets/amiga_topaz.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

12
charsets/apple2.char Normal file
View file

@ -0,0 +1,12 @@
apple2.png
// Apple II
// from http://www.lazilong.com/apple_ii/a2font/readme.html
16, 8
!"#$%^'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
`abcdefghijklmno
pqrstuvwxyz{|}~

BIN
charsets/apple2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

11
charsets/atari.char Normal file
View file

@ -0,0 +1,11 @@
// ATASCII (Atari ASCII)
atari.png
16, 8
!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
abcdefghijklmno
pqrstuvwxyz |

BIN
charsets/atari.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

11
charsets/atari_st.char Normal file
View file

@ -0,0 +1,11 @@
// Atari ST desktop font
atari_st.png
32, 8
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~

BIN
charsets/atari_st.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

18
charsets/bbc_master.char Normal file
View file

@ -0,0 +1,18 @@
// BBC Micro
bbc_master.png
16, 14
!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
£abcdefghijklmno
pqrstuvwxyz{|}~
//end

BIN
charsets/bbc_master.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

16
charsets/c64_edscii.char Normal file
View file

@ -0,0 +1,16 @@
// Commodore 64 (EDSCII version)
// first line: source image (base filename + .png assumed)
c64_edscii.png
// second line: map dimensions
16, 10
!"#$%&'()*+,-./
0123456789:;<=>?
@abcdefghijklmno
pqrstuvwxyz[ ]
ABCDEFGHIJKLMNO
PQRSTUVWXYZ
_
\
// end

BIN
charsets/c64_edscii.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

16
charsets/c64_petscii.char Normal file
View file

@ -0,0 +1,16 @@
// Commodore 64 (PETSCII editor version)
// first line: source image (base filename + .png assumed)
c64_petscii.png
// second line: map dimensions
16, 10
ABCDEFGHIJKLMNO
PQRSTUVWXYZ[ ]
@!"#$%&'()*+,-./
0123456789:;<=>?
abcdefghijklmnop
qrstuvwxyz
// end

BIN
charsets/c64_petscii.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,12 @@
// Commodore 64 (real hardware, shifted set)
c64_real_shifted.png
16, 8
!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[ ]
\

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,12 @@
// Commodore 64 (real hardware, unshifted set)
c64_real_unshifted.png
16, 8
!"#$%&'()*+,-./
0123456789:;<=>?
@abcdefghijklmno
pqrstuvwxyz[ ]
ABCDEFGHIJKLMNO
PQRSTUVWXYZ

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,13 @@
// Commodore PET
commodore_pet.png
16, 10
ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]
@!"#$%&'()*+,-./
0123456789:;<=>?
\
abcdefghijklmno
pqrstuvwxyz

BIN
charsets/commodore_pet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

17
charsets/cpc.char Normal file
View file

@ -0,0 +1,17 @@
// Amstrad CPC
cpc.png
16, 14
!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\] _
`abcdefghijklmno
pqrstuvwxyz{|}~
^`

BIN
charsets/cpc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

11
charsets/dos.char Normal file
View file

@ -0,0 +1,11 @@
// IBM PC code page 437 (aka "extended ASCII")
dos.png
32, 8
 
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~
ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒ
áíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐
└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀
αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ

BIN
charsets/dos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

19
charsets/dos40.char Normal file
View file

@ -0,0 +1,19 @@
// IBM PC code page 437 (40-column version)
dos40.png
16, 16
 

!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
`abcdefghijklmno
pqrstuvwxyz{|}~
ÇüéâäàåçêëèïîìÄÅ
ÉæÆôöòûùÿÖÜ¢£¥₧ƒ
áíóúñѪº¿⌐¬½¼¡«»
░▒▓│┤╡╢╖╕╣║╗╝╜╛┐
└┴┬├─┼╞╟╚╔╩╦╠═╬╧
╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀
αßΓπΣσµτΦΘΩδ∞φε∩
≡±≥≤⌠⌡÷≈°∙·√ⁿ

BIN
charsets/dos40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

6
charsets/dos_basic.char Normal file
View file

@ -0,0 +1,6 @@
// "Basic" (only keyboardable chars) ASCII
dos_basic.png
32, 3
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~

BIN
charsets/dos_basic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

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

BIN
charsets/intellivision.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

20
charsets/jpetscii.char Normal file
View file

@ -0,0 +1,20 @@
// custom, PETSCII-derived set by JP LeBreton
jpetscii.png
16, 16
ABCDEFGHIJKLMNO
PQRSTUVWXYZ[]{}
"abcdefghijklmno
pqrstuvwxyz
1234567890-=_+:;
!@#$%^&*()</>,.?
`'
// end

BIN
charsets/jpetscii.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

12
charsets/msx.char Normal file
View file

@ -0,0 +1,12 @@
// MSX home computers
msx.png
32, 8
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~
ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒ
áíóúñѪº¿⌐¬½¼¡«»ÃãĨĩÕõŨũIJij¾∽◇‰¶§
// end

BIN
charsets/msx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,4 @@
// by @lunlumoart
osamu-micro.png
24, 7

BIN
charsets/osamu-micro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

9
charsets/pacman.char Normal file
View file

@ -0,0 +1,9 @@
// Pac-Man ROM tiles
pacman.png
16, 6
ABCDEFGHIJKLMNO
PQRSTUVWXYZ!
0123456789/-"

BIN
charsets/pacman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,021 B

26
charsets/sharp.char Normal file
View file

@ -0,0 +1,26 @@
// Sharp MZ-700 aka "SharpSCII"
sharp.png
16, 22
!"#$%&'()<>[]/\
0123456789:;*=+-
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ,.?
abcdefghijklmno
pqrstuvwxyz

BIN
charsets/sharp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

10
charsets/speccy.char Normal file
View file

@ -0,0 +1,10 @@
// ZX Spectrum
speccy.png
16,7
!"#$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]↑_
£abcdefghijklmno
pqrstuvwxyz{|}~©

BIN
charsets/speccy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

13
charsets/teletext_uk.char Normal file
View file

@ -0,0 +1,13 @@
// Teletext (Mullard SAA5050 character generator)
teletext_uk.png
16, 10
!"£$%&'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ½#
─abcdefghijklmno
pqrstuvwxyz¼ ÷

BIN
charsets/teletext_uk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

9
charsets/ui.char Normal file
View file

@ -0,0 +1,9 @@
// Playscii default UI font
ui.png
27, 6
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789!@#$%^&*()
`-=[]\;',./~_+{}|:"<>?
óćø

BIN
charsets/ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

20
charsets/ultima4.char Normal file
View file

@ -0,0 +1,20 @@
// Ultima IV: Quest of the Avatar tileset
ultima4.png
16, 16
ABCDEFGHIJKLMNOP
QRSTUVWXYZ
// end

BIN
charsets/ultima4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

28
code_of_conduct.txt Normal file
View file

@ -0,0 +1,28 @@
Contributor Code of Conduct
Please note that this project is released with a Contributor Code of Conduct. By
participating in this project you agree to abide by its terms:
As contributors and maintainers of this project, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in this project a harassment-free experience for
everyone, regardless of level of experience, gender, gender identity and expression,
sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
Examples of unacceptable behavior by participants include the use of sexual language or
imagery, derogatory comments or personal attacks, trolling, public or private harassment,
insults, or other unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit, or reject comments,
commits, code, wiki edits, issues, and other contributions that are not aligned to this
Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed
from the project team.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
opening an issue or contacting one or more of the project maintainers.
This Code of Conduct is adapted from the Contributor Covenant
(http:contributor-covenant.org), version 1.0.0, available at
http://contributor-covenant.org/version/1/0/0/

577
collision.py Normal file
View file

@ -0,0 +1,577 @@
import math
from collections import namedtuple
from renderable import TileRenderable
from renderable_line import CircleCollisionRenderable, BoxCollisionRenderable, TileBoxCollisionRenderable
# collision shape types
CST_NONE = 0
"Don't use a CollisionShape"
CST_CIRCLE = 1
"Use a CircleCollisionShap"
CST_AABB = 2
"Use an AABBCollisionShape"
CST_TILE = 3
"""
Tile-based collision: generate multiple AABBCollisionShapes to approximate all
non-blank (character index 0) tiles of our GameObject's default Art's
"collision layer", whose string name is defined in GO.col_layer_name.
"""
# collision types
CT_NONE = 0
CT_PLAYER = 1
CT_GENERIC_STATIC = 2
CT_GENERIC_DYNAMIC = 3
# collision type groups, eg static and dynamic
CTG_STATIC = [CT_GENERIC_STATIC]
'"Collision type group", collections of CT_* values for more convenient checks.'
CTG_DYNAMIC = [CT_GENERIC_DYNAMIC, CT_PLAYER]
'"Collision type group", collections of CT_* values for more convenient checks.'
__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."
ShapeOverlap = namedtuple('ShapeOverlap', ['x', 'y', 'dist', 'area', 'other'])
__pdoc__['ShapeOverlap'] = "Represents a CollisionShape's overlap with another."
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 = []
for other in shapes:
if other is self:
continue
overlap = self.get_overlap(other)
if overlap.dist < 0:
overlaps.append(overlap)
if len(overlaps) == 0:
return
# resolve collisions in order of largest -> smallest overlap
overlaps.sort(key=lambda item: item.area, reverse=True)
for i,old_overlap in enumerate(overlaps):
# resolve first overlap without recalculating
overlap = self.get_overlap(old_overlap.other) if i > 0 else overlaps[0]
self.resolve_overlap(overlap)
def resolve_overlap(self, overlap):
"Resolve this shape's given overlap."
other = overlap.other
# tell objects they're overlapping, pass penetration vector
a_coll_b, a_started_b = self.go.overlapped(other.go, overlap)
b_coll_a, b_started_a = other.go.overlapped(self.go, overlap)
# if either object says it shouldn't collide with other, don't
if not a_coll_b or not b_coll_a:
return
# push shapes apart according to mass
total_mass = max(0, self.go.mass) + max(0, other.go.mass)
if self.go.is_dynamic():
if not other.go.is_dynamic() or other.go.mass < 0:
a_push = overlap.dist
else:
a_push = (self.go.mass / total_mass) * overlap.dist
# move parent object, not shape
self.go.x += a_push * overlap.x
self.go.y += a_push * overlap.y
# update all shapes based on object's new position
self.go.collision.update_transform_from_object()
if other.go.is_dynamic():
if not self.go.is_dynamic() or self.go.mass < 0:
b_push = overlap.dist
else:
b_push = (other.go.mass / total_mass) * overlap.dist
other.go.x -= b_push * overlap.x
other.go.y -= b_push * overlap.y
other.go.collision.update_transform_from_object()
# call objs' started_colliding once collisions have been resolved
world = self.go.world
if a_started_b:
world.try_object_method(self.go, self.go.started_colliding, [other.go])
if b_started_a:
world.try_object_method(other.go, other.go.started_colliding, [self.go])
def get_overlapping_static_shapes(self):
"Return a list of static shapes that overlap with this shape."
overlapping_shapes = []
shape_left, shape_top, shape_right, shape_bottom = self.get_box()
# add padding to overlapping tiles check
if False:
padding = 0.01
shape_left -= padding
shape_top -= padding
shape_right += padding
shape_bottom += padding
for obj in self.go.world.objects.values():
if obj is self.go or not obj.should_collide() or obj.is_dynamic():
continue
# always check non-tile-based static shapes
if obj.collision_shape_type != CST_TILE:
overlapping_shapes += obj.collision.shapes
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):
continue
overlapping_shapes += obj.collision.get_shapes_overlapping_box(shape_left, shape_top, shape_right, shape_bottom)
return overlapping_shapes
class CircleCollisionShape(CollisionShape):
"CollisionShape using a circle area."
def __init__(self, loc_x, loc_y, radius, game_object):
self.x, self.y = loc_x, loc_y
self.radius = radius
self.go = game_object
def get_box(self):
"Return world coordinates of our bounds (left, top, right, bottom)"
return self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius ** 2
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this circle overlaps given line segment."
return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2)
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is CircleCollisionShape:
px, py, pdist1, pdist2 = point_circle_penetration(self.x, self.y,
other.x, other.y,
self.radius + other.radius)
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)
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
class AABBCollisionShape(CollisionShape):
"CollisionShape using an axis-aligned bounding box area."
def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object):
self.x, self.y = loc_x, loc_y
self.halfwidth, self.halfheight = halfwidth, halfheight
self.go = game_object
# for CST_TILE objects, lists of tile(s) we cover
self.tiles = []
def get_box(self):
return self.x - self.halfwidth, self.y - self.halfheight, self.x + self.halfwidth, self.y + self.halfheight
def is_point_inside(self, x, y):
"Return True if given point is inside this shape."
return point_in_box(x, y, *self.get_box())
def overlaps_line(self, x1, y1, x2, y2):
"Return True if this box overlaps given line segment."
left, top, right, bottom = self.get_box()
return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2)
def get_overlap(self, other):
"Return ShapeOverlap data for this shape's overlap with given other."
if type(other) is AABBCollisionShape:
px, py, pdist1, pdist2 = box_penetration(self.x, self.y,
other.x, other.y,
self.halfwidth, self.halfheight,
other.halfwidth, other.halfheight)
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)
# reverse result if we're shape B
px, py = -px, -py
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
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
self.cl = self.go.world.cl
self.renderables, self.shapes = [], []
self.tile_shapes = {}
"Dict of shapes accessible by (x,y) tile coordinates"
self.contacts = {}
"Dict of contacts with other objects, by object name"
self.create_shapes()
def create_shapes(self):
"""
Create collision shape(s) appropriate to our game object's
collision_shape_type value.
"""
self._clear_shapes()
if self.go.collision_shape_type == CST_NONE:
return
elif self.go.collision_shape_type == CST_CIRCLE:
self._create_circle()
elif self.go.collision_shape_type == CST_AABB:
self._create_box()
elif self.go.collision_shape_type == CST_TILE:
self.tile_shapes.clear()
self._create_merged_tile_boxes()
# update renderables once if static
if not self.go.is_dynamic():
self.update_renderables()
def _clear_shapes(self):
for r in self.renderables:
r.destroy()
self.renderables = []
for shape in self.shapes:
self.cl._remove_shape(shape)
self.shapes = []
"List of CollisionShapes"
def _create_circle(self):
x = self.go.x + self.go.col_offset_x
y = self.go.y + self.go.col_offset_y
shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go)
self.shapes = [shape]
self.renderables = [CircleCollisionRenderable(shape)]
def _create_box(self):
x = self.go.x # + self.go.col_offset_x
y = self.go.y # + self.go.col_offset_y
shape = self.cl._add_box_shape(x, y,
self.go.col_width / 2,
self.go.col_height / 2,
self.go)
self.shapes = [shape]
self.renderables = [BoxCollisionRenderable(shape)]
def _create_merged_tile_boxes(self):
"Create AABB shapes for a CST_TILE object"
# generate fewer, larger boxes!
frame = self.go.renderable.frame
if not self.go.col_layer_name in self.go.art.layer_names:
self.go.app.dev_log("%s: Couldn't find collision layer with name '%s'" % (self.go.name, self.go.col_layer_name))
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
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):
continue
# determine how big we can make this box
# first fill left to right
end_x = x
while end_x < self.go.art.width - 1 and tile_available(end_x + 1, y):
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):
end_y += 1
# compute origin and halfsizes of box covering tile range
wx1, wy1 = self.go.get_tile_loc(x, y, tile_center=True)
wx2, wy2 = self.go.get_tile_loc(end_x, end_y, tile_center=True)
wx = (wx1 + wx2) / 2
halfwidth = (end_x - x) * self.go.art.quad_width
halfwidth /= 2
halfwidth += self.go.art.quad_width / 2
wy = (wy1 + wy2) / 2
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)
# 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):
for tile_x in range(x, end_x + 1):
self.tile_shapes[(tile_x, tile_y)] = shape
shape.tiles.append((tile_x, tile_y))
r = TileBoxCollisionRenderable(shape)
# update renderable once to set location correctly
r.update()
self.shapes.append(shape)
self.renderables.append(r)
def get_shape_overlapping_point(self, x, y):
"Return shape if it's overlapping given point, None if no overlap."
tile_x, tile_y = self.go.get_tile_at_point(x, y)
return self.tile_shapes.get((tile_x, tile_y), None)
def get_shapes_overlapping_box(self, left, top, right, bottom):
"Return a list of our shapes that overlap given box."
shapes = []
tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom)
for (x, y) in tiles:
shape = self.tile_shapes.get((x, y), None)
if shape and not shape in shapes:
shapes.append(shape)
return shapes
def update(self):
if self.go and self.go.is_dynamic():
self.update_transform_from_object()
def update_transform_from_object(self, obj=None):
"Snap our shapes to location of given object (if unspecified, our GO)."
obj = obj or self.go
# CST_TILE shouldn't run here, it's static-only
if obj.collision_shape_type == CST_TILE:
return
for shape in self.shapes:
shape.x = obj.x + obj.col_offset_x
shape.y = obj.y + obj.col_offset_y
def set_shape_color(self, shape, new_color):
"Set the color of a given shape's debug LineRenderable."
try:
shape_index = self.shapes.index(shape)
except ValueError:
return
self.renderables[shape_index].color = new_color
self.renderables[shape_index].build_geo()
self.renderables[shape_index].rebind_buffers()
def update_renderables(self):
for r in self.renderables:
r.update()
def render(self):
for r in self.renderables:
r.render()
def destroy(self):
for r in self.renderables:
r.destroy()
# remove our shapes from CollisionLord's shape list
for shape in self.shapes:
self.cl._remove_shape(shape)
class CollisionLord:
"""
Collision manager object, tracks Collideables, detects overlaps and
resolves collisions.
"""
iterations = 7
"""
Number of times to resolve collisions per update. Lower at own risk;
multi-object collisions require multiple iterations to settle correctly.
"""
def __init__(self, world):
self.world = world
self.ticks = 0
# list of objects processed for collision this frame
self.collisions_this_frame = []
self.reset()
def report(self):
print('%s: %s dynamic shapes, %s static shapes' % (self,
len(self.dynamic_shapes),
len(self.static_shapes)))
def reset(self):
self.dynamic_shapes, self.static_shapes = [], []
def _add_circle_shape(self, x, y, radius, game_object):
shape = CircleCollisionShape(x, y, radius, game_object)
if game_object.is_dynamic():
self.dynamic_shapes.append(shape)
else:
self.static_shapes.append(shape)
return shape
def _add_box_shape(self, x, y, halfwidth, halfheight, game_object):
shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object)
if game_object.is_dynamic():
self.dynamic_shapes.append(shape)
else:
self.static_shapes.append(shape)
return shape
def _remove_shape(self, shape):
if shape in self.dynamic_shapes:
self.dynamic_shapes.remove(shape)
elif shape in self.static_shapes:
self.static_shapes.remove(shape)
def update(self):
"Resolve overlaps between all relevant world objects."
for i in range(self.iterations):
# filter shape lists for anything out of room etc
valid_dynamic_shapes = []
for shape in self.dynamic_shapes:
if shape.go.should_collide():
valid_dynamic_shapes.append(shape)
for shape in valid_dynamic_shapes:
shape.resolve_overlaps_with_shapes(valid_dynamic_shapes)
for shape in valid_dynamic_shapes:
static_shapes = shape.get_overlapping_static_shapes()
shape.resolve_overlaps_with_shapes(static_shapes)
# check which objects stopped colliding
for obj in self.world.objects.values():
obj.check_finished_contacts()
self.ticks += 1
self.collisions_this_frame = []
# 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):
"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)):
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)
numer = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)
numer2 = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)
if denom == 0:
if numer == 0 and numer2 == 0:
# coincident
return False
# parallel
return False
ua = numer / denom
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
dir_x, dir_y = x2 - x1, y2 - y1
proj = wx * dir_x + wy * dir_y
if proj <= 0:
# line point 1 is closest
return x1, y1
vsq = dir_x ** 2 + dir_y ** 2
if proj >= vsq:
# line point 2 is closest
return x2, y2
else:
# 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)
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):
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)
def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
"Return True if given box overlaps given ray."
# TODO: determine if this can be adapted for line segments
# (just a matter of setting tmin/tmax properly?)
tmin, tmax = -math.inf, math.inf
dir_x, dir_y = x2 - x1, y2 - y1
if abs(dir_x) > 0:
tx1 = (left - x1) / dir_x
tx2 = (right - x1) / dir_x
tmin = max(tmin, min(tx1, tx2))
tmax = min(tmax, max(tx1, tx2))
if abs(dir_y) > 0:
ty1 = (top - y1) / dir_y
ty2 = (bottom - y1) / dir_y
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
return tmax >= tmin
def point_circle_penetration(point_x, point_y, circle_x, circle_y, radius):
"Return normalized penetration x, y, and distance for given circles."
dx, dy = circle_x - point_x, circle_y - point_y
pdist = math.sqrt(dx ** 2 + dy ** 2)
# point is center of circle, arbitrarily project out in +X
if pdist == 0:
return 1, 0, -radius, -radius
# TODO: calculate other axis of intersection for area?
return dx / pdist, dy / pdist, pdist - radius, pdist - radius
def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
"Return penetration vector and magnitude for given boxes."
left_a, right_a = ax - ahw, ax + ahw
top_a, bottom_a = ay + ahh, ay - ahh
left_b, right_b = bx - bhw, bx + bhw
top_b, bottom_b = by + bhh, by - bhh
# A to left or right of B?
px = right_a - left_b if ax <= bx else right_b - left_a
# A above or below B?
py = top_b - bottom_a if ay >= by else top_a - bottom_b
dx, dy = bx - ax, by - ay
widths, heights = ahw + bhw, ahh + bhh
# return separating axis + penetration depth (+ other axis for area calc)
if widths + px - abs(dx) < heights + py - abs(dy):
if dx >= 0:
return 1, 0, -px, -py
elif dx < 0:
return -1, 0, -px, -py
else:
if dy >= 0:
return 0, 1, -py, -px
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):
"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)
# find point on AABB edges closest to center of circle
# clamp = min(highest, max(lowest, val))
px = min(box_right, max(box_left, circle_x))
py = min(box_top, max(box_bottom, circle_y))
closest_x = circle_x - px
closest_y = circle_y - py
d = math.sqrt(closest_x ** 2 + closest_y ** 2)
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

376
cursor.py Normal file
View file

@ -0,0 +1,376 @@
import math, ctypes
import numpy as np
from OpenGL import GL
import vector
from edit_command import EditCommand
from renderable_sprite import UISpriteRenderable
"""
reference diagram:
0 0.2 0.8 1.0
A--------B *--------*
| | | |
0.1 | D-----C *-----* |
| | | |
| | | |
0.2 F--E *--*
etc
"""
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
]
# vert indices for the above
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)
]
# offsets to translate the 4 corners by
corner_offsets = [
(0, 0),
(1, 0),
(0, -1),
(1, -1)
]
BASE_COLOR = (0.8, 0.8, 0.8, 1)
# why do we use the weird transforms and offsets?
# 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'
alpha = 1
icon_scale_factor = 4
logg = False
def __init__(self, app):
self.app = app
self.x, self.y, self.z = 0, 0, 0
self.last_x, self.last_y = 0, 0
self.scale_x, self.scale_y, self.scale_z = 1, 1, 1
# list of EditCommandTiles for preview
self.preview_edits = []
self.current_command = None
# offsets to render the 4 corners at
self.mouse_x, self.mouse_y = 0, 0
self.moved = False
self.color = np.array(BASE_COLOR, dtype=np.float32)
# GL objects
if self.app.use_vao:
self.vao = GL.glGenVertexArrays(1)
GL.glBindVertexArray(self.vao)
self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2)
self.vert_array = np.array(corner_verts, dtype=np.float32)
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.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)
# shader, attributes
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')
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)
# 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')
# finish
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
if self.app.use_vao:
GL.glBindVertexArray(0)
# init tool sprite, tool will provide texture when rendered
self.tool_sprite = UISpriteRenderable(self.app)
def clamp_to_active_art(self):
self.x = max(0, min(self.x, self.app.ui.active_art.width - 1))
self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1))
def keyboard_move(self, delta_x, delta_y):
if not self.app.ui.active_art:
return
self.x += delta_x
self.y += delta_y
self.clamp_to_active_art()
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))
def set_scale(self, new_scale):
self.scale_x = self.scale_y = new_scale
def get_tile(self):
# adjust for brush size
size = self.app.ui.selected_tool.brush_size
if size:
size_offset = math.ceil(size / 2) - 1
return int(self.x + size_offset), int(-self.y + size_offset)
else:
return int(self.x), int(-self.y)
def center_in_art(self):
art = self.app.ui.active_art
if not art:
return
self.x = round(art.width / 2) * art.quad_width
self.y = round(-art.height / 2) * art.quad_height
self.moved = True
# !!TODO!! finish this, work in progress
def get_tiles_under_drag(self):
"""
returns list of tuple coordinates of all tiles under cursor's current
position AND tiles it's moved over since last update
"""
# TODO: get vector of last to current position, for each tile under
# current brush, do line trace along grid towards last point
# TODO: this works in two out of four diagonals,
# swap current and last positions to determine delta?
if self.last_x <= self.x:
x0, y0 = self.last_x, -self.last_y
x1, y1 = self.x, -self.y
else:
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(tiles)
return tiles
def get_tiles_under_brush(self):
"""
returns list of tuple coordinates of all tiles under the cursor @ its
current brush size
"""
size = self.app.ui.selected_tool.brush_size
tiles = []
x_start, y_start = int(self.x), int(-self.y)
for y in range(y_start, y_start + size):
for x in range(x_start, x_start + size):
tiles.append((x, y))
return tiles
def undo_preview_edits(self):
for edit in self.preview_edits:
edit.undo()
def update_cursor_preview(self):
# rebuild list of cursor preview commands
if self.app.ui.selected_tool.show_preview:
self.preview_edits = self.app.ui.selected_tool.get_paint_commands()
for edit in self.preview_edits:
edit.apply()
else:
self.preview_edits = []
def start_paint(self):
if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements:
return
if self.app.ui.selected_tool is self.app.ui.grab_tool:
self.app.ui.grab_tool.grab()
return
# start a new command group, commit and clear any preview edits
self.current_command = EditCommand(self.app.ui.active_art)
self.current_command.add_command_tiles(self.preview_edits)
self.preview_edits = []
self.app.ui.active_art.set_unsaved_changes(True)
#print(self.app.ui.active_art.command_stack)
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:
return
# push current command group onto undo stack
if not self.current_command:
return
self.current_command.finish_time = self.app.get_elapsed_time()
self.app.ui.active_art.command_stack.commit_commands([self.current_command])
self.current_command = None
# tools like rotate produce a different change each time, so update again
if self.app.ui.selected_tool.update_preview_after_paint:
self.update_cursor_preview()
#print(self.app.ui.active_art.command_stack)
def moved_this_frame(self):
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)
def snap_to_tile(self):
w, h = self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height
char_aspect = w / h
# round result for oddly proportioned charsets
self.x = round(math.floor(self.x / w) * w)
self.y = round(math.ceil(self.y / h) * h * char_aspect)
def pre_first_update(self):
# vector.screen_to_world result will be off because camera hasn't
# moved yet, recalc view matrix
self.app.camera.calc_view_matrix()
self.reposition_from_mouse()
self.snap_to_tile()
self.update_cursor_preview()
self.entered_new_tile()
def update(self):
# save old positions before update
self.last_x, self.last_y = self.x, self.y
# pulse alpha and scale
self.alpha = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2)
#self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5)
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):
# 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()
# cursor always at depth of active layer
art = self.app.ui.active_art
self.z = art.layers_z[art.active_layer] if art else 0
self.moved = True
if not self.moved and not self.app.ui.tool_settings_changed:
return
if not self.app.keyboard_editing and not self.app.ui.tool_settings_changed:
self.snap_to_tile()
# adjust for brush size
if self.app.ui.selected_tool.brush_size:
size = self.app.ui.selected_tool.brush_size
self.scale_x = self.scale_y = size
# don't reposition on resize if keyboard navigating
if mouse_moved:
size_offset = math.ceil(size / 2) - 1
self.x -= size_offset
self.y += size_offset
else:
self.scale_x = self.scale_y = 1
self.undo_preview_edits()
self.update_cursor_preview()
if self.moved_this_frame():
self.entered_new_tile()
def end_update(self):
"called at the end of App.update"
self.moved = False
def entered_new_tile(self):
if self.current_command and self.app.ui.selected_tool.paint_while_dragging:
# add new tile(s) to current command group
self.current_command.add_command_tiles(self.preview_edits)
self.app.ui.active_art.set_unsaved_changes(True)
self.preview_edits = []
def render(self):
GL.glUseProgram(self.shader.program)
GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix)
GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix)
GL.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.glUniform1f(self.alpha_uniform, self.alpha)
# VAO vs non-VAO paths
if self.app.use_vao:
GL.glBindVertexArray(self.vao)
else:
attrib = self.shader.get_attrib_location # for brevity
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'))
# 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!
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 4 corners
for i in range(4):
tx,ty = corner_transforms[i][0], corner_transforms[i][1]
ox,oy = corner_offsets[i][0], corner_offsets[i][1]
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.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
GL.glDisable(GL.GL_BLEND)
if self.app.use_vao:
GL.glBindVertexArray(0)
GL.glUseProgram(0)
# position and render tool icon
ui = self.app.ui
# special handling for quick grab
if self.app.right_mouse:
self.tool_sprite.texture = ui.grab_tool.get_icon_texture()
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
scale_y = self.tool_sprite.texture.height / self.app.window_height
scale_y *= self.icon_scale_factor * self.app.ui.scale
self.tool_sprite.scale_y = scale_y
# top left of icon at bottom right of cursor
size = ui.selected_tool.brush_size or 1
x, y = self.x, self.y
x += size * ui.active_art.quad_width
# non-square charsets a bit tricky to properly account for
char_aspect = ui.active_art.quad_height / ui.active_art.quad_width
y -= (size / char_aspect) * ui.active_art.quad_height
y *= char_aspect
sx, sy = vector.world_to_screen_normalized(self.app, x, y, self.z)
# screen-space offset by icon's height
sy -= scale_y
self.tool_sprite.x, self.tool_sprite.y = sx, sy
self.tool_sprite.render()

17
docs/bugs.txt Normal file
View file

@ -0,0 +1,17 @@
bug list
PNG export: layers that have too high a Z won't show up in export, special-case behavior for near/far Z in renderable export?
rewrite Cursor.screen_to_world to produce same results as gluUnProject:
https://www.opengl.org/wiki/GluProject_and_gluUnProject_code
https://www.opengl.org/sdk/docs/man2/xhtml/gluUnProject.xml
more on above: when camera tilt engaged, cursor is closest to accurate along middle of bottom edge - apply aspect correction to both axes?
multiplying y by aspect (w/h) causes more distortion, but recenters most accurate point at middle of screen instead
lower priority:
problem discovered during 2015-01-04~06:
GLSL really can't handle int/uint attributes! charIndex looks fine in numpy int32 array data but comes into GLSL totally screwy. works fine when the array and attribute are floats instead. bug for PyOpenGL devs?
possible test program: two quads side by side, each doing some trivial shader that involves an arbitrary number, only difference being one is driven by an int attribute and the other by a float.

View file

@ -0,0 +1,50 @@
~/src/_/playscii ♪ python3 playscii.py
Playscii v0.1.1
OpenGL detected: 4.1 NVIDIA-10.0.43 310.41.05f01
GLSL detected: 4.10
Vertex Array Object support found.
creating new document art/new.psci
Traceback (most recent call last):
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 41, in __call__
return self._finalCall( *args, **named )
TypeError: 'NoneType' object is not callable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "playscii.py", line 531, in <module>
app = Application(log_file, log_lines, file_to_load)
File "playscii.py", line 117, in __init__
self.load_art(art_filename)
File "playscii.py", line 163, in load_art
art = self.new_art(filename)
File "playscii.py", line 139, in new_art
charset = self.load_charset(self.starting_charset)
File "playscii.py", line 190, in load_charset
new_charset = CharacterSet(self, charset_to_load, log)
File "/Users/jesper/src/_/playscii/charset.py", line 73, in __init__
self.texture = Texture(img.tostring(), self.image_width, self.image_height)
File "/Users/jesper/src/_/playscii/texture.py", line 16, in __init__
self.gltex = GL.glGenTextures(1)
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 61, in __call__
return self.wrapperFunction( self.baseFunction, *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/GL/exceptional.py", line 178, in glGenTextures
baseFunction( count, textures)
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 45, in __call__
return self._finalCall( *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/wrapper.py", line 664, in wrapperCall
raise err
File "/usr/local/lib/python3.4/site-packages/OpenGL/wrapper.py", line 657, in wrapperCall
result = wrappedOperation( *cArguments )
File "/usr/local/lib/python3.4/site-packages/OpenGL/platform/baseplatform.py", line 402, in __call__
return self( *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/error.py", line 232, in glCheckError
baseOperation = baseOperation,
OpenGL.error.GLError: GLError(
err = 1282,
description = b'invalid operation',
baseOperation = glGenTextures,
pyArgs = (1, c_uint(1)),
cArgs = (1, <cparam 'P' (0x110ef9450)>),
cArguments = (1, <cparam 'P' (0x110ef9450)>)
)

View file

@ -0,0 +1,61 @@
winXP 32-bit virtualbox image, hardware acceleration enabled
OpenGL Warning: No pincher, please call crStateSetCurrentPointers() in your SPU
creating new document art/new.psci
Traceback (most recent call last):
File "playscii.py", line 491, in <module>
app = Application(log_file, log_lines, file_to_load)
File "playscii.py", line 87, in __init__
self.load_art(art_filename)
File "playscii.py", line 130, in load_art
art = self.new_art(filename)
File "playscii.py", line 106, in new_art
charset = self.load_charset(self.starting_charset)
File "playscii.py", line 159, in load_charset
new_charset = CharacterSet(self, charset_to_load, log)
File "c:\playscii\charset.py", line 73, in __init__
self.texture = Texture(img.tostring(), self.image_width, self.image_height)
File "c:\playscii\texture.py", line 24, in __init__
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)
File "c:\python34\lib\site-packages\OpenGL\platform\baseplatform.py", line 407, in __call__
self.__name__, self.__name__,
OpenGL.error.NullFunctionError: Attempt to call an undefined function glGenerateMipmap, check for bool(glGenerateMipmap) before calling
---
bind VAO before texture stuff:
OpenGL Warning: No pincher, please call crStateSetCurrentPointers() in your SPU
Traceback (most recent call last):
File "c:\python34\lib\site-packages\OpenGL\latebind.py", line 41, in __call__
return self._finalCall( *args, **named )
TypeError: 'NoneType' object is not callable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "playscii.py", line 491, in <module>
app = Application(log_file, log_lines, file_to_load)
File "playscii.py", line 87, in __init__
self.load_art(art_filename)
File "playscii.py", line 130, in load_art
art = self.new_art(filename)
File "playscii.py", line 106, in new_art
charset = self.load_charset(self.starting_charset)
File "playscii.py", line 159, in load_charset
new_charset = CharacterSet(self, charset_to_load, log)
File "c:\playscii\charset.py", line 73, in __init__
self.texture = Texture(img.tostring(), self.image_width, self.image_height)
File "c:\playscii\texture.py", line 17, in __init__
vao = GL.glGenVertexArrays(1)
File "c:\python34\lib\site-packages\OpenGL\latebind.py", line 45, in __call__
return self._finalCall( *args, **named )
File "c:\python34\lib\site-packages\OpenGL\wrapper.py", line 657, in wrapperCall
result = wrappedOperation( *cArguments )
File "c:\python34\lib\site-packages\OpenGL\platform\baseplatform.py", line 407, in __call__
self.__name__, self.__name__,
OpenGL.error.NullFunctionError: Attempt to call an undefined function glGenVertexArrays, check for bool(glGenVertexArrays) before calling

37
docs/bugs/terryc.txt Normal file
View file

@ -0,0 +1,37 @@
Traceback (most recent call last):
File ".\OpenGL\latebind.py", line 41, in __call__
return self._finalCall( *args, **named )
TypeError: 'NoneType' object is not callable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "playscii.py", line 489, in <module>
File "playscii.py", line 85, in __init__
File "playscii.py", line 128, in load_art
File "playscii.py", line 104, in new_art
File "playscii.py", line 157, in load_charset
File "c:\playscii\charset.py", line 73, in __init__
File "c:\playscii\texture.py", line 16, in __init__
File ".\OpenGL\latebind.py", line 61, in __call__
return self.wrapperFunction( self.baseFunction, *args, **named )
File ".\OpenGL\GL\exceptional.py", line 178, in glGenTextures
baseFunction( count, textures)
File ".\OpenGL\latebind.py", line 45, in __call__
return self._finalCall( *args, **named )
File ".\OpenGL\wrapper.py", line 664, in wrapperCall
raise err
File ".\OpenGL\wrapper.py", line 657, in wrapperCall
result = wrappedOperation( *cArguments )
File ".\OpenGL\platform\baseplatform.py", line 402, in __call__
return self( *args, **named )
File ".\OpenGL\error.py", line 232, in glCheckError
baseOperation = baseOperation,
OpenGL.error.GLError: GLError(
err = 1282,
description = b'invalid operation',
baseOperation = glGenTextures,
pyArgs = (1, c_ulong(0)),
cArgs = (1, <cparam 'P' (000000000464FB90)>),
cArguments = (1, <cparam 'P' (000000000464FB90)>)
)

5
docs/bugs/v21.txt Normal file
View file

@ -0,0 +1,5 @@
tried launching in Parallels (as I run a Mac). Didn't work! playscii.log reads:
Traceback (most recent call last):
File "playscii.py", line 12, in <module>
File "c:\python34\lib\site-packages\zipextimporter.py", line 116, in load_module
zipimport.ZipImportError: can't find module sdl2

51
docs/bugs/zensaiyuki.txt Normal file
View file

@ -0,0 +1,51 @@
Playscii v0.3.1
OpenGL detected: 4.1 INTEL-10.2.46
GLSL detected: 4.10
Vertex Array Object support found.
creating new document art/new.psci
Traceback (most recent call last):
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 41, in __call__
return self._finalCall( *args, **named )
TypeError: 'NoneType' object is not callable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "playscii.py", line 664, in <module>
app = Application(log_file, log_lines, file_to_load)
File "playscii.py", line 123, in __init__
self.load_art(art_filename)
File "playscii.py", line 187, in load_art
art = self.new_art(filename)
File "playscii.py", line 163, in new_art
charset = self.load_charset(self.starting_charset)
File "playscii.py", line 223, in load_charset
new_charset = CharacterSet(self, charset_to_load, log)
File "/Users/bretonslivka/Downloads/JPLeBreton-playscii-337ccff3951d/charset.py", line 74, in __init__
self.texture = Texture(img.tostring(), self.image_width, self.image_height)
File "/Users/bretonslivka/Downloads/JPLeBreton-playscii-337ccff3951d/texture.py", line 16, in __init__
self.gltex = GL.glGenTextures(1)
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 61, in __call__
return self.wrapperFunction( self.baseFunction, *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/GL/exceptional.py", line 178, in glGenTextures
baseFunction( count, textures)
File "/usr/local/lib/python3.4/site-packages/OpenGL/latebind.py", line 45, in __call__
return self._finalCall( *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/wrapper.py", line 664, in wrapperCall
raise err
File "/usr/local/lib/python3.4/site-packages/OpenGL/wrapper.py", line 657, in wrapperCall
result = wrappedOperation( *cArguments )
File "/usr/local/lib/python3.4/site-packages/OpenGL/platform/baseplatform.py", line 402, in __call__
return self( *args, **named )
File "/usr/local/lib/python3.4/site-packages/OpenGL/error.py", line 232, in glCheckError
baseOperation = baseOperation,
OpenGL.error.GLError: GLError(
err = 1282,
description = b'invalid operation',
baseOperation = glGenTextures,
pyArgs = (1, c_uint(1)),
cArgs = (1, <cparam 'P' (0x101e1dbc0)>),
cArguments = (1, <cparam 'P' (0x101e1dbc0)>)
)
zenpsycho:JPLeBreton-playscii-337ccff3951d bretonslivka$

View file

@ -0,0 +1,87 @@
second design approach circa first week of january 2015
art stores no intermediate lists, just arrays of ints for char, fg & bg colors
shader does the rest!
compute char UV in the vert shader
get fg and bg colors from index by sampling from a 1D palette texture
pass in texture dimensions as uniforms (2 bits of data that don't change per object)
art's get and set methods compute index into array and return/set directly
no layer or frame objects! an Art keeps lists of em, and a few lists for data that used to live in each instance eg frame delay and layer Z.
---
design notes circa xmas 2014
art only changes when user edits
renderable updates from art when user edits or animation frame changes
is a texture lookup for the palette (1D or otherwise) even necessary? table of colors in the renderable's color buffers might be sufficient
is iterating through every tile in an art (layer) to update a renderable going to be bad for perf when lots of renderables are doing it every few frames?
PROBABLY, YES
so: arts generate the openGL arrays (verts, elements, UVs, colors) and keep them around, update them piecemeal and only when edits are made; renderables grab these arrays from their art and resubmit the buffer data as needed
---
ascii engine - playscii?
early notes circa september/october 2014
core principles:
- ASCII art with transparency and multiple layers (mainly for easier editing but also FX)
- animation support - multiple frames per file, # of layers constant across frames
- edit (art and animation) mode integrated with live game mode, press a key to start editing a game you're playing
-- NOT a full game creation tool, no in-app code editing or visual scripting - game behavior defined through python objects/compionents
- important stuff hot reloads, definitely: sprites, animations, shaders(?), possibly: character sets, palettes
- MDI: edit multiple files
X developed in tandem + ships with example games in different styles: a top-down vs side view, realtime vs turn-based
-- on hold: all work goes towards Secret Game Project
---
- copy unity's rad ortho/persp camera mode switch a la https://twitter.com/pixelatedcrown/status/530857568240168960
- characters can be >1 gridsquares big (but are always 1:1 aspect?)
- objects can consist of >1 gridsquares
- files can have multiple "pages", eg for animation
-- how to define frame timing? for an anim, each page stays up for a certain time
- edit mode vs play mode
-- or: edit mode (like a level editor) vs paint mode (like edscii) vs play mode
- MDI: multiple files can be open, switch between em
- file references update when you change the referenced file (edit a sprite, see changes immediately)
- "transparent" is a valid BG or FG color
- objects can reference files, their pages define animations
- objects specify whether they move on char grid or pixel grid
- layers can have z-depths set, only drawn in "play" mode
- levels: single page of a file, collision can be painted in a special layer
- edit mode concepts: file, page/frame, layer, tile, character, color
- animation playback in sub window? edit while watching anim, set pages that define anim and their timings
- play mode concepts: world (collection of levels?), level, layer, object, sprite, animation
- levels (screens) can scroll on char grid or pixel grid
- selection of different CRT emulation shaders
test content:
- fireworks animation
- matrix screensaver-like noninteractive nonanimation
- endless ladder climbing remake
- real example game: "escape tunnel"
work:
- UI mockups
- architecture, UI/concept classes
- what drives object behavior?
- how does edit mode work exactly?
-- pick and place objects from a library?
-- how to edit object properties?
-- how to specify connections between objects?
references:
- Mark Wonnacott's kooltool: http://ragzouken.itch.io/kooltool
- libtcod / libdoryen: http://doryen.eptalys.net/libtcod
edscii daily doodle ideas:
- big wizard
- toucan
- tunnels under earth

BIN
docs/design/mock1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
docs/design/mock2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -0,0 +1,29 @@
first design approach notes, started 2014-12-31 abandoned 2015-01-03
geo + uvs + color data for all layers on a frame are precomputed for each frame. when renderables animate they just bind different arrays
art.vert_array, art.elem_array: geo is same across all frames
ArtFrame.uv/fg/bg_array: uv/fg/bg color arrays for all layers
Art.init(): initialize lists for 1 frame with 1 blank layer of specified size, update all tiles' char/fg/bg
ArtFromDisk.init(): populate lists from saved data, update all tiles' char/fg/bg
art.build_geo(): create vert and elem arrays for given size and update all tiles' char/colors - okay if it's slower because this only happens on init/resize
ArtFrame.build_arrays(): creates uv/fgcolor/bgcolor arrays
art.set_char_index/color_at(): only called by user or sim edits, update internal lists only, add each changed tile to "to update" lists: "characters to update", "fg/bg colors to update"
art.update(): called from app.update(): process "to update" lists, calling art.update_char_array etc as needed (theoretrically this could be parallelized if lots of chars + colors are changing?)
art.update_char_array: computes index into uv array for given tile, sets uvs
art.update_color_array(fg=True): computers index into color array for given tile, sets color data
art.do_test(): add layers and frames, set chars and colors manually
renderable.update(): called from app.update(): has our art updated, or is it time to swap in a new animation frame? update buffers from art's arrays accordingly
- investigate whether storing color (vec4) per vertex is better than color index (U of 1D color texture?)

View file

@ -0,0 +1,62 @@
example: an art with 4 frames and 3 layers
Art
|width, height, charset, palette: stuff that's written to / read from disk
|renderables: list of renderables using us
|vert_array, elem_array: geo array for all layers (changes on: resize, layer add/del)
|update lists: tiles of specific frame+layers whose array data we should update
|frames
|0
|delay: time before display next frame
|uv_array, fg_color_array, bg_color array: arrays for all layers of this frame
| (changes on: tile edit, art resize, layer add/del)
|layers
|0
|z: z depth for this layer
|chars, fg_colors, bg_colors: data (lists of rows) for this layer
|1
|z
|chars, fg_colors, bg_colors
|2
|z
|chars, fg_colors, bg_colors
|1
|delay
|uv_array, fg_color_array, bg_color array
|layers
|0
|z
|chars, fg_colors, bg_colors
|1
|z
|chars, fg_colors, bg_colors
|2
|z
|chars, fg_colors, bg_colors
|2
|delay
|uv_array, fg_color_array, bg_color array
|layers
|0
|z
|chars, fg_colors, bg_colors
|1
|z
|chars, fg_colors, bg_colors
|2
|z
|chars, fg_colors, bg_colors
|3
|delay
|uv_array, fg_color_array, bg_color array
|layers
|0
|z
|chars, fg_colors, bg_colors
|1
|z
|chars, fg_colors, bg_colors
|2
|z
|chars, fg_colors, bg_colors

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

11
docs/docs_todo.txt Normal file
View file

@ -0,0 +1,11 @@
documentation TODO
auto-generated docs, add strings to code as needed
readme in each example game dir explaining what it demonstrates
GameWorld/GameObject/GameHUD/GameRoom full public API
underlying Playscii classes, eg Art and Renderable
license clarification: any art or games you /create/ with Playscii does not inherit this license, you're free to set terms on your own work

21
docs/feedback.txt Normal file
View file

@ -0,0 +1,21 @@
user feedback
---
from @Flipyap 2015-01-24:
The way EDSCII works makes it pretty hard to shade things the old-fashioned ASCII way, without the use of colors.
I'd like to be able to bind characters to number keys for quick access to a palette of most frequently used characters/shapes.
The second one is weirder, but it's based on the way an ASCII artist's brain operates.
A pixel weight brush - draw random characters comprised of the same number of pixels (with a slider from fewest to most pixels).
With maybe a lock button to make the brush use the same set of characters for consistency.
Also, the current character palette makes it hard to distinguish connected shaped. It could use some spacing or a grid.
It would also be nice if different tool groups used different cursors. I often couldn't tell if I was in paint or erase mode.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
docs/html/art_crt_off.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
docs/html/art_crt_on.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/html/art_layermenu.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/html/art_mask1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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