diff options
| author | Gene Pasquet <dev@etenil.net> | 2026-04-05 16:51:45 +0100 |
|---|---|---|
| committer | Gene Pasquet <dev@etenil.net> | 2026-04-05 16:51:45 +0100 |
| commit | 92990d363171c795202a123479ed59443f5d0375 (patch) | |
| tree | 455ee112cb47df83e15b87773ba2aa07d5c05321 /docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md | |
| parent | 94bc0b83ac7110d6f06e0454f7891a695a37c7a2 (diff) | |
docs: add Milestone 8 implementation plan
6-task TDD plan covering assets.scm, render-scene!, engine.scm,
game-run!, Makefile updates, and macroknight port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md')
| -rw-r--r-- | docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md | 826 |
1 files changed, 826 insertions, 0 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 new file mode 100644 index 0000000..0d12308 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md @@ -0,0 +1,826 @@ +# 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 |
