diff options
| author | Gene Pasquet <dev@etenil.net> | 2026-04-05 19:47:05 +0100 |
|---|---|---|
| committer | Gene Pasquet <dev@etenil.net> | 2026-04-05 19:47:05 +0100 |
| commit | 027053b11a3a5d861ed2fa2db245388bd95ac246 (patch) | |
| tree | 84dfd90642bb6d8eb4e0e3fa3a9d651ba29b41e8 /docs | |
| parent | 927f37639a3d5a0d881a5c8709f2cf577aadb15e (diff) | |
Progress
Diffstat (limited to 'docs')
3 files changed, 0 insertions, 1283 deletions
diff --git a/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md b/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md deleted file mode 100644 index 0d12308..0000000 --- a/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md +++ /dev/null @@ -1,826 +0,0 @@ -# Milestone 8 — Game Object and Lifecycle API - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Introduce `make-game` + `game-run!` as the public entry point for downstroke, backed by a minimal asset registry, and port macroknight to use them. - -**Architecture:** Two new modules (`assets.scm` — key/value registry; `engine.scm` — game struct, lifecycle, frame loop), plus `render-scene!` added to `renderer.scm`. `game-run!` owns SDL2 init, window/renderer creation, and the frame loop; lifecycle hooks (`preload:`, `create:`, `update:`, `render:`) are user-supplied lambdas. - -**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), sdl2-ttf, sdl2-image, SRFI-64 (tests), defstruct egg - -**Spec:** `docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md` - ---- - -## File Map - -| Action | File | Purpose | -|---|---|---| -| Create | `assets.scm` | Key→value asset registry | -| Create | `tests/assets-test.scm` | SRFI-64 unit tests for assets.scm | -| Modify | `renderer.scm` | Add `render-scene!` | -| Modify | `tests/renderer-test.scm` | Add tests for `render-scene!` | -| Create | `engine.scm` | `make-game`, `game-run!`, accessors | -| Create | `tests/engine-test.scm` | Unit tests for make-game, accessors (SDL2 mocked) | -| Modify | `Makefile` | Add assets + engine to build, add test targets | -| Modify | `/home/gene/src/macroknight/game.scm` | Port to `make-game` + `game-run!` | - ---- - -## Task 1: `assets.scm` — minimal key/value registry - -**Files:** -- Create: `assets.scm` -- Create: `tests/assets-test.scm` - -### What this module does - -A thin wrapper around a hash table. No asset-type logic — that's Milestone 6. Three public functions: - -```scheme -(make-asset-registry) ;; → hash-table -(asset-set! registry key val) ;; → unspecified (mutates) -(asset-ref registry key) ;; → value or #f if missing -``` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/assets-test.scm`: - -```scheme -(import scheme (chicken base) srfi-64) - -(include "assets.scm") -(import downstroke/assets) - -(test-begin "assets") - -(test-group "make-asset-registry" - (test-assert "returns a value" - (make-asset-registry))) - -(test-group "asset-set! and asset-ref" - (let ((reg (make-asset-registry))) - (test-equal "missing key returns #f" - #f - (asset-ref reg 'missing)) - - (asset-set! reg 'my-tilemap "data") - (test-equal "stored value is retrievable" - "data" - (asset-ref reg 'my-tilemap)) - - (asset-set! reg 'my-tilemap "updated") - (test-equal "overwrite replaces value" - "updated" - (asset-ref reg 'my-tilemap)) - - (asset-set! reg 'other 42) - (test-equal "multiple keys coexist" - "updated" - (asset-ref reg 'my-tilemap)) - (test-equal "second key retrievable" - 42 - (asset-ref reg 'other)))) - -(test-end "assets") -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/assets-test.scm -``` - -Expected: error — `assets.scm` not found / `downstroke/assets` not defined. - -- [ ] **Step 3: Implement `assets.scm`** - -Create `assets.scm`: - -```scheme -(module downstroke/assets * - -(import scheme - (chicken base) - (srfi 69)) - -(define (make-asset-registry) - (make-hash-table)) - -(define (asset-set! registry key value) - (hash-table-set! registry key value)) - -(define (asset-ref registry key) - (hash-table-ref/default registry key #f)) - -) ;; end module -``` - -- [ ] **Step 4: Run test to confirm it passes** - -```bash -cd /home/gene/src/downstroke -csi -s tests/assets-test.scm -``` - -Expected: all tests pass, no failures reported. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add assets.scm tests/assets-test.scm -git commit -m "feat: add assets.scm — minimal key/value asset registry" -``` - ---- - -## Task 2: Add `render-scene!` to `renderer.scm` - -**Files:** -- Modify: `renderer.scm` (add one function after `draw-entities`) -- Modify: `tests/renderer-test.scm` (add one test group) - -### What this adds - -`render-scene!` draws a complete scene (all tilemap layers + all entities) given a renderer and a scene struct. It delegates to the already-tested `draw-tilemap` and `draw-entities`. - -Existing signatures in `renderer.scm`: -- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args -- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args - -The `tileset` is extracted from `tilemap` via the `tilemap-tileset` accessor (from `tilemap.scm`). - -- [ ] **Step 1: Write the failing test** - -Add to `tests/renderer-test.scm`, before `(test-end "renderer")`: - -```scheme -(test-group "render-scene!" - ;; render-scene! calls draw-tilemap and draw-entities — both are mocked to return #f. - ;; We verify it doesn't crash on a valid scene and returns unspecified. - (let* ((cam (make-camera x: 0 y: 0)) - (tileset (make-tileset tilewidth: 16 tileheight: 16 - spacing: 0 tilecount: 100 columns: 10 - image-source: "" image: #f)) - (layer (make-layer name: "ground" width: 2 height: 2 - map: '((1 2) (3 4)))) - (tilemap (make-tilemap width: 2 height: 2 - tilewidth: 16 tileheight: 16 - tileset-source: "" - tileset: tileset - layers: (list layer) - objects: '())) - (scene (make-scene entities: '() - tilemap: tilemap - camera: cam - tileset-texture: #f))) - (test-assert "does not crash on valid scene" - (begin (render-scene! #f scene) #t)))) -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/renderer-test.scm -``` - -Expected: error — `render-scene!` not defined. - -- [ ] **Step 3: Add `render-scene!` to `renderer.scm`** - -Add after the `draw-entities` definition (before the closing `)`): - -```scheme - ;; --- Scene drawing --- - - (define (render-scene! renderer scene) - (let ((camera (scene-camera scene)) - (tilemap (scene-tilemap scene)) - (tileset-texture (scene-tileset-texture scene)) - (tileset (tilemap-tileset (scene-tilemap scene))) - (entities (scene-entities scene))) - (draw-tilemap renderer camera tileset-texture tilemap) - (draw-entities renderer camera tileset tileset-texture entities))) -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cd /home/gene/src/downstroke -csi -s tests/renderer-test.scm -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add renderer.scm tests/renderer-test.scm -git commit -m "feat: add render-scene! to renderer — draw full scene in one call" -``` - ---- - -## Task 3: `engine.scm` — game struct, constructor, accessors - -**Files:** -- Create: `engine.scm` -- Create: `tests/engine-test.scm` - -This task covers only the data structure and constructor — no `game-run!` yet. TDD applies to the parts we can unit-test (struct creation, accessors). - -`game-run!` requires a live SDL2 window and cannot be unit-tested; it is tested in Task 6 via macroknight integration. - -### Mock strategy for tests - -`engine.scm` imports `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, and the SDL2 sub-libraries. The test file follows the same mock-module pattern as `tests/renderer-test.scm`: define mock modules that satisfy the imports, then `(include "engine.scm")`. - -- [ ] **Step 1: Write the failing tests** - -Create `tests/engine-test.scm`: - -```scheme -(import scheme (chicken base) (chicken keyword) srfi-64 defstruct) - -;; --- Mocks --- - -(module sdl2 * - (import scheme (chicken base)) - (define (set-main-ready!) #f) - (define (init! . args) #f) - (define (quit! . args) #f) - (define (get-ticks) 0) - (define (delay! ms) #f) - (define (pump-events!) #f) - (define (has-events?) #f) - (define (make-event) #f) - (define (poll-event! e) #f) - (define (num-joysticks) 0) - (define (is-game-controller? i) #f) - (define (game-controller-open! i) #f) - (define (create-window! . args) 'mock-window) - (define (create-renderer! . args) 'mock-renderer) - (define (destroy-window! . args) #f) - (define (render-clear! . args) #f) - (define (render-present! . args) #f)) -(import (prefix sdl2 "sdl2:")) - -(module sdl2-ttf * - (import scheme (chicken base)) - (define (init!) #f)) -(import (prefix sdl2-ttf "ttf:")) - -(module sdl2-image * - (import scheme (chicken base)) - (define (init! . args) #f)) -(import (prefix sdl2-image "img:")) - -;; --- Real deps (include order follows dependency order) --- -(import simple-logger) ;; required by input.scm -(include "entity.scm") (import downstroke/entity) -(include "tilemap.scm") (import downstroke/tilemap) -(include "world.scm") (import downstroke/world) -(include "input.scm") (import downstroke/input) -(include "assets.scm") (import downstroke/assets) - -;; Mock renderer (render-scene! can't run without real SDL2) -(module downstroke/renderer * - (import scheme (chicken base)) - (define (render-scene! . args) #f)) -(import downstroke/renderer) - -(include "engine.scm") -(import downstroke/engine) - -;; --- Tests --- - -(test-begin "engine") - -(test-group "make-game defaults" - (let ((g (make-game))) - (test-equal "default title" - "Downstroke Game" - (game-title g)) - (test-equal "default width" - 640 - (game-width g)) - (test-equal "default height" - 480 - (game-height g)) - (test-equal "default frame-delay" - 16 - (game-frame-delay g)) - (test-equal "scene starts as #f" - #f - (game-scene g)) - (test-equal "window starts as #f" - #f - (game-window g)) - (test-equal "renderer starts as #f" - #f - (game-renderer g)) - (test-assert "assets registry is created" - (game-assets g)) - (test-assert "input state is created" - (game-input g)))) - -(test-group "make-game with keyword args" - (let ((g (make-game title: "My Game" width: 320 height: 240 frame-delay: 33))) - (test-equal "custom title" "My Game" (game-title g)) - (test-equal "custom width" 320 (game-width g)) - (test-equal "custom height" 240 (game-height g)) - (test-equal "custom frame-delay" 33 (game-frame-delay g)))) - -(test-group "game-asset and game-asset-set!" - (let ((g (make-game))) - (test-equal "missing key returns #f" - #f - (game-asset g 'no-such-asset)) - (game-asset-set! g 'my-font 'font-object) - (test-equal "stored asset is retrievable" - 'font-object - (game-asset g 'my-font)) - (game-asset-set! g 'my-font 'updated-font) - (test-equal "overwrite replaces asset" - 'updated-font - (game-asset g 'my-font)))) - -(test-group "make-game hooks default to #f" - (let ((g (make-game))) - (test-equal "preload-hook is #f" #f (game-preload-hook g)) - (test-equal "create-hook is #f" #f (game-create-hook g)) - (test-equal "update-hook is #f" #f (game-update-hook g)) - (test-equal "render-hook is #f" #f (game-render-hook g)))) - -(test-group "make-game accepts hook lambdas" - (let* ((called #f) - (g (make-game update: (lambda (game dt) (set! called #t))))) - (test-assert "update hook is stored" - (procedure? (game-update-hook g))))) - -(test-end "engine") -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: error — `engine.scm` not found. - -- [ ] **Step 3: Implement `engine.scm` struct + constructor + accessors (no `game-run!`)** - -Create `engine.scm`. `game-run!` is a stub for now — it will be filled in Task 4. - -```scheme -(module downstroke/engine * - -(import scheme - (chicken base) - (chicken keyword) - (prefix sdl2 "sdl2:") - (prefix sdl2-ttf "ttf:") - (prefix sdl2-image "img:") - defstruct - downstroke/world - downstroke/input - downstroke/assets - downstroke/renderer) - -;; ── Game struct ──────────────────────────────────────────────────────────── -;; constructor: make-game* (raw) so we can define make-game as keyword wrapper - -(defstruct (game constructor: make-game*) - title width height - window renderer - input ;; input-state record - input-config ;; input-config record - assets ;; asset registry (hash-table from assets.scm) - frame-delay - preload-hook ;; (lambda (game) ...) - create-hook ;; (lambda (game) ...) - update-hook ;; (lambda (game dt) ...) - render-hook ;; (lambda (game) ...) — post-render overlay - scene) ;; current scene struct; #f until create: runs - -;; ── Public constructor ───────────────────────────────────────────────────── - -(define (make-game #!key - (title "Downstroke Game") - (width 640) (height 480) - (frame-delay 16) - (input-config *default-input-config*) - (preload #f) (create #f) (update #f) (render #f)) - (make-game* - title: title - width: width - height: height - window: #f - renderer: #f - scene: #f - input: (create-input-state input-config) - input-config: input-config - assets: (make-asset-registry) - frame-delay: frame-delay - preload-hook: preload - create-hook: create - update-hook: update - render-hook: render)) - -;; ── Convenience accessors ────────────────────────────────────────────────── - -;; game-camera: derived from the current scene (only valid after create: runs) -(define (game-camera game) - (scene-camera (game-scene game))) - -;; game-asset: retrieve an asset by key -(define (game-asset game key) - (asset-ref (game-assets game) key)) - -;; game-asset-set!: store an asset by key -(define (game-asset-set! game key value) - (asset-set! (game-assets game) key value)) - -;; ── game-run! ────────────────────────────────────────────────────────────── -;; Stub — implemented in Task 4 - -(define (game-run! game) - (error "game-run! not yet implemented")) - -) ;; end module -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add engine.scm tests/engine-test.scm -git commit -m "feat: add engine.scm — game struct, make-game constructor, accessors" -``` - ---- - -## Task 4: Implement `game-run!` - -**Files:** -- Modify: `engine.scm` (replace stub with real implementation) - -`game-run!` is tested via macroknight integration in Task 6, not via unit tests (it requires a real SDL2 display). - -- [ ] **Step 1: Replace the `game-run!` stub in `engine.scm`** - -Replace the stub `(define (game-run! game) (error ...))` with: - -```scheme -(define (game-run! game) - ;; 1. SDL2 init (audio excluded — mixer.scm not yet extracted; - ;; user calls init-audio! in their preload: hook) - (sdl2:set-main-ready!) - (sdl2:init! '(video joystick game-controller)) - (ttf:init!) - (img:init! '(png)) - - ;; Open any already-connected game controllers - (let init-controllers ((i 0)) - (when (< i (sdl2:num-joysticks)) - (when (sdl2:is-game-controller? i) - (sdl2:game-controller-open! i)) - (init-controllers (+ i 1)))) - - ;; 2. Create window + renderer - (game-window-set! game - (sdl2:create-window! (game-title game) 'centered 'centered - (game-width game) (game-height game) '())) - (game-renderer-set! game - (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync))) - - ;; 3. preload: hook — user loads assets here - (when (game-preload-hook game) - ((game-preload-hook game) game)) - - ;; 4. create: hook — user builds initial scene here - (when (game-create-hook game) - ((game-create-hook game) game)) - - ;; 5. Frame loop - (let loop ((last-ticks (sdl2:get-ticks))) - (let* ((now (sdl2:get-ticks)) - (dt (- now last-ticks))) - ;; Collect all pending SDL2 events - (sdl2:pump-events!) - (let* ((events (let collect ((lst '())) - (if (not (sdl2:has-events?)) - (reverse lst) - (let ((e (sdl2:make-event))) - (sdl2:poll-event! e) - (collect (cons e lst)))))) - (input (input-state-update (game-input game) events - (game-input-config game)))) - (game-input-set! game input) - (unless (input-held? input 'quit) - ;; update: hook — user game logic - (when (game-update-hook game) - ((game-update-hook game) game dt)) - ;; render: engine draws world, then user overlay - (sdl2:render-clear! (game-renderer game)) - (when (game-scene game) - (render-scene! (game-renderer game) (game-scene game))) - (when (game-render-hook game) - ((game-render-hook game) game)) - (sdl2:render-present! (game-renderer game)) - (sdl2:delay! (game-frame-delay game)) - (loop now))))) - - ;; 6. Cleanup - (sdl2:destroy-window! (game-window game)) - (sdl2:quit!)) -``` - -Note: `sdl2:pump-events!` processes the OS event queue; `sdl2:has-events?`/`sdl2:make-event`/`sdl2:poll-event!` drain it into a list. This matches the pattern used in macroknight's existing game loop (lines 661–667). - -**Spec discrepancy:** The spec document shows `(sdl2:collect-events!)` in its pseudocode — that function does not exist in the sdl2 egg. The plan's pump+collect loop above is the correct implementation. Ignore the spec's `sdl2:collect-events!` reference. - -- [ ] **Step 2: Verify engine-test.scm still passes** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: all tests pass (game-run! itself is not called in unit tests). - -- [ ] **Step 3: Commit** - -```bash -cd /home/gene/src/downstroke -git add engine.scm tests/engine-test.scm -git commit -m "feat: implement game-run! — SDL2 init, lifecycle hooks, frame loop" -``` - ---- - -## Task 5: Update the Makefile - -**Files:** -- Modify: `Makefile` - -- [ ] **Step 1: Add `assets` and `engine` to `MODULE_NAMES`** - -In `Makefile`, change: -```makefile -MODULE_NAMES := entity tilemap world input physics renderer -``` -to: -```makefile -MODULE_NAMES := entity tilemap world input physics renderer assets engine -``` - -- [ ] **Step 2: Add dependency declarations** - -Add after `bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o`: - -```makefile -bin/assets.o: -bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o -``` - -`assets.scm` has no inter-module dependencies. `engine.scm` depends on renderer, world, input, and assets. - -- [ ] **Step 3: Add test targets** - -In the `test:` rule, add: -```makefile - @csi -s tests/assets-test.scm - @csi -s tests/engine-test.scm -``` - -- [ ] **Step 4: Verify the build** - -```bash -cd /home/gene/src/downstroke -make clean && make -``` - -Expected: all `.o` files created in `bin/`, including `bin/assets.o` and `bin/engine.o`. No errors. - -- [ ] **Step 5: Run all tests** - -```bash -cd /home/gene/src/downstroke -make test -``` - -Expected: all test suites pass. - -- [ ] **Step 6: Commit** - -```bash -cd /home/gene/src/downstroke -git add Makefile -git commit -m "build: add assets and engine modules to Makefile" -``` - ---- - -## Task 6: Port macroknight to `make-game` + `game-run!` - -**Files:** -- Modify: `/home/gene/src/macroknight/game.scm` - -This is the integration test for the whole milestone. macroknight's current `game.scm` is ~678 lines with SDL2 init, the frame loop, and all game state mixed together. After this task it should be ≤50 lines: only `make-game`, the three lifecycle hooks, and `game-run!`. - -### What moves vs. what stays - -**Moves to engine (delete from game.scm):** -- Lines 87–137: SDL2 init block — from `(sdl2:set-main-ready!)` through `(define *title-font* ...)` inclusive. This covers: `sdl2:set-main-ready!`, `sdl2:init!`, `init-game-controllers!` call, `ttf:init!`, `img:init!`, audio init calls, `on-exit` handlers, exception handler wrapper, `sdl2:set-hint!`, `sdl2:create-window!`, `sdl2:create-renderer!`, `ttf:open-font` calls. -- Lines 659–673: The main `let/cc` game loop - -**Stays in macroknight (wrapped in lifecycle hooks):** -- `init-audio!` + `load-sounds!` + `load-music!` → `preload:` hook -- `ttf:open-font` calls (fonts) → `preload:` hook -- `make-initial-game-state` → `create:` hook -- `update-game-state` dispatch → `update:` hook -- `render-frame` mode dispatch (minus clear/present) → `render:` hook - -**macroknight-specific state** (`game-state` struct: mode, menu-cursor, scene, input, etc.) stays as the module-level `*gs*` variable — set in `create:`, mutated in `update:`. - -**`render-frame` split:** The existing `render-frame` calls `sdl2:render-clear!` and `sdl2:render-present!` — those are now owned by the engine. Modify `render-frame` to remove those two calls, keeping only the draw-color set and the mode dispatch. The modified `render-frame` becomes the body of the `render:` hook. Since the background color is black (`0 0 0`) and the engine's clear already clears to black, there is no visual difference. - -### Implementation steps - -- [ ] **Step 1: Add `downstroke/engine` to macroknight's imports** - -In `/home/gene/src/macroknight/game.scm`, add to the import list: -```scheme -downstroke/engine -downstroke/assets -``` - -- [ ] **Step 2: Restructure as `make-game` call** - -Replace the top-level SDL2 init block and the game loop at the bottom of `game.scm` with a `make-game` call. The three lifecycle hooks capture the existing functions from the rest of the file. - -**First, modify `render-frame`** to remove the SDL2 clear/present calls (the engine now owns those). Change lines 378–385 of `game.scm` from: - -```scheme -(define (render-frame gs) - (set! (sdl2:render-draw-color *renderer*) +background-color+) - (sdl2:render-clear! *renderer*) - (case (game-state-mode gs) - ((main-menu) (draw-main-menu gs)) - ((stage-select) (draw-stage-select gs)) - ((playing) (draw-playing gs))) - (sdl2:render-present! *renderer*)) -``` - -to: - -```scheme -(define (render-frame gs) - (set! (sdl2:render-draw-color *renderer*) +background-color+) - (case (game-state-mode gs) - ((main-menu) (draw-main-menu gs)) - ((stage-select) (draw-stage-select gs)) - ((playing) (draw-playing gs)))) -``` - -**Then add module-level state and the engine entry point** at the bottom of `game.scm` (replacing the old `let/cc` loop): - -```scheme -;; ── Module-level game state ──────────────────────────────────────────────── - -(define *gs* #f) ;; macroknight game state, set in create: - -;; ── Engine entry point ───────────────────────────────────────────────────── - -(define *the-game* - (make-game - title: "MacroKnight" - width: +screen-width+ - height: +screen-height+ - frame-delay: 10 - - preload: (lambda (game) - ;; Audio (mixer not yet extracted — call directly) - (init-audio!) - (load-sounds! '((jump . "assets/jump.wav"))) - (load-music! "assets/theme.ogg") - (play-music! 0.6) - ;; Fonts - (set! *font* (ttf:open-font "assets/DejaVuSans.ttf" 12)) - (set! *title-font* (ttf:open-font "assets/DejaVuSans.ttf" 32)) - ;; Make the SDL2 renderer available to macroknight functions - (set! *renderer* (game-renderer game))) - - create: (lambda (game) - (set! *gs* (make-initial-game-state))) - - update: (lambda (game dt) - ;; update-game-state uses a continuation for the "quit" menu item; - ;; pass a no-op since the engine handles Escape→quit automatically. - (update-game-state *gs* (lambda () #f)) - (maybe-advance-level *gs*)) - - render: (lambda (game) - ;; engine has already called render-clear! and render-scene! - ;; (game-scene is #f so render-scene! is a no-op for now); - ;; render-frame draws the mode-specific content. - (render-frame *gs*)))) - -(game-run! *the-game*) -``` - -**Notes:** -- `*renderer*`, `*font*`, `*title-font*` remain module-level variables (set in `preload:`). Keep their `(define ...)` declarations at the top of `game.scm` as `(define *font* #f)` etc. (uninitialized — they'll be set in `preload:`). -- The engine's input handles Escape → quit via `input-held? input 'quit`; the old `let/cc exit-main-loop!` mechanism and `exit-main-loop!` call in `update-game-state` are no longer needed. **However, do not change `update-game-state` in this milestone** — the `(lambda () #f)` passed as `exit!` means the "Quit" menu item is a no-op for now. Fix it in a later milestone. -- `(game-scene game)` is never set from macroknight, so the engine's `render-scene!` is always a no-op. All rendering happens in the `render:` hook via `render-frame`. This is intentional for this port milestone. - -- [ ] **Step 3: Remove the old SDL2 init block from `game.scm`** - -Delete lines 87–137 — everything from `(sdl2:set-main-ready!)` through `(define *title-font* (ttf:open-font ...))` inclusive. - -This also removes the `on-exit` cleanup handlers (lines 107–108) and the custom exception handler (lines 113–117). **This is intentional for this milestone.** The engine's `game-run!` calls `sdl2:destroy-window!` and `sdl2:quit!` at cleanup, but does not install an exception handler. If macroknight crashes mid-run, SDL2 will not be shut down cleanly. This will be addressed in a later milestone (either by adding error handling to `game-run!` or by macroknight wrapping `game-run!` in a guard). - -After deletion, add these uninitialized stubs near the top of `game.scm` (after the imports) so the rest of the file can still reference the variables. Note: `*window*` only appears inside the deleted range, so its stub is precautionary only. - -```scheme -(define *window* #f) ;; owned by engine; not used after this port -(define *renderer* #f) ;; set in preload: -(define *font* #f) ;; set in preload: -(define *title-font* #f) ;; set in preload: -(define *text-color* (sdl2:make-color 255 255 255)) -``` - -- [ ] **Step 4: Remove the old main loop from `game.scm`** - -Delete lines 659–678 (the `let/cc` game loop through the end of the file including the `"Bye!\n"` print). The engine's `game-run!` replaces it. The new `(define *the-game* ...)` and `(game-run! *the-game*)` from Step 2 are the new end of the file. - -- [ ] **Step 5: Build macroknight to verify it compiles** - -```bash -cd /home/gene/src/macroknight -make clean && make -``` - -Expected: successful compilation. Fix any symbol-not-found or import errors before continuing. - -- [ ] **Step 6: Run macroknight to verify it plays** - -```bash -cd /home/gene/src/macroknight -./bin/game -``` - -Expected: game launches, title screen appears, gameplay works correctly (title → stage select → play a level). Press Escape to quit. - -**Known regression (intentional):** Selecting "Quit" from the main menu is a no-op in this milestone. The old `exit-main-loop!` continuation no longer exists; the `update:` hook passes `(lambda () #f)` in its place. This will be fixed in a later milestone. - -- [ ] **Step 7: Verify game.scm is ≤50 lines** - -```bash -wc -l /home/gene/src/macroknight/game.scm -``` - -If still over 50 lines, extract helper functions that belong in other modules. Game-specific logic (menu rendering, level loading) can stay; SDL2 boilerplate must be gone. - -- [ ] **Step 8: Commit** - -```bash -cd /home/gene/src/macroknight -git add game.scm -git commit -m "feat: port macroknight to use make-game + game-run! (Milestone 8)" -``` - -Then commit the downstroke side: - -```bash -cd /home/gene/src/downstroke -git add -A -git commit -m "feat: Milestone 8 complete — make-game + game-run! engine entry point" -``` - ---- - -## Acceptance Criteria - -- [ ] `make test` in downstroke passes all suites including assets and engine -- [ ] `make` in downstroke builds all modules including `bin/assets.o` and `bin/engine.o` -- [ ] `make && ./bin/game` in macroknight launches and plays correctly -- [ ] macroknight `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate -- [ ] `make-game` + `game-run!` are the sole entry point — no top-level SDL2 calls outside them diff --git a/docs/superpowers/specs/2026-04-05-demos-design.md b/docs/superpowers/specs/2026-04-05-demos-design.md deleted file mode 100644 index f51f260..0000000 --- a/docs/superpowers/specs/2026-04-05-demos-design.md +++ /dev/null @@ -1,223 +0,0 @@ -# Downstroke Demo Games Design - -**Date:** 2026-04-05 -**Status:** Approved -**Scope:** `demo/` folder, 5 demo games, Makefile `make demos` target, CLAUDE.md update - ---- - -## Goal - -Provide 5 small self-contained demo games in `demo/` that collectively exercise every engine system. Each demo compiles to its own executable (`bin/demo-*`). They replace the macroknight integration test for Milestone 8 and serve as living documentation of the engine API. - ---- - -## File Layout - -``` -demo/ - assets/ ← copied from macroknight/assets (not symlinked) - monochrome-transparent.png ← tileset spritesheet - monochrome_transparent.tsx ← tileset metadata (TSX) - level-0.tmx ← level used by platformer, topdown, sandbox - DejaVuSans.ttf ← font for audio demo text - jump.wav ← sound effect (platformer jump, shmup shoot) - theme.ogg ← music (audio demo) - platformer.scm - shmup.scm - topdown.scm - audio.scm - sandbox.scm -``` - -**Omitted from copy:** `prefabs.scm`, `macroknight.tiled-project`, `macroknight.tiled-session` — macroknight-specific files not needed by any demo. - ---- - -## Build - -### Makefile additions - -```makefile -DEMO_NAMES := platformer shmup topdown audio sandbox -DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES)) - -demos: engine $(DEMO_BINS) - -bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin - csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin -``` - -- `make` — builds engine modules only (unchanged) -- `make demos` — builds all 5 demo executables; depends on engine being built first -- Demos are compiled as programs (not units), linked against all engine `.o` files - -### CLAUDE.md update - -Add to the Build & Test section: - -> `make demos` must always succeed. A demo that fails to compile is a build failure. Run `make && make demos` to verify both engine and demos build cleanly. - ---- - -## Demo Code Pattern - -Every demo follows this ~30-line structure: - -```scheme -(import (prefix sdl2 "sdl2:") - (prefix sdl2-ttf "ttf:") - (prefix sdl2-image "img:") - downstroke/engine - downstroke/world - downstroke/tilemap - downstroke/renderer - downstroke/input - downstroke/physics - downstroke/assets) - -(define *game* - (make-game - title: "Demo: <Name>" width: 600 height: 400 - preload: (lambda (game) ...) ;; load tilemap, tileset texture, sounds - create: (lambda (game) ...) ;; build scene, place entities - update: (lambda (game dt) ...) ;; input dispatch, physics calls - render: (lambda (game) ...))) ;; HUD overlay (optional) - -(game-run! *game*) -``` - -Tile IDs in entity plists are placeholder values — to be adjusted visually after first run. - ---- - -## The 5 Demos - -### 1. `demo/platformer.scm` — Platformer - -**Systems exercised:** `input`, `physics` (gravity + tile collision), `renderer` (tilemap + entities), `world`/scene, camera follow, audio (sound effect) - -**Mechanics:** -- Player entity with gravity, left/right movement, jump -- Tile collision via `apply-physics` from `physics.scm` -- Camera follows player horizontally -- Jump sound via macroknight's `sound.scm` (or direct SDL_mixer call) -- Level: `demo/assets/level-0.tmx` -- Tile IDs: placeholder (user adjusts) - -**Key entity plist:** -```scheme -(list #:type 'player - #:x 100 #:y 50 - #:width 16 #:height 16 - #:vx 0 #:vy 0 - #:gravity? #t - #:on-ground? #f - #:tile-id 1) -``` - -**Update logic:** read input → set `#:vx` from left/right → jump sets `#:vy` → call physics step → update camera x to follow player x. - ---- - -### 2. `demo/shmup.scm` — Shoot-em-up - -**Systems exercised:** `entity` (spawning/removal), entity-entity collision via `physics.scm`, `input`, `renderer`, `world`/scene (no tilemap) - -**Mechanics:** -- Player ship at bottom, moves left/right -- Space bar fires bullet upward (new entity added to scene) -- Enemies spawn from top at random x positions every N frames, move downward -- Bullet-enemy collision: both entities removed from scene -- No tilemap — plain background (black/SDL clear) -- No gravity on any entity -- `jump.wav` plays on shoot - -**Key entity plists:** -```scheme -;; player -(list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0 #:tile-id 2) -;; bullet -(list #:type 'bullet #:x px #:y 340 #:width 4 #:height 8 #:vx 0 #:vy -5 #:tile-id 3) -;; enemy -(list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2 #:tile-id 4) -``` - -**Update logic:** move entities by vx/vy each frame → AABB collision check between bullets and enemies → filter dead entities from scene → spawn new enemy every 60 frames → read input for player movement and shoot. - ---- - -### 3. `demo/topdown.scm` — Top-down explorer - -**Systems exercised:** `input` (8-directional), `renderer` (tilemap + entity), `world`/scene, camera follow (both axes), `physics` (no gravity) - -**Mechanics:** -- Player entity moves in 8 directions (WASD or arrows) -- No gravity (`#:gravity? #f`) -- Camera follows player on both x and y axes -- Level: `demo/assets/level-0.tmx` (same tilemap, different movement feel) -- No audio - -**Update logic:** read input → set `#:vx` and `#:vy` from direction keys → apply tile collision (no gravity component) → update camera to center on player. - ---- - -### 4. `demo/audio.scm` — Audio showcase - -**Systems exercised:** audio (sound effects + music), `renderer` (text via `draw-ui-text`), `input`, `assets` - -**Mechanics:** -- Static screen with text instructions rendered via `draw-ui-text` -- Press **J** → play `jump.wav` -- Press **M** → toggle `theme.ogg` music on/off -- Press **Escape** → quit -- No tilemap, no physics, no moving entities -- Uses `DejaVuSans.ttf` for text -- Audio calls via macroknight's `sound.scm` functions (`load-sounds!`, `play-sound!`, `load-music!`, `play-music!`, `stop-music!`) — or direct SDL_mixer FFI if sound.scm is not available - -**Display:** colored rectangle background + text labels for each key binding. - ---- - -### 5. `demo/sandbox.scm` — Physics sandbox - -**Systems exercised:** `physics` (gravity + tile collision + entity-entity collision), `renderer`, `world`/scene, no player input - -**Mechanics:** -- 10 entities spawned at random x positions near the top of the screen -- All have `#:gravity? #t` -- Physics step runs each frame (gravity accelerates, tile collision stops them) -- Entities rest on floor tiles or bounce (depending on physics.scm behavior) -- No player — pure observation of physics pipeline -- Level: `demo/assets/level-0.tmx` -- After all entities settle (or after 10 seconds), loop: despawn all, respawn at new random positions - ---- - -## Systems Coverage Matrix - -| System / Module | platformer | shmup | topdown | audio | sandbox | -|---|---|---|---|---|---| -| `engine` (make-game, game-run!) | ✓ | ✓ | ✓ | ✓ | ✓ | -| `input` | ✓ | ✓ | ✓ | ✓ | — | -| `physics` (gravity) | ✓ | — | — | — | ✓ | -| `physics` (tile collision) | ✓ | — | ✓ | — | ✓ | -| `physics` (entity collision) | — | ✓ | — | — | ✓ | -| `renderer` (tilemap) | ✓ | — | ✓ | — | ✓ | -| `renderer` (entities) | ✓ | ✓ | ✓ | — | ✓ | -| `renderer` (text) | — | — | — | ✓ | — | -| `world` / scene | ✓ | ✓ | ✓ | — | ✓ | -| `assets` registry | ✓ | ✓ | ✓ | ✓ | ✓ | -| audio (sound) | ✓ | ✓ | — | ✓ | — | -| audio (music) | — | — | — | ✓ | — | -| camera follow | ✓ (x) | — | ✓ (xy) | — | — | - ---- - -## Out of Scope - -- Animation state machine (`animation.scm`) — not yet extracted to downstroke -- AI (`ai.scm`) — not yet extracted -- Prefab system — not yet extracted -- Scene transitions — Milestone 9 -- Asset-type-specific load helpers (`game-load-tilemap!` etc.) — Milestone 6 diff --git a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md b/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md deleted file mode 100644 index d6e71be..0000000 --- a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md +++ /dev/null @@ -1,234 +0,0 @@ -# Milestone 8 — The Game Object and Lifecycle API - -**Date:** 2026-04-05 -**Status:** Approved -**Scope:** `engine.scm`, `assets.scm`, macroknight port -**Milestone numbering:** Follows `downstroke.org` / `TODO-engine.org` (Milestone 8). CLAUDE.md refers to the same work as "Milestone 7". - ---- - -## Goal - -Introduce `make-game` and `game-run!` as the public entry point for downstroke. A minimal game becomes ~20 lines of Scheme. This is the "Phaser moment" — the API stabilises around a game struct with lifecycle hooks. - ---- - -## Architecture - -Two new files: - -- **`engine.scm`** — `make-game` struct, `game-run!`, the frame loop, and all public accessors. -- **`assets.scm`** — minimal key→value asset registry. No asset-type-specific logic; that is Milestone 6's domain. - -`engine.scm` imports: `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, the sdl2 egg (prefix `sdl2:`), `sdl2-ttf` (prefix `ttf:`), and `sdl2-image` (prefix `img:`). - -**Audio / mixer is not initialised by the engine in this milestone.** `mixer.scm` and `sound.scm` remain in macroknight and are not yet extracted. macroknight's `preload:` hook calls `init-audio!` directly from `sound.scm` until those modules are extracted. - -`physics.scm` is **not** imported by the engine. Physics is a library; the user requires it if needed and calls physics functions from their `update:` hook. - -### New function in `renderer.scm` - -`render-scene!` does not yet exist. It must be added to `renderer.scm` as part of this milestone. The module uses `export *` so no explicit export update is needed. - -Existing signatures in `renderer.scm` that `render-scene!` calls: -- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args -- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args -- `tilemap-tileset` is a field accessor on the `tilemap` struct (defined in `tilemap.scm`) - -```scheme -(define (render-scene! renderer scene) - (let ((camera (scene-camera scene)) - (tilemap (scene-tilemap scene)) - (tileset-texture (scene-tileset-texture scene)) - (tileset (tilemap-tileset (scene-tilemap scene))) - (entities (scene-entities scene))) - (draw-tilemap renderer camera tileset-texture tilemap) - (draw-entities renderer camera tileset tileset-texture entities))) -``` - -### Build order (Makefile) - -Only modules that currently exist in downstroke are listed. Modules still in macroknight (`animation`, `ai`, `prefabs`, `mixer`, `sound`) are not prerequisites for this milestone. - -``` -entity → tilemap → world → input → renderer → assets → engine -``` - -`physics` is not a link dependency of `engine` and is omitted from this chain. It is a separate build target. - ---- - -## Data Structures - -### `game` struct (`engine.scm`) - -`defstruct` is called with `constructor: make-game*` so the generated raw constructor does not collide with the public keyword constructor `make-game`. - -```scheme -(defstruct (game constructor: make-game*) - title width height - window renderer - input ;; input-state record (accessor: game-input) - input-config ;; input-config record (accessor: game-input-config) - assets ;; asset registry hash table (from assets.scm) - frame-delay - preload-hook ;; (lambda (game) ...) - create-hook ;; (lambda (game) ...) - update-hook ;; (lambda (game dt) ...) - render-hook ;; (lambda (game) ...) — post-render overlay - scene) ;; current scene struct (accessor: game-scene) -``` - -**Note:** There is no separate `camera` field. The camera lives inside the scene (`scene-camera`). `game-camera` is a convenience accessor (see Accessors section). - -### `make-game` keyword constructor - -```scheme -(define (make-game #!key - (title "Downstroke Game") - (width 640) (height 480) - (frame-delay 16) - (input-config *default-input-config*) - (preload #f) (create #f) (update #f) (render #f)) - (make-game* - title: title width: width height: height - window: #f renderer: #f scene: #f - input: (create-input-state input-config) ;; create-input-state takes one arg: config - input-config: input-config - assets: (make-asset-registry) - frame-delay: frame-delay - preload-hook: preload - create-hook: create - update-hook: update - render-hook: render)) -``` - -### Asset registry (`assets.scm`) - -```scheme -(define (make-asset-registry) (make-hash-table)) -(define (asset-set! reg key val) (hash-table-set! reg key val)) -(define (asset-ref reg key) (hash-table-ref reg key (lambda () #f))) -``` - ---- - -## Lifecycle: `game-run!` - -```scheme -(define (game-run! game) - ;; 1. SDL2 init (no audio — mixer not yet extracted, user calls init-audio! in preload:) - (sdl2:init! '(video joystick game-controller)) - (ttf:init!) - (img:init! '(png)) - - ;; 2. Create window + renderer - (game-window-set! game - (sdl2:create-window! (game-title game) 'centered 'centered - (game-width game) (game-height game) '())) - (game-renderer-set! game - (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync))) - - ;; 3. preload hook - (when (game-preload-hook game) ((game-preload-hook game) game)) - - ;; 4. create hook - (when (game-create-hook game) ((game-create-hook game) game)) - - ;; 5. Frame loop - (let loop ((last-ticks (sdl2:get-ticks))) - (let* ((now (sdl2:get-ticks)) - (dt (- now last-ticks)) - (events (sdl2:collect-events!)) - (input (input-state-update (game-input game) events (game-input-config game)))) - (game-input-set! game input) - (unless (input-held? input 'quit) - (when (game-update-hook game) ((game-update-hook game) game dt)) - (sdl2:render-clear! (game-renderer game)) - (when (game-scene game) - (render-scene! (game-renderer game) (game-scene game))) - (when (game-render-hook game) ((game-render-hook game) game)) - (sdl2:render-present! (game-renderer game)) - (sdl2:delay! (game-frame-delay game)) - (loop now)))) - - ;; 6. Cleanup - (sdl2:destroy-window! (game-window game)) - (sdl2:quit!)) -``` - -**Notes:** -- `sdl2:collect-events!` pumps the SDL2 event queue and returns a list of events (from the sdl2 egg). -- `input-state-update` is the existing function in `input.scm`: `(input-state-update state events config)`. It is purely functional — it returns a new `input-state` record and does not mutate the existing one. -- `create-input-state` in `input.scm` takes one argument: `(create-input-state config)` — it initialises all actions to `#f`. -- Quit is detected via `(input-held? input 'quit)` — the `'quit` action is in `*default-input-config*` mapped to `escape` key: `(escape . quit)` in `keyboard-map`. -- `sdl2:render-clear!` and `sdl2:render-present!` are called directly from the sdl2 egg (no wrapper needed). -- The loop exits when `input-held?` returns true for `'quit`. No user-settable `game-running?` flag in this milestone. - ---- - -## `render:` Hook Semantics - -Following Phaser 2.x: the engine draws the full scene first via `render-scene!` (tilemap layers + entities), then calls the user's `render:` hook. The hook is for post-render overlays only — debug info, HUD elements, custom visuals. It does not replace or suppress the engine's render pass. - ---- - -## Physics - -Physics (`physics.scm`) is a library, not a pipeline. `game-run!` does not call any physics function automatically. Users who want physics `(require-extension downstroke/physics)` and call functions from their `update:` hook. - ---- - -## Public Accessors - -All `game-*` readers and `game-*-set!` mutators are generated by `defstruct` and exported. Additional convenience accessors: - -| Accessor | Description | -|---|---| -| `game-renderer` | SDL2 renderer — available from `preload:` onward | -| `game-window` | SDL2 window | -| `game-scene` / `game-scene-set!` | Current scene struct; user sets this in `create:` | -| `game-camera` | Convenience: `(scene-camera (game-scene game))` — derived, not a struct field. Only valid after `create:` sets a scene; calling before that is an error. | -| `game-assets` | Full asset registry hash table | -| `game-input` | Current `input-state` record | -| `game-input-config` | Current `input-config` record | -| `(game-asset game key)` | Convenience: `(asset-ref (game-assets game) key)` | -| `(game-asset-set! game key val)` | Convenience: `(asset-set! (game-assets game) key val)` | - -`game-camera` is not a struct field — it is a derived accessor: -```scheme -(define (game-camera game) - (scene-camera (game-scene game))) -``` - ---- - -## Scene-Level Preloading - -Out of scope for this milestone. A `preload:` slot will be reserved in the scene struct when `scene-loader.scm` is implemented (Milestone 7), and the two-level asset registry (global + per-scene) will be designed then. - ---- - -## macroknight Port - -macroknight's `game.scm` must be ported to use `make-game` + `game-run!` as part of this milestone. It is the primary integration test. - -**What moves to the engine:** SDL2 init block (lines 87–137), the main loop (line 659+), and all frame-level rendering. - -**What stays in macroknight `game.scm`:** Only the three lifecycle hooks and `game-run!`: -- `preload:` — load fonts, sounds, tilemaps (currently scattered across lines 87–137) -- `create:` — build initial scene (currently `make-initial-game-state`, line 196) -- `update:` — game logic, input dispatch, physics calls (currently the per-frame update functions) - -**Acceptance criterion:** macroknight compiles (`make` succeeds), the game runs and plays correctly (title screen → stage select → gameplay), and `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate. - ---- - -## Out of Scope - -- Scene-level preloading (Milestone 7) -- Asset-type-specific load functions (`game-load-tilemap!`, `game-load-font!`, etc.) — Milestone 6 -- `game-running?` quit flag -- Scene state machine (Milestone 9) -- AI tag lookup (Milestone 10) -- Extraction of `animation`, `ai`, `prefabs`, `mixer`, `sound` modules into downstroke |
