diff options
| author | Gene Pasquet <dev@etenil.net> | 2026-04-05 23:12:54 +0100 |
|---|---|---|
| committer | Gene Pasquet <dev@etenil.net> | 2026-04-05 23:12:54 +0100 |
| commit | b99ada53b715def5492c7d04c0d327fa7048e5d3 (patch) | |
| tree | 9e94dbc8ff863ef09ef18f4be31fb45e085572a4 | |
| parent | 027053b11a3a5d861ed2fa2db245388bd95ac246 (diff) | |
Complete implementation
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | demo/platformer.scm | 25 | ||||
| -rw-r--r-- | demo/sandbox.scm | 19 | ||||
| -rw-r--r-- | demo/shmup.scm | 3 | ||||
| -rw-r--r-- | demo/topdown.scm | 25 | ||||
| -rw-r--r-- | docs/api.org | 846 | ||||
| -rw-r--r-- | docs/entities.org | 375 | ||||
| -rw-r--r-- | docs/guide.org | 261 | ||||
| -rw-r--r-- | docs/physics.org | 502 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-04-05-demos.md | 922 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-04-05-milestone-14-docs.md | 167 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md | 201 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md | 826 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-04-05-demos-design.md | 256 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md | 234 | ||||
| -rw-r--r-- | downstroke.egg | 5 | ||||
| -rw-r--r-- | engine.scm | 10 | ||||
| -rw-r--r-- | scene-loader.scm | 85 | ||||
| -rw-r--r-- | tests/engine-test.scm | 25 | ||||
| -rw-r--r-- | tests/renderer-test.scm | 3 | ||||
| -rw-r--r-- | tests/scene-loader-test.scm | 148 | ||||
| -rw-r--r-- | tests/world-test.scm | 24 | ||||
| -rw-r--r-- | world.scm | 3 |
23 files changed, 4900 insertions, 69 deletions
@@ -1,7 +1,7 @@ .DEFAULT_GOAL := engine # Modules listed in dependency order -MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound animation ai +MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound animation ai scene-loader OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES)) DEMO_NAMES := platformer shmup topdown audio sandbox @@ -32,6 +32,7 @@ bin/mixer.o: bin/sound.o: bin/mixer.o bin/animation.o: bin/entity.o bin/world.o bin/ai.o: bin/entity.o bin/world.o +bin/scene-loader.o: bin/world.o bin/tilemap.o bin/assets.o bin/engine.o # Pattern rule: compile each module as a library unit bin/%.o: %.scm | bin downstroke @@ -57,6 +58,7 @@ test: @csi -s tests/engine-test.scm @csi -s tests/animation-test.scm @csi -s tests/ai-test.scm + @csi -s tests/scene-loader-test.scm demos: engine $(DEMO_BINS) diff --git a/demo/platformer.scm b/demo/platformer.scm index d9276b8..2d30bea 100644 --- a/demo/platformer.scm +++ b/demo/platformer.scm @@ -11,7 +11,8 @@ downstroke/physics downstroke/assets downstroke/entity - downstroke/sound) + downstroke/sound + downstroke/scene-loader) (define *game* (make-game @@ -19,28 +20,20 @@ preload: (lambda (game) (init-audio!) - (load-sounds! '((jump . "demo/assets/jump.wav"))) - (game-asset-set! game 'tilemap - (load-tilemap "demo/assets/level-0.tmx"))) + (load-sounds! '((jump . "demo/assets/jump.wav")))) create: (lambda (game) - (let* ((tm (game-asset game 'tilemap)) - (tex (sdl2:create-texture-from-surface - (game-renderer game) - (tileset-image (tilemap-tileset tm)))) + (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx")) (player (list #:type 'player #:x 100 #:y 50 #:width 16 #:height 16 #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f - #:tile-id 1))) - (game-scene-set! game - (make-scene - entities: (list player) - tilemap: tm - camera: (make-camera x: 0 y: 0) - tileset-texture: tex)))) + #:tile-id 1 + #:tags '(player))) + (_ (scene-add-entity scene player))) + (scene-camera-target-set! scene 'player))) update: (lambda (game dt) (let* ((input (game-input game)) @@ -63,8 +56,6 @@ (player (apply-velocity-y player)) (player (resolve-tile-collisions-y player tm)) (player (detect-ground player tm))) - (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300)))) - (camera-x-set! (scene-camera scene) cam-x)) (scene-entities-set! scene (list player)))))) (game-run! *game*) diff --git a/demo/sandbox.scm b/demo/sandbox.scm index 1be3968..ffc0cca 100644 --- a/demo/sandbox.scm +++ b/demo/sandbox.scm @@ -11,7 +11,8 @@ downstroke/input downstroke/physics downstroke/assets - downstroke/entity) + downstroke/entity + downstroke/scene-loader) (define *elapsed* 0) (define *respawn-interval* 10000) @@ -36,21 +37,9 @@ (make-game title: "Demo: Physics Sandbox" width: 600 height: 400 - preload: (lambda (game) - (game-asset-set! game 'tilemap - (load-tilemap "demo/assets/level-0.tmx"))) - create: (lambda (game) - (let* ((tm (game-asset game 'tilemap)) - (tex (sdl2:create-texture-from-surface - (game-renderer game) - (tileset-image (tilemap-tileset tm))))) - (game-scene-set! game - (make-scene - entities: (spawn-entities) - tilemap: tm - camera: (make-camera x: 0 y: 0) - tileset-texture: tex)))) + (let ((scene (game-load-scene! game "demo/assets/level-0.tmx"))) + (scene-entities-set! scene (spawn-entities)))) update: (lambda (game dt) (let* ((scene (game-scene game)) diff --git a/demo/shmup.scm b/demo/shmup.scm index ae7748d..19cc478 100644 --- a/demo/shmup.scm +++ b/demo/shmup.scm @@ -59,7 +59,8 @@ entities: (list (make-player)) tilemap: #f camera: (make-camera x: 0 y: 0) - tileset-texture: #f))) + tileset-texture: #f + camera-target: #f))) update: (lambda (game dt) (let* ((input (game-input game)) diff --git a/demo/topdown.scm b/demo/topdown.scm index 9bbaf08..b95ccc9 100644 --- a/demo/topdown.scm +++ b/demo/topdown.scm @@ -10,33 +10,24 @@ downstroke/input downstroke/physics downstroke/assets - downstroke/entity) + downstroke/entity + downstroke/scene-loader) (define *game* (make-game title: "Demo: Top-down Explorer" width: 600 height: 400 - preload: (lambda (game) - (game-asset-set! game 'tilemap - (load-tilemap "demo/assets/level-0.tmx"))) - create: (lambda (game) - (let* ((tm (game-asset game 'tilemap)) - (tex (sdl2:create-texture-from-surface - (game-renderer game) - (tileset-image (tilemap-tileset tm)))) + (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx")) (player (list #:type 'player #:x 100 #:y 100 #:width 16 #:height 16 #:vx 0 #:vy 0 #:gravity? #f - #:tile-id 1))) - (game-scene-set! game - (make-scene - entities: (list player) - tilemap: tm - camera: (make-camera x: 0 y: 0) - tileset-texture: tex)))) + #:tile-id 1 + #:tags '(player))) + (_ (scene-add-entity scene player))) + (scene-camera-target-set! scene 'player))) update: (lambda (game dt) (let* ((input (game-input game)) @@ -52,8 +43,6 @@ (player (resolve-tile-collisions-x player tm)) (player (apply-velocity-y player)) (player (resolve-tile-collisions-y player tm))) - (camera-x-set! (scene-camera scene) (max 0 (- (entity-ref player #:x 0) 300))) - (camera-y-set! (scene-camera scene) (max 0 (- (entity-ref player #:y 0) 200))) (scene-entities-set! scene (list player)))))) (game-run! *game*) diff --git a/docs/api.org b/docs/api.org new file mode 100644 index 0000000..0a443d6 --- /dev/null +++ b/docs/api.org @@ -0,0 +1,846 @@ +#+TITLE: Downstroke API Reference + +This document describes the public API for the Downstroke game engine. All exported functions are organized by module. + +* Engine (~downstroke/engine~) + +#+begin_src scheme +(import downstroke/engine) +#+end_src + +The engine module provides the top-level game lifecycle and state management. + +** ~make-game~ + +#+begin_src scheme +(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)) +#+end_src + +Creates and initializes a game object. All parameters are optional keywords. + +| Parameter | Type | Default | Description | +|-----------+------+---------+-------------| +| ~title~ | string | "Downstroke Game" | Window title | +| ~width~ | integer | 640 | Game window width in pixels | +| ~height~ | integer | 480 | Game window height in pixels | +| ~frame-delay~ | integer | 16 | Delay between frames in milliseconds (30 FPS ≈ 33) | +| ~input-config~ | input-config | *default-input-config* | Keyboard/controller mappings | +| ~preload~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called once before create | +| ~create~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called once at startup | +| ~update~ | procedure/false | #f | Hook: ~(lambda (game dt) ...)~ called each frame | +| ~render~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called after render-scene! | + +The game object is the central hub. Use it to store/retrieve assets, manage scenes, and access the current input state. + +** ~game-run!~ + +#+begin_src scheme +(game-run! game) +#+end_src + +Starts the main event loop. Initializes SDL2, opens the window, and runs the frame loop indefinitely until the user quits or the ~quit~ action is pressed. Never returns. + +Lifecycle order within each frame: +1. Collect SDL2 events +2. Update input state +3. Call ~update:~ hook (or active state's ~update~) +4. Clear renderer +5. Call ~render-scene!~ (if scene is set) +6. Call ~render:~ hook (or active state's ~render~) +7. Present renderer +8. Apply frame delay + +** ~game-camera~ + +#+begin_src scheme +(game-camera game) +#+end_src + +Returns the current scene's camera struct. Only valid after ~create:~ runs. Returns a camera record with ~x~ and ~y~ fields for the top-left viewport corner in world coordinates. + +** ~game-asset~ + +#+begin_src scheme +(game-asset game key) +#+end_src + +Retrieves an asset from the game's registry by key. Returns ~#f~ if the key is not found. + +** ~game-asset-set!~ + +#+begin_src scheme +(game-asset-set! game key value) +#+end_src + +Stores an asset in the game's registry. Overwrites any existing value at the key. + +** ~make-game-state~ + +#+begin_src scheme +(make-game-state #!key (create #f) (update #f) (render #f)) +#+end_src + +Creates a state record (plist) with optional lifecycle hooks. Used with ~game-add-state!~ and ~game-start-state!~ to build a state machine within the game. + +| Parameter | Type | Default | Description | +|-----------+------+---------+-------------| +| ~create~ | procedure/false | #f | Called when entering this state | +| ~update~ | procedure/false | #f | Called each frame while active | +| ~render~ | procedure/false | #f | Called each frame after rendering (overlay) | + +** ~game-add-state!~ + +#+begin_src scheme +(game-add-state! game name state) +#+end_src + +Registers a named state (created with ~make-game-state~) in the game's state table. ~name~ must be a symbol. + +** ~game-start-state!~ + +#+begin_src scheme +(game-start-state! game name) +#+end_src + +Transitions to a named state, activating its lifecycle hooks. Calls the state's ~create:~ hook (if present) immediately. + +** Example: Game with State Machine + +#+begin_src scheme +(define my-game (make-game title: "My Game")) + +(game-add-state! my-game 'playing + (make-game-state + create: (lambda (game) (print "Game started")) + update: (lambda (game dt) (print "Updating...")) + render: (lambda (game) (print "Rendering overlay")))) + +(game-add-state! my-game 'paused + (make-game-state + create: (lambda (game) (print "Paused")))) + +; Start the game in 'playing state +(game-start-state! my-game 'playing) + +; Later, transition to paused +(game-start-state! my-game 'paused) +#+end_src + +* World (~downstroke/world~) + +#+begin_src scheme +(import downstroke/world) +#+end_src + +The world module provides the scene (level) abstraction and camera management. + +** ~make-scene~ + +Auto-generated by defstruct. Use keyword arguments: + +#+begin_src scheme +(make-scene #!key + (entities '()) + (tilemap #f) + (camera #f) + (tileset-texture #f)) +#+end_src + +Creates a scene record representing the current level state. + +| Parameter | Type | Default | Description | +|-----------+------+---------+-------------| +| ~entities~ | list | ~'()~ | List of entity plists | +| ~tilemap~ | tilemap/false | #f | Tile grid and collisions | +| ~camera~ | camera/false | #f | Viewport position | +| ~tileset-texture~ | SDL2 texture/false | #f | Rendered tileset image | + +** ~make-camera~ + +Auto-generated by defstruct. Use keyword arguments: + +#+begin_src scheme +(make-camera #!key (x 0) (y 0)) +#+end_src + +Creates a camera record. ~x~ and ~y~ are the pixel coordinates of the viewport's top-left corner in world space. + +** ~camera-x~, ~camera-y~ + +#+begin_src scheme +(camera-x camera) +(camera-y camera) +#+end_src + +Accessors for camera position. + +** ~camera-x-set!~, ~camera-y-set!~ + +#+begin_src scheme +(camera-x-set! camera x) +(camera-y-set! camera y) +#+end_src + +Mutate camera position (in-place). + +** ~camera-follow!~ + +#+begin_src scheme +(camera-follow! camera entity viewport-w viewport-h) +#+end_src + +Centers the camera on an entity, clamping to stay within world bounds (never negative). ~viewport-w~ and ~viewport-h~ are the game window dimensions. + +** ~scene-add-entity~ + +#+begin_src scheme +(scene-add-entity scene entity) +#+end_src + +Appends an entity to the scene's entity list. Returns the modified scene. + +** ~scene-update-entities~ + +#+begin_src scheme +(scene-update-entities scene proc1 proc2 ...) +#+end_src + +Applies each procedure in sequence to all entities in the scene. Each procedure takes a single entity and returns a modified entity. The scene's entity list is updated once with the final result. Returns the modified scene. + +Example: + +#+begin_src scheme +(define (increment-x entity) + (entity-set entity #:x (+ 1 (entity-ref entity #:x 0)))) + +(define (apply-gravity entity) + (entity-set entity #:vy (+ 1 (entity-ref entity #:vy 0)))) + +(scene-update-entities scene increment-x apply-gravity) +; Each entity is passed through increment-x, then through apply-gravity +#+end_src + +** ~scene-filter-entities~ + +#+begin_src scheme +(scene-filter-entities scene predicate) +#+end_src + +Removes all entities that do not satisfy the predicate. Returns the modified scene. + +Example: + +#+begin_src scheme +; Remove all entities with type 'enemy +(scene-filter-entities scene + (lambda (e) (not (eq? (entity-type e) 'enemy)))) +#+end_src + +** ~scene-find-tagged~ + +#+begin_src scheme +(scene-find-tagged scene tag) +#+end_src + +Returns the first entity whose ~#:tags~ list (a list of symbols) contains the given tag, or ~#f~ if not found. + +** ~scene-find-all-tagged~ + +#+begin_src scheme +(scene-find-all-tagged scene tag) +#+end_src + +Returns a list of all entities whose ~#:tags~ list contains the given tag. Returns ~'()~ if none found. + +** Accessor functions (auto-generated by defstruct) + +- ~scene-entities~, ~scene-entities-set!~ +- ~scene-tilemap~, ~scene-tilemap-set!~ +- ~scene-camera~, ~scene-camera-set!~ +- ~scene-tileset-texture~, ~scene-tileset-texture-set!~ + +* Entity (~downstroke/entity~) + +#+begin_src scheme +(import downstroke/entity) +#+end_src + +The entity module provides property list (plist) accessors for game objects. Entities are immutable plists, never modified in place. + +** ~entity-ref~ + +#+begin_src scheme +(entity-ref entity key #!optional default) +#+end_src + +Retrieves the value of a key from an entity plist. Returns ~default~ (or ~#f~) if the key is not found. If ~default~ is a procedure, it is called with no arguments to produce the default. + +Example: + +#+begin_src scheme +(entity-ref player #:x 0) ; Get x, defaulting to 0 +(entity-ref player #:tags '()) ; Get tags, defaulting to empty list +#+end_src + +** ~entity-type~ + +#+begin_src scheme +(entity-type entity) +#+end_src + +Shorthand for ~(entity-ref entity #:type #f)~. Returns the entity's ~#:type~ field or ~#f~. + +** ~entity-set~ + +#+begin_src scheme +(entity-set entity key value) +#+end_src + +Returns a new plist with the key/value updated. **Does not modify the original entity.** This is a functional (immutable) operation. + +Example: + +#+begin_src scheme +(define player (list #:x 100 #:y 200 #:type 'player)) +(define moved (entity-set player #:x 150)) +; player is still (list #:x 100 #:y 200 #:type 'player) +; moved is (list #:x 150 #:y 200 #:type 'player) +#+end_src + +** ~entity-update~ + +#+begin_src scheme +(entity-update entity key proc #!optional default) +#+end_src + +Functional update: applies ~proc~ to the current value of ~key~ and updates the entity with the result. Returns a new plist. + +Example: + +#+begin_src scheme +(entity-update player #:x (lambda (x) (+ x 10))) +; Equivalent to (entity-set player #:x (+ 10 (entity-ref player #:x 0))) +#+end_src + +** Shared Entity Keys + +All entities can have these keys. Not all are required: + +| Key | Type | Description | +|-----|------|-------------| +| ~#:type~ | symbol | Entity type (e.g., 'player, 'enemy) | +| ~#:x~ | number | X position in pixels | +| ~#:y~ | number | Y position in pixels | +| ~#:width~ | number | Bounding box width in pixels | +| ~#:height~ | number | Bounding box height in pixels | +| ~#:vx~ | number | X velocity in pixels/frame | +| ~#:vy~ | number | Y velocity in pixels/frame | +| ~#:tile-id~ | integer | Sprite index in tileset (1-indexed) | +| ~#:tags~ | list | List of symbols for lookup (e.g., '(player)) | +| ~#:gravity?~ | boolean | Apply gravity to this entity? | +| ~#:on-ground?~ | boolean | Is entity touching a solid tile below? | +| ~#:facing~ | integer | 1 (right) or -1 (left) | +| ~#:solid?~ | boolean | Participate in AABB entity collisions? | +| ~#:anim-name~ | symbol | Current animation name | +| ~#:anim-frame~ | integer | Current frame index | +| ~#:anim-tick~ | integer | Ticks in current frame | + +* Physics (~downstroke/physics~) + +#+begin_src scheme +(import downstroke/physics) +#+end_src + +The physics module implements the main collision and movement pipeline. The physics pipeline runs automatically before the user's ~update:~ hook. + +** Physics Pipeline Order + +The built-in physics runs in this order each frame: + +1. ~apply-acceleration~ — consume ~#:ay~ into ~#:vy~ +2. ~apply-gravity~ — add gravity to ~#:vy~ +3. ~apply-velocity-x~ — move by ~#:vx~ +4. ~resolve-tile-collisions-x~ — snap against horizontal tile collisions +5. ~apply-velocity-y~ — move by ~#:vy~ +6. ~resolve-tile-collisions-y~ — snap against vertical tile collisions +7. ~detect-ground~ — set ~#:on-ground?~ if standing on a tile + +(This separation ensures smooth sliding along walls.) + +** ~apply-acceleration~ + +#+begin_src scheme +(apply-acceleration entity) +#+end_src + +Consumes ~#:ay~ (one-shot acceleration) into ~#:vy~ and clears ~#:ay~ to 0. Only applies if ~#:gravity?~ is true. + +** ~apply-gravity~ + +#+begin_src scheme +(apply-gravity entity) +#+end_src + +Adds the gravity constant (1 pixel/frame²) to ~#:vy~. Only applies if ~#:gravity?~ is true. + +** ~apply-velocity-x~ + +#+begin_src scheme +(apply-velocity-x entity) +#+end_src + +Updates ~#:x~ by adding ~#:vx~. Returns a new entity. + +** ~apply-velocity-y~ + +#+begin_src scheme +(apply-velocity-y entity) +#+end_src + +Updates ~#:y~ by adding ~#:vy~. Returns a new entity. + +** ~apply-velocity~ + +#+begin_src scheme +(apply-velocity entity) +#+end_src + +Legacy function: updates both ~#:x~ and ~#:y~ by their respective velocities. + +** ~resolve-tile-collisions-x~ + +#+begin_src scheme +(resolve-tile-collisions-x entity tilemap) +#+end_src + +Detects and resolves collisions between the entity's bounding box and solid tiles along the X axis. Snaps the entity's ~#:x~ to the near/far tile edge and sets ~#:vx~ to 0. Returns a new entity. + +** ~resolve-tile-collisions-y~ + +#+begin_src scheme +(resolve-tile-collisions-y entity tilemap) +#+end_src + +Detects and resolves collisions between the entity's bounding box and solid tiles along the Y axis. Snaps the entity's ~#:y~ to the near/far tile edge and sets ~#:vy~ to 0. Returns a new entity. + +** ~detect-ground~ + +#+begin_src scheme +(detect-ground entity tilemap) +#+end_src + +Probes one pixel below the entity's feet to detect if it is standing on a solid tile. Sets ~#:on-ground?~ to true or false. Only applies if ~#:gravity?~ is true. Returns a new entity. + +** ~apply-jump~ + +#+begin_src scheme +(apply-jump entity jump-pressed?) +#+end_src + +If the jump button is pressed and the entity is on ground, sets ~#:ay~ to ~(- #:jump-force)~ (default 15 pixels/frame). On the next frame, ~apply-acceleration~ consumes this into ~#:vy~. Returns a new entity. + +** ~resolve-entity-collisions~ + +#+begin_src scheme +(resolve-entity-collisions entities) +#+end_src + +Detects and resolves AABB collisions between all pairs of entities with ~#:solid?~ true. Pushes overlapping entities apart along the axis of minimum penetration and sets their velocities in the push direction. Returns a new entity list. + +** ~scene-resolve-collisions~ + +#+begin_src scheme +(scene-resolve-collisions scene) +#+end_src + +Applies ~resolve-entity-collisions~ to the scene's entity list. Returns the modified scene. + +** Physics Constants + +- ~*gravity*~ = 1 (pixels per frame per frame) +- ~*jump-force*~ = 15 (vertical acceleration on jump) + +* Input (~downstroke/input~) + +#+begin_src scheme +(import downstroke/input) +#+end_src + +The input module handles keyboard, joystick, and game controller events. It maintains the current and previous input state to support pressed/released detection. + +** ~*default-input-config*~ + +The default input configuration record. Use as the ~input-config:~ parameter to ~make-game~, or create a custom one with ~make-input-config~. + +| Action | Keyboard | Joystick | Controller | +|--------|----------|----------|------------| +| ~up~ | W or Up | Y-axis negative | DPad Up, Left-Y negative | +| ~down~ | S or Down | Y-axis positive | DPad Down, Left-Y positive | +| ~left~ | A or Left | X-axis negative | DPad Left, Left-X negative | +| ~right~ | D or Right | X-axis positive | DPad Right, Left-X positive | +| ~a~ | J or Z | Button 0 | A button | +| ~b~ | K or X | Button 1 | B button | +| ~start~ | Return | Button 7 | Start button | +| ~select~ | (unmapped) | Button 6 | Back button | +| ~quit~ | Escape | (unmapped) | (unmapped) | + +** ~input-held?~ + +#+begin_src scheme +(input-held? state action) +#+end_src + +Returns true if the action is currently held down. ~action~ is a symbol like ~'left~ or ~'a~. + +** ~input-pressed?~ + +#+begin_src scheme +(input-pressed? state action) +#+end_src + +Returns true if the action was pressed in this frame (held now but not in the previous frame). + +** ~input-released?~ + +#+begin_src scheme +(input-released? state action) +#+end_src + +Returns true if the action was released in this frame (held previously but not now). + +** ~create-input-state~ + +#+begin_src scheme +(create-input-state config) +#+end_src + +Initializes an input state record from a configuration. All actions start as unpressed. + +** Example: Player Movement + +#+begin_src scheme +(define (update-player game dt) + (let ((input (game-input game)) + (player (car (scene-entities (game-scene game))))) + (if (input-pressed? input 'a) + (apply-jump player #t) + player))) +#+end_src + +* Renderer (~downstroke/renderer~) + +#+begin_src scheme +(import downstroke/renderer) +#+end_src + +The renderer module provides SDL2 drawing abstractions. + +** ~render-scene!~ + +#+begin_src scheme +(render-scene! renderer scene) +#+end_src + +Draws the entire scene: first the tilemap layers, then all entities. Called automatically by ~game-run!~ before the user's ~render:~ hook. Does nothing if the scene is ~#f~. + +** ~entity-screen-coords~ + +#+begin_src scheme +(entity-screen-coords entity camera) +#+end_src + +Returns a list ~(x y width height)~ of the entity's bounding box in screen (viewport) coordinates. Pure function, testable without SDL2. + +Example: + +#+begin_src scheme +(entity-screen-coords player (make-camera x: 100 y: 50)) +; If player is at world (200, 100) with size (16, 16): +; Returns (100 50 16 16) -- offset by camera position +#+end_src + +** ~entity-flip~ + +#+begin_src scheme +(entity-flip entity) +#+end_src + +Returns an SDL2 flip list based on the entity's ~#:facing~ field. Returns ~'(horizontal)~ if facing is -1, ~'()~ otherwise. + +** ~draw-ui-text~ + +#+begin_src scheme +(draw-ui-text renderer font text color x y) +#+end_src + +Renders a single line of text to the screen at the given pixel coordinates. ~color~ is an SDL2 color struct. Positions are in screen (viewport) space, not world space. Does not cache; call once per frame for each text element. + +* Assets (~downstroke/assets~) + +#+begin_src scheme +(import downstroke/assets) +#+end_src + +The assets module provides a simple registry for game resources. + +** ~make-asset-registry~ + +#+begin_src scheme +(make-asset-registry) +#+end_src + +Creates an empty asset registry (hash-table). Returned by ~make-game~ automatically. + +** ~asset-set!~ + +#+begin_src scheme +(asset-set! registry key value) +#+end_src + +Stores a value in the registry by key (any hashable value). Overwrites any existing value. + +** ~asset-ref~ + +#+begin_src scheme +(asset-ref registry key) +#+end_src + +Retrieves a value from the registry by key. Returns ~#f~ if not found. + +** Example: Storing Fonts + +#+begin_src scheme +(define (preload game) + (let ((font (ttf:open-font "assets/font.ttf" 16))) + (game-asset-set! game 'main-font font))) + +(define (render-overlay game) + (let ((font (game-asset game 'main-font))) + (draw-ui-text (game-renderer game) font "Score: 100" + (sdl2:make-color 255 255 255) 10 10))) +#+end_src + +* Sound (~downstroke/sound~) + +#+begin_src scheme +(import downstroke/sound) +#+end_src + +The sound module provides music and sound effect playback via SDL_mixer. + +** ~init-audio!~ + +#+begin_src scheme +(init-audio!) +#+end_src + +Initializes SDL_mixer with default settings (44100 Hz, stereo, 512 buffer). Call once in the ~preload:~ hook. + +** ~load-sounds!~ + +#+begin_src scheme +(load-sounds! sound-alist) +#+end_src + +Loads sound effects from an alist of ~(name . path)~ pairs, where ~name~ is a symbol and ~path~ is a file path. Stores them globally for playback. + +Example: + +#+begin_src scheme +(load-sounds! '((jump . "assets/jump.wav") + (hit . "assets/hit.wav"))) +#+end_src + +** ~play-sound~ + +#+begin_src scheme +(play-sound name) +#+end_src + +Plays a loaded sound effect by name (symbol). Plays on the first available channel. No-op if the sound is not found. + +** ~load-music!~ + +#+begin_src scheme +(load-music! path) +#+end_src + +Loads background music from a file. Replaces any previously loaded music. + +** ~play-music!~ + +#+begin_src scheme +(play-music! volume) +#+end_src + +Plays the loaded music on an infinite loop. ~volume~ is a number from 0.0 to 1.0. + +** ~stop-music!~ + +#+begin_src scheme +(stop-music!) +#+end_src + +Stops the currently playing music immediately. + +** ~set-music-volume!~ + +#+begin_src scheme +(set-music-volume! volume) +#+end_src + +Changes the music volume while it is playing. ~volume~ is 0.0 to 1.0. + +** ~cleanup-audio!~ + +#+begin_src scheme +(cleanup-audio!) +#+end_src + +Releases all audio resources. Call at shutdown or in a cleanup hook. + +* Animation (~downstroke/animation~) + +#+begin_src scheme +(import downstroke/animation) +#+end_src + +The animation module provides simple frame-based sprite animation. + +** ~set-animation~ + +#+begin_src scheme +(set-animation entity name) +#+end_src + +Switches the entity to a named animation, resetting frame and tick counters to 0. No-op if the animation is already active (prevents restarting mid-loop). Returns a new entity. + +** ~animate-entity~ + +#+begin_src scheme +(animate-entity entity animations) +#+end_src + +Advances the entity's animation frame based on elapsed ticks. ~animations~ is an alist of ~(name . animation-plist)~ pairs. Each animation plist has ~#:frames~ (list of tile IDs, 0-indexed) and ~#:duration~ (ticks per frame). Returns a new entity with updated ~#:anim-tick~, ~#:anim-frame~, and ~#:tile-id~. + +Example: + +#+begin_src scheme +(define player-animations + '((idle #:frames (28) #:duration 10) + (walk #:frames (27 28) #:duration 10) + (jump #:frames (29) #:duration 1))) + +(define player (list #:anim-name 'walk #:anim-frame 0 #:anim-tick 0)) +(animate-entity player player-animations) +; After 10 ticks, frame advances to 1 (wraps to 0 after reaching length) +#+end_src + +** ~frame->tile-id~ + +#+begin_src scheme +(frame->tile-id frames frame-idx) +#+end_src + +Converts a frame index to a tile ID (1-indexed). Used internally by ~animate-entity~. + +* Scene Loader (~downstroke/scene-loader~) + +#+begin_src scheme +(import downstroke/scene-loader) +#+end_src + +The scene-loader module provides utilities for loading Tiled maps and instantiating entities from prefabs. + +** ~game-load-scene!~ + +#+begin_src scheme +(game-load-scene! game filename) +#+end_src + +Loads a TMX (Tiled) map from a file and creates a scene. Steps: + +1. Loads the tilemap from the file +2. Creates an SDL2 texture from the tileset image +3. Creates an empty scene with the tilemap and camera +4. Stores the tilemap in game assets under key ~'tilemap~ +5. Sets the scene on the game +6. Returns the scene + +Example: + +#+begin_src scheme +(define (create-game game) + (game-load-scene! game "assets/level1.tmx") + ; Now add entities to the scene... + ) +#+end_src + +** ~create-tileset-texture~ + +#+begin_src scheme +(create-tileset-texture renderer tilemap) +#+end_src + +Creates an SDL2 texture from the tileset image embedded in a tilemap struct. Useful when you need the texture independently of ~game-load-scene!~. + +** ~make-prefab-registry~ + +#+begin_src scheme +(make-prefab-registry name1 constructor1 name2 constructor2 ...) +#+end_src + +Creates a hash-table mapping symbol names to entity constructor functions. Constructors have the signature ~(lambda (x y w h) entity)~. + +Example: + +#+begin_src scheme +(define (make-player x y w h) + (list #:type 'player #:x x #:y y #:width w #:height h + #:vx 0 #:vy 0 #:gravity? #t #:tile-id 29)) + +(define (make-enemy x y w h) + (list #:type 'enemy #:x x #:y y #:width w #:height h + #:vx -2 #:vy 0 #:gravity? #t #:tile-id 5)) + +(define prefabs + (make-prefab-registry + 'player make-player + 'enemy make-enemy)) +#+end_src + +** ~instantiate-prefab~ + +#+begin_src scheme +(instantiate-prefab registry type x y w h) +#+end_src + +Looks up a constructor by type in the registry and calls it with the given position and size. Returns the entity plist, or ~#f~ if the type is not registered. + +** ~tilemap-objects->entities~ + +#+begin_src scheme +(tilemap-objects->entities tilemap instantiate-fn) +#+end_src + +Converts the TMX object list (from Tiled) into entity plists. Each object's type (string from XML) is converted to a symbol and passed to ~instantiate-fn~. Filters out ~#f~ results (unregistered types). + +~instantiate-fn~ should have the signature ~(lambda (type x y w h) entity-or-#f)~, typically the curried version of ~instantiate-prefab~: + +Example: + +#+begin_src scheme +(let* ((prefabs (make-prefab-registry 'player make-player 'enemy make-enemy)) + (inst (lambda (t x y w h) (instantiate-prefab prefabs t x y w h))) + (entities (tilemap-objects->entities tilemap inst))) + (scene-entities-set! scene entities)) +#+end_src diff --git a/docs/entities.org b/docs/entities.org new file mode 100644 index 0000000..db5663d --- /dev/null +++ b/docs/entities.org @@ -0,0 +1,375 @@ +#+TITLE: Entity Model +#+AUTHOR: Downstroke +#+DESCRIPTION: How to create, update, and manage entities in the Downstroke game engine + +* Overview + +Entities in Downstroke are plain Scheme **plists** (property lists) — alternating keyword/value pairs with no special structure or classes. An entity is just a list: + +#+begin_src scheme +(list #:type 'player + #:x 100 #:y 200 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:on-ground? #f + #:tile-id 1) +#+end_src + +This minimal approach keeps the engine lean: your game defines whatever keys it needs. The shared keys listed below are *conventions* for physics, rendering, and animation — use them to integrate with the engine's built-in systems. Custom keys are always allowed. + +* Creating Entities + +There is no ~make-entity~ constructor. Create an entity as a plain list: + +#+begin_src scheme +(define my-entity + (list #:type 'enemy + #:x 200 #:y 150 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:tile-id 42)) +#+end_src + +Add whatever additional keys your game needs — tags, state, custom data, anything. Entities are pure data. + +* Accessing and Updating Entities + +Entities are **immutable**. Use these three functions to read and update them: + +** ~entity-ref entity key [default]~ + +Returns the value associated with ~key~ in the entity plist, or ~default~ if the key is absent. + +#+begin_src scheme +(entity-ref player #:x 0) ; → 100 +(entity-ref player #:vx) ; → 0 (if #:vx is absent) +(entity-ref player #:custom #f) ; → #f (no #:custom key) +#+end_src + +If ~default~ is a procedure, it is called to compute the default: + +#+begin_src scheme +(entity-ref player #:x (lambda () (error "x is required"))) +#+end_src + +** ~entity-type entity~ + +Shorthand for ~(entity-ref entity #:type #f)~. Returns the entity's ~#:type~ or ~#f~. + +#+begin_src scheme +(entity-type player) ; → 'player +#+end_src + +** ~entity-set entity key val~ + +Returns a **new** plist with the key/value pair set (or replaced). The original entity is unchanged — this is functional, immutable update. + +#+begin_src scheme +(define updated-player (entity-set player #:vx 5)) +;; original player is still unchanged: +(entity-ref player #:vx) ; → 0 +(entity-ref updated-player #:vx) ; → 5 +#+end_src + +** ~entity-update entity key proc [default]~ + +Returns a new entity with ~key~ set to ~(proc (entity-ref entity key default))~. Useful for incrementing or transforming a value: + +#+begin_src scheme +(entity-update player #:x (lambda (x) (+ x 3))) ; move right 3 pixels +#+end_src + +** Chaining Updates: The let* Pattern + +Since each update returns a new entity, chain updates with ~let*~: + +#+begin_src scheme +(let* ((player (entity-set player #:vx 3)) + (player (apply-velocity-x player)) + (player (resolve-tile-collisions-x player tilemap))) + ;; now use the updated player + player) +#+end_src + +This is how the platformer demo applies physics in order: each step reads the input, computes the next state, and passes it to the next step. + +* Plist Key Reference + +The engine recognizes these standard keys. Use them to integrate with the physics pipeline, rendering, and animation systems. Custom keys are always allowed. + +| Key | Type | Description | +|---|---|---| +| ~#:type~ | symbol | Entity type, e.g., ~'player~, ~'enemy~, ~'coin~. No built-in enforcement; use for ~entity-type~ checks and scene queries. | +| ~#:x~, ~#:y~ | number | World position in pixels (top-left corner of bounding box). Updated by ~apply-velocity-x~, ~apply-velocity-y~, and collision resolvers. | +| ~#:width~, ~#:height~ | number | Bounding box size in pixels. Used for AABB tile collision checks and entity-entity collision. Required for physics. | +| ~#:vx~, ~#:vy~ | number | Velocity in pixels per frame. ~#:vx~ is updated by ~apply-velocity-x~; ~#:vy~ is updated by ~apply-velocity-y~. Both consumed by collision resolvers. | +| ~#:ay~ | number | Y acceleration (e.g., from jumping or knockback). Consumed by ~apply-acceleration~, which adds it to ~#:vy~. Optional; default is 0. | +| ~#:gravity?~ | boolean | Whether gravity applies to this entity. Set to ~#t~ for platformers (gravity pulls down), ~#f~ for top-down or flying entities. Used by ~apply-gravity~. | +| ~#:on-ground?~ | boolean | Whether the entity is touching a solid tile below (set by ~detect-ground~). Use this to gate jump input: only allow jumping if ~#:on-ground?~ is true. | +| ~#:solid?~ | boolean | Whether this entity participates in entity-entity collision. If ~#t~, ~resolve-entity-collisions~ will check it against other solid entities. | +| ~#:tile-id~ | integer | Sprite index in the tileset (1-indexed). Required for rendering with ~draw-sprite~. Updated automatically by animation (~animate-entity~). | +| ~#:facing~ | number | Horizontal flip direction: ~1~ = right (default), ~-1~ = left. Used by renderer to flip sprite horizontally. Update when changing direction. | +| ~#:tags~ | list of symbols | List of tag symbols, e.g., ~'(player solid)~. Used by ~scene-find-tagged~ and ~scene-find-all-tagged~ for fast lookups. | +| ~#:animations~ | alist | Animation definitions (see Animation section). Keys are animation names (symbols); values are animation specs. | +| ~#:anim-name~ | symbol | Currently active animation name, e.g., ~'walk~, ~'jump~. Set with ~set-animation~; reset by ~animate-entity~. | +| ~#:anim-frame~ | integer | Current frame index within the animation (0-indexed). Updated automatically by ~animate-entity~. | +| ~#:anim-tick~ | integer | Tick counter for frame timing (0 to ~#:duration - 1~). Incremented by ~animate-entity~; resets when frame advances. | + +* Entities in Scenes + +A **scene** is a level state: it holds a tilemap, a camera, and a list of entities. These functions manipulate scene entities: + +** ~scene-entities scene~ + +Returns the list of all entities in the scene. + +#+begin_src scheme +(define all-entities (scene-entities scene)) +#+end_src + +** ~scene-entities-set! scene entities~ + +Mutates the scene to replace the entity list. Use after ~scene-update-entities~ or batch operations. + +#+begin_src scheme +(scene-entities-set! scene (list updated-player updated-enemy)) +#+end_src + +** ~scene-add-entity scene entity~ + +Adds an entity to the scene and returns the scene. Appends to the entity list. + +#+begin_src scheme +(scene-add-entity scene new-enemy) +#+end_src + +** ~scene-update-entities scene proc1 proc2 ...~ + +Maps each procedure over the scene's entities, applying them in sequence. Each proc must be a function of one entity, returning a new entity. + +#+begin_src scheme +;; Apply physics pipeline to all entities: +(scene-update-entities scene + apply-gravity + apply-velocity-x + apply-velocity-y) +#+end_src + +The result is equivalent to: + +#+begin_src scheme +(let* ((es (scene-entities scene)) + (es (map apply-gravity es)) + (es (map apply-velocity-x es)) + (es (map apply-velocity-y es))) + (scene-entities-set! scene es) + scene) +#+end_src + +** ~scene-filter-entities scene pred~ + +Removes all entities that do not satisfy the predicate. Use to despawn dead enemies, collected items, etc. + +#+begin_src scheme +;; Remove all entities with #:health <= 0: +(scene-filter-entities scene + (lambda (e) (> (entity-ref e #:health 1) 0))) +#+end_src + +** ~scene-find-tagged scene tag~ + +Returns the first entity whose ~#:tags~ list contains ~tag~, or ~#f~ if none found. + +#+begin_src scheme +(scene-find-tagged scene 'player) ; → player entity or #f +#+end_src + +** ~scene-find-all-tagged scene tag~ + +Returns a list of all entities whose ~#:tags~ list contains ~tag~. + +#+begin_src scheme +(scene-find-all-tagged scene 'enemy) ; → (enemy1 enemy2 enemy3) or () +#+end_src + +* Animation + +Entities with the ~#:animations~ key can cycle through sprite frames automatically. Animation is data-driven: define sprite sequences once, then switch between them in your update logic. + +** Animation Data Format + +The ~#:animations~ key holds an **alist** (association list) of animation entries. Each entry is ~(name #:frames (frame-indices...) #:duration ticks-per-frame)~: + +#+begin_src scheme +(list #:type 'player + ... + #:animations + '((idle #:frames (28) #:duration 10) + (walk #:frames (27 28) #:duration 6) + (jump #:frames (29) #:duration 10) + (fall #:frames (30) #:duration 10)) + #:anim-name 'idle + #:anim-frame 0 + #:anim-tick 0) +#+end_src + +- ~idle~, ~walk~, ~jump~, ~fall~ are animation **names** (symbols). +- ~#:frames (28)~ means frame 0 of the animation displays sprite tile 28 (1-indexed in tileset). +- ~#:frames (27 28)~ means frame 0 displays tile 27, frame 1 displays tile 28, then loops back to frame 0. +- ~#:duration 6~ means each frame displays for 6 game ticks before advancing to the next frame. + +** Switching Animations + +Use ~set-animation~ to switch to a new animation, resetting frame and tick counters. If the animation is already active, it is a no-op (avoids restarting mid-loop): + +#+begin_src scheme +(set-animation player 'walk) ; switch to walk animation +;; → entity with #:anim-name 'walk, #:anim-frame 0, #:anim-tick 0 +#+end_src + +** Advancing Animation + +Call ~animate-entity~ once per game frame to step the animation. Pass the entity and its animation table: + +#+begin_src scheme +(define animations + '((idle #:frames (28) #:duration 10) + (walk #:frames (27 28) #:duration 6))) + +(let* ((player (set-animation player 'walk)) + (player (animate-entity player animations))) + player) +#+end_src + +~animate-entity~ does four things: +1. Increments ~#:anim-tick~ by 1. +2. If ~#:anim-tick~ reaches ~#:duration~, advances ~#:anim-frame~ and resets tick to 0. +3. Updates ~#:tile-id~ to the sprite ID for the current frame. +4. Returns the updated entity (or unchanged entity if ~#:anim-name~ is not set). + +** Typical Animation Update Pattern + +#+begin_src scheme +(define (update-player-animation player input) + (let* ((anim (if (input-held? input 'left) 'walk 'idle)) + (player (set-animation player anim))) + (animate-entity player player-animations))) +#+end_src + +* Tags + +The ~#:tags~ key is a list of symbols used to label and query entities. Tags are arbitrary — define whatever makes sense for your game. + +** Creating Entities with Tags + +#+begin_src scheme +(list #:type 'player + #:tags '(player solid) + ...) + +(list #:type 'enemy + #:tags '(enemy dangerous) + ...) + +(list #:type 'coin + #:tags '(collectible) + ...) +#+end_src + +** Querying by Tag + +Use the scene tag lookup functions: + +#+begin_src scheme +;; Find the first entity tagged 'player: +(define player (scene-find-tagged scene 'player)) + +;; Find all enemies: +(define enemies (scene-find-all-tagged scene 'enemy)) + +;; Find all collectibles and remove them: +(scene-filter-entities scene + (lambda (e) (not (member 'collectible (entity-ref e #:tags '()))))) +#+end_src + +** Tag Conventions + +While tags are free-form, consider using these conventions in your game: + +- ~player~: the player character +- ~enemy~: hostile entities +- ~solid~: entities that participate in collision +- ~collectible~: items to pick up +- ~projectile~: bullets, arrows, etc. +- ~hazard~: spikes, lava, etc. + +* Example: Complete Entity Setup + +Here is a full example showing entity creation, initialization in the scene, and update logic: + +#+begin_src scheme +(define (create-player-entity) + (list #:type 'player + #:x 100 #:y 200 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:ay 0 + #:gravity? #t + #:on-ground? #f + #:tile-id 1 + #:facing 1 + #:tags '(player solid) + #:animations + '((idle #:frames (1) #:duration 10) + (walk #:frames (1 2) #:duration 6) + (jump #:frames (3) #:duration 10)) + #:anim-name 'idle + #:anim-frame 0 + #:anim-tick 0)) + +(define (create-hook game) + (let* ((scene (game-load-scene! game "assets/level.tmx")) + (player (create-player-entity))) + (scene-add-entity scene player))) + +(define (update-hook game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (player (scene-find-tagged scene 'player)) + (tm (scene-tilemap scene))) + ;; Update input-driven velocity + (let* ((player (entity-set player #:vx + (cond + ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + ;; Handle jump + (player (if (and (input-pressed? input 'a) + (entity-ref player #:on-ground? #f)) + (entity-set player #:ay -5) + player)) + ;; Apply physics + (player (apply-acceleration player)) + (player (apply-gravity player)) + (player (apply-velocity-x player)) + (player (resolve-tile-collisions-x player tm)) + (player (apply-velocity-y player)) + (player (resolve-tile-collisions-y player tm)) + (player (detect-ground player tm)) + ;; Update animation + (player (set-animation player + (cond + ((not (entity-ref player #:on-ground? #f)) 'jump) + ((not (zero? (entity-ref player #:vx 0))) 'walk) + (else 'idle)))) + (player (animate-entity player player-animations))) + ;; Update facing direction + (let ((player (if (< (entity-ref player #:vx 0) 0) + (entity-set player #:facing -1) + (entity-set player #:facing 1)))) + (scene-entities-set! scene (list player)))))) +#+end_src + +Note the let*-chaining pattern: each update builds on the previous result, keeping the data flow clear and each step testable. diff --git a/docs/guide.org b/docs/guide.org new file mode 100644 index 0000000..5119cea --- /dev/null +++ b/docs/guide.org @@ -0,0 +1,261 @@ +#+TITLE: Downstroke — Getting Started +#+AUTHOR: Downstroke Project + +* Introduction + +Downstroke is a 2D tile-driven game engine for Chicken Scheme, built on SDL2. It is inspired by Phaser 2: a minimal game is about 20 lines of Scheme. + +The engine handles SDL2 initialization, the event loop, input, rendering, and physics. You provide lifecycle hooks to customize behavior. This guide walks you through building your first game with Downstroke. + +* Installation + +** System Dependencies + +Downstroke requires the following system libraries: + +- =SDL2= +- =SDL2_mixer= +- =SDL2_ttf= +- =SDL2_image= +- =expat= + +On Debian/Ubuntu: +#+begin_src bash +sudo apt-get install libsdl2-dev libsdl2-mixer-dev libsdl2-ttf-dev libsdl2-image-dev libexpat1-dev +#+end_src + +On macOS with Homebrew: +#+begin_src bash +brew install sdl2 sdl2_mixer sdl2_ttf sdl2_image expat +#+end_src + +** Chicken Eggs + +Install the Downstroke egg along with its dependencies: + +#+begin_src bash +chicken-install downstroke sdl2 sdl2-image defstruct matchable states +#+end_src + +* Hello World — Your First Game + +Create a file called =mygame.scm=: + +#+begin_src scheme +(import downstroke/engine) + +(define *game* + (make-game title: "Hello World" width: 640 height: 480)) + +(game-run! *game*) +#+end_src + +Build and run: +#+begin_src bash +csc mygame.scm -o mygame +./mygame +#+end_src + +You should see a black window titled "Hello World". Press =Escape= or close the window to quit. The =game-run!= function handles SDL2 initialization, window creation, the event loop, and cleanup automatically. The engine also provides a default =quit= action (Escape key and window close button). + +* Moving Square — First Entity + +Now let's add an entity you can move with the keyboard. Create =square.scm=: + +#+begin_src scheme +(import scheme + (chicken base) + (prefix sdl2 "sdl2:") + downstroke/engine + downstroke/world + downstroke/entity + downstroke/input) + +(define *game* + (make-game + title: "Moving Square" width: 640 height: 480 + + create: (lambda (game) + (game-scene-set! game + (make-scene + entities: (list (list #:type 'box #:x 300 #:y 200 + #:width 32 #:height 32 + #:vx 0 #:vy 0)) + tilemap: #f + camera: (make-camera x: 0 y: 0) + tileset-texture: #f))) + + update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (box (car (scene-entities scene))) + ;; Read input and update velocity + (box (entity-set box #:vx (cond ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + (box (entity-set box #:vy (cond ((input-held? input 'up) -3) + ((input-held? input 'down) 3) + (else 0)))) + ;; Apply velocity to position + (box (entity-set box #:x (+ (entity-ref box #:x 0) + (entity-ref box #:vx 0)))) + (box (entity-set box #:y (+ (entity-ref box #:y 0) + (entity-ref box #:vy 0))))) + (scene-entities-set! scene (list box)))) + + render: (lambda (game) + (let* ((scene (game-scene game)) + (box (car (scene-entities scene))) + (x (inexact->exact (floor (entity-ref box #:x 0)))) + (y (inexact->exact (floor (entity-ref box #:y 0)))) + (w (entity-ref box #:width 32)) + (h (entity-ref box #:height 32))) + (sdl2:set-render-draw-color! (game-renderer game) 255 200 0 255) + (sdl2:render-fill-rect! (game-renderer game) + (sdl2:make-rect x y w h)))))) + +(game-run! *game*) +#+end_src + +Run it: +#+begin_src bash +csc square.scm -o square +./square +#+end_src + +Press arrow keys to move the yellow square around. Here are the key ideas: + +- **Scenes**: =make-scene= creates a container for entities, tilemaps, and the camera. It holds the game state each frame. +- **Entities**: Entities are plists (property lists). They have no class; they're pure data. Access properties with =entity-ref=, and update with =entity-set= (which returns a *new* plist — always bind the result). +- **Input**: =input-held?= returns =#t= if an action is currently pressed. Actions are symbols like ='left=, ='right=, ='up=, ='down= (from the default input config). +- **Update & Render**: The =update:= hook runs first and updates entities. The =render:= hook runs after the default rendering pipeline and is used for custom drawing like this colored rectangle. +- **Rendering**: Since our tilemap is =#f=, the default renderer draws nothing; all drawing happens in the =render:= hook using SDL2 functions. + +* Adding a Tilemap and Physics + +For a real game, you probably want tilemaps, gravity, and collision detection. Downstroke includes a full physics pipeline. Here's the pattern: + +#+begin_src scheme +(import scheme + (chicken base) + downstroke/engine + downstroke/world + downstroke/entity + downstroke/input + downstroke/physics + downstroke/scene-loader + downstroke/sound) + +(define *game* + (make-game + title: "Platformer Demo" width: 600 height: 400 + + preload: (lambda (game) + ;; Initialize audio and load sounds (optional) + (init-audio!) + (load-sounds! '((jump . "assets/jump.wav")))) + + create: (lambda (game) + ;; Load the tilemap from a TMX file (made with Tiled editor) + (let* ((scene (game-load-scene! game "assets/level.tmx")) + ;; Create a player entity + (player (list #:type 'player + #:x 100 #:y 50 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:on-ground? #f + #:tile-id 1))) + ;; Add player to the scene + (scene-add-entity scene player))) + + update: (lambda (game dt) + ;; Typical pattern for platformer physics + (let* ((input (game-input game)) + (scene (game-scene game)) + (tm (scene-tilemap scene)) + (player (car (scene-entities scene))) + ;; Set horizontal velocity from input + (player (entity-set player #:vx + (cond + ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + ;; Jump on button press if on ground + (_ (when (and (input-pressed? input 'a) + (entity-ref player #:on-ground? #f)) + (play-sound 'jump))) + (player (apply-jump player (input-pressed? input 'a))) + ;; Run physics pipeline + (player (apply-acceleration player)) + (player (apply-gravity player)) + (player (apply-velocity-x player)) + (player (resolve-tile-collisions-x player tm)) + (player (apply-velocity-y player)) + (player (resolve-tile-collisions-y player tm)) + (player (detect-ground player tm))) + ;; Update camera to follow player + (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300)))) + (camera-x-set! (scene-camera scene) cam-x)) + ;; Replace entities in scene + (scene-entities-set! scene (list player)))))) + +(game-run! *game*) +#+end_src + +Key points: + +- **=game-load-scene!=** loads a TMX tilemap file (created with the Tiled editor), creates the tileset texture, and builds the scene. It returns the scene so you can add more entities. +- **=init-audio!=** and **=load-sounds!=** initialize the audio subsystem and load sound files. Call these in the =preload:= hook. +- **=play-sound=** plays a loaded sound effect. +- **Physics pipeline**: Functions like =apply-gravity=, =apply-velocity-x=, =resolve-tile-collisions-x= form the physics pipeline. Apply them in order each frame to get correct platformer behavior. +- **Tile collisions**: =resolve-tile-collisions-x= and =resolve-tile-collisions-y= snap entities to tile edges, preventing clipping. +- **Ground detection**: =detect-ground= sets the =#:on-ground?= flag so you know if a jump is valid. + +See =demo/platformer.scm= in the engine source for a complete working example. + +* Demo Overview + +Downstroke includes five complete demo games that showcase different features: + +| Demo | File | What it shows | +|------|------|--------------| +| Platformer | =demo/platformer.scm= | Gravity, jump, tile collision, camera follow, sound effects | +| Top-down | =demo/topdown.scm= | 8-directional movement, no gravity, tilemap, camera follow | +| Physics Sandbox | =demo/sandbox.scm= | Entity-entity collision, multi-entity physics, auto-respawn | +| Shoot-em-up | =demo/shmup.scm= | No tilemap, entity spawning/removal, manual AABB collision | +| Audio | =demo/audio.scm= | Sound effects, music toggle, text rendering | + +Each demo is self-contained and serves as a working reference for a particular game mechanic. + +* Build & Run + +If you cloned the downstroke source, you can build everything: + +#+begin_src bash +cd /path/to/downstroke +make # Compile all engine modules +make demos # Build all 5 demo executables +./bin/demo-platformer +./bin/demo-topdown +# etc. +#+end_src + +* Next Steps + +You now know how to: + +- Create a game with =make-game= and =game-run!= +- Add entities to scenes +- Read input with =input-held?= and =input-pressed?= +- Apply physics to entities +- Load tilemaps with =game-load-scene!= +- Play sounds with =load-sounds!= and =play-sound= + +For more details: + +- **Full API reference**: See =docs/api.org= for all functions and keyword arguments. +- **Entity model**: See =docs/entities.org= to learn about plist keys, tags, prefabs, and mixins. +- **Physics pipeline**: See =docs/physics.org= for the full physics specification and collision model. + +Happy coding! diff --git a/docs/physics.org b/docs/physics.org new file mode 100644 index 0000000..a267d6d --- /dev/null +++ b/docs/physics.org @@ -0,0 +1,502 @@ +#+title: Physics System +#+author: Downstroke Contributors + +* Overview + +The downstroke physics system is **explicit**: YOU call each physics step in your =update:= hook. This design gives you full control over the game's behavior—skip gravity for top-down games, skip tile collision for shmups, or skip entity collision for single-player platformers. + +All physics functions are **functional and immutable**: they take an entity (plist) and return a NEW plist with updated values. No side effects. This makes it easy to reason about physics state and compose multiple steps together. + +The =tilemap= argument is required by collision functions, since collision data lives in the tilemap metadata (which tiles are solid, which are not). + +** Key Physics Functions + +- =apply-jump= — trigger jump acceleration if on ground +- =apply-acceleration= — consume one-shot #:ay into velocity +- =apply-gravity= — add gravity to falling entities +- =apply-velocity-x=, =apply-velocity-y= — move entity by velocity +- =resolve-tile-collisions-x=, =resolve-tile-collisions-y= — snap entity off tiles on collision +- =detect-ground= — probe 1px below feet to set #:on-ground? +- =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities +- =aabb-overlap?= — pure boolean collision test (for queries, not resolution) + +* Physics Pipeline + +The canonical 9-step physics pipeline is: + +#+begin_src +input + ↓ +apply-jump (set #:ay if jump pressed and on-ground) + ↓ +apply-acceleration (consume #:ay into #:vy) + ↓ +apply-gravity (add gravity constant to #:vy) + ↓ +apply-velocity-x (add #:vx to #:x) + ↓ +resolve-tile-collisions-x (snap off horizontal tiles, zero #:vx) + ↓ +apply-velocity-y (add #:vy to #:y) + ↓ +resolve-tile-collisions-y (snap off vertical tiles, zero #:vy) + ↓ +detect-ground (probe 1px below feet, set #:on-ground?) + ↓ +resolve-entity-collisions (push apart overlapping solid entities) + ↓ +render +#+end_src + +**Not all steps are needed for all game types.** See the examples section for three different patterns: + +- **Platformer**: uses all 9 steps +- **Top-down**: skips gravity, acceleration, jump, ground detection +- **Physics Sandbox**: uses all steps, applies them to multiple entities + +* Pipeline Steps + +** apply-jump + +#+begin_src scheme +(apply-jump entity jump-pressed?) +#+end_src + +**Signature**: Takes an entity plist and a boolean (jump button state). + +**Reads**: =#:on-ground?= (must be #t), =#:jump-force= (default: 15 pixels/frame²) + +**Writes**: =#:ay= (acceleration-y), set to =(- jump-force)= if jump pressed and on ground, else #:ay is left untouched + +**Description**: Sets up a one-shot vertical acceleration if the jump button was just pressed AND the entity is standing on ground. The acceleration is consumed on the next frame by =apply-acceleration=. Do not call this repeatedly in a loop; call it once per frame. + +#+begin_src scheme +;; Example: +(define entity (list #:type 'player #:on-ground? #t #:jump-force 15)) +(define jumped (apply-jump entity #t)) +;; jumped has #:ay -15 + +(define jumped2 (apply-jump entity #f)) +;; jumped2 is unchanged (not on ground) +#+end_src + +** apply-acceleration + +#+begin_src scheme +(apply-acceleration entity) +#+end_src + +**Reads**: =#:ay= (default 0), =#:vy= (default 0), =#:gravity?= to decide whether to consume + +**Writes**: =#:vy= (adds #:ay to it), =#:ay= (reset to 0) + +**Description**: Consumes the one-shot acceleration into velocity. Used for jumps and other instant bursts. Only works if =#:gravity?= is true (gravity-enabled entities). Call once per frame, after =apply-jump=. + +** apply-gravity + +#+begin_src scheme +(apply-gravity entity) +#+end_src + +**Reads**: =#:gravity?= (boolean), =#:vy= (default 0) + +**Writes**: =#:vy= (adds 1 pixel/frame²) + +**Description**: Applies a constant gravitational acceleration. The gravity constant is =*gravity* = 1= pixel per frame per frame. Only applies if =#:gravity?= is true. Safe to call every frame. + +** apply-velocity-x + +#+begin_src scheme +(apply-velocity-x entity) +#+end_src + +**Reads**: =#:vx= (default 0), =#:x= (default 0) + +**Writes**: =#:x= (adds #:vx to it) + +**Description**: Moves the entity horizontally by its velocity. Call once per frame, before =resolve-tile-collisions-x=. + +** apply-velocity-y + +#+begin_src scheme +(apply-velocity-y entity) +#+end_src + +**Reads**: =#:vy= (default 0), =#:y= (default 0) + +**Writes**: =#:y= (adds #:vy to it) + +**Description**: Moves the entity vertically by its velocity. Call once per frame, before =resolve-tile-collisions-y=. + +** resolve-tile-collisions-x + +#+begin_src scheme +(resolve-tile-collisions-x entity tilemap) +#+end_src + +**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vx= + +**Writes**: =#:x= (snapped to tile edge), =#:vx= (zeroed if collision occurs) + +**Description**: Detects all solid tiles overlapping the entity's AABB and snaps the entity to the nearest tile edge on the X-axis. If a collision is found: + +- **Moving right** (vx > 0): entity's right edge is snapped to the left edge of the tile (position = tile-x - width) +- **Moving left** (vx < 0): entity's left edge is snapped to the right edge of the tile (position = tile-x + tile-width) + +Velocity is zeroed to stop the entity from sliding. Call this immediately after =apply-velocity-x=. + +** resolve-tile-collisions-y + +#+begin_src scheme +(resolve-tile-collisions-y entity tilemap) +#+end_src + +**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vy= + +**Writes**: =#:y= (snapped to tile edge), =#:vy= (zeroed if collision occurs) + +**Description**: Detects all solid tiles overlapping the entity's AABB and snaps the entity to the nearest tile edge on the Y-axis. If a collision is found: + +- **Moving down** (vy > 0): entity's bottom edge is snapped to the top edge of the tile (position = tile-y - height) +- **Moving up** (vy < 0): entity's top edge is snapped to the bottom edge of the tile (position = tile-y + tile-height) + +Velocity is zeroed. Call this immediately after =apply-velocity-y=. + +** detect-ground + +#+begin_src scheme +(detect-ground entity tilemap) +#+end_src + +**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height= + +**Writes**: =#:on-ground?= (set to #t if tile found 1px below feet, else #f) + +**Description**: Probes 1 pixel directly below the entity's feet (bottom edge) to see if it's standing on a tile. The probe checks both corners of the entity's width to handle partial overlaps. Only works if =#:gravity?= is true. + +Used by =apply-jump= to decide whether jump is allowed. Call this at the end of each physics frame, after all collision resolution. + +** resolve-entity-collisions + +#+begin_src scheme +(resolve-entity-collisions entities) +#+end_src + +**Input**: A list of entity plists + +**Reads**: For each entity: =#:solid?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vx=, =#:vy= + +**Writes**: For colliding pairs: =#:x=, =#:y=, =#:vx=, =#:vy= (pushed apart, velocities set to ±1) + +**Description**: Performs all-pairs AABB overlap detection. For each pair of entities where BOTH have =#:solid? #t=, if they overlap: + +1. Calculate overlap on both X and Y axes +2. Push apart along the axis with smaller overlap (to avoid getting stuck in corners) +3. Set each entity's velocity along that axis to push them in opposite directions + +Entities without =#:solid?= or with =#:solid? #f= are skipped. Returns a new entity list with collisions resolved. + +This is relatively expensive: O(n²) for n entities. Use only when entity count is low (< 100) or for game objects where push-apart is desired. + +** scene-resolve-collisions + +#+begin_src scheme +(scene-resolve-collisions scene) +#+end_src + +**Description**: Convenience wrapper. Extracts all entities from the scene, passes them to =resolve-entity-collisions=, and updates the scene in place. Modifies the scene. + +** aabb-overlap? + +#+begin_src scheme +(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2) +#+end_src + +**Returns**: Boolean (#t if overlapping, #f if separated or touching) + +**Description**: Pure collision test for two axis-aligned bounding boxes. Does not modify any state. Useful for manual collision queries (e.g., "did bullet hit enemy?") where you want to decide the response manually rather than using push-apart. + +#+begin_src scheme +;; Example: check if player overlaps enemy +(if (aabb-overlap? (entity-ref player #:x 0) (entity-ref player #:y 0) + (entity-ref player #:width 0) (entity-ref player #:height 0) + (entity-ref enemy #:x 0) (entity-ref enemy #:y 0) + (entity-ref enemy #:width 0) (entity-ref enemy #:height 0)) + (do-damage!)) +#+end_src + +* Tile Collision Model + +Tiles come from **TMX maps** loaded by the tilemap module. The tilemap parses the XML and builds a 2D tile grid. Each cell references a tile ID from the tileset. + +**Solid tiles** are identified by **tile metadata** in the TSX tileset (defined in Tiled editor). The physics module checks each tile to see if it has collision metadata. Tiles with no metadata are treated as non-solid background tiles. + +** How Collision Works + +1. Entity's AABB (x, y, width, height) is used to compute all overlapping tile cells +2. For each cell, the physics module queries the tilemap: "does this cell have a solid tile?" +3. If yes, the entity is snapped to the edge of that tile in the axis being resolved +4. Velocity is zeroed in that axis to prevent sliding + +** Why X and Y are Separate + +Tile collisions are resolved in two passes: + +#+begin_src +apply-velocity-x → resolve-tile-collisions-x → apply-velocity-y → resolve-tile-collisions-y +#+end_src + +This prevents a common platformer bug: if you moved and collided on both axes at once, you could get "corner clipped" (stuck in a diagonal corner). By separating X and Y, you guarantee the entity snaps cleanly. + +* Entity-Entity Collision + +Entity-entity collisions are for when two **game objects** overlap: a player and an enemy, two boxes stacked, etc. + +=resolve-entity-collisions= does all-pairs AABB checks. Any entity with =#:solid? #t= that overlaps another solid entity is pushed apart. + +The push-apart is along the **minimum penetration axis** (X or Y, whichever overlap is smaller). Entities are pushed in opposite directions by half the overlap distance, and their velocities are set to ±1 to prevent stacking. + +** When to Use + +- **Physics sandbox**: multiple boxes/balls that should bounce apart +- **Solid obstacles**: pushable crates that block the player +- **Knockback**: enemies that push back when hit + +** When NOT to Use + +- **Trigger zones**: enemies that should detect the player without colliding — use =aabb-overlap?= manually +- **Bullets**: should pass through enemies — don't mark bullet as solid, use =aabb-overlap?= to check hits +- **High entity count**: 100+ entities = O(n²) is too slow + +* Platformer Example + +A minimal platformer with a player, gravity, jumping, and tile collisions: + +#+begin_src scheme +update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (tm (scene-tilemap scene)) + (player (car (scene-entities scene))) + ;; Set horizontal velocity based on input + (player (entity-set player #:vx + (cond + ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + ;; Play sound if jump pressed + (_ (when (and (input-pressed? input 'a) + (entity-ref player #:on-ground? #f)) + (play-sound 'jump))) + ;; Apply jump acceleration if button pressed + (player (apply-jump player (input-pressed? input 'a))) + ;; Consume #:ay into #:vy + (player (apply-acceleration player)) + ;; Apply gravity constant + (player (apply-gravity player)) + ;; Move horizontally + (player (apply-velocity-x player)) + ;; Resolve tile collisions on x-axis + (player (resolve-tile-collisions-x player tm)) + ;; Move vertically + (player (apply-velocity-y player)) + ;; Resolve tile collisions on y-axis + (player (resolve-tile-collisions-y player tm)) + ;; Check if standing on ground + (player (detect-ground player tm))) + ;; Update camera to follow player + (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300)))) + (camera-x-set! (scene-camera scene) cam-x)) + ;; Store updated player back in scene + (scene-entities-set! scene (list player)))) +#+end_src + +** Step-by-Step + +1. **Set #:vx** from input (left = -3, right = +3, idle = 0) +2. **Check jump**: if A button pressed and on-ground, sound plays +3. **apply-jump**: if A pressed and on-ground, set #:ay to -15 (jump force) +4. **apply-acceleration**: consume #:ay into #:vy (so #:ay becomes 0) +5. **apply-gravity**: add 1 to #:vy each frame (falling acceleration) +6. **apply-velocity-x**: add #:vx to #:x (move left/right) +7. **resolve-tile-collisions-x**: if hit a tile, snap #:x and zero #:vx +8. **apply-velocity-y**: add #:vy to #:y (move up/down) +9. **resolve-tile-collisions-y**: if hit a tile, snap #:y and zero #:vy +10. **detect-ground**: probe 1px below feet, set #:on-ground? for next frame's jump check + +* Top-Down Example + +A top-down game with no gravity, no jumping, and free 4-way movement: + +#+begin_src scheme +update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (tm (scene-tilemap scene)) + (player (car (scene-entities scene))) + ;; Combine directional input + (dx (+ (if (input-held? input 'left) -3 0) + (if (input-held? input 'right) 3 0))) + (dy (+ (if (input-held? input 'up) -3 0) + (if (input-held? input 'down) 3 0))) + ;; Set both velocities + (player (entity-set (entity-set player #:vx dx) #:vy dy)) + ;; Move horizontally + (player (apply-velocity-x player)) + ;; Resolve tile collisions on x-axis + (player (resolve-tile-collisions-x player tm)) + ;; Move vertically + (player (apply-velocity-y player)) + ;; Resolve tile collisions on y-axis + (player (resolve-tile-collisions-y player tm))) + ;; Update camera to follow player + (camera-x-set! (scene-camera scene) (max 0 (- (entity-ref player #:x 0) 300))) + (camera-y-set! (scene-camera scene) (max 0 (- (entity-ref player #:y 0) 200))) + ;; Store updated player back in scene + (scene-entities-set! scene (list player)))) +#+end_src + +** Step-by-Step + +1. **Read input**: combine left/right into dx, up/down into dy +2. **Set velocity**: both #:vx and #:vy based on input +3. **apply-velocity-x** and **resolve-tile-collisions-x**: move and collide horizontally +4. **apply-velocity-y** and **resolve-tile-collisions-y**: move and collide vertically +5. **No gravity, no jumping, no ground detection**: top-down games don't need these + +The pipeline is much simpler because there's no vertical acceleration to manage. + +* Physics Sandbox Example + +Multiple entities falling and colliding with each other: + +#+begin_src scheme +update: (lambda (game dt) + (let* ((scene (game-scene game)) + (tm (scene-tilemap scene))) + ;; Apply physics to all entities in one pass + (scene-update-entities scene + apply-gravity + apply-velocity-x + (lambda (e) (resolve-tile-collisions-x e tm)) + apply-velocity-y + (lambda (e) (resolve-tile-collisions-y e tm)) + (lambda (e) (detect-ground e tm))) + ;; Then resolve entity-entity collisions + (scene-resolve-collisions scene))) +#+end_src + +** step-by-step + +1. **scene-update-entities**: applies each step to all entities in order + - =apply-gravity= (all entities fall) + - =apply-velocity-x=, =resolve-tile-collisions-x= (move and collide on x-axis) + - =apply-velocity-y=, =resolve-tile-collisions-y= (move and collide on y-axis) + - =detect-ground= (set #:on-ground? for next frame) + +2. **scene-resolve-collisions**: after all entities are moved and collided with tiles, resolve entity-entity overlaps (boxes pushing apart) + +This pattern is efficient for sandbox simulations: apply the same pipeline to all entities, then resolve inter-entity collisions once. + +** Lambdas with Tilemap + +Notice that =resolve-tile-collisions-x= needs the tilemap argument, so it's wrapped in a lambda: + +#+begin_src scheme +(lambda (e) (resolve-tile-collisions-x e tm)) +#+end_src + +Same for other functions that take tilemap. The =scene-update-entities= macro applies each function to all entities, so you wrap single-argument functions in a lambda to capture the tilemap. + +* Common Patterns + +** Jumping + +#+begin_src scheme +;; In update: +(player (apply-jump player (input-pressed? input 'jump))) +(player (apply-acceleration player)) +(player (apply-gravity player)) +;; ... rest of pipeline +#+end_src + +You only need =apply-jump=, =apply-acceleration=, and =apply-gravity=. =detect-ground= is needed to prevent double-jumping. + +** No Gravity + +For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-ground=. Just do velocity + tile collision. + +** Knockback + +Set #:vx or #:vy directly to push an entity: + +#+begin_src scheme +(entity-set enemy #:vx -5) ;; Push left +#+end_src + +Gravity will resume on the next frame if =#:gravity?= is #t. + +** Trigger Zones + +For areas that don't collide but detect presence (spawn zones, damage zones): + +#+begin_src scheme +;; In update: +(if (aabb-overlap? (entity-ref trigger #:x 0) (entity-ref trigger #:y 0) + (entity-ref trigger #:width 0) (entity-ref trigger #:height 0) + (entity-ref player #:x 0) (entity-ref player #:y 0) + (entity-ref player #:width 0) (entity-ref player #:height 0)) + (on-trigger-enter! player)) +#+end_src + +Don't mark the trigger as solid; just use =aabb-overlap?= to query. + +** Slopes and Ramps + +The current tile collision system is AABB-based and does not support slopes. All tiles are axis-aligned rectangles. To simulate slopes, you can: + +1. Use small tiles to approximate a slope (many tiny tiles at angles) +2. Post-process the player's position after collision (rotate velocity vector) +3. Use a custom collision function instead of =resolve-tile-collisions-y= + +This is beyond the scope of the basic physics system. + +* Performance Notes + +- **Tile collision**: O(overlapping tiles) per entity. Usually 1–4 tiles per frame. +- **Entity collision**: O(n²) where n = entity count. Avoid for > 100 solid entities. +- **Ground detection**: O(2) tile lookups per entity (corners below feet). + +For large games, consider spatial partitioning (grid, quadtree) to cull entity pairs. The basic physics system is designed for small to medium games (< 100 entities). + +* Troubleshooting + +** Entity Sinks Into Floor + +- Make sure =#:height= is set correctly (not 0 or too large) +- Verify tileset metadata marks floor tiles as solid in Tiled editor +- Check that =resolve-tile-collisions-y= is called after =apply-velocity-y= + +** Double-Jump / Can't Jump + +- Ensure =detect-ground= is called after all collision resolution +- Verify =#:on-ground?= is checked in =apply-jump= (not hardcoded) +- Make sure you're checking =input-pressed?= (not =input-held?=) for jump + +** Entity Slides Through Walls + +- Check that #:width and #:height are set correctly +- Verify tileset marks wall tiles as solid +- Ensure =resolve-tile-collisions-x= is called after =apply-velocity-x= + +** Entities Get Stuck Overlapping + +- Use =scene-resolve-collisions= after all physics steps +- Verify both entities have =#:solid? #t= +- Reduce =*gravity*= or max velocity if entities are moving too fast (can cause multi-frame overlap) + +** Performance Drops + +- Profile entity count (should be < 100 for full physics pipeline) +- Disable =resolve-entity-collisions= if not needed +- Use =aabb-overlap?= for queries instead of marking many entities solid diff --git a/docs/superpowers/plans/2026-04-05-demos.md b/docs/superpowers/plans/2026-04-05-demos.md new file mode 100644 index 0000000..6fea0bb --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-demos.md @@ -0,0 +1,922 @@ +# Demo Games Implementation Plan + +> **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:** Build 5 self-contained demo games in `demo/` that collectively exercise every engine system and compile via `make demos`. + +**Architecture:** Each demo is a standalone Chicken Scheme program that imports downstroke engine modules and links against all engine `.o` files. Shared assets (tileset, levels, fonts, sounds) live in `demo/assets/` copied from macroknight. `mixer.scm` and `sound.scm` are adapted from macroknight and added to the engine build. + +**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), SDL2_mixer (via mixer FFI), SDL2_ttf (sdl2-ttf egg), SDL2_image (sdl2-image egg), SRFI-64 (tests not applicable to demo programs — `make demos` compilation success is the test). + +--- + +## Key API Reference + +Before implementing, keep these exact signatures in mind: + +```scheme +;; Entity ops (downstroke/entity) +(entity-ref entity #:key [default]) ;; read field, default=#f +(entity-set entity #:key value) ;; returns NEW entity (immutable) +(entity-type entity) ;; = (entity-ref entity #:type #f) + +;; World (downstroke/world) +(make-scene entities: '() tilemap: #f camera: (make-camera x: 0 y: 0) tileset-texture: #f) +(make-camera x: 0 y: 0) +(scene-entities scene) +(scene-entities-set! scene list) ;; mutates in place +(scene-camera scene) ;; camera-x, camera-y, camera-x-set!, camera-y-set! +(scene-tilemap scene) +(scene-tileset-texture scene) +(scene-add-entity scene entity) ;; mutates, returns scene +(scene-filter-entities scene pred) ;; mutates, returns scene +(scene-update-entities scene proc ...) ;; maps each proc over all entities + +;; Tilemap (downstroke/tilemap) +(load-tilemap "path.tmx") ;; loads TMX + TSX + PNG; returns tilemap +(tilemap-tileset tilemap) ;; → tileset struct +(tileset-image tileset) ;; → SDL texture (the tileset-texture for make-scene) + +;; Physics (downstroke/physics) +;; Full platformer pipeline (execute in this order): +(apply-jump entity jump-pressed?) ;; sets #:ay if on-ground +(apply-acceleration entity) ;; consumes #:ay → #:vy, gated on #:gravity? +(apply-gravity entity) ;; adds *gravity*(=1) to #:vy, gated on #:gravity? +(apply-velocity-x entity) ;; #:x += #:vx +(resolve-tile-collisions-x entity tm) ;; snaps #:x, zeros #:vx on hit +(apply-velocity-y entity) ;; #:y += #:vy +(resolve-tile-collisions-y entity tm) ;; snaps #:y, zeros #:vy on hit +(detect-ground entity tm) ;; sets #:on-ground?, gated on #:gravity? +;; Entity-entity collision: +(scene-resolve-collisions scene) ;; mutates scene; needs #:solid? #t on both entities +;; AABB check: +(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2) ;; → bool + +;; Input (downstroke/input) +(game-input game) ;; current input-state +(input-held? state 'action) ;; true while button held +(input-pressed? state 'action) ;; true only on first frame pressed +;; Default action names: up down left right a b start select quit +;; Keyboard: W/up=up, S/down=down, A/left=left, D/right=right, +;; J/Z=a, K/X=b, return=start, escape=quit + +;; Engine (downstroke/engine) +(game-run! game) +(game-scene game), (game-scene-set! game scene) +(game-renderer game) ;; SDL renderer — valid from preload: onward +(game-input game) ;; current input-state +(game-asset game key) ;; = (asset-ref (game-assets game) key) +(game-asset-set! game key value) +(game-camera game) ;; = (scene-camera (game-scene game)) + +;; Renderer (downstroke/renderer) +(render-scene! renderer scene) ;; draws tilemap + entities (when tilemap present) +(draw-ui-text renderer font str color x y) + +;; Sound (downstroke/sound) — after copying from macroknight +(init-audio!) +(load-sounds! '((key . "path.wav") ...)) +(play-sound 'key) ;; NOTE: no bang +(load-music! "path.ogg") +(play-music! volume) ;; volume = 0.0–1.0 +(stop-music!) ;; added when copying sound.scm +``` + +--- + +## File Structure + +**New files to create:** +``` +demo/assets/ ← copied from macroknight/assets/ + monochrome-transparent.png + monochrome_transparent.tsx + level-0.tmx + DejaVuSans.ttf + jump.wav + theme.ogg +demo/platformer.scm +demo/shmup.scm +demo/topdown.scm +demo/audio.scm +demo/sandbox.scm +mixer.scm ← adapted from macroknight (module renamed to downstroke/mixer) +sound.scm ← adapted from macroknight (module renamed to downstroke/sound, +stop-music!) +``` + +**Files to modify:** +``` +Makefile ← add mixer/sound to MODULE_NAMES, update demos: target +CLAUDE.md ← add make demos requirement +``` + +--- + +## Task 1: Infrastructure — assets, audio modules, Makefile, CLAUDE.md + +**Files:** +- Create: `demo/assets/` (7 files copied from macroknight) +- Create: `mixer.scm` (adapted from `/home/gene/src/macroknight/mixer.scm`) +- Create: `sound.scm` (adapted from `/home/gene/src/macroknight/sound.scm`) +- Modify: `Makefile` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Create demo/assets/ and copy asset files** + +```bash +mkdir -p demo/assets +cp /home/gene/src/macroknight/assets/monochrome-transparent.png demo/assets/ +cp /home/gene/src/macroknight/assets/monochrome_transparent.tsx demo/assets/ +cp /home/gene/src/macroknight/assets/level-0.tmx demo/assets/ +cp /home/gene/src/macroknight/assets/DejaVuSans.ttf demo/assets/ +cp /home/gene/src/macroknight/assets/jump.wav demo/assets/ +cp /home/gene/src/macroknight/assets/theme.ogg demo/assets/ +``` + +Verify: `ls demo/assets/` shows 6 files (png, tsx, tmx, ttf, wav, ogg). + +- [ ] **Step 2: Copy and adapt mixer.scm** + +Copy `/home/gene/src/macroknight/mixer.scm` to `mixer.scm` in the downstroke root. Change the module declaration from: +```scheme +(module mixer * +``` +to: +```scheme +(module downstroke/mixer * +``` + +No other changes needed. + +- [ ] **Step 3: Copy and adapt sound.scm** + +Copy `/home/gene/src/macroknight/sound.scm` to `sound.scm` in the downstroke root. Make three changes: + +1. Change module declaration from `(module sound *` to `(module downstroke/sound *` +2. Change `(import ... mixer)` to `(import ... downstroke/mixer)` +3. Add `stop-music!` near the other music functions: + +```scheme +(define (stop-music!) (mix-halt-music)) +``` + +The full adapted `sound.scm` should look like: + +```scheme +(module downstroke/sound * + (import scheme + (chicken base) + (only srfi-1 for-each) + downstroke/mixer) + + (define *sound-registry* '()) + (define *music* #f) + + (define (init-audio!) + (mix-open-audio! 44100 mix-default-format 2 512)) + + (define (load-sounds! sound-alist) + (set! *sound-registry* + (map (lambda (pair) + (cons (car pair) (mix-load-chunk (cdr pair)))) + sound-alist))) + + (define (play-sound sym) + (let ((entry (assq sym *sound-registry*))) + (when (and entry (cdr entry)) + (mix-play-channel -1 (cdr entry) 0)))) + + (define (load-music! path) + (set! *music* (mix-load-mus path))) + + (define (play-music! volume) + (when *music* + (mix-play-music *music* -1) + (mix-volume-music (inexact->exact (round (* volume 128)))))) + + (define (stop-music!) (mix-halt-music)) + + (define (set-music-volume! volume) + (mix-volume-music (inexact->exact (round (* volume 128))))) + + (define (cleanup-audio!) + (when *music* + (mix-halt-music) + (mix-free-music! *music*) + (set! *music* #f)) + (for-each (lambda (pair) (mix-free-chunk! (cdr pair))) + *sound-registry*) + (set! *sound-registry* '()) + (mix-close-audio!))) +``` + +- [ ] **Step 4: Update Makefile** + +Replace the current `MODULE_NAMES` line and add mixer/sound deps and demos target. The updated Makefile: + +```makefile +.DEFAULT_GOAL := engine + +# Modules listed in dependency order +MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound +OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES)) + +DEMO_NAMES := platformer shmup topdown audio sandbox +DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES)) + +# Build all engine modules +engine: $(OBJECT_FILES) + +bin: + @mkdir -p $@ + +downstroke: + @mkdir -p $@ + +# Explicit inter-module dependencies +bin/entity.o: +bin/tilemap.o: +bin/world.o: bin/entity.o bin/tilemap.o +bin/input.o: bin/entity.o +bin/physics.o: bin/entity.o bin/world.o bin/tilemap.o +bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o +bin/assets.o: +bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o +bin/mixer.o: +bin/sound.o: bin/mixer.o + +# Pattern rule: compile each module as a library unit +bin/%.o: %.scm | bin downstroke + csc -c -J -unit downstroke/$* $*.scm -o bin/$*.o -I bin -L bin/downstroke + @mkdir -p bin/downstroke && if [ -f downstroke/$*.import.scm ]; then mv downstroke/$*.import.scm bin/downstroke/; fi + +.PHONY: clean test engine demos + +clean: + rm -rf bin + rm -f *.import.scm + rm -f *.log + +test: + @echo "Running unit tests..." + @csi -s tests/entity-test.scm + @csi -s tests/world-test.scm + @csi -s tests/tilemap-test.scm + @csi -s tests/physics-test.scm + @csi -s tests/input-test.scm + @csi -s tests/renderer-test.scm + @csi -s tests/assets-test.scm + @csi -s tests/engine-test.scm + +demos: engine $(DEMO_BINS) + +bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin + csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin +``` + +- [ ] **Step 5: Update CLAUDE.md** + +In the "Build & Test" section of `/home/gene/src/downstroke/CLAUDE.md`, update the code block: + +```bash +make # compile engine + all demos in demo/ +make test # run all SRFI-64 test suites +make demos # build demo games only (verify they compile) +``` + +Also add after the build section (or at end of Build & Test): + +> `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. + +- [ ] **Step 6: Verify engine builds with mixer and sound** + +```bash +cd /home/gene/src/downstroke +make clean && make +``` + +Expected: All 10 modules compile without errors. `bin/` contains `.o` files for all MODULE_NAMES including `mixer.o` and `sound.o`. + +- [ ] **Step 7: Commit** + +```bash +git add demo/assets/ mixer.scm sound.scm Makefile CLAUDE.md +git commit -m "feat: add demo infrastructure (assets, mixer/sound, Makefile demos target)" +``` + +--- + +## Task 2: demo/platformer.scm + +Exercises: input, physics (gravity + tile collision + jump), renderer (tilemap + entities), world/scene, camera follow (x), audio (sound effect). + +**Files:** +- Create: `demo/platformer.scm` + +- [ ] **Step 1: Create demo/platformer.scm** + +```scheme +(import scheme + (chicken base) + (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 + downstroke/entity + downstroke/sound) + +(define *game* + (make-game + title: "Demo: Platformer" width: 600 height: 400 + + preload: (lambda (game) + (init-audio!) + (load-sounds! '((jump . "demo/assets/jump.wav"))) + (game-asset-set! game 'tilemap + (load-tilemap "demo/assets/level-0.tmx"))) + + create: (lambda (game) + (let* ((tm (game-asset game 'tilemap)) + (tex (tileset-image (tilemap-tileset tm))) + (player (list #:type 'player + #:x 100 #:y 50 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:on-ground? #f + #:tile-id 1))) + (game-scene-set! game + (make-scene + entities: (list player) + tilemap: tm + camera: (make-camera x: 0 y: 0) + tileset-texture: tex)))) + + update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (tm (scene-tilemap scene)) + (player (car (scene-entities scene))) + ;; horizontal input + (player (entity-set player #:vx + (cond + ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + ;; play jump sound when jump fires + (_ (when (and (input-pressed? input 'a) + (entity-ref player #:on-ground? #f)) + (play-sound 'jump))) + ;; physics pipeline + (player (apply-jump player (input-pressed? input 'a))) + (player (apply-acceleration player)) + (player (apply-gravity player)) + (player (apply-velocity-x player)) + (player (resolve-tile-collisions-x player tm)) + (player (apply-velocity-y player)) + (player (resolve-tile-collisions-y player tm)) + (player (detect-ground player tm))) + ;; camera follows player x, clamped so we don't go negative + (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300)))) + (camera-x-set! (scene-camera scene) cam-x)) + ;; put player back + (scene-entities-set! scene (list player)))))) + +(game-run! *game*) +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /home/gene/src/downstroke +make demos +``` + +Expected: `bin/demo-platformer` is created with no errors. + +- [ ] **Step 3: Smoke test — run and verify it opens a window** + +```bash +./bin/demo-platformer +``` + +Expected: A window titled "Demo: Platformer" opens. Player entity (tile sprite) appears. Left/right moves it, J/Z triggers a jump with sound. Escape quits. + +Note: Tile ID 1 may show the wrong sprite — that's expected. The spec says tile IDs are placeholder values to be adjusted visually. + +- [ ] **Step 4: Commit** + +```bash +git add demo/platformer.scm +git commit -m "feat: add platformer demo" +``` + +--- + +## Task 3: demo/shmup.scm + +Exercises: entity spawning/removal, manual AABB collision, input, renderer (SDL colored rects in render: hook), world/scene (no tilemap), audio (sound effect on shoot). + +The engine's `render-scene!` draws nothing for this scene (no tilemap). All visual output comes from the `render:` hook using `sdl2:render-fill-rect!`. + +**Files:** +- Create: `demo/shmup.scm` + +- [ ] **Step 1: Create demo/shmup.scm** + +```scheme +(import scheme + (chicken base) + (prefix sdl2 "sdl2:") + (prefix sdl2-ttf "ttf:") + (prefix sdl2-image "img:") + downstroke/engine + downstroke/world + downstroke/physics + downstroke/input + downstroke/entity + downstroke/assets + downstroke/sound) + +;; Frame counter for enemy spawning +(define *frame-count* 0) + +(define (make-bullet px py) + (list #:type 'bullet #:x px #:y py #:width 4 #:height 8 #:vx 0 #:vy -6)) + +(define (make-enemy rx) + (list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2)) + +(define (make-player) + (list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0)) + +;; Check collision between two entities using physics.scm's aabb-overlap? +(define (entities-overlap? a b) + (aabb-overlap? + (entity-ref a #:x 0) (entity-ref a #:y 0) + (entity-ref a #:width 1) (entity-ref a #:height 1) + (entity-ref b #:x 0) (entity-ref b #:y 0) + (entity-ref b #:width 1) (entity-ref b #:height 1))) + +;; Returns list of entities to remove (bullets and enemies that collide) +(define (find-collisions entities) + (let ((bullets (filter (lambda (e) (eq? (entity-ref e #:type) 'bullet)) entities)) + (enemies (filter (lambda (e) (eq? (entity-ref e #:type) 'enemy)) entities)) + (dead '())) + (for-each + (lambda (b) + (for-each + (lambda (en) + (when (entities-overlap? b en) + (set! dead (cons b (cons en dead))))) + enemies)) + bullets) + dead)) + +(define *game* + (make-game + title: "Demo: Shoot-em-up" width: 600 height: 400 + + preload: (lambda (game) + (init-audio!) + (load-sounds! '((shoot . "demo/assets/jump.wav")))) + + create: (lambda (game) + ;; No tilemap — render-scene! will draw nothing for this scene. + ;; All drawing happens in the render: hook below. + (game-scene-set! game + (make-scene + entities: (list (make-player)) + tilemap: #f + camera: (make-camera x: 0 y: 0) + tileset-texture: #f))) + + update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (entities (scene-entities scene)) + (player (car (filter (lambda (e) (eq? (entity-ref e #:type) 'player)) entities)))) + (set! *frame-count* (+ *frame-count* 1)) + + ;; Move player left/right + (let ((player (entity-set player #:vx + (cond + ((input-held? input 'left) -4) + ((input-held? input 'right) 4) + (else 0))))) + (let ((player (apply-velocity-x player))) + ;; Clamp player to screen + (let ((player (entity-set player #:x + (max 0 (min 584 (entity-ref player #:x 0)))))) + ;; Fire bullet on 'a' + (when (input-pressed? input 'a) + (play-sound 'shoot) + (scene-add-entity scene + (make-bullet (+ (entity-ref player #:x 0) 6) 340))) + + ;; Spawn enemy every 60 frames + (when (zero? (modulo *frame-count* 60)) + (scene-add-entity scene + (make-enemy (+ 20 (* (random 28) 20))))) + + ;; Update scene with current player + (scene-entities-set! scene + (cons player + (filter (lambda (e) (not (eq? (entity-ref e #:type) 'player))) + (scene-entities scene))))))) + + ;; Move bullets and enemies + (scene-update-entities scene + (lambda (e) + (if (eq? (entity-ref e #:type) 'player) + e + (entity-set (entity-set e + #:x (+ (entity-ref e #:x 0) (entity-ref e #:vx 0))) + #:y (+ (entity-ref e #:y 0) (entity-ref e #:vy 0)))))) + + ;; Remove bullet-enemy collisions + (let ((dead (find-collisions (scene-entities scene)))) + (scene-filter-entities scene + (lambda (e) (not (memq e dead))))) + + ;; Remove out-of-bounds bullets and enemies + (scene-filter-entities scene + (lambda (e) + (let ((y (entity-ref e #:y 0))) + (or (eq? (entity-ref e #:type) 'player) + (and (> y -20) (< y 420)))))))) + + render: (lambda (game) + (let* ((renderer (game-renderer game)) + (scene (game-scene game)) + (entities (scene-entities scene))) + (for-each + (lambda (e) + (let ((type (entity-ref e #:type 'unknown)) + (x (inexact->exact (floor (entity-ref e #:x 0)))) + (y (inexact->exact (floor (entity-ref e #:y 0)))) + (w (entity-ref e #:width 16)) + (h (entity-ref e #:height 16))) + (sdl2:render-draw-color-set! renderer + (case type + ((player) (sdl2:make-color 255 255 255 255)) + ((bullet) (sdl2:make-color 255 255 0 255)) + ((enemy) (sdl2:make-color 255 50 50 255)) + (else (sdl2:make-color 100 100 100 255)))) + (sdl2:render-fill-rect! renderer + (sdl2:make-rect x y w h)))) + entities))))) + +(game-run! *game*) +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +make demos +``` + +Expected: `bin/demo-shmup` created with no errors. + +- [ ] **Step 3: Smoke test** + +```bash +./bin/demo-shmup +``` + +Expected: Black window. White player rectangle at bottom. J/Z fires yellow bullet upward (with sound). Red enemy rectangles fall from the top. Bullets disappear on contact with enemies. Escape quits. + +- [ ] **Step 4: Commit** + +```bash +git add demo/shmup.scm +git commit -m "feat: add shmup demo" +``` + +--- + +## Task 4: demo/topdown.scm + +Exercises: input (8-directional), renderer (tilemap + entities), world/scene, camera follow (both axes), physics tile collision (no gravity). + +**Files:** +- Create: `demo/topdown.scm` + +- [ ] **Step 1: Create demo/topdown.scm** + +```scheme +(import scheme + (chicken base) + (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 + downstroke/entity) + +(define (clamp val lo hi) (max lo (min hi val))) + +(define *game* + (make-game + title: "Demo: Top-down Explorer" width: 600 height: 400 + + preload: (lambda (game) + (game-asset-set! game 'tilemap + (load-tilemap "demo/assets/level-0.tmx"))) + + create: (lambda (game) + (let* ((tm (game-asset game 'tilemap)) + (tex (tileset-image (tilemap-tileset tm))) + (player (list #:type 'player + #:x 100 #:y 100 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #f + #:tile-id 1))) + (game-scene-set! game + (make-scene + entities: (list player) + tilemap: tm + camera: (make-camera x: 0 y: 0) + tileset-texture: tex)))) + + update: (lambda (game dt) + (let* ((input (game-input game)) + (scene (game-scene game)) + (tm (scene-tilemap scene)) + (player (car (scene-entities scene))) + ;; 8-directional velocity + (dx (+ (if (input-held? input 'left) -3 0) + (if (input-held? input 'right) 3 0))) + (dy (+ (if (input-held? input 'up) -3 0) + (if (input-held? input 'down) 3 0))) + (player (entity-set (entity-set player #:vx dx) #:vy dy)) + ;; tile collision on both axes + (player (apply-velocity-x player)) + (player (resolve-tile-collisions-x player tm)) + (player (apply-velocity-y player)) + (player (resolve-tile-collisions-y player tm)) + ;; update camera to center on player + (px (entity-ref player #:x 0)) + (py (entity-ref player #:y 0))) + (camera-x-set! (scene-camera scene) (max 0 (- px 300))) + (camera-y-set! (scene-camera scene) (max 0 (- py 200))) + (scene-entities-set! scene (list player)))))) + +(game-run! *game*) +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +make demos +``` + +Expected: `bin/demo-topdown` created with no errors. + +- [ ] **Step 3: Smoke test** + +```bash +./bin/demo-topdown +``` + +Expected: Window with tilemap visible. Player sprite moves in 8 directions with WASD or arrow keys. Camera follows player on both axes. Escape quits. + +- [ ] **Step 4: Commit** + +```bash +git add demo/topdown.scm +git commit -m "feat: add topdown demo" +``` + +--- + +## Task 5: demo/audio.scm + +Exercises: audio (sound effects + music), renderer (text via draw-ui-text), input, assets. No scene, no physics. + +**Files:** +- Create: `demo/audio.scm` + +- [ ] **Step 1: Create demo/audio.scm** + +```scheme +(import scheme + (chicken base) + (prefix sdl2 "sdl2:") + (prefix sdl2-ttf "ttf:") + (prefix sdl2-image "img:") + downstroke/engine + downstroke/renderer + downstroke/input + downstroke/assets + downstroke/sound) + +;; Music toggle state +(define *music-on?* #f) + +(define *game* + (make-game + title: "Demo: Audio" width: 600 height: 400 + + preload: (lambda (game) + (init-audio!) + (load-sounds! '((jump . "demo/assets/jump.wav"))) + (load-music! "demo/assets/theme.ogg") + (game-asset-set! game 'font + (ttf:open-font "demo/assets/DejaVuSans.ttf" 20))) + + ;; No create: hook — no scene needed + ;; engine.scm guards render-scene! with (when (game-scene game) ...) + ;; so omitting game-scene-set! is safe. + + update: (lambda (game dt) + (let ((input (game-input game))) + ;; J = play jump sound + (when (input-pressed? input 'a) + (play-sound 'jump)) + ;; K/X = toggle music (mapped to 'b' in default input config) + (when (input-pressed? input 'b) + (if *music-on?* + (begin (stop-music!) (set! *music-on?* #f)) + (begin (play-music! 0.5) (set! *music-on?* #t)))))) + + render: (lambda (game) + (let* ((renderer (game-renderer game)) + (font (game-asset game 'font)) + (white (sdl2:make-color 255 255 255 255)) + (gray (sdl2:make-color 180 180 180 255))) + ;; Background + (sdl2:render-draw-color-set! renderer (sdl2:make-color 30 30 60 255)) + (sdl2:render-fill-rect! renderer (sdl2:make-rect 0 0 600 400)) + ;; Title + (draw-ui-text renderer font "Audio Demo" white 220 80) + ;; Instructions + (draw-ui-text renderer font "J / Z — play sound effect" gray 160 160) + (draw-ui-text renderer font "K / X — toggle music on/off" gray 160 200) + (draw-ui-text renderer font "Escape — quit" gray 160 240) + ;; Music status + (draw-ui-text renderer font + (if *music-on?* "Music: ON" "Music: OFF") + (if *music-on?* + (sdl2:make-color 100 255 100 255) + (sdl2:make-color 255 100 100 255)) + 240 310))))) + +(game-run! *game*) +``` + +**Note on key bindings:** The default input config maps `j`/`z` → `'a` action and `k`/`x` → `'b` action. So pressing J plays the sound and K toggles music. The display labels say "J / Z" and "K / X" accordingly. There is no direct key-to-symbol mapping in `*default-input-config*` for arbitrary keys; use the existing action names. + +- [ ] **Step 2: Verify it compiles** + +```bash +make demos +``` + +Expected: `bin/demo-audio` created with no errors. + +- [ ] **Step 3: Smoke test** + +```bash +./bin/demo-audio +``` + +Expected: Dark blue window with white text showing key bindings. J plays a sound. K toggles music (status shows ON/OFF in green/red). Escape quits. + +- [ ] **Step 4: Commit** + +```bash +git add demo/audio.scm +git commit -m "feat: add audio demo" +``` + +--- + +## Task 6: demo/sandbox.scm + +Exercises: physics (gravity + tile collision + entity-entity collision), renderer, world/scene. No player input. + +**Files:** +- Create: `demo/sandbox.scm` + +- [ ] **Step 1: Create demo/sandbox.scm** + +```scheme +(import scheme + (chicken base) + (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 + downstroke/entity) + +;; Respawn timer (accumulates dt in milliseconds) +(define *elapsed* 0) +(define *respawn-interval* 10000) ;; 10 seconds + +(define (spawn-entities) + (let loop ((i 0) (acc '())) + (if (= i 10) + acc + (loop (+ i 1) + (cons (list #:type 'box + #:x (+ 30 (* i 55)) + #:y (+ 10 (* (random 4) 20)) + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:on-ground? #f + #:solid? #t + #:tile-id 1) + acc))))) + +(define *game* + (make-game + title: "Demo: Physics Sandbox" width: 600 height: 400 + + preload: (lambda (game) + (game-asset-set! game 'tilemap + (load-tilemap "demo/assets/level-0.tmx"))) + + create: (lambda (game) + (let* ((tm (game-asset game 'tilemap)) + (tex (tileset-image (tilemap-tileset tm)))) + (game-scene-set! game + (make-scene + entities: (spawn-entities) + tilemap: tm + camera: (make-camera x: 0 y: 0) + tileset-texture: tex)))) + + update: (lambda (game dt) + (let* ((scene (game-scene game)) + (tm (scene-tilemap scene))) + ;; Advance respawn timer + (set! *elapsed* (+ *elapsed* dt)) + (when (>= *elapsed* *respawn-interval*) + (set! *elapsed* 0) + (scene-entities-set! scene (spawn-entities))) + + ;; Physics pipeline for all entities + (scene-update-entities scene + apply-gravity + apply-velocity-x + (lambda (e) (resolve-tile-collisions-x e tm)) + apply-velocity-y + (lambda (e) (resolve-tile-collisions-y e tm)) + (lambda (e) (detect-ground e tm))) + + ;; Entity-entity collision (push-apart, requires #:solid? #t) + (scene-resolve-collisions scene))))) + +(game-run! *game*) +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +make demos +``` + +Expected: `bin/demo-sandbox` created with no errors. All 5 `bin/demo-*` executables exist. + +- [ ] **Step 3: Smoke test** + +```bash +./bin/demo-sandbox +``` + +Expected: Window shows 10 tile sprites falling with gravity. They land on tilemap floor tiles, pile up, and push each other apart. After 10 seconds they respawn at random positions near the top. Escape quits. + +- [ ] **Step 4: Final verification — make demos succeeds cleanly** + +```bash +cd /home/gene/src/downstroke && make clean && make && make demos +``` + +Expected: Full clean build succeeds. All 5 demo binaries exist in `bin/`. + +- [ ] **Step 5: Commit** + +```bash +git add demo/sandbox.scm +git commit -m "feat: add sandbox demo" +``` diff --git a/docs/superpowers/plans/2026-04-05-milestone-14-docs.md b/docs/superpowers/plans/2026-04-05-milestone-14-docs.md new file mode 100644 index 0000000..0688bf5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-milestone-14-docs.md @@ -0,0 +1,167 @@ +# Milestone 14: End-User Documentation Implementation Plan + +> **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:** Write the 4 missing end-user documentation files (`docs/guide.org`, `docs/api.org`, `docs/entities.org`, `docs/physics.org`) that ship with the downstroke egg. + +**Architecture:** Each file is a self-contained org-mode document covering one aspect of the engine. Content is derived exclusively from the public API inventory provided in the task description and from the 5 existing demo games in `demo/`. No API fabrication -- only document what exists. Code examples should be drawn from or closely mirror the real demos. + +**Tech Stack:** Org-mode markup, Chicken Scheme code examples + +--- + +### Task 1: docs/guide.org -- Getting Started Guide + +**Files:** +- Create: `docs/guide.org` + +- [ ] **Step 1:** Write the file with these sections: + 1. **Title / Introduction** -- What downstroke is (2D tile-driven game engine for Chicken Scheme, built on SDL2) + 2. **Installation** -- `chicken-install downstroke`, system deps (SDL2, SDL2_mixer, SDL2_ttf, SDL2_image), egg deps + 3. **Hello World** -- Minimal ~20-line game: blank window that quits on Escape. Use `make-game` with just `title:`, `width:`, `height:` and an empty `update:` that does nothing. Call `game-run!`. Show the full import list needed. + 4. **Moving Square** -- Step up: add an entity with `#:x`, `#:y`, `#:width`, `#:height`; read input with `input-held?`; update position with `entity-set` + `apply-velocity-x`; use a custom `render:` hook to draw an SDL2 colored rect (mirror the shmup demo pattern: `sdl2:render-fill-rect!`). No tilemap needed -- set `tilemap: #f` and `tileset-texture: #f`. + 5. **Adding a Tilemap** -- Show how to load a TMX map in `preload:`, create a scene with it in `create:`, and let `render-scene!` handle drawing. Reference `demo/platformer.scm`. + 6. **Demo Overview** -- Table of the 5 demos with 1-line descriptions and what each demonstrates: + - `demo/platformer.scm` -- Gravity, jump, tile collision, camera follow, sound + - `demo/topdown.scm` -- 8-dir movement, no gravity, tilemap, camera follow + - `demo/sandbox.scm` -- Entity-entity collision, multi-entity physics, auto-respawn + - `demo/shmup.scm` -- No tilemap, entity spawning/removal, manual AABB collision, SDL colored rects + - `demo/audio.scm` -- Sound effects, music toggle, draw-ui-text + 7. **Build & Run** -- `make && make demos` to build everything; run a demo with `./bin/demo-platformer` (or however the Makefile names them) + 8. **Next Steps** -- Pointers to the other 3 doc files + +- [ ] **Step 2:** Self-review: verify every function/keyword used in code examples exists in the public API inventory. Verify import lists match what the real demos use. No fabricated API. + +**Key content notes:** +- The Hello World must actually work -- the minimum viable `make-game` call needs at least the `title:` keyword; all others have defaults (width 640, height 480, frame-delay 16, input-config uses `*default-input-config*`) +- `game-run!` handles SDL2 init, window creation, event loop, and cleanup -- the user does NOT call any SDL2 init themselves (except `init-audio!` for sound) +- Quit is automatic: the engine checks `(input-held? input 'quit)` every frame (bound to Escape + window close) + +--- + +### Task 2: docs/api.org -- Public API Reference + +**Files:** +- Create: `docs/api.org` + +- [ ] **Step 1:** Write the file with one top-level section per module, in this order: + 1. **Engine** (`downstroke/engine`) -- `make-game`, `game-run!`, `game-camera`, `game-asset`, `game-asset-set!`, `make-game-state`, `game-add-state!`, `game-start-state!`, `game-scene-set!`, auto-generated accessors (`game-renderer`, `game-input`, `game-window`, `game-title`, `game-width`, `game-height`, `game-scene`) + 2. **World** (`downstroke/world`) -- `make-scene`, `scene-entities`, `scene-entities-set!`, `scene-tilemap`, `scene-tileset-texture`, `scene-camera`, `scene-add-entity`, `scene-update-entities`, `scene-filter-entities`, `scene-find-tagged`, `scene-find-all-tagged`, `make-camera`, `camera-x`, `camera-y`, `camera-x-set!`, `camera-y-set!`, `camera-follow!` + 3. **Entity** (`downstroke/entity`) -- `entity-ref`, `entity-type`, `entity-set`, `entity-update` + 4. **Physics** (`downstroke/physics`) -- `apply-gravity`, `apply-acceleration`, `apply-velocity-x`, `apply-velocity-y`, `apply-jump`, `resolve-tile-collisions-x`, `resolve-tile-collisions-y`, `detect-ground`, `aabb-overlap?`, `resolve-entity-collisions`, `scene-resolve-collisions` + 5. **Input** (`downstroke/input`) -- `create-input-state`, `input-state-update`, `input-held?`, `input-pressed?`, `input-released?`, `input-any-pressed?`, `*default-input-config*`; include the default action/binding table + 6. **Renderer** (`downstroke/renderer`) -- `render-scene!`, `draw-ui-text`, `entity-screen-coords`, `entity-flip` + 7. **Assets** (`downstroke/assets`) -- `make-asset-registry`, `asset-set!`, `asset-ref` + 8. **Sound** (`downstroke/sound`) -- `init-audio!`, `load-sounds!`, `play-sound`, `load-music!`, `play-music!`, `stop-music!` + 9. **Animation** (`downstroke/animation`) -- `set-animation`, `animate-entity`; document the `#:animations` plist format + 10. **Tilemap** (`downstroke/tilemap`) -- `load-tilemap`, `tilemap-tileset`, `tileset-image`; note that TMX/TSX parsing uses expat + +- [ ] **Step 2:** For each function entry, include: + - Signature line in a `#+begin_src scheme` block + - 1-2 sentence description + - Brief code example where the usage is non-obvious (e.g., `scene-update-entities` taking variadic procs, `entity-set` returning a new plist, `make-game-state` + `game-add-state!` + `game-start-state!` flow) + +- [ ] **Step 3:** Self-review: cross-check every entry against the API inventory. Ensure no function is missing and no function is fabricated. Verify signatures match (especially keyword args for `make-game`, `make-scene`, `make-camera`, `make-game-state`). + +**Key content notes:** +- `make-game` keyword args: `title:` (default "Downstroke Game"), `width:` (640), `height:` (480), `frame-delay:` (16), `input-config:` (default `*default-input-config*`), `preload:`, `create:`, `update:`, `render:` (all default `#f`) +- `scene-update-entities` takes a scene + rest-arg list of procs, applies them sequentially to all entities +- `entity-set` is functional (returns new plist) -- this is critical to document clearly +- `game-scene` is auto-generated by defstruct, not manually defined +- `render-scene!` is called automatically by `game-run!` before the user's `render:` hook -- the user's `render:` is for overlays/UI + +--- + +### Task 3: docs/entities.org -- Entity Model & Prefabs + +**Files:** +- Create: `docs/entities.org` + +- [ ] **Step 1:** Write the file with these sections: + 1. **Entity Model Overview** -- Entities are plists (property lists), not objects or records. Pure data. No classes, no inheritance. Access via `entity-ref`, mutation via `entity-set` (which returns a new plist -- functional/immutable style). + 2. **Creating Entities** -- Show a literal plist creation: `(list #:type 'player #:x 100 #:y 200 ...)`. Explain that you just use `list` -- there is no `make-entity` constructor. + 3. **Plist Key Reference** -- Full table of all documented keys with type and description. Use the table from the API inventory verbatim: + + | Key | Type | Description | + |-----|------|-------------| + | `#:type` | symbol | Entity type | + | `#:x` `#:y` | number | World position | + | `#:width` `#:height` | number | Bounding box size | + | `#:vx` `#:vy` | number | Velocity | + | `#:ay` | number | Y acceleration (consumed by apply-acceleration) | + | `#:gravity?` | bool | Whether gravity applies | + | `#:on-ground?` | bool | Set by detect-ground | + | `#:solid?` | bool | Participates in entity-entity collision | + | `#:tile-id` | integer | Sprite index in tileset (1-indexed) | + | `#:facing` | number | 1 = right, -1 = left (affects flip) | + | `#:tags` | list of symbols | Used for scene-find-tagged | + | `#:animations` | alist | Animation data | + | `#:anim-name` | symbol | Current animation name | + | `#:anim-frame` | integer | Current frame index | + | `#:anim-tick` | integer | Tick counter for frame advance | + + 4. **Accessing & Updating Entities** -- `entity-ref` with default value, `entity-type` shortcut, `entity-set` (stress: returns NEW plist, original unchanged), `entity-update` with a proc. Show the let*-chaining pattern from the platformer demo. + 5. **Entities in Scenes** -- `scene-entities`, `scene-entities-set!`, `scene-add-entity`, `scene-update-entities` (show the sandbox demo pattern of passing multiple procs), `scene-filter-entities`, `scene-find-tagged` / `scene-find-all-tagged`. + 6. **Animation** -- The `#:animations` key format: `((idle #:frames (28) #:duration 10) (walk #:frames (27 28) #:duration 10))`. `set-animation` to switch, `animate-entity` to advance each frame. `#:tile-id` is updated automatically. + 7. **Tags** -- The `#:tags` key is a list of symbols. Use `scene-find-tagged` to find the first entity with a given tag, `scene-find-all-tagged` for all matches. Common pattern: tag the player with `'player`, enemies with `'enemy`. + +- [ ] **Step 2:** Self-review: verify all function signatures and plist keys match the API inventory exactly. + +**Key content notes:** +- Do NOT document prefab/mixin internals (`make-prefab-registry`, `instantiate-prefab` from scene-loader) beyond a brief mention -- these are in `scene-loader.scm` which is not in the public API inventory provided. Mention the concept exists and point to CLAUDE.md's description, but do not fabricate API signatures. +- The `#:animations` format uses keyword-style keys inside the alist entries -- this is unusual and worth a clear example. + +--- + +### Task 4: docs/physics.org -- Physics Pipeline & Collision + +**Files:** +- Create: `docs/physics.org` + +- [ ] **Step 1:** Write the file with these sections: + 1. **Overview** -- Downstroke provides a built-in physics pipeline for platformers and top-down games. All physics operates on entity plists -- functions take an entity and return a new entity. The pipeline is explicit: the user calls each step in their `update:` hook. + 2. **Pipeline Diagram** -- Show the 9-step pipeline order as a clear diagram: + ``` + apply-jump -> apply-acceleration -> apply-gravity -> + apply-velocity-x -> resolve-tile-collisions-x -> + apply-velocity-y -> resolve-tile-collisions-y -> + detect-ground -> resolve-entity-collisions + ``` + 3. **Pipeline Steps** -- One subsection per function. For each: signature, what entity keys it reads, what entity keys it writes/returns, and a 1-2 sentence explanation: + - `apply-jump` -- reads `#:on-ground?`, sets `#:ay` if pressed? and on-ground + - `apply-acceleration` -- reads `#:ay`, adds to `#:vy`, zeroes `#:ay` + - `apply-gravity` -- reads `#:gravity?`, adds gravity constant to `#:vy` + - `apply-velocity-x` -- reads `#:vx`, adds to `#:x` + - `resolve-tile-collisions-x` -- reads `#:x`, `#:width`, `#:vx`; snaps `#:x` on tile hit, zeroes `#:vx` + - `apply-velocity-y` -- reads `#:vy`, adds to `#:y` + - `resolve-tile-collisions-y` -- reads `#:y`, `#:height`, `#:vy`; snaps `#:y` on tile hit, zeroes `#:vy` + - `detect-ground` -- probes 1px below entity feet, sets `#:on-ground?` + - `resolve-entity-collisions` / `scene-resolve-collisions` -- all-pairs push-apart for entities with `#:solid? #t` + 4. **Tile Collision Model** -- AABB overlap detection. The physics module checks all tile cells overlapping the entity's bounding box. On collision, the entity is snapped to the nearest tile edge in the direction of travel. X and Y axes are resolved independently (hence the interleaved velocity/collision pattern). + 5. **Entity-Entity Collision** -- `aabb-overlap?` for manual checks; `resolve-entity-collisions` for automatic push-apart. Entities must have `#:solid? #t` to participate. `scene-resolve-collisions` is the convenience wrapper. + 6. **Platformer Example** -- Full `update:` lambda from `demo/platformer.scm` (verbatim or near-verbatim). Annotate the let*-chain showing each pipeline step. + 7. **Top-Down Example** -- Full `update:` lambda from `demo/topdown.scm`. Highlight that `apply-gravity`, `apply-jump`, `apply-acceleration`, and `detect-ground` are skipped; only velocity + tile-collision steps are used. Set `#:gravity? #f` on entities. + 8. **Physics Sandbox Example** -- Show the `scene-update-entities` pattern from `demo/sandbox.scm` where multiple physics procs are passed as rest args. Show `scene-resolve-collisions` for entity-entity push-apart. + +- [ ] **Step 2:** Self-review: verify all function names, signatures, and entity key names match the API inventory. Ensure the pipeline order matches exactly. Verify code examples are accurate against the real demo source files. + +**Key content notes:** +- The pipeline is NOT automatic -- the user must call each step explicitly in their `update:` hook. This is by design (flexibility for top-down vs platformer vs custom). +- `resolve-tile-collisions-x` and `resolve-tile-collisions-y` take `(entity tilemap)` -- the tilemap must be passed in. +- `detect-ground` also takes `(entity tilemap)`. +- `apply-jump` takes `(entity pressed?)` where pressed? is typically `(input-pressed? input 'a)`. +- `scene-update-entities` with rest-arg procs is the idiomatic way to apply physics to all entities at once (sandbox pattern). + +--- + +## Rejected Alternatives + +- **Single monolithic doc file**: Rejected. Four focused files are easier to navigate and maintain. Each serves a different reader need (tutorial vs reference vs concept guide). +- **Auto-generated API docs from source**: Rejected. Chicken Scheme has no standard doc-generation tool that would produce org-mode output. Hand-written docs allow better examples and narrative. +- **Markdown instead of org-mode**: Rejected. CLAUDE.md explicitly requires org-mode format. + +## Gaps -- follow-up investigation needed + +- **Tilemap public API**: The API inventory lists `load-tilemap`, `tilemap-tileset`, and `tileset-image` but the full set of tilemap accessors (e.g., `tilemap-layers`, tile GID access) was not provided. `docs/api.org` should document only the three confirmed exports; if more exist, they can be added later. +- **Prefab/scene-loader API**: `scene-loader.scm` exports `instantiate-prefab` and likely `make-prefab-registry`, but these were not in the provided API inventory. `docs/entities.org` should mention the concept but not fabricate signatures. A follow-up investigation of `scene-loader.scm` would allow documenting these fully. +- **Animation internals**: The exact behavior of `animate-entity` (how it advances `#:anim-tick`, when it wraps `#:anim-frame`, how it sets `#:tile-id`) was not fully specified. Document the public interface only. diff --git a/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md b/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md new file mode 100644 index 0000000..2b7f86f --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md @@ -0,0 +1,201 @@ +# Scene Loader Implementation Plan + +> **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:** Create `scene-loader.scm` -- a module that encapsulates the repeated tilemap-load / texture-create / scene-build pattern currently duplicated across platformer, topdown, and sandbox demos. + +**Architecture:** A new `downstroke/scene-loader` module provides `game-load-scene!` as the single entry point for the common pattern (load TMX, create texture, build scene, set on game). Two pure helpers (`tilemap-objects->entities` and `make-prefab-registry` / `instantiate-prefab`) handle object-layer entity instantiation with a simple hash-table registry. The module sits between `assets`/`world`/`tilemap` and `engine` in the dependency graph; demos import it and replace ~10 lines of boilerplate with a single call. + +**Tech Stack:** Chicken Scheme 5, `defstruct`, `srfi-1` (filter-map), `srfi-69` (hash-tables), `sdl2` (texture creation), existing downstroke modules (`world`, `tilemap`, `assets`). + +**Rejected alternatives:** +- Putting scene-loading logic directly into `engine.scm` -- rejected because engine should stay lifecycle-only; scene loading is an opt-in convenience. +- Making `game-load-scene!` accept an entity list parameter -- rejected because callers should add entities after via `scene-add-entity` (which already exists and mutates in place); keeps the loader focused on the tilemap/texture concern. + +--- + +### Task 1: Tests for pure helpers (TDD -- red phase) + +**Files:** +- Create: `tests/scene-loader-test.scm` + +- [ ] **Step 1:** Create `tests/scene-loader-test.scm` with `(import srfi-64)` and mock modules. Mock `downstroke/tilemap` inline (same pattern as `tests/renderer-test.scm` lines 10-18) defining the `object` defstruct (`object-type`, `object-x`, `object-y`, `object-width`, `object-height`) and `tilemap-objects` accessor. Mock `downstroke/world` with the `scene`, `camera` defstructs. Mock `sdl2` with a stub `create-texture-from-surface`. Mock `downstroke/assets` with stubs for `asset-set!` and `asset-ref`. +- [ ] **Step 2:** Write tests for `make-prefab-registry` + `instantiate-prefab`: + - `make-prefab-registry` with two type/constructor pairs returns a registry. + - `instantiate-prefab` with a known type calls the constructor and returns the entity plist. + - `instantiate-prefab` with an unknown type returns `#f`. +- [ ] **Step 3:** Write tests for `tilemap-objects->entities`: + - Given a mock tilemap whose `tilemap-objects` returns a list of 3 fake objects (with `object-type`, `object-x`, `object-y`, `object-width`, `object-height`), and an `instantiate-fn` that returns a plist for type `"player"` but `#f` for type `"decoration"`, verify the result is a list containing only the matched entity (i.e., `#f` results are filtered out). + - Given a tilemap with zero objects, verify the result is `'()`. +- [ ] **Step 4:** Add a placeholder `(include "scene-loader.scm")` and `(import downstroke/scene-loader)` after the mocks. Run `csi -s tests/scene-loader-test.scm` and confirm it fails (module not found). This is the red phase. + +--- + +### Task 2: Implement `scene-loader.scm` (green phase) + +**Files:** +- Create: `scene-loader.scm` + +- [ ] **Step 1:** Create `scene-loader.scm` with the module declaration: + ```scheme + (module downstroke/scene-loader * + (import scheme + (chicken base) + (only srfi-1 filter-map) + (srfi 69) + (prefix sdl2 "sdl2:") + defstruct + downstroke/world + downstroke/tilemap + downstroke/assets) + ...) + ``` +- [ ] **Step 2:** Implement `make-prefab-registry` -- takes alternating `symbol constructor` pairs, returns an `srfi-69` hash-table mapping each symbol to its constructor lambda: + ```scheme + (define (make-prefab-registry . pairs) + (let ((ht (make-hash-table))) + (let loop ((p pairs)) + (if (null? p) ht + (begin + (hash-table-set! ht (car p) (cadr p)) + (loop (cddr p))))))) + ``` +- [ ] **Step 3:** Implement `instantiate-prefab` -- looks up `type` (a symbol) in registry, calls the constructor with `(x y w h)`, returns entity plist or `#f`: + ```scheme + (define (instantiate-prefab registry type x y w h) + (let ((ctor (hash-table-ref/default registry type #f))) + (and ctor (ctor x y w h)))) + ``` +- [ ] **Step 4:** Implement `tilemap-objects->entities`: + ```scheme + (define (tilemap-objects->entities tilemap instantiate-fn) + (filter-map + (lambda (obj) + (instantiate-fn (string->symbol (object-type obj)) + (object-x obj) (object-y obj) + (object-width obj) (object-height obj))) + (tilemap-objects tilemap))) + ``` + Note: `object-type` returns a string (from TMX XML); convert to symbol so callers work with symbols. +- [ ] **Step 5:** Implement `create-tileset-texture`: + ```scheme + (define (create-tileset-texture renderer tilemap) + (sdl2:create-texture-from-surface + renderer + (tileset-image (tilemap-tileset tilemap)))) + ``` +- [ ] **Step 6:** Implement `game-load-scene!`: + ```scheme + (define (game-load-scene! game filename) + (let* ((tm (load-tilemap filename)) + (tex (create-tileset-texture (game-renderer game) tm)) + (scene (make-scene entities: '() + tilemap: tm + camera: (make-camera x: 0 y: 0) + tileset-texture: tex))) + (game-asset-set! game 'tilemap tm) + (game-scene-set! game scene) + scene)) + ``` + Note: `game-load-scene!` needs `game-renderer`, `game-asset-set!`, and `game-scene-set!` from `downstroke/engine`. However, importing engine from scene-loader would create a circular dependency (engine depends on world, scene-loader depends on world+tilemap, engine should not depend on scene-loader). **Resolution:** `game-load-scene!` must accept `renderer` explicitly, or scene-loader must also import `downstroke/engine`. Check: engine exports `game-renderer`, `game-asset-set!`, `game-scene-set!`. Scene-loader depends on engine; engine does NOT need to depend on scene-loader. This is safe -- add `downstroke/engine` to imports. +- [ ] **Step 7:** Run `csi -s tests/scene-loader-test.scm`. All tests should pass (green phase). + +--- + +### Task 3: Build system integration + +**Files:** +- Modify: `/home/gene/src/downstroke/Makefile` +- Modify: `/home/gene/src/downstroke/downstroke.egg` + +- [ ] **Step 1:** In `Makefile`, add `scene-loader` to `MODULE_NAMES` after `engine` (it depends on engine, world, tilemap, assets): + ``` + MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound animation ai scene-loader + ``` +- [ ] **Step 2:** Add explicit dependency line: + ```makefile + bin/scene-loader.o: bin/world.o bin/tilemap.o bin/assets.o bin/engine.o + ``` +- [ ] **Step 3:** Add the test to the `test:` target: + ```makefile + @csi -s tests/scene-loader-test.scm + ``` +- [ ] **Step 4:** In `downstroke.egg`, add the extension after the `downstroke/engine` entry: + ```scheme + (extension downstroke/scene-loader + (source "scene-loader.scm") + (component-dependencies downstroke/world downstroke/tilemap downstroke/assets downstroke/engine)) + ``` +- [ ] **Step 5:** Run `make clean && make && make test` to verify engine compiles and all tests pass. + +--- + +### Task 4: Update demos to use scene-loader + +**Files:** +- Modify: `/home/gene/src/downstroke/demo/platformer.scm` +- Modify: `/home/gene/src/downstroke/demo/topdown.scm` +- Modify: `/home/gene/src/downstroke/demo/sandbox.scm` + +- [ ] **Step 1:** In `demo/platformer.scm`: + - Add `downstroke/scene-loader` to the imports. + - In `preload:`, replace the `game-asset-set!` call for tilemap with nothing (remove it; `game-load-scene!` handles this). Keep the `init-audio!` and `load-sounds!` calls. + - In `create:`, replace the entire `let*` body with: + ```scheme + (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx")) + (player (list #:type 'player + #:x 100 #:y 50 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #t + #:on-ground? #f + #:tile-id 1))) + (scene-add-entity scene player)) + ``` + Note: `scene-add-entity` (from `world.scm` line 41) mutates and returns the scene, which is already set on the game by `game-load-scene!`, so no further `game-scene-set!` needed. + +- [ ] **Step 2:** In `demo/topdown.scm`: + - Add `downstroke/scene-loader` to imports. + - Remove the `preload:` tilemap loading entirely (the `preload:` lambda can just be `(lambda (game) #f)` or removed if `make-game` allows it -- check engine.scm; if preload is optional, omit it). + - In `create:`, replace with: + ```scheme + (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx")) + (player (list #:type 'player + #:x 100 #:y 100 + #:width 16 #:height 16 + #:vx 0 #:vy 0 + #:gravity? #f + #:tile-id 1))) + (scene-add-entity scene player)) + ``` + +- [ ] **Step 3:** In `demo/sandbox.scm`: + - Add `downstroke/scene-loader` to imports. + - Remove the `preload:` tilemap loading. + - In `create:`, replace with: + ```scheme + (let ((scene (game-load-scene! game "demo/assets/level-0.tmx"))) + (scene-entities-set! scene (spawn-entities))) + ``` + Note: sandbox replaces the entire entity list rather than adding one, so use `scene-entities-set!` directly. + +- [ ] **Step 4:** Run `make clean && make && make demos` to verify all demos compile. + +--- + +### Task 5: Verify and clean up + +**Files:** +- All modified files + +- [ ] **Step 1:** Run the full build and test suite: `make clean && make && make demos && make test`. All must pass. +- [ ] **Step 2:** Review that no demo still imports `(prefix sdl2 "sdl2:")` solely for `create-texture-from-surface`. If that was the only sdl2 usage in the demo's `create:` hook, the `sdl2` import can remain (it may be needed for other SDL2 calls in `update:` or elsewhere) -- do not remove imports that are still used. +- [ ] **Step 3:** Verify the three demos no longer contain the duplicated tilemap-load/texture-create/scene-build pattern. Each demo's `create:` hook should be noticeably shorter. + +--- + +## Gaps -- follow-up investigation needed + +- **`game-load-scene!` importing engine:** The plan assumes `scene-loader.scm` can import `downstroke/engine` without creating a circular dependency. Verify by checking that `engine.scm` does NOT import `scene-loader`. From the Makefile deps (`bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o`), this is confirmed safe. +- **`preload:` optionality:** The plan suggests removing `preload:` from topdown and sandbox demos. Need to verify whether `make-game` requires the `preload:` keyword or treats it as optional. If required, keep it as `(lambda (game) #f)`. +- **`object-type` return type:** The plan assumes `object-type` returns a string (from TMX XML parsing). If it already returns a symbol, remove the `string->symbol` call in `tilemap-objects->entities`. Check the TMX parser in `tilemap.scm` to confirm. 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 diff --git a/docs/superpowers/specs/2026-04-05-demos-design.md b/docs/superpowers/specs/2026-04-05-demos-design.md new file mode 100644 index 0000000..78ed3f4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-demos-design.md @@ -0,0 +1,256 @@ +# 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. + +**Audio modules:** `mixer.scm` and `sound.scm` are copied from `macroknight/` into the downstroke repo root and added to the build. They are engine-level modules and are required by any demo that uses audio. + +When copying `sound.scm`, add one function that macroknight's version omits: +```scheme +(define (stop-music!) (mix-halt-music)) +``` +The audio demo uses `stop-music!` to toggle music off. + +--- + +## 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 +- `$(OBJECT_FILES)` includes `mixer` and `sound` once those modules are added to `MODULE_NAMES` + +### `render-scene!` nil guards + +`render-scene!` in `renderer.scm` is updated so that entity drawing is **nested inside** the tilemap guard: + +- Tilemap drawing only fires if `(scene-tilemap scene)` is not `#f` +- Entity drawing only fires if **both** `(scene-tilemap scene)` AND `(scene-tileset-texture scene)` are not `#f` + +```scheme +(when tilemap + (draw-tilemap renderer camera tileset-texture tilemap) + (when tileset-texture + (let ((tileset (tilemap-tileset tilemap))) + (draw-entities renderer camera tileset tileset-texture entities)))) +``` + +**Consequence for shmup:** since shmup has no tilemap, the engine will not draw its entities. Shmup draws its player, bullets, and enemies as **colored SDL rectangles** in its `render:` hook — matching the original "colored rects" intent. This is acceptable because shmup entities are simple geometric shapes, not sprites. + +Audio (no scene at all) works because `engine.scm` guards `render-scene!` with `(when (game-scene game) ...)`. + +### 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 `(play-sound 'jump)` (loaded in preload: as `'(jump . "demo/assets/jump.wav")`) +- 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), manual AABB entity-entity collision (removal-based, not `physics.scm`), `input`, `renderer` (SDL colored rects), `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:** (no `#:tile-id` — entities are drawn as colored rects) +```scheme +;; player +(list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0) +;; bullet +(list #:type 'bullet #:x px #:y 340 #:width 4 #:height 8 #:vx 0 #:vy -5) +;; enemy +(list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2) +``` + +**Rendering:** shmup has no tilemap, so `render-scene!` draws nothing for this scene. Shmup implements its own `render:` hook using `sdl2:render-fill-rect!` with distinct colors (player = white, bullet = yellow, enemy = red). The camera is at (0,0) so screen coords == world coords. + +**Update logic:** move entities by vx/vy each frame → manual AABB collision check between bullets and enemies (not `resolve-entity-collisions` — shmup uses removal, not push-apart) → filter removed 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) +- Tileset texture loaded in preload: — required for entity sprite rendering (tilemap + tileset-texture both present so render-scene! draws entities via tileset) +- 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 `sound.scm` functions: `load-sounds!`, `play-sound`, `load-music!`, `play-music!`, `stop-music!` (added when copying sound.scm — see File Layout) + +**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` and `#:solid? #t` (required for `resolve-entity-collisions` to participate) +- 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) | — | — | — | — | ✓ | +| manual AABB (removal) | — | ✓ | — | — | — | +| `renderer` (tilemap) | ✓ | — | ✓ | — | ✓ | +| `renderer` (entities) | ✓ | — | ✓ | — | ✓ | +| `renderer` (SDL colored rects) | — | ✓ | — | — | — | +| `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 new file mode 100644 index 0000000..d6e71be --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md @@ -0,0 +1,234 @@ +# 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 diff --git a/downstroke.egg b/downstroke.egg index 309c545..8f7dea2 100644 --- a/downstroke.egg +++ b/downstroke.egg @@ -36,4 +36,7 @@ (component-dependencies downstroke/entity downstroke/world)) (extension downstroke/ai (source "ai.scm") - (component-dependencies downstroke/entity downstroke/world)))) + (component-dependencies downstroke/entity downstroke/world)) + (extension downstroke/scene-loader + (source "scene-loader.scm") + (component-dependencies downstroke/world downstroke/tilemap downstroke/assets downstroke/engine)))) @@ -154,6 +154,16 @@ (render-fn (or (and state (state-hook state #:render)) (game-render-hook game)))) (when update-fn (update-fn game dt)) + ;; Auto camera-follow: if scene has a camera-target tag, follow it + (when (game-scene game) + (let ((target-tag (scene-camera-target (game-scene game)))) + (when target-tag + (let ((target (scene-find-tagged (game-scene game) target-tag))) + (when target + (camera-follow! (scene-camera (game-scene game)) + target + (game-width game) + (game-height game))))))) (sdl2:render-clear! (game-renderer game)) (when (game-scene game) (render-scene! (game-renderer game) (game-scene game))) diff --git a/scene-loader.scm b/scene-loader.scm new file mode 100644 index 0000000..519622e --- /dev/null +++ b/scene-loader.scm @@ -0,0 +1,85 @@ +(module downstroke/scene-loader * + (import scheme + (chicken base) + (only srfi-1 filter-map) + (srfi 69) + (prefix sdl2 "sdl2:") + (prefix sdl2-ttf "ttf:") + defstruct + downstroke/tilemap + downstroke/world + downstroke/engine) + + ;; Create a prefab registry from alternating symbol/constructor pairs. + ;; Returns a srfi-69 hash-table mapping symbols to constructor functions. + (define (make-prefab-registry . pairs) + (let ((ht (make-hash-table))) + (let loop ((p pairs)) + (if (null? p) ht + (begin + (hash-table-set! ht (car p) (cadr p)) + (loop (cddr p))))))) + + ;; Instantiate a prefab by type from the registry. + ;; Returns the entity plist if type exists, #f otherwise. + (define (instantiate-prefab registry type x y w h) + (let ((ctor (hash-table-ref/default registry type #f))) + (and ctor (ctor x y w h)))) + + ;; Convert TMX object list to entities. + ;; Object types are strings from XML; convert to symbols before instantiating. + ;; Filters out #f results (objects without registered prefabs). + (define (tilemap-objects->entities tilemap instantiate-fn) + (filter-map + (lambda (obj) + (instantiate-fn (string->symbol (object-type obj)) + (object-x obj) (object-y obj) + (object-width obj) (object-height obj))) + (tilemap-objects tilemap))) + + ;; Create an SDL2 texture from the tileset image embedded in a tilemap. + (define (create-tileset-texture renderer tilemap) + (sdl2:create-texture-from-surface + renderer + (tileset-image (tilemap-tileset tilemap)))) + + ;; Load a TMX tilemap file and store it in the game asset registry. + ;; Returns the loaded tilemap struct. + (define (game-load-tilemap! game key filename) + (let ((tm (load-tilemap filename))) + (game-asset-set! game key tm) + tm)) + + ;; Load a TSX tileset file and store it in the game asset registry. + ;; Returns the loaded tileset struct. + (define (game-load-tileset! game key filename) + (let ((ts (load-tileset filename))) + (game-asset-set! game key ts) + ts)) + + ;; Load a TTF font file and store it in the game asset registry. + ;; size is the point size. Returns the loaded font. + (define (game-load-font! game key filename size) + (let ((font (ttf:open-font filename size))) + (game-asset-set! game key font) + font)) + + ;; Load a scene from a TMX tilemap file. + ;; 1. Loads the tilemap from the file (and stores in assets) + ;; 2. Creates a texture from the tilemap's tileset image + ;; 3. Creates a scene with empty entities list + ;; 4. Sets the scene on the game + ;; Returns the scene. + (define (game-load-scene! game filename) + (let* ((tm (game-load-tilemap! game 'tilemap filename)) + (tex (create-tileset-texture (game-renderer game) tm)) + (scene (make-scene + entities: '() + tilemap: tm + camera: (make-camera x: 0 y: 0) + tileset-texture: tex + camera-target: #f))) + (game-scene-set! game scene) + scene)) + +) ;; end module diff --git a/tests/engine-test.scm b/tests/engine-test.scm index 67b9942..99bb12f 100644 --- a/tests/engine-test.scm +++ b/tests/engine-test.scm @@ -39,7 +39,13 @@ ;; --- Entity module (mock minimal structs) --- (module downstroke/entity * - (import scheme (chicken base))) + (import scheme (chicken base)) + (define (entity-ref entity key #!optional (default #f)) + (let loop ((plist entity)) + (cond + ((null? plist) (if (procedure? default) (default) default)) + ((eq? (car plist) key) (cadr plist)) + (else (loop (cddr plist))))))) (import downstroke/entity) ;; --- Input module (mock) --- @@ -74,8 +80,20 @@ ;; --- World module (mock) --- (module downstroke/world * (import scheme (chicken base) defstruct) + (import downstroke/entity) (defstruct camera x y) - (defstruct scene entities tilemap camera tileset-texture)) + (defstruct scene entities tilemap camera tileset-texture camera-target) + ;; Mock camera-follow! - just clamps camera position + (define (camera-follow! camera entity viewport-w viewport-h) + (camera-x-set! camera (max 0 (- (entity-ref entity #:x 0) (/ viewport-w 2)))) + (camera-y-set! camera (max 0 (- (entity-ref entity #:y 0) (/ viewport-h 2))))) + ;; Mock scene-find-tagged - finds first entity with matching tag + (define (scene-find-tagged scene tag) + (let loop ((entities (scene-entities scene))) + (cond + ((null? entities) #f) + ((member tag (entity-ref (car entities) #:tags '())) (car entities)) + (else (loop (cdr entities))))))) (import downstroke/world) ;; --- Real deps --- @@ -164,7 +182,8 @@ (scene (make-scene entities: '() tilemap: #f camera: cam - tileset-texture: #f)) + tileset-texture: #f + camera-target: #f)) (g (make-game))) (game-scene-set! g scene) (test-equal "returns scene camera" diff --git a/tests/renderer-test.scm b/tests/renderer-test.scm index fb91f54..d14f12c 100644 --- a/tests/renderer-test.scm +++ b/tests/renderer-test.scm @@ -105,7 +105,8 @@ (scene (make-scene entities: '() tilemap: tilemap camera: cam - tileset-texture: #f))) + tileset-texture: #f + camera-target: #f))) (test-assert "does not crash on valid scene" (begin (render-scene! #f scene) #t)))) diff --git a/tests/scene-loader-test.scm b/tests/scene-loader-test.scm new file mode 100644 index 0000000..61f142f --- /dev/null +++ b/tests/scene-loader-test.scm @@ -0,0 +1,148 @@ +;; Load base deps +(import scheme + (chicken base) + (chicken keyword) + (only srfi-1 fold filter) + defstruct + srfi-64) + +;; Mock tilemap module +(module downstroke/tilemap * + (import scheme (chicken base) defstruct) + (defstruct tileset tilewidth tileheight spacing tilecount columns image-source image) + (defstruct layer name width height map) + (defstruct object name type x y width height properties) + (defstruct tilemap width height tilewidth tileheight tileset-source tileset layers objects) + (defstruct tile id rect) + (define (tileset-tile ts id) (make-tile id: id rect: #f)) + (define (tile-rect t) #f) + (define (load-tilemap filename) (make-tilemap width: 100 height: 100 tilewidth: 16 tileheight: 16 tileset-source: "" tileset: (make-tileset tilewidth: 16 tileheight: 16 spacing: 0 tilecount: 256 columns: 16 image-source: "" image: #f) layers: '() objects: '())) + (define (load-tileset filename) (make-tileset tilewidth: 16 tileheight: 16 spacing: 0 tilecount: 256 columns: 16 image-source: "" image: #f))) +(import downstroke/tilemap) + +;; Mock entity module (minimal) +(module downstroke/entity * + (import scheme (chicken base)) + (define (entity-ref entity key #!optional (default #f)) + (let loop ((plist entity)) + (cond + ((null? plist) (if (procedure? default) (default) default)) + ((eq? (car plist) key) (cadr plist)) + (else (loop (cddr plist)))))) + (define (entity-set entity key val) + (let loop ((plist entity) (acc '())) + (cond + ((null? plist) (reverse (cons val (cons key acc)))) + ((eq? (car plist) key) (append (reverse acc) (cons key (cons val (cddr plist))))) + (else (loop (cddr plist) (cons (cadr plist) (cons (car plist) acc))))))) + (define (entity-type entity) + (entity-ref entity #:type #f))) +(import downstroke/entity) + +;; Mock world module +(module downstroke/world * + (import scheme (chicken base) defstruct) + (defstruct camera x y) + (defstruct scene entities tilemap camera tileset-texture camera-target) + (define (scene-add-entity scene entity) + (scene-entities-set! scene (cons entity (scene-entities scene))) + scene)) +(import downstroke/world) + +;; Mock assets module +(module downstroke/assets * + (import scheme (chicken base)) + (define (asset-set! assets key value) #f)) +(import downstroke/assets) + +;; Mock engine module +(module downstroke/engine * + (import scheme (chicken base)) + (define (game-renderer game) #f) + (define (game-asset-set! game key value) #f) + (define (game-scene-set! game scene) #f)) +(import downstroke/engine) + +;; Mock sdl2 +(module sdl2 * + (import scheme (chicken base)) + (define (create-texture-from-surface renderer surface) #f)) +(import (prefix sdl2 "sdl2:")) + +;; Mock sdl2-ttf +(module sdl2-ttf * + (import scheme (chicken base)) + (define (open-font filename size) (list 'font filename size))) +(import (prefix sdl2-ttf "ttf:")) + +;; Load scene-loader module +(include "scene-loader.scm") +(import downstroke/scene-loader) + +(test-begin "scene-loader") + +(test-group "make-prefab-registry + instantiate-prefab" + (let* ((registry (make-prefab-registry + 'player (lambda (x y w h) (list #:type 'player #:x x #:y y #:width w #:height h)) + 'enemy (lambda (x y w h) (list #:type 'enemy #:x x #:y y #:width w #:height h)))) + (result (instantiate-prefab registry 'player 10 20 16 16))) + (test-assert "instantiate-prefab returns a plist for known type" + (list? result)) + (test-equal "player has correct x" + 10 + (entity-ref result #:x)) + (test-equal "player has correct type" + 'player + (entity-ref result #:type)) + (test-assert "unknown type returns #f" + (not (instantiate-prefab registry 'unknown 10 20 16 16))))) + +(test-group "tilemap-objects->entities" + (let* ((obj1 (make-object name: "player1" type: "player" x: 10 y: 20 width: 16 height: 16 properties: '())) + (obj2 (make-object name: "deco" type: "decoration" x: 50 y: 60 width: 32 height: 32 properties: '())) + (obj3 (make-object name: "enemy1" type: "enemy" x: 100 y: 120 width: 16 height: 16 properties: '())) + (tm (make-tilemap width: 100 height: 100 tilewidth: 16 tileheight: 16 + tileset-source: "" tileset: #f layers: '() + objects: (list obj1 obj2 obj3))) + (fn (lambda (type x y w h) + (cond + ((eq? type 'player) (list #:type 'player #:x x #:y y #:width w #:height h)) + ((eq? type 'enemy) (list #:type 'enemy #:x x #:y y #:width w #:height h)) + (else #f)))) + (result (tilemap-objects->entities tm fn))) + (test-equal "filters #f results: 2 entities from 3 objects" + 2 + (length result)) + (test-equal "first entity is player" + 'player + (entity-ref (car result) #:type)) + (test-equal "second entity is enemy" + 'enemy + (entity-ref (cadr result) #:type))) + + (let* ((tm-empty (make-tilemap width: 100 height: 100 tilewidth: 16 tileheight: 16 + tileset-source: "" tileset: #f layers: '() + objects: '())) + (result (tilemap-objects->entities tm-empty (lambda (t x y w h) #f)))) + (test-equal "empty object list returns empty list" + 0 + (length result)))) + +(test-group "game-load-tilemap! / game-load-tileset! / game-load-font!" + ;; game-load-tilemap! calls load-tilemap and stores result + ;; We can't test file I/O directly, but we can verify the function exists + ;; and that our mock game-asset-set! doesn't crash + (test-assert "game-load-tilemap! is a procedure" + (procedure? game-load-tilemap!)) + (test-assert "game-load-tileset! is a procedure" + (procedure? game-load-tileset!)) + (test-assert "game-load-font! is a procedure" + (procedure? game-load-font!)) + ;; game-load-font! with mock ttf returns a font value + (let* ((game #f) ; mock game (game-asset-set! ignores it in mock) + (font (ttf:open-font "test.ttf" 16))) + (test-equal "mock font is a list" + 'font + (car font)))) + +(test-end "scene-loader") diff --git a/tests/world-test.scm b/tests/world-test.scm index c4fd887..451ab2a 100644 --- a/tests/world-test.scm +++ b/tests/world-test.scm @@ -95,7 +95,7 @@ ;; Test: scene record creation (test-group "scene-structure" - (let ((scene (make-scene entities: '() tilemap: #f))) + (let ((scene (make-scene entities: '() tilemap: #f camera-target: #f))) (test-assert "scene is a record" (scene? scene)) (test-equal "entities list is empty" '() (scene-entities scene)) (test-equal "tilemap is #f" #f (scene-tilemap scene)))) @@ -106,7 +106,8 @@ (enemy '(#:type enemy #:x 200 #:y 200)) (tilemap "mock-tilemap") (scene (make-scene entities: (list player enemy) - tilemap: tilemap))) + tilemap: tilemap + camera-target: #f))) (test-equal "scene has 2 entities" 2 (length (scene-entities scene))) @@ -120,7 +121,7 @@ ;; Test: scene-add-entity adds entity to scene (test-group "scene-add-entity" (let* ((player (make-player-entity 100 100 16 16)) - (scene (make-scene entities: (list player) tilemap: #f)) + (scene (make-scene entities: (list player) tilemap: #f camera-target: #f)) (enemy '(#:type enemy #:x 200 #:y 200))) (test-equal "initial entity count" 1 (length (scene-entities scene))) @@ -137,7 +138,7 @@ (let* ((e1 '(#:type a #:x 1)) (e2 '(#:type b #:x 2)) (e3 '(#:type c #:x 3)) - (scene (make-scene entities: (list e1) tilemap: #f))) + (scene (make-scene entities: (list e1) tilemap: #f camera-target: #f))) (scene-add-entity scene e2) (scene-add-entity scene e3) @@ -150,7 +151,7 @@ (test-group "scene-update-entities" (let* ((e1 '(#:type player #:x 100 #:y 100)) (e2 '(#:type enemy #:x 200 #:y 200)) - (scene (make-scene entities: (list e1 e2) tilemap: #f)) + (scene (make-scene entities: (list e1 e2) tilemap: #f camera-target: #f)) ;; Function that moves all entities right by 10 (move-right (lambda (entity) (let ((x (entity-ref entity #:x)) @@ -174,7 +175,7 @@ (test-group "scene-update-entities-identity" (let* ((e1 '(#:type player #:x 100)) (e2 '(#:type enemy #:x 200)) - (scene (make-scene entities: (list e1 e2) tilemap: #f))) + (scene (make-scene entities: (list e1 e2) tilemap: #f camera-target: #f))) (scene-update-entities scene (lambda (e) e)) @@ -185,7 +186,7 @@ ;; Test: scene mutation (test-group "scene-mutation" - (let* ((scene (make-scene entities: '() tilemap: #f)) + (let* ((scene (make-scene entities: '() tilemap: #f camera-target: #f)) (player (make-player-entity 10 20 16 16))) ;; Add entity @@ -206,7 +207,7 @@ ;; Test: scene-tilemap-set! (test-group "scene-tilemap-mutation" - (let ((scene (make-scene entities: '() tilemap: #f))) + (let ((scene (make-scene entities: '() tilemap: #f camera-target: #f))) (test-equal "tilemap initially #f" #f (scene-tilemap scene)) (scene-tilemap-set! scene "new-tilemap") @@ -228,7 +229,8 @@ (scene (make-scene entities: (list e1 e2) tilemap: test-tilemap camera: (make-camera x: 0 y: 0) - tileset-texture: #f))) + tileset-texture: #f + camera-target: #f))) (scene-filter-entities scene (lambda (e) (eq? (entity-ref e #:type #f) 'player))) (test-equal "keeps matching entities" 1 (length (scene-entities scene))) @@ -252,7 +254,7 @@ (let* ((p (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16 #:tags '(player))) (e (list #:type 'enemy #:x 0 #:y 0 #:width 16 #:height 16 #:tags '(enemy npc))) (s (make-scene entities: (list p e) tilemap: #f - camera: (make-camera x: 0 y: 0) tileset-texture: #f))) + camera: (make-camera x: 0 y: 0) tileset-texture: #f camera-target: #f))) (test-equal "finds entity with matching tag" p (scene-find-tagged s 'player)) (test-equal "finds enemy by 'enemy tag" e (scene-find-tagged s 'enemy)) (test-equal "finds entity with second tag in list" e (scene-find-tagged s 'npc)) @@ -263,7 +265,7 @@ (p2 (list #:type 'ally #:x 0 #:y 0 #:width 16 #:height 16 #:tags '(ally friendly))) (e (list #:type 'enemy #:x 0 #:y 0 #:width 16 #:height 16 #:tags '(enemy))) (s (make-scene entities: (list p1 p2 e) tilemap: #f - camera: (make-camera x: 0 y: 0) tileset-texture: #f))) + camera: (make-camera x: 0 y: 0) tileset-texture: #f camera-target: #f))) (test-equal "returns all friendly entities" 2 (length (scene-find-all-tagged s 'friendly))) (test-equal "returns empty list when none match" '() (scene-find-all-tagged s 'boss)))) @@ -36,7 +36,8 @@ entities tilemap camera - tileset-texture) + tileset-texture + camera-target) ; symbol tag or #f (define (scene-add-entity scene entity) (scene-entities-set! scene (append (scene-entities scene) (list entity))) |
