kaypy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kaypy-0.1.0/DEVELOPING.md +333 -0
- kaypy-0.1.0/GUIDE.md +1524 -0
- kaypy-0.1.0/LICENSE +33 -0
- kaypy-0.1.0/MANIFEST.in +13 -0
- kaypy-0.1.0/PKG-INFO +437 -0
- kaypy-0.1.0/README.md +406 -0
- kaypy-0.1.0/examples/CREDITS.md +134 -0
- kaypy-0.1.0/examples/dungeon/elf_m.png +0 -0
- kaypy-0.1.0/examples/dungeon.png +0 -0
- kaypy-0.1.0/examples/gen_assets.py +60 -0
- kaypy-0.1.0/examples/images/bean.png +0 -0
- kaypy-0.1.0/examples/images/coin.png +0 -0
- kaypy-0.1.0/examples/images/dino_0.png +0 -0
- kaypy-0.1.0/examples/images/dino_1.png +0 -0
- kaypy-0.1.0/examples/images/dino_2.png +0 -0
- kaypy-0.1.0/examples/images/dino_3.png +0 -0
- kaypy-0.1.0/examples/images/dino_4.png +0 -0
- kaypy-0.1.0/examples/images/dino_5.png +0 -0
- kaypy-0.1.0/examples/images/dino_6.png +0 -0
- kaypy-0.1.0/examples/images/dino_7.png +0 -0
- kaypy-0.1.0/examples/images/dino_8.png +0 -0
- kaypy-0.1.0/examples/images/ghosty.png +0 -0
- kaypy-0.1.0/examples/images/grass.png +0 -0
- kaypy-0.1.0/examples/images/portal.png +0 -0
- kaypy-0.1.0/examples/images/spike.png +0 -0
- kaypy-0.1.0/examples/images/steel.png +0 -0
- kaypy-0.1.0/examples/lesson10_levels.py +43 -0
- kaypy-0.1.0/examples/lesson11_camera.py +58 -0
- kaypy-0.1.0/examples/lesson12_sprite_atlas.py +100 -0
- kaypy-0.1.0/examples/lesson13_state_ai.py +61 -0
- kaypy-0.1.0/examples/lesson1_adding_object.py +12 -0
- kaypy-0.1.0/examples/lesson2_player_movement.py +25 -0
- kaypy-0.1.0/examples/lesson3_collision.py +48 -0
- kaypy-0.1.0/examples/lesson5_gravity.py +39 -0
- kaypy-0.1.0/examples/lesson6_sprite_animation.py +67 -0
- kaypy-0.1.0/examples/lesson7_scenes.py +91 -0
- kaypy-0.1.0/examples/lesson8_audio_buttons.py +38 -0
- kaypy-0.1.0/examples/lesson9_timer_loop.py +17 -0
- kaypy-0.1.0/examples/sounds/background.wav +0 -0
- kaypy-0.1.0/examples/sounds/ding.wav +0 -0
- kaypy-0.1.0/examples/sounds/screech.wav +0 -0
- kaypy-0.1.0/kaplay/__init__.py +192 -0
- kaypy-0.1.0/kaplay/assets.py +127 -0
- kaypy-0.1.0/kaplay/callutil.py +54 -0
- kaypy-0.1.0/kaplay/camera.py +44 -0
- kaypy-0.1.0/kaplay/cli.py +86 -0
- kaypy-0.1.0/kaplay/comps/__init__.py +0 -0
- kaypy-0.1.0/kaplay/comps/area.py +24 -0
- kaypy-0.1.0/kaplay/comps/body.py +50 -0
- kaypy-0.1.0/kaplay/comps/move.py +67 -0
- kaypy-0.1.0/kaplay/comps/pos.py +52 -0
- kaypy-0.1.0/kaplay/comps/shapes.py +96 -0
- kaypy-0.1.0/kaplay/comps/sprite.py +86 -0
- kaypy-0.1.0/kaplay/comps/state.py +66 -0
- kaypy-0.1.0/kaplay/comps/transform.py +104 -0
- kaypy-0.1.0/kaplay/debugmod.py +10 -0
- kaypy-0.1.0/kaplay/engine.py +311 -0
- kaypy-0.1.0/kaplay/events.py +128 -0
- kaypy-0.1.0/kaplay/gameobj.py +192 -0
- kaypy-0.1.0/kaplay/geometry.py +53 -0
- kaypy-0.1.0/kaplay/kaboom.py +42 -0
- kaypy-0.1.0/kaplay/level.py +44 -0
- kaypy-0.1.0/kaplay/physics.py +208 -0
- kaypy-0.1.0/kaplay/render.py +119 -0
- kaypy-0.1.0/kaplay/starter/CREDITS.md +134 -0
- kaypy-0.1.0/kaplay/starter/dungeon/elf_m.png +0 -0
- kaypy-0.1.0/kaplay/starter/dungeon.png +0 -0
- kaypy-0.1.0/kaplay/starter/game.py +81 -0
- kaypy-0.1.0/kaplay/starter/images/bean.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/coin.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_0.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_1.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_2.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_3.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_4.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_5.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_6.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_7.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/dino_8.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/ghosty.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/grass.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/portal.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/spike.png +0 -0
- kaypy-0.1.0/kaplay/starter/images/steel.png +0 -0
- kaypy-0.1.0/kaplay/starter/sounds/background.wav +0 -0
- kaypy-0.1.0/kaplay/starter/sounds/ding.wav +0 -0
- kaypy-0.1.0/kaplay/starter/sounds/screech.wav +0 -0
- kaypy-0.1.0/kaplay/timers.py +57 -0
- kaypy-0.1.0/kaplay/vec2.py +114 -0
- kaypy-0.1.0/kaplay/webbuild.py +361 -0
- kaypy-0.1.0/kaypy.egg-info/PKG-INFO +437 -0
- kaypy-0.1.0/kaypy.egg-info/SOURCES.txt +105 -0
- kaypy-0.1.0/kaypy.egg-info/dependency_links.txt +1 -0
- kaypy-0.1.0/kaypy.egg-info/entry_points.txt +2 -0
- kaypy-0.1.0/kaypy.egg-info/requires.txt +5 -0
- kaypy-0.1.0/kaypy.egg-info/top_level.txt +1 -0
- kaypy-0.1.0/pyproject.toml +60 -0
- kaypy-0.1.0/setup.cfg +4 -0
- kaypy-0.1.0/tests/test_core.py +86 -0
- kaypy-0.1.0/tests/test_decorators.py +174 -0
- kaypy-0.1.0/tests/test_guide_code.py +98 -0
- kaypy-0.1.0/tests/test_lazy_key_map.py +49 -0
- kaypy-0.1.0/tests/test_packaging.py +108 -0
- kaypy-0.1.0/tests/test_physics.py +77 -0
- kaypy-0.1.0/tests/test_tile_floor_landing.py +110 -0
- kaypy-0.1.0/tests/test_web_platform_guard.py +76 -0
- kaypy-0.1.0/webbuild.py +19 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Developer notes
|
|
2
|
+
|
|
3
|
+
How kaypy is put together, what's been verified and how, where it's still
|
|
4
|
+
thin, and the bugs that were expensive enough to be worth writing down.
|
|
5
|
+
|
|
6
|
+
For how to *use* the engine, see [README.md](README.md).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
- **`kaplay/gameobj.py`** — `GameObj` merges each component's attributes onto
|
|
13
|
+
itself via `__getattr__`/`__setattr__` delegation, the same way KAPLAY's JS
|
|
14
|
+
does `Object.assign`. Calling `.jump()` on an object without `body()` raises
|
|
15
|
+
exactly the `AttributeError` the guide's troubleshooting section describes.
|
|
16
|
+
- **`kaplay/comps/`** — one file per component family. Components are data
|
|
17
|
+
holders with optional `add`/`update`/`destroy` hooks; nothing draws itself.
|
|
18
|
+
- **`kaplay/geometry.py`** — the one place that turns `pos()` + `anchor()` +
|
|
19
|
+
`scale()` + parent offsets into a world-space rect. Both collision
|
|
20
|
+
(`area.py`) and rendering (`render.py`) call into it, so "does this touch
|
|
21
|
+
that" and "where does this draw" can never disagree.
|
|
22
|
+
- **`kaplay/physics.py`** — gravity and integration, then AABB overlap and
|
|
23
|
+
resolution. Physical pushback only happens between two objects that **both**
|
|
24
|
+
have `body()`; a coin or enemy with only `area()` is a pass-through trigger,
|
|
25
|
+
per Lesson 3. Collision rects are `pygame.FRect` (float precision) rather
|
|
26
|
+
than `pygame.Rect` — a resting object's sub-pixel gravity drift would
|
|
27
|
+
otherwise round away and make `isGrounded()` flicker.
|
|
28
|
+
- **`kaplay/engine.py`** — the `Engine` singleton: window, clock, asset/event/
|
|
29
|
+
timer managers, camera, and the frame loop. The loop is `asyncio`-shaped so
|
|
30
|
+
the same body drives both the native run and the pygbag web build.
|
|
31
|
+
- **`kaplay/render.py`** — draws sprite/rect/circle/text in `z()` order,
|
|
32
|
+
through the camera unless `fixed()`.
|
|
33
|
+
|
|
34
|
+
### How the loop starts
|
|
35
|
+
|
|
36
|
+
There is no `run()` call anywhere in the KAPLAY guide, so there is none here
|
|
37
|
+
either. Natively, `kaplay()` registers the frame loop with `atexit`, so it
|
|
38
|
+
starts the instant your script's top level finishes. `run()` is still exposed
|
|
39
|
+
for tooling that wants to be explicit, and is a harmless no-op the second time.
|
|
40
|
+
|
|
41
|
+
The web build can't use that trick (see *pygbag incompatibilities* below), so
|
|
42
|
+
`webbuild.py` generates a `main.py` that imports your script as a module —
|
|
43
|
+
which runs its whole top level exactly once, same as atexit firing after it —
|
|
44
|
+
and then awaits `run_async()` directly. Your game script is byte-for-byte the
|
|
45
|
+
same on both targets.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
Standalone scripts, run directly, using plain `assert`. They set
|
|
52
|
+
`SDL_VIDEODRIVER=dummy` themselves so they run headless.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python tests/test_core.py # collision edge-triggering, child objects, scenes, state()
|
|
56
|
+
python tests/test_physics.py # gravity, grounding, mass-based pushing
|
|
57
|
+
python tests/test_tile_floor_landing.py # landing on a tiled floor, at uneven frame rates
|
|
58
|
+
python tests/test_web_platform_guard.py # kaplay() never touches pygbag's broken atexit
|
|
59
|
+
python tests/test_lazy_key_map.py # pygame.K_* is never read before pygame.init()
|
|
60
|
+
python tests/test_decorators.py # every event works as a callback AND as a decorator
|
|
61
|
+
python tests/test_guide_code.py # every program printed in GUIDE.md actually runs
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`test_web_platform_guard`, `test_lazy_key_map` and `test_tile_floor_landing`
|
|
65
|
+
all exist because of bugs that only ever showed up in a browser.
|
|
66
|
+
|
|
67
|
+
`KAYPY_TEST_MAX_FRAMES=n` makes any game script run `n` frames and exit, which
|
|
68
|
+
is how the lessons get smoke-tested. `KAYPY_SCREENSHOT_AT=n` plus
|
|
69
|
+
`KAYPY_SCREENSHOT_PATH=out.png` dumps a frame to disk, which is how they get
|
|
70
|
+
checked by eye.
|
|
71
|
+
|
|
72
|
+
**No pytest wiring yet** — worth converting once the API settles.
|
|
73
|
+
|
|
74
|
+
> A caution learned the hard way: "the lesson script exits 0" is not the same
|
|
75
|
+
> as "the lesson works." Lesson 10's player fell through the floor for a long
|
|
76
|
+
> time while the script exited 0 every single run. Render a frame and look at
|
|
77
|
+
> it.
|
|
78
|
+
|
|
79
|
+
`test_guide_code.py` applies the weaker half of that lesson to GUIDE.md: it pulls
|
|
80
|
+
every complete program out of the guide and runs it, so a sample can't rot into
|
|
81
|
+
something that doesn't start. Knowing it is *correct* still means rendering it
|
|
82
|
+
and looking.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Physics: three real bugs
|
|
87
|
+
|
|
88
|
+
All three were found by watching Lesson 10 actually run, not by reading code.
|
|
89
|
+
`tests/test_tile_floor_landing.py` pins down all three.
|
|
90
|
+
|
|
91
|
+
**1. Push direction was read off `pos()`, which is anchor-dependent.**
|
|
92
|
+
`_resolve` decided which object was on top by comparing the two objects' raw
|
|
93
|
+
`pos` values. But `pos` means different things to different objects: a plain
|
|
94
|
+
tile's `pos.y` is its *top edge*, while `anchor("bot")` — what a platformer
|
|
95
|
+
character wants, so it can be placed by its feet — makes `pos.y` the *bottom
|
|
96
|
+
edge*. A bean standing on a tile therefore had the larger `pos.y` of the two,
|
|
97
|
+
so the engine concluded the tile was above the bean and pushed the bean
|
|
98
|
+
**downward**, deeper in, every frame, until it was through the floor and
|
|
99
|
+
falling forever. Any object combining `anchor("bot")` with `body()` hit this,
|
|
100
|
+
on every platform, at every frame rate. Now read off the collision rects, which
|
|
101
|
+
`get_world_rect()` has already folded the anchor into.
|
|
102
|
+
|
|
103
|
+
**2. The resolution axis came from whichever overlap was smaller.**
|
|
104
|
+
That's the usual shortcut and it fails on the case a platformer hits
|
|
105
|
+
constantly: a floor built of separate tiles. A player landing on the seam
|
|
106
|
+
between two tiles overlaps each by only half its width; sink deeper than that
|
|
107
|
+
in a single frame and both tiles push *sideways*, in opposite directions, so
|
|
108
|
+
they cancel and nothing holds the player up. Now the axis comes from where the
|
|
109
|
+
two were before the step — if they already lined up horizontally and have only
|
|
110
|
+
now begun overlapping vertically, it's a landing, however deep it got.
|
|
111
|
+
|
|
112
|
+
**3. Fast bodies tunneled through walls.**
|
|
113
|
+
Movement and collision now run in as many substeps as needed to keep any body
|
|
114
|
+
under `MAX_STEP_PX` (16px) of travel per step, up to `MAX_SUBSTEPS` (4).
|
|
115
|
+
Normal frames need exactly one substep, so this costs nothing until something
|
|
116
|
+
is genuinely moving fast. Frame times aren't ours to control — a backgrounded
|
|
117
|
+
tab, a slow machine, a breakpoint — so the fix has to be in how far one step
|
|
118
|
+
may move things.
|
|
119
|
+
|
|
120
|
+
Still not stress-tested: simultaneous multi-body stacking.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Events: two forms, one registration path
|
|
125
|
+
|
|
126
|
+
Every event accepts a function, or acts as a decorator over one. Both go
|
|
127
|
+
through `callutil.register_or_decorate(fn, register)`: given a function it
|
|
128
|
+
registers immediately, given `None` it hands back a decorator that registers
|
|
129
|
+
and then returns the function unchanged — so the decorated name stays bound to
|
|
130
|
+
the function rather than to `None` or a wrapper.
|
|
131
|
+
|
|
132
|
+
The callback form is Kaplay's and is not going anywhere; it's what makes
|
|
133
|
+
Kaplay's docs translate line for line. The decorator form exists because
|
|
134
|
+
Python's lambdas hold a single *expression*, and the workarounds teach things
|
|
135
|
+
you would not choose to teach:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# what the lesson had to write
|
|
139
|
+
onKeyPress("space", lambda: player.jump(1000) if player.isGrounded() else None)
|
|
140
|
+
player.onCollide("danger", lambda d: setattr(player, "pos", level.tile2Pos(2, 5)))
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The `else None` is inert — it exists only because a conditional expression
|
|
144
|
+
demands an else. `setattr` appears only because a lambda cannot assign. Neither
|
|
145
|
+
is game logic.
|
|
146
|
+
|
|
147
|
+
Adding it cost one helper and an `fn=None` default on each event. Bare
|
|
148
|
+
decorators (`@onUpdate`, `@player.onGround`) already worked before any of this,
|
|
149
|
+
because the registration functions returned `fn`.
|
|
150
|
+
|
|
151
|
+
`tests/test_decorators.py` checks each event twice, once per form, asserting
|
|
152
|
+
the same registration and the same firing — plus that decorated handlers stay
|
|
153
|
+
callable and keep their `__name__`.
|
|
154
|
+
|
|
155
|
+
**Naming stays camelCase.** `onKeyPress`, not `on_key_press`. snake_case would
|
|
156
|
+
be more idiomatic Python in isolation, but the entire premise of this project
|
|
157
|
+
is that Kaplay's documentation and examples apply as written; two spellings for
|
|
158
|
+
every event would cost that and hand students two names for one idea. The
|
|
159
|
+
decorator gain doesn't require giving it up.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Web export (pygbag)
|
|
164
|
+
|
|
165
|
+
`webbuild.py` assembles a folder, converts audio, runs pygbag, and serves the
|
|
166
|
+
result. Four separate incompatibilities had to be fixed before a page would run
|
|
167
|
+
at all. Each one is cheap to re-break, so they're documented here with the
|
|
168
|
+
evidence that found them.
|
|
169
|
+
|
|
170
|
+
### 1. pygbag's `atexit` replacement is broken
|
|
171
|
+
|
|
172
|
+
pygbag replaces the stdlib `atexit` module outright
|
|
173
|
+
(`pygbag/support/cross/aio/atexit.py`), and that replacement has a genuine bug:
|
|
174
|
+
its `register()` closes over an undefined `arg` instead of `args`, so calling
|
|
175
|
+
it raises `NameError` immediately. kaypy's atexit-based "no `run()` call" trick
|
|
176
|
+
would have crashed instantly under pygbag.
|
|
177
|
+
|
|
178
|
+
Fixed by detecting `sys.platform == "emscripten"` (the real marker pygbag and
|
|
179
|
+
CPython both use) and skipping atexit registration there entirely. The web
|
|
180
|
+
build's generated `main.py` calls `run_async()` explicitly instead. Pinned by
|
|
181
|
+
`tests/test_web_platform_guard.py`, which recreates pygbag's actual broken
|
|
182
|
+
module and confirms `kaplay()` never touches it once that platform is detected.
|
|
183
|
+
|
|
184
|
+
### 2. pygame was a stub, because nothing declared it
|
|
185
|
+
|
|
186
|
+
pygbag's in-browser bootstrap only links in the *real*, compiled pygame WASM
|
|
187
|
+
extension when it finds a PEP 723 dependency block naming `pygame.base` in the
|
|
188
|
+
entry script it runs:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
# /// script
|
|
192
|
+
# dependencies = [
|
|
193
|
+
# "pygame.base",
|
|
194
|
+
# ]
|
|
195
|
+
# ///
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Without it, `import pygame` still silently succeeds, but you get a stub with
|
|
199
|
+
the constants (`pygame.K_LEFT` and friends) and **none of the real functions**.
|
|
200
|
+
|
|
201
|
+
Ordinary pygbag games never hit this, because they write `import pygame` at the
|
|
202
|
+
top of their own `main.py`. A kaypy game never writes it at all — `kaplay` does
|
|
203
|
+
that internally, several imports removed from anything pygbag's scanner reads —
|
|
204
|
+
so that block could only ever come from the generated `main.py`, and now does.
|
|
205
|
+
|
|
206
|
+
Symptom: the page loaded, the click did nothing, and the crash was
|
|
207
|
+
`AttributeError: module 'pygame' has no attribute 'init'` at the exact line
|
|
208
|
+
`Engine.__init__` calls `pygame.init()` — with pygbag's own boot log printing
|
|
209
|
+
`"# 696: no pep 723 block found"` immediately before it.
|
|
210
|
+
|
|
211
|
+
### 3. `pygame.K_*` read at import time
|
|
212
|
+
|
|
213
|
+
`kaplay/events.py` used to build its key-name lookup table at module level.
|
|
214
|
+
Native pygame-ce doesn't care — those constants exist the moment you
|
|
215
|
+
`import pygame`, before `pygame.init()`. Under pygbag's WASM pygame they don't
|
|
216
|
+
exist until after `init()`, and `import kaplay` reaches `events.py` (via
|
|
217
|
+
`engine.py`'s `from .events import EventManager`) well before a script's own
|
|
218
|
+
`kaplay()` call runs `pygame.init()`. Every web export died with
|
|
219
|
+
`AttributeError: module 'pygame' has no attribute 'K_LEFT'` before a single
|
|
220
|
+
line of the game ran.
|
|
221
|
+
|
|
222
|
+
The table is now built lazily on first use — `onKeyDown`/`onKeyPress`/
|
|
223
|
+
`onKeyRelease` or the frame loop, all of which only happen after `kaplay()`.
|
|
224
|
+
Pinned by `tests/test_lazy_key_map.py`.
|
|
225
|
+
|
|
226
|
+
### 4. A reboot loop on the very first click
|
|
227
|
+
|
|
228
|
+
pygbag's default (`--can_close 0`) registers a `beforeunload` handler that
|
|
229
|
+
calls `confirm("Are you sure you want to navigate away from this page ?")`.
|
|
230
|
+
Modern Chrome and Safari flatly refuse to run a synchronous `confirm()` during
|
|
231
|
+
`beforeunload` — and rather than the site just failing to prompt, that sends
|
|
232
|
+
pygbag's runtime into a loop: fetch the game archive, wait for the click it
|
|
233
|
+
demands to unlock audio, hit the blocked `confirm()`, reboot from scratch,
|
|
234
|
+
forever, before the first frame ever runs. From outside it looks exactly like
|
|
235
|
+
"I clicked, saw a border, then nothing."
|
|
236
|
+
|
|
237
|
+
`webbuild.py` always passes `--can_close 1`, which skips the handler. Confirmed
|
|
238
|
+
against a real built site by driving a browser through the click and watching
|
|
239
|
+
the console log the fetch → focus → blocked-confirm → reboot cycle fire
|
|
240
|
+
repeatedly with the flag unset, then stop once it was set.
|
|
241
|
+
|
|
242
|
+
**This is why `webbuild.py` must be the thing that runs pygbag.** The flags
|
|
243
|
+
above only get added when it's the one invoking it. Running
|
|
244
|
+
`python -m pygbag --build <dir>` yourself afterwards skips them and puts you
|
|
245
|
+
straight back on the broken defaults.
|
|
246
|
+
|
|
247
|
+
### Audio: `.wav` → `.ogg`
|
|
248
|
+
|
|
249
|
+
Browsers' WASM audio can't reliably decode most `.wav` files, and pygbag's
|
|
250
|
+
build step refuses to package one at all ("has a common unsupported format. Use
|
|
251
|
+
OGG format instead."). `webbuild.py` converts every `.wav` it copies into a
|
|
252
|
+
same-named `.ogg`, and `AssetManager.loadSound` prefers that sibling `.ogg`
|
|
253
|
+
automatically whenever it detects the browser platform — so the game script
|
|
254
|
+
still just says `loadSound("ding", "sounds/ding.wav")` with no if-web branch.
|
|
255
|
+
|
|
256
|
+
The conversion uses `ffmpeg` if it's on PATH, otherwise the `imageio-ffmpeg`
|
|
257
|
+
package (pulled in by `pip install -e ".[web]"`), whose wheel bundles an actual
|
|
258
|
+
static ffmpeg binary per platform — so no Homebrew or system package manager is
|
|
259
|
+
ever required. Only if neither is available does it fall back to
|
|
260
|
+
`--disable-sound-format-error` and warn.
|
|
261
|
+
|
|
262
|
+
> A `soundfile`-only pure-Python fallback was tried first and rejected: its
|
|
263
|
+
> bundled libsndfile OGG/Vorbis encoder **segfaulted silently** on a real
|
|
264
|
+
> 22.05kHz mono `.wav`, writing a valid-looking but completely empty `.ogg`.
|
|
265
|
+
> Every conversion now runs in a subprocess so an encoder crash can't take the
|
|
266
|
+
> build down with it.
|
|
267
|
+
|
|
268
|
+
### Frame pacing on the web
|
|
269
|
+
|
|
270
|
+
Natively, `clock.tick(60)` sleeps to pace the loop. In the browser that sleep
|
|
271
|
+
can't yield, so it just burns the main thread and makes frame times *less*
|
|
272
|
+
even — measured in a real build, `dt` alternated between 16ms and the engine's
|
|
273
|
+
50ms ceiling. On web the loop now only measures, and lets the browser's
|
|
274
|
+
animation frame do the pacing.
|
|
275
|
+
|
|
276
|
+
### Debugging a running web build
|
|
277
|
+
|
|
278
|
+
Worth knowing, because it turns a rebuild-and-squint loop into a live one:
|
|
279
|
+
|
|
280
|
+
- pygbag prints Python tracebacks to its **own in-page terminal**, not the
|
|
281
|
+
browser console. `read_page` on the accessibility tree shows them; the
|
|
282
|
+
devtools console will not.
|
|
283
|
+
- `window.python.PyRun_SimpleString("...")` runs arbitrary Python **inside the
|
|
284
|
+
live game**. That's how the falling-bean bug was diagnosed: reset the player,
|
|
285
|
+
record its rect each frame from an injected `onUpdate`, and read the numbers
|
|
286
|
+
back out. The physics fix was verified the same way — monkeypatched into the
|
|
287
|
+
running build and confirmed landing before anything was rebuilt.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Why pygame-ce is pinned, not vendored
|
|
292
|
+
|
|
293
|
+
It's pinned to an exact version (`pygame-ce==2.5.8`), not vendored:
|
|
294
|
+
|
|
295
|
+
- **It's a compiled C extension**, shipped as separate binary wheels per OS and
|
|
296
|
+
CPU. There's no single file to vendor — one wheel's contents only work on the
|
|
297
|
+
platform it was built for, and shipping all of them is what PyPI already does
|
|
298
|
+
for you, at its hosting cost rather than this repo's.
|
|
299
|
+
- **Vendoring the source** means bundling SDL2's source and a build toolchain,
|
|
300
|
+
and compiling on every machine it installs to. That trades "pip might break
|
|
301
|
+
someday" for "the install needs a C compiler and SDL2 headers today" — a
|
|
302
|
+
bigger, earlier cost for a smaller, later risk.
|
|
303
|
+
- **A pin already buys the thing vendoring was for.** `pygame-ce==2.5.8`
|
|
304
|
+
resolves to the same wheel every time, forever; PyPI doesn't let a published
|
|
305
|
+
version change underneath you. Bumping it is a deliberate one-line change you
|
|
306
|
+
test against, not something that happens to you.
|
|
307
|
+
|
|
308
|
+
If the real worry is installing with no internet at all, that's a different and
|
|
309
|
+
solvable problem (a local wheel cache, or an offline `pip download` bundle) and
|
|
310
|
+
worth deciding on separately.
|
|
311
|
+
|
|
312
|
+
Note that the web build runs a *different* pygame — pygbag supplies
|
|
313
|
+
`pygame-ce 2.5.7` compiled for WASM, and you don't get to choose it. Anything
|
|
314
|
+
that depends on 2.5.8-specific behaviour will diverge in the browser.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Known limitations
|
|
319
|
+
|
|
320
|
+
- **No tile-based pathfinding.** `tile(isObstacle=True)` is stored (Lesson 12)
|
|
321
|
+
but nothing reads it to route around obstacles the way KAPLAY's own
|
|
322
|
+
pathfinding helpers do.
|
|
323
|
+
- **Multi-body stacking** is untested — see *Physics* above.
|
|
324
|
+
- **No pytest suite** — `tests/` are standalone scripts.
|
|
325
|
+
- **Audio on a headless machine** degrades to silent no-ops rather than
|
|
326
|
+
crashing (`pygame.mixer.init()` and `loadSound()` are both wrapped), which is
|
|
327
|
+
useful for CI. On a real desktop, `examples/lesson8_audio_buttons.py` plays
|
|
328
|
+
real sound.
|
|
329
|
+
- **The native build step for the web** has only ever been exercised up to the
|
|
330
|
+
network wall in this project's sandbox, which blocks pygbag's CDN
|
|
331
|
+
(`pygame-web.github.io`). Folder assembly and file gathering are verified
|
|
332
|
+
there; the actual WASM packaging is verified on a normal machine. The browser
|
|
333
|
+
side has been driven end-to-end against a real local server.
|