diff options
31 files changed, 993 insertions, 1498 deletions
@@ -13,3 +13,5 @@ *.log logs /.agent-shell/ +CLAUDE.md +superpowers/*
\ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fb196e5..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,193 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this is - -**Downstroke** is a 2D tile-driven game engine for Chicken Scheme, built on SDL2. API inspired by Phaser 2: a minimal game is ~20 lines of Scheme. - -The engine is being extracted from the testbed game **macroknight** (`/home/gene/src/macroknight`). Milestones 1–6 are pure refactoring (no behavior changes); Milestone 7 is the design pivot where the public API stabilizes. - -**Detailed extraction plan**: `/home/gene/src/macroknight/TODO-engine.org` -**Project milestones**: `/home/gene/Documents/Perso/Projects/downstroke.org` (also in README.org) - -## Target API - -```scheme -(define my-game - (make-game - title: "My Game" width: 600 height: 400 - preload: (lambda (game) ...) ; load assets - create: (lambda (game) ...) ; init scene - update: (lambda (game dt) ...))) ; game-specific logic (physics runs first) - -(game-run! my-game) -``` - -Built-in physics pipeline (runs before user `update:` hook): -``` -input → acceleration → gravity → velocity-x → tile-collision-x → -velocity-y → tile-collision-y → ground-detection → entity-collisions -``` - -## Build & Test (macroknight — source of truth) - -All engine code currently lives in `/home/gene/src/macroknight`. Until Milestone 1 is complete, build and test from there. - -```bash -cd /home/gene/src/macroknight - -make # compile all modules + link bin/game -make test # run all 8 SRFI-64 test suites via csi -make clean # remove bin/ and .import.scm files - -# Run a single test module: -csi -s tests/physics-test.scm -csi -s tests/entity-test.scm -# etc. -``` - -Once extraction begins, the downstroke Makefile must also build all demos: - -```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) -``` - -**Module compile order** (dependency order, must be respected in Makefile): -`tilemap → entity → world → animation → physics → ai → input → prefabs → mixer → sound` - -Modules are compiled as **units** (`csc -c -J -unit $*`) to avoid C toplevel name collisions when linking multiple `.o` files. Each module generates both a `.o` and a `.import.scm` in `bin/`. - -## Test-Driven Development - -**Tests are mandatory for all engine code.** Write tests before or alongside implementation — never after. The test suite is the primary correctness guarantee for the engine, since behavior regressions are easy to introduce during extraction. - -- Test files live in `tests/`, named `<module>-test.scm` -- Framework: SRFI-64 (`test-begin`, `test-equal`, `test-assert`, `test-end`) -- Tests run via `csi -s` (interpreter, not compiled) and must not require SDL2 — mock or stub any SDL2-dependent code -- Each engine module must have a corresponding test module before the module is considered done - -## Documentation - -End-user documentation lives in `docs/` as **org-mode files** and must be kept up to date as the API evolves. This is not optional — docs ship with the egg. - -- `docs/api.org` — public API reference (`make-game`, `game-run!`, all accessors and hooks) -- `docs/guide.org` — getting started guide with the minimal ~20-line game example -- `docs/entities.org` — entity model, plist keys, prefab/mixin system -- `docs/physics.org` — physics pipeline, collision model, gravity/velocity API - -When adding or changing any public-facing function or keyword argument, update the relevant doc file in the same commit. - -## Demo Games - -`demo/` contains small self-contained example games that exercise the engine API. They serve as living documentation and integration tests. - -- Each demo is a single `.scm` file (plus any assets in `demo/<name>/`) -- The Makefile must build all demos as part of `make` or `make demos` — a demo that fails to compile is a build failure -- Demos should be minimal: one mechanic per demo (gravity+jump, tilemap rendering, animation, etc.) -- Do not add game-specific logic to the engine to make a demo work; if a demo needs something, it belongs in the engine's public API - -## Engine Module Architecture - -| Module | File | Responsibility | -|---|---|---| -| `engine` | engine.scm | `make-game`, `game-run!`, lifecycle orchestration | -| `world` | world.scm | Scene struct, entity list ops, camera | -| `entity` | entity.scm | Entity plist accessors (`entity-ref`, `entity-set`, `entity-type`) | -| `physics` | physics.scm | Gravity, velocity, AABB tile + entity collisions, ground detection | -| `tilemap` | tilemap.scm | TMX/TSX XML parsing (expat), tileset loading, tile rect calculations | -| `input` | input.scm | SDL2 event → action mapping, keyboard/joystick/controller | -| `animation` | animation.scm | Frame/tick tracking, sprite ID mapping, animation state machine | -| `prefabs` | prefabs.scm | Mixin composition, prefab data loading, entity instantiation + hooks | -| `ai` | ai.scm | FSM-based enemy AI (idle/patrol/chase) via `states` egg | -| `renderer` | renderer.scm | SDL2 drawing abstraction: `draw-sprite`, `draw-tilemap-layer`, `draw-text` | -| `assets` | assets.scm | Asset registry for `preload:` lifecycle hook | -| `scene-loader` | scene-loader.scm | `game-load-scene!`, `instantiate-prefab` | -| `sound` | sound.scm | Sound registry, music playback | -| `mixer` | mixer.scm | SDL_mixer FFI bindings (no Scheme dependencies) | - -## Entity Model - -Entities are **plists** (property lists) — no classes, pure data + functions: - -```scheme -(list #:type 'player - #:x 100 #:y 200 - #:width 16 #:height 16 - #:vx 0 #:vy 0 - #:gravity? #t - #:on-ground? #f - #:tile-id 29 ; sprite index in tileset - #:tags '(player) - #:anim-name 'walk - #:animations ((idle #:frames (28) #:duration 10) - (walk #:frames (27 28) #:duration 10))) -``` - -Key shared entity keys: `#:type`, `#:x`, `#:y`, `#:width`, `#:height`, `#:vx`, `#:vy`, `#:tile-id`, `#:tags`. - -Access via `entity-ref`, `entity-set` (returns new plist — functional/immutable), `entity-type`. - -## Scene & Camera - -```scheme -(make-scene - entities: (list ...) - tilemap: tm - camera: (make-camera x: 0 y: 0) - tileset-texture: tex) -``` - -## Prefab / Mixin System - -```scheme -(mixins - (physics-body #:vx 0 #:vy 0 ...) - (has-facing #:facing 1)) - -(prefabs - (player physics-body has-facing #:type player #:tile-id 29 ...)) -``` - -Prefabs are loaded from a data file (`assets/prefabs.scm`). `instantiate-prefab` merges mixins + overrides into a fresh entity plist. - -## Tile Collision Model - -Tiles come from TMX maps (Tiled editor). The tilemap module parses TMX XML via expat into a struct with layers, tile GIDs, and a tileset. Collision tiles are identified by metadata in the TSX tileset. The physics module checks all tile cells overlapping an entity's AABB and snaps the entity to the nearest edge (velocity-direction-aware). - -## AI State Machine - -Uses the `states` egg. States: `idle → patrol → chase → patrol` (cycles back). Guards: `player-in-range?`, `player-in-attack-range?`, `chase-give-up?`. After Milestone 10, hardcoded `'player` type checks are replaced with `(scene-find-tagged scene 'player)`. - -## Dependencies - -System: `SDL2`, `SDL2_mixer`, `SDL2_ttf`, `SDL2_image` - -Chicken eggs: `sdl2`, `sdl2-image`, `expat`, `matchable`, `defstruct`, `states`, `srfi-64` (tests), `srfi-197` - -## Egg Packaging - -Downstroke is distributed as a **Chicken egg** named `downstroke`. The egg spec (`downstroke.egg`) declares all modules as installable units. Once published, games depend on it via `chicken-install downstroke`. - -Module namespacing follows the egg convention: `downstroke/physics`, `downstroke/world`, etc. All module source files live at the project root; the egg spec maps them to the namespaced identifiers. - -## Macroknight Port - -As soon as the first installable egg exists (Milestone 11), macroknight must be ported to depend on it. This is an ongoing obligation — macroknight is the primary validation that the engine API is usable. Any engine change that requires macroknight to be updated should update macroknight in the same work session. - -The ported macroknight `game.scm` is the definition of "Milestone 12 done": it should be ~20–30 lines, containing only `make-game` + lifecycle hooks + `game-run!`. - -## Tracking Progress - -As milestones and tasks are completed, update the TODO items in `/home/gene/Documents/Perso/Projects/downstroke.org`. Mark tasks with `DONE` as they are finished — this file is the authoritative project tracker. - -## Milestone Status - -Milestones 1–6: pure refactoring — extract modules into the project root, no behavior changes. -Milestone 7: design pivot — `make-game` + `game-run!` public API becomes stable. -Milestones 8–10: camera follow, scene state machine, AI tag lookup. -Milestones 11–12: package as Chicken egg, macroknight uses it as dependency. - -Current status: all milestones at 0/N — extraction has not yet begun. @@ -1,9 +1,15 @@ .DEFAULT_GOAL := engine # Modules listed in dependency order -MODULE_NAMES := entity tilemap world input physics renderer assets engine +MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound animation ai OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES)) +DEMO_NAMES := platformer shmup topdown audio sandbox +DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES)) + +UNIT_NAMES := $(patsubst %,downstroke/%,$(MODULE_NAMES)) +USES_FLAGS := $(patsubst %,-uses %,$(UNIT_NAMES)) + # Build all engine modules engine: $(OBJECT_FILES) @@ -22,6 +28,10 @@ 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 +bin/animation.o: bin/entity.o bin/world.o +bin/ai.o: bin/entity.o bin/world.o # Pattern rule: compile each module as a library unit bin/%.o: %.scm | bin downstroke @@ -45,6 +55,10 @@ test: @csi -s tests/renderer-test.scm @csi -s tests/assets-test.scm @csi -s tests/engine-test.scm + @csi -s tests/animation-test.scm + @csi -s tests/ai-test.scm + +demos: engine $(DEMO_BINS) -demos: - @echo "No demos yet." +bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin + csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin $(USES_FLAGS) -L -lSDL2_mixer @@ -0,0 +1,135 @@ +(module downstroke/ai * + (import scheme + (chicken base) + (chicken keyword) + (only srfi-1 find) + states + downstroke/entity + downstroke/world) + + ;; Patrol speed in pixels per frame + (define *patrol-speed* 1) + + ;; Chase behavior constants + (define *chase-speed* 2) + (define *detect-range-x* 80) + (define *detect-range-y* 16) + (define *chase-max-distance* 96) + (define *blind-spot-x* 16) + (define *attack-range-x* 20) + (define *attack-duration* 10) + + ;; ---- Geometry helpers ---- + + (define (entity-center-x e) + (+ (entity-ref e #:x 0) (/ (entity-ref e #:width 0) 2))) + + (define (entity-center-y e) + (+ (entity-ref e #:y 0) (/ (entity-ref e #:height 0) 2))) + + (define (direction-to entity target) + (if (>= (entity-center-x target) (entity-center-x entity)) 1 -1)) + + ;; ---- Entity update helpers ---- + + ;; Find player by #:tags list (tag-based lookup, Milestone 10) + (define (find-player entities) + (find (lambda (e) (member 'player (entity-ref e #:tags '()))) entities)) + + ;; Set both facing fields in the same direction + (define (face-toward entity dir) + (entity-set (entity-set entity #:ai-facing dir) #:facing dir)) + + ;; Face a direction and set horizontal velocity + (define (move-toward entity dir speed) + (entity-set (face-toward entity dir) #:vx (* speed dir))) + + ;; ---- Detection predicates ---- + + (define (player-in-range? entity entities) + (let ((player (find-player entities))) + (and player + (let ((dx (abs (- (entity-center-x player) (entity-center-x entity)))) + (dy (abs (- (entity-center-y player) (entity-center-y entity))))) + (and (<= dx *detect-range-x*) + (<= dy *detect-range-y*) + (not (and (< (entity-center-y player) (entity-center-y entity)) + (<= dx *blind-spot-x*)))))))) + + (define (player-in-attack-range? entity entities) + (let ((player (find-player entities))) + (and player + (<= (abs (- (entity-center-x player) (entity-center-x entity))) *attack-range-x*) + (<= (abs (- (entity-center-y player) (entity-center-y entity))) *detect-range-y*)))) + + ;; ---- State machine ---- + + (define (idle-trigger) #f) + (define (patrol-trigger) #f) + (define (chase-trigger) #f) + + (define (make-enemy-ai-machine) + (state-machine idle + (idle-trigger ! idle -> patrol) + (patrol-trigger ! patrol -> chase) + (chase-trigger ! chase -> patrol))) + + ;; ---- Per-state handlers ---- + + (define (ai-state-idle entity sm) + (%make-transition sm 'patrol) + (entity-set entity #:vx (* *patrol-speed* (entity-ref entity #:ai-facing 1)))) + + (define (patrol-wall-flip entity) + (let ((new-facing (- (entity-ref entity #:ai-facing 1)))) + (move-toward entity new-facing *patrol-speed*))) + + (define (ai-state-patrol entity entities sm) + (if (player-in-range? entity entities) + (let* ((player (find-player entities)) + (dir (direction-to entity player))) + (%make-transition sm 'chase) + (move-toward (entity-set entity #:chase-origin-x (entity-ref entity #:x 0)) + dir *chase-speed*)) + (if (zero? (entity-ref entity #:vx 0)) + (patrol-wall-flip entity) + entity))) + + (define (chase-give-up? entity entities) + (let ((distance-chased (abs (- (entity-ref entity #:x 0) + (entity-ref entity #:chase-origin-x 0))))) + (or (not (player-in-range? entity entities)) + (> distance-chased *chase-max-distance*)))) + + (define (ai-state-chase entity entities sm) + (if (player-in-attack-range? entity entities) + (let* ((player (find-player entities)) + (dir (direction-to entity player)) + (stopped (entity-set (face-toward entity dir) #:vx 0))) + (if (= (entity-ref stopped #:attack-timer 0) 0) + (entity-set stopped #:attack-timer *attack-duration*) + stopped)) + (if (chase-give-up? entity entities) + (begin + (%make-transition sm 'patrol) + (entity-set entity #:vx (* *patrol-speed* (entity-ref entity #:ai-facing 1)))) + (move-toward entity + (direction-to entity (find-player entities)) + *chase-speed*)))) + + ;; ---- Top-level dispatcher ---- + + ;; Update AI for a single enemy entity. + ;; entities: all entities in the current scene (used for player detection). + (define (update-enemy-ai entity entities) + (if (entity-ref entity #:disabled #f) + entity + (let ((sm (entity-ref entity #:ai-machine #f))) + (if (not sm) + entity + (case (machine-state sm) + ((idle) (ai-state-idle entity sm)) + ((patrol) (ai-state-patrol entity entities sm)) + ((chase) (ai-state-chase entity entities sm)) + (else entity)))))) +) diff --git a/animation.scm b/animation.scm new file mode 100644 index 0000000..e6627fe --- /dev/null +++ b/animation.scm @@ -0,0 +1,55 @@ +(module downstroke/animation * + (import scheme + (chicken base) + (chicken keyword) + downstroke/entity + downstroke/world) + + ;; ---- Animation data accessors ---- + + (define (animation-frames anim) (get-keyword #:frames anim)) + (define (animation-duration anim) (get-keyword #:duration anim)) + + ;; ---- frame->tile-id ---- + ;; Given a frames list and frame index, return the tile ID (1-indexed). + + (define (frame->tile-id frames frame-idx) + (+ 1 (list-ref frames (modulo frame-idx (length frames))))) + + ;; ---- set-animation ---- + ;; Switch to a new animation, resetting frame and tick counters. + ;; No-op if the animation is already active (avoids restart mid-loop). + + (define (set-animation entity name) + (if (eq? (entity-ref entity #:anim-name #f) name) + entity + (entity-set (entity-set (entity-set entity #:anim-name name) + #:anim-frame 0) + #:anim-tick 0))) + + ;; ---- animate-entity ---- + ;; Advance the animation tick/frame counter for one game tick. + ;; Pass the animation table for this entity's type. + ;; Entities without #:anim-name are returned unchanged. + + (define (animate-entity entity animations) + (let ((anim-name (entity-ref entity #:anim-name #f))) + (if (not anim-name) + entity + (let* ((entry (assq anim-name animations)) + (anim (and entry (cdr entry)))) + (if (not anim) + entity + (let* ((tick (+ 1 (entity-ref entity #:anim-tick 0))) + (duration (animation-duration anim)) + (frames (animation-frames anim)) + (frame (entity-ref entity #:anim-frame 0))) + (if (>= tick duration) + (let ((new-frame (modulo (+ frame 1) (length frames)))) + (entity-set (entity-set (entity-set entity + #:anim-tick 0) + #:anim-frame new-frame) + #:tile-id (frame->tile-id frames new-frame))) + (entity-set (entity-set entity #:anim-tick tick) + #:tile-id (frame->tile-id frames frame))))))))) +) ;; End of animation module diff --git a/demo/assets/DejaVuSans.ttf b/demo/assets/DejaVuSans.ttf Binary files differnew file mode 100644 index 0000000..9d40c32 --- /dev/null +++ b/demo/assets/DejaVuSans.ttf diff --git a/demo/assets/jump.wav b/demo/assets/jump.wav Binary files differnew file mode 100644 index 0000000..89eb587 --- /dev/null +++ b/demo/assets/jump.wav diff --git a/demo/assets/level-0.tmx b/demo/assets/level-0.tmx new file mode 100644 index 0000000..300e1a2 --- /dev/null +++ b/demo/assets/level-0.tmx @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<map version="1.10" tiledversion="1.12.0" orientation="orthogonal" renderorder="right-down" width="40" height="30" tilewidth="16" tileheight="16" infinite="0" nextlayerid="8" nextobjectid="5"> + <tileset firstgid="1" source="monochrome_transparent.tsx"/> + <layer id="3" name="ground" width="40" height="30"> + <data encoding="csv"> +0,0,0,0,168,216,0,0,0,0,215,169,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,168,216,0,0,215,169,0,0,0,0, +0,0,0,168,216,0,0,0,0,0,0,215,169,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,168,216,0,0,0,0,215,169,0,0,168, +0,0,168,216,0,0,0,0,0,0,0,0,215,169,0,0,0,0,0,0,0,0,168,169,0,0,0,0,168,216,0,0,0,0,0,0,215,169,168,216, +0,168,216,0,0,0,0,0,0,0,0,0,0,215,169,0,0,168,169,0,0,168,216,215,169,0,0,168,216,0,0,0,0,0,0,0,0,215,169,0, +168,216,0,0,0,0,0,0,0,0,0,0,0,0,215,169,168,216,215,169,168,216,0,0,215,169,168,216,0,0,0,0,0,0,0,0,0,0,215,169, +216,0,0,0,0,0,0,0,0,0,0,0,0,0,0,215,169,0,0,168,216,0,0,0,0,215,216,0,0,0,0,0,0,0,0,0,0,0,0,215, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,215,169,168,216,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,215,216,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +939,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +893,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,688,0,0,0, +846,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,844,0,0,0, +844,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,845,546,546,546, +846,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,844,844,844,844, +844,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,444,844,844,844, +548,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,215,20,20,19,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20, +20,20,20,20,21,0,0,0,0,0,0,0,0,0,0,0,0,0,0,742,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1058,0,0,0, +0,0,0,0,215,21,0,0,0,0,0,0,0,0,0,0,0,0,0,644,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,69,0,215,21,0,0,0,0,0,0,0,0,0,0,0,0,638,0,0,0,0,0,0,0,0,0,0,0,69,0,0,0,0,0,0,69,0, +0,0,0,0,0,0,215,21,0,0,0,0,0,0,0,0,19,20,20,216,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,215,21,0,0,0,0,0,0,0,68,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,215,20,20,20,20,20,20,20,216,0,0,0,0,0,0,69,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +69,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,69,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +</data> + </layer> + <objectgroup id="7" name="entities"> + <object id="2" name="player" type="Player" gid="29" x="182" y="350.5" width="16" height="16"/> + <object id="3" name="hint" type="Text" x="98.5" y="432.5" width="197" height="78"> + <properties> + <property name="text" value="hit enter to start a macro"/> + </properties> + </object> + <object id="4" name="goal" type="Goal" x="565.694" y="287.776" width="16" height="16"/> + </objectgroup> +</map> diff --git a/demo/assets/monochrome-transparent.png b/demo/assets/monochrome-transparent.png Binary files differnew file mode 100644 index 0000000..b95bef5 --- /dev/null +++ b/demo/assets/monochrome-transparent.png diff --git a/demo/assets/monochrome_transparent.tsx b/demo/assets/monochrome_transparent.tsx new file mode 100644 index 0000000..279c64d --- /dev/null +++ b/demo/assets/monochrome_transparent.tsx @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tileset version="1.10" tiledversion="1.11.2" name="monochrome_transparent" tilewidth="16" tileheight="16" spacing="1" tilecount="1078" columns="49"> + <image source="monochrome-transparent.png" width="832" height="373"/> +</tileset> diff --git a/demo/assets/theme.ogg b/demo/assets/theme.ogg Binary files differnew file mode 100644 index 0000000..8325258 --- /dev/null +++ b/demo/assets/theme.ogg diff --git a/demo/audio.scm b/demo/audio.scm new file mode 100644 index 0000000..8ae4d62 --- /dev/null +++ b/demo/audio.scm @@ -0,0 +1,52 @@ +(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) + +(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))) + + update: (lambda (game dt) + (let ((input (game-input game))) + (when (input-pressed? input 'a) + (play-sound 'jump)) + (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))) + (set! (sdl2:render-draw-color renderer) (sdl2:make-color 30 30 60 255)) + (sdl2:render-fill-rect! renderer (sdl2:make-rect 0 0 600 400)) + (draw-ui-text renderer font "Audio Demo" white 220 80) + (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) + (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*) diff --git a/demo/platformer.scm b/demo/platformer.scm new file mode 100644 index 0000000..d9276b8 --- /dev/null +++ b/demo/platformer.scm @@ -0,0 +1,70 @@ +(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 (sdl2:create-texture-from-surface + (game-renderer game) + (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))) + (player (entity-set player #:vx + (cond + ((input-held? input 'left) -3) + ((input-held? input 'right) 3) + (else 0)))) + (_ (when (and (input-pressed? input 'a) + (entity-ref player #:on-ground? #f)) + (play-sound 'jump))) + (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))) + (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 new file mode 100644 index 0000000..1be3968 --- /dev/null +++ b/demo/sandbox.scm @@ -0,0 +1,71 @@ +(import scheme + (chicken base) + (chicken random) + (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 *elapsed* 0) +(define *respawn-interval* 10000) + +(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 (* (pseudo-random-integer 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 (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)))) + + update: (lambda (game dt) + (let* ((scene (game-scene game)) + (tm (scene-tilemap scene))) + (set! *elapsed* (+ *elapsed* dt)) + (when (>= *elapsed* *respawn-interval*) + (set! *elapsed* 0) + (scene-entities-set! scene (spawn-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))) + (scene-resolve-collisions scene))))) + +(game-run! *game*) diff --git a/demo/shmup.scm b/demo/shmup.scm new file mode 100644 index 0000000..ae7748d --- /dev/null +++ b/demo/shmup.scm @@ -0,0 +1,133 @@ +(import scheme + (chicken base) + (chicken random) + (only srfi-1 filter) + (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) + +(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)) + +(define (entities-overlap? a b) + (aabb-overlap? + (entity-ref a #:x 0) (entity-ref a #:y 0) + (entity-ref a #:width 0) (entity-ref a #:height 0) + (entity-ref b #:x 0) (entity-ref b #:y 0) + (entity-ref b #:width 0) (entity-ref b #:height 0))) + +(define (find-dead 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) + (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 + (let* ((player (entity-set player #:vx + (cond ((input-held? input 'left) -4) + ((input-held? input 'right) 4) + (else 0)))) + (player (apply-velocity-x player)) + (player (entity-set player #:x + (max 0 (min 584 (entity-ref player #:x 0)))))) + ;; Fire bullet + (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 (* (pseudo-random-integer 28) 20))))) + ;; Update player in scene + (scene-entities-set! scene + (cons player + (filter (lambda (e) (not (eq? (entity-ref e #:type) 'player))) + (scene-entities scene))))) + ;; Move non-player entities + (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 collisions + (let ((dead (find-dead (scene-entities scene)))) + (scene-filter-entities scene + (lambda (e) (not (memq e dead))))) + ;; Remove out-of-bounds + (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))) + (set! (sdl2:render-draw-color 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*) diff --git a/demo/topdown.scm b/demo/topdown.scm new file mode 100644 index 0000000..9bbaf08 --- /dev/null +++ b/demo/topdown.scm @@ -0,0 +1,59 @@ +(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 *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)))) + (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))) + (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)) + (player (apply-velocity-x player)) + (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/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md b/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md deleted file mode 100644 index 0d12308..0000000 --- a/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md +++ /dev/null @@ -1,826 +0,0 @@ -# Milestone 8 — Game Object and Lifecycle API - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Introduce `make-game` + `game-run!` as the public entry point for downstroke, backed by a minimal asset registry, and port macroknight to use them. - -**Architecture:** Two new modules (`assets.scm` — key/value registry; `engine.scm` — game struct, lifecycle, frame loop), plus `render-scene!` added to `renderer.scm`. `game-run!` owns SDL2 init, window/renderer creation, and the frame loop; lifecycle hooks (`preload:`, `create:`, `update:`, `render:`) are user-supplied lambdas. - -**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), sdl2-ttf, sdl2-image, SRFI-64 (tests), defstruct egg - -**Spec:** `docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md` - ---- - -## File Map - -| Action | File | Purpose | -|---|---|---| -| Create | `assets.scm` | Key→value asset registry | -| Create | `tests/assets-test.scm` | SRFI-64 unit tests for assets.scm | -| Modify | `renderer.scm` | Add `render-scene!` | -| Modify | `tests/renderer-test.scm` | Add tests for `render-scene!` | -| Create | `engine.scm` | `make-game`, `game-run!`, accessors | -| Create | `tests/engine-test.scm` | Unit tests for make-game, accessors (SDL2 mocked) | -| Modify | `Makefile` | Add assets + engine to build, add test targets | -| Modify | `/home/gene/src/macroknight/game.scm` | Port to `make-game` + `game-run!` | - ---- - -## Task 1: `assets.scm` — minimal key/value registry - -**Files:** -- Create: `assets.scm` -- Create: `tests/assets-test.scm` - -### What this module does - -A thin wrapper around a hash table. No asset-type logic — that's Milestone 6. Three public functions: - -```scheme -(make-asset-registry) ;; → hash-table -(asset-set! registry key val) ;; → unspecified (mutates) -(asset-ref registry key) ;; → value or #f if missing -``` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/assets-test.scm`: - -```scheme -(import scheme (chicken base) srfi-64) - -(include "assets.scm") -(import downstroke/assets) - -(test-begin "assets") - -(test-group "make-asset-registry" - (test-assert "returns a value" - (make-asset-registry))) - -(test-group "asset-set! and asset-ref" - (let ((reg (make-asset-registry))) - (test-equal "missing key returns #f" - #f - (asset-ref reg 'missing)) - - (asset-set! reg 'my-tilemap "data") - (test-equal "stored value is retrievable" - "data" - (asset-ref reg 'my-tilemap)) - - (asset-set! reg 'my-tilemap "updated") - (test-equal "overwrite replaces value" - "updated" - (asset-ref reg 'my-tilemap)) - - (asset-set! reg 'other 42) - (test-equal "multiple keys coexist" - "updated" - (asset-ref reg 'my-tilemap)) - (test-equal "second key retrievable" - 42 - (asset-ref reg 'other)))) - -(test-end "assets") -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/assets-test.scm -``` - -Expected: error — `assets.scm` not found / `downstroke/assets` not defined. - -- [ ] **Step 3: Implement `assets.scm`** - -Create `assets.scm`: - -```scheme -(module downstroke/assets * - -(import scheme - (chicken base) - (srfi 69)) - -(define (make-asset-registry) - (make-hash-table)) - -(define (asset-set! registry key value) - (hash-table-set! registry key value)) - -(define (asset-ref registry key) - (hash-table-ref/default registry key #f)) - -) ;; end module -``` - -- [ ] **Step 4: Run test to confirm it passes** - -```bash -cd /home/gene/src/downstroke -csi -s tests/assets-test.scm -``` - -Expected: all tests pass, no failures reported. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add assets.scm tests/assets-test.scm -git commit -m "feat: add assets.scm — minimal key/value asset registry" -``` - ---- - -## Task 2: Add `render-scene!` to `renderer.scm` - -**Files:** -- Modify: `renderer.scm` (add one function after `draw-entities`) -- Modify: `tests/renderer-test.scm` (add one test group) - -### What this adds - -`render-scene!` draws a complete scene (all tilemap layers + all entities) given a renderer and a scene struct. It delegates to the already-tested `draw-tilemap` and `draw-entities`. - -Existing signatures in `renderer.scm`: -- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args -- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args - -The `tileset` is extracted from `tilemap` via the `tilemap-tileset` accessor (from `tilemap.scm`). - -- [ ] **Step 1: Write the failing test** - -Add to `tests/renderer-test.scm`, before `(test-end "renderer")`: - -```scheme -(test-group "render-scene!" - ;; render-scene! calls draw-tilemap and draw-entities — both are mocked to return #f. - ;; We verify it doesn't crash on a valid scene and returns unspecified. - (let* ((cam (make-camera x: 0 y: 0)) - (tileset (make-tileset tilewidth: 16 tileheight: 16 - spacing: 0 tilecount: 100 columns: 10 - image-source: "" image: #f)) - (layer (make-layer name: "ground" width: 2 height: 2 - map: '((1 2) (3 4)))) - (tilemap (make-tilemap width: 2 height: 2 - tilewidth: 16 tileheight: 16 - tileset-source: "" - tileset: tileset - layers: (list layer) - objects: '())) - (scene (make-scene entities: '() - tilemap: tilemap - camera: cam - tileset-texture: #f))) - (test-assert "does not crash on valid scene" - (begin (render-scene! #f scene) #t)))) -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/renderer-test.scm -``` - -Expected: error — `render-scene!` not defined. - -- [ ] **Step 3: Add `render-scene!` to `renderer.scm`** - -Add after the `draw-entities` definition (before the closing `)`): - -```scheme - ;; --- Scene drawing --- - - (define (render-scene! renderer scene) - (let ((camera (scene-camera scene)) - (tilemap (scene-tilemap scene)) - (tileset-texture (scene-tileset-texture scene)) - (tileset (tilemap-tileset (scene-tilemap scene))) - (entities (scene-entities scene))) - (draw-tilemap renderer camera tileset-texture tilemap) - (draw-entities renderer camera tileset tileset-texture entities))) -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cd /home/gene/src/downstroke -csi -s tests/renderer-test.scm -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add renderer.scm tests/renderer-test.scm -git commit -m "feat: add render-scene! to renderer — draw full scene in one call" -``` - ---- - -## Task 3: `engine.scm` — game struct, constructor, accessors - -**Files:** -- Create: `engine.scm` -- Create: `tests/engine-test.scm` - -This task covers only the data structure and constructor — no `game-run!` yet. TDD applies to the parts we can unit-test (struct creation, accessors). - -`game-run!` requires a live SDL2 window and cannot be unit-tested; it is tested in Task 6 via macroknight integration. - -### Mock strategy for tests - -`engine.scm` imports `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, and the SDL2 sub-libraries. The test file follows the same mock-module pattern as `tests/renderer-test.scm`: define mock modules that satisfy the imports, then `(include "engine.scm")`. - -- [ ] **Step 1: Write the failing tests** - -Create `tests/engine-test.scm`: - -```scheme -(import scheme (chicken base) (chicken keyword) srfi-64 defstruct) - -;; --- Mocks --- - -(module sdl2 * - (import scheme (chicken base)) - (define (set-main-ready!) #f) - (define (init! . args) #f) - (define (quit! . args) #f) - (define (get-ticks) 0) - (define (delay! ms) #f) - (define (pump-events!) #f) - (define (has-events?) #f) - (define (make-event) #f) - (define (poll-event! e) #f) - (define (num-joysticks) 0) - (define (is-game-controller? i) #f) - (define (game-controller-open! i) #f) - (define (create-window! . args) 'mock-window) - (define (create-renderer! . args) 'mock-renderer) - (define (destroy-window! . args) #f) - (define (render-clear! . args) #f) - (define (render-present! . args) #f)) -(import (prefix sdl2 "sdl2:")) - -(module sdl2-ttf * - (import scheme (chicken base)) - (define (init!) #f)) -(import (prefix sdl2-ttf "ttf:")) - -(module sdl2-image * - (import scheme (chicken base)) - (define (init! . args) #f)) -(import (prefix sdl2-image "img:")) - -;; --- Real deps (include order follows dependency order) --- -(import simple-logger) ;; required by input.scm -(include "entity.scm") (import downstroke/entity) -(include "tilemap.scm") (import downstroke/tilemap) -(include "world.scm") (import downstroke/world) -(include "input.scm") (import downstroke/input) -(include "assets.scm") (import downstroke/assets) - -;; Mock renderer (render-scene! can't run without real SDL2) -(module downstroke/renderer * - (import scheme (chicken base)) - (define (render-scene! . args) #f)) -(import downstroke/renderer) - -(include "engine.scm") -(import downstroke/engine) - -;; --- Tests --- - -(test-begin "engine") - -(test-group "make-game defaults" - (let ((g (make-game))) - (test-equal "default title" - "Downstroke Game" - (game-title g)) - (test-equal "default width" - 640 - (game-width g)) - (test-equal "default height" - 480 - (game-height g)) - (test-equal "default frame-delay" - 16 - (game-frame-delay g)) - (test-equal "scene starts as #f" - #f - (game-scene g)) - (test-equal "window starts as #f" - #f - (game-window g)) - (test-equal "renderer starts as #f" - #f - (game-renderer g)) - (test-assert "assets registry is created" - (game-assets g)) - (test-assert "input state is created" - (game-input g)))) - -(test-group "make-game with keyword args" - (let ((g (make-game title: "My Game" width: 320 height: 240 frame-delay: 33))) - (test-equal "custom title" "My Game" (game-title g)) - (test-equal "custom width" 320 (game-width g)) - (test-equal "custom height" 240 (game-height g)) - (test-equal "custom frame-delay" 33 (game-frame-delay g)))) - -(test-group "game-asset and game-asset-set!" - (let ((g (make-game))) - (test-equal "missing key returns #f" - #f - (game-asset g 'no-such-asset)) - (game-asset-set! g 'my-font 'font-object) - (test-equal "stored asset is retrievable" - 'font-object - (game-asset g 'my-font)) - (game-asset-set! g 'my-font 'updated-font) - (test-equal "overwrite replaces asset" - 'updated-font - (game-asset g 'my-font)))) - -(test-group "make-game hooks default to #f" - (let ((g (make-game))) - (test-equal "preload-hook is #f" #f (game-preload-hook g)) - (test-equal "create-hook is #f" #f (game-create-hook g)) - (test-equal "update-hook is #f" #f (game-update-hook g)) - (test-equal "render-hook is #f" #f (game-render-hook g)))) - -(test-group "make-game accepts hook lambdas" - (let* ((called #f) - (g (make-game update: (lambda (game dt) (set! called #t))))) - (test-assert "update hook is stored" - (procedure? (game-update-hook g))))) - -(test-end "engine") -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: error — `engine.scm` not found. - -- [ ] **Step 3: Implement `engine.scm` struct + constructor + accessors (no `game-run!`)** - -Create `engine.scm`. `game-run!` is a stub for now — it will be filled in Task 4. - -```scheme -(module downstroke/engine * - -(import scheme - (chicken base) - (chicken keyword) - (prefix sdl2 "sdl2:") - (prefix sdl2-ttf "ttf:") - (prefix sdl2-image "img:") - defstruct - downstroke/world - downstroke/input - downstroke/assets - downstroke/renderer) - -;; ── Game struct ──────────────────────────────────────────────────────────── -;; constructor: make-game* (raw) so we can define make-game as keyword wrapper - -(defstruct (game constructor: make-game*) - title width height - window renderer - input ;; input-state record - input-config ;; input-config record - assets ;; asset registry (hash-table from assets.scm) - frame-delay - preload-hook ;; (lambda (game) ...) - create-hook ;; (lambda (game) ...) - update-hook ;; (lambda (game dt) ...) - render-hook ;; (lambda (game) ...) — post-render overlay - scene) ;; current scene struct; #f until create: runs - -;; ── Public constructor ───────────────────────────────────────────────────── - -(define (make-game #!key - (title "Downstroke Game") - (width 640) (height 480) - (frame-delay 16) - (input-config *default-input-config*) - (preload #f) (create #f) (update #f) (render #f)) - (make-game* - title: title - width: width - height: height - window: #f - renderer: #f - scene: #f - input: (create-input-state input-config) - input-config: input-config - assets: (make-asset-registry) - frame-delay: frame-delay - preload-hook: preload - create-hook: create - update-hook: update - render-hook: render)) - -;; ── Convenience accessors ────────────────────────────────────────────────── - -;; game-camera: derived from the current scene (only valid after create: runs) -(define (game-camera game) - (scene-camera (game-scene game))) - -;; game-asset: retrieve an asset by key -(define (game-asset game key) - (asset-ref (game-assets game) key)) - -;; game-asset-set!: store an asset by key -(define (game-asset-set! game key value) - (asset-set! (game-assets game) key value)) - -;; ── game-run! ────────────────────────────────────────────────────────────── -;; Stub — implemented in Task 4 - -(define (game-run! game) - (error "game-run! not yet implemented")) - -) ;; end module -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /home/gene/src/downstroke -git add engine.scm tests/engine-test.scm -git commit -m "feat: add engine.scm — game struct, make-game constructor, accessors" -``` - ---- - -## Task 4: Implement `game-run!` - -**Files:** -- Modify: `engine.scm` (replace stub with real implementation) - -`game-run!` is tested via macroknight integration in Task 6, not via unit tests (it requires a real SDL2 display). - -- [ ] **Step 1: Replace the `game-run!` stub in `engine.scm`** - -Replace the stub `(define (game-run! game) (error ...))` with: - -```scheme -(define (game-run! game) - ;; 1. SDL2 init (audio excluded — mixer.scm not yet extracted; - ;; user calls init-audio! in their preload: hook) - (sdl2:set-main-ready!) - (sdl2:init! '(video joystick game-controller)) - (ttf:init!) - (img:init! '(png)) - - ;; Open any already-connected game controllers - (let init-controllers ((i 0)) - (when (< i (sdl2:num-joysticks)) - (when (sdl2:is-game-controller? i) - (sdl2:game-controller-open! i)) - (init-controllers (+ i 1)))) - - ;; 2. Create window + renderer - (game-window-set! game - (sdl2:create-window! (game-title game) 'centered 'centered - (game-width game) (game-height game) '())) - (game-renderer-set! game - (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync))) - - ;; 3. preload: hook — user loads assets here - (when (game-preload-hook game) - ((game-preload-hook game) game)) - - ;; 4. create: hook — user builds initial scene here - (when (game-create-hook game) - ((game-create-hook game) game)) - - ;; 5. Frame loop - (let loop ((last-ticks (sdl2:get-ticks))) - (let* ((now (sdl2:get-ticks)) - (dt (- now last-ticks))) - ;; Collect all pending SDL2 events - (sdl2:pump-events!) - (let* ((events (let collect ((lst '())) - (if (not (sdl2:has-events?)) - (reverse lst) - (let ((e (sdl2:make-event))) - (sdl2:poll-event! e) - (collect (cons e lst)))))) - (input (input-state-update (game-input game) events - (game-input-config game)))) - (game-input-set! game input) - (unless (input-held? input 'quit) - ;; update: hook — user game logic - (when (game-update-hook game) - ((game-update-hook game) game dt)) - ;; render: engine draws world, then user overlay - (sdl2:render-clear! (game-renderer game)) - (when (game-scene game) - (render-scene! (game-renderer game) (game-scene game))) - (when (game-render-hook game) - ((game-render-hook game) game)) - (sdl2:render-present! (game-renderer game)) - (sdl2:delay! (game-frame-delay game)) - (loop now))))) - - ;; 6. Cleanup - (sdl2:destroy-window! (game-window game)) - (sdl2:quit!)) -``` - -Note: `sdl2:pump-events!` processes the OS event queue; `sdl2:has-events?`/`sdl2:make-event`/`sdl2:poll-event!` drain it into a list. This matches the pattern used in macroknight's existing game loop (lines 661–667). - -**Spec discrepancy:** The spec document shows `(sdl2:collect-events!)` in its pseudocode — that function does not exist in the sdl2 egg. The plan's pump+collect loop above is the correct implementation. Ignore the spec's `sdl2:collect-events!` reference. - -- [ ] **Step 2: Verify engine-test.scm still passes** - -```bash -cd /home/gene/src/downstroke -csi -s tests/engine-test.scm -``` - -Expected: all tests pass (game-run! itself is not called in unit tests). - -- [ ] **Step 3: Commit** - -```bash -cd /home/gene/src/downstroke -git add engine.scm tests/engine-test.scm -git commit -m "feat: implement game-run! — SDL2 init, lifecycle hooks, frame loop" -``` - ---- - -## Task 5: Update the Makefile - -**Files:** -- Modify: `Makefile` - -- [ ] **Step 1: Add `assets` and `engine` to `MODULE_NAMES`** - -In `Makefile`, change: -```makefile -MODULE_NAMES := entity tilemap world input physics renderer -``` -to: -```makefile -MODULE_NAMES := entity tilemap world input physics renderer assets engine -``` - -- [ ] **Step 2: Add dependency declarations** - -Add after `bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o`: - -```makefile -bin/assets.o: -bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o -``` - -`assets.scm` has no inter-module dependencies. `engine.scm` depends on renderer, world, input, and assets. - -- [ ] **Step 3: Add test targets** - -In the `test:` rule, add: -```makefile - @csi -s tests/assets-test.scm - @csi -s tests/engine-test.scm -``` - -- [ ] **Step 4: Verify the build** - -```bash -cd /home/gene/src/downstroke -make clean && make -``` - -Expected: all `.o` files created in `bin/`, including `bin/assets.o` and `bin/engine.o`. No errors. - -- [ ] **Step 5: Run all tests** - -```bash -cd /home/gene/src/downstroke -make test -``` - -Expected: all test suites pass. - -- [ ] **Step 6: Commit** - -```bash -cd /home/gene/src/downstroke -git add Makefile -git commit -m "build: add assets and engine modules to Makefile" -``` - ---- - -## Task 6: Port macroknight to `make-game` + `game-run!` - -**Files:** -- Modify: `/home/gene/src/macroknight/game.scm` - -This is the integration test for the whole milestone. macroknight's current `game.scm` is ~678 lines with SDL2 init, the frame loop, and all game state mixed together. After this task it should be ≤50 lines: only `make-game`, the three lifecycle hooks, and `game-run!`. - -### What moves vs. what stays - -**Moves to engine (delete from game.scm):** -- Lines 87–137: SDL2 init block — from `(sdl2:set-main-ready!)` through `(define *title-font* ...)` inclusive. This covers: `sdl2:set-main-ready!`, `sdl2:init!`, `init-game-controllers!` call, `ttf:init!`, `img:init!`, audio init calls, `on-exit` handlers, exception handler wrapper, `sdl2:set-hint!`, `sdl2:create-window!`, `sdl2:create-renderer!`, `ttf:open-font` calls. -- Lines 659–673: The main `let/cc` game loop - -**Stays in macroknight (wrapped in lifecycle hooks):** -- `init-audio!` + `load-sounds!` + `load-music!` → `preload:` hook -- `ttf:open-font` calls (fonts) → `preload:` hook -- `make-initial-game-state` → `create:` hook -- `update-game-state` dispatch → `update:` hook -- `render-frame` mode dispatch (minus clear/present) → `render:` hook - -**macroknight-specific state** (`game-state` struct: mode, menu-cursor, scene, input, etc.) stays as the module-level `*gs*` variable — set in `create:`, mutated in `update:`. - -**`render-frame` split:** The existing `render-frame` calls `sdl2:render-clear!` and `sdl2:render-present!` — those are now owned by the engine. Modify `render-frame` to remove those two calls, keeping only the draw-color set and the mode dispatch. The modified `render-frame` becomes the body of the `render:` hook. Since the background color is black (`0 0 0`) and the engine's clear already clears to black, there is no visual difference. - -### Implementation steps - -- [ ] **Step 1: Add `downstroke/engine` to macroknight's imports** - -In `/home/gene/src/macroknight/game.scm`, add to the import list: -```scheme -downstroke/engine -downstroke/assets -``` - -- [ ] **Step 2: Restructure as `make-game` call** - -Replace the top-level SDL2 init block and the game loop at the bottom of `game.scm` with a `make-game` call. The three lifecycle hooks capture the existing functions from the rest of the file. - -**First, modify `render-frame`** to remove the SDL2 clear/present calls (the engine now owns those). Change lines 378–385 of `game.scm` from: - -```scheme -(define (render-frame gs) - (set! (sdl2:render-draw-color *renderer*) +background-color+) - (sdl2:render-clear! *renderer*) - (case (game-state-mode gs) - ((main-menu) (draw-main-menu gs)) - ((stage-select) (draw-stage-select gs)) - ((playing) (draw-playing gs))) - (sdl2:render-present! *renderer*)) -``` - -to: - -```scheme -(define (render-frame gs) - (set! (sdl2:render-draw-color *renderer*) +background-color+) - (case (game-state-mode gs) - ((main-menu) (draw-main-menu gs)) - ((stage-select) (draw-stage-select gs)) - ((playing) (draw-playing gs)))) -``` - -**Then add module-level state and the engine entry point** at the bottom of `game.scm` (replacing the old `let/cc` loop): - -```scheme -;; ── Module-level game state ──────────────────────────────────────────────── - -(define *gs* #f) ;; macroknight game state, set in create: - -;; ── Engine entry point ───────────────────────────────────────────────────── - -(define *the-game* - (make-game - title: "MacroKnight" - width: +screen-width+ - height: +screen-height+ - frame-delay: 10 - - preload: (lambda (game) - ;; Audio (mixer not yet extracted — call directly) - (init-audio!) - (load-sounds! '((jump . "assets/jump.wav"))) - (load-music! "assets/theme.ogg") - (play-music! 0.6) - ;; Fonts - (set! *font* (ttf:open-font "assets/DejaVuSans.ttf" 12)) - (set! *title-font* (ttf:open-font "assets/DejaVuSans.ttf" 32)) - ;; Make the SDL2 renderer available to macroknight functions - (set! *renderer* (game-renderer game))) - - create: (lambda (game) - (set! *gs* (make-initial-game-state))) - - update: (lambda (game dt) - ;; update-game-state uses a continuation for the "quit" menu item; - ;; pass a no-op since the engine handles Escape→quit automatically. - (update-game-state *gs* (lambda () #f)) - (maybe-advance-level *gs*)) - - render: (lambda (game) - ;; engine has already called render-clear! and render-scene! - ;; (game-scene is #f so render-scene! is a no-op for now); - ;; render-frame draws the mode-specific content. - (render-frame *gs*)))) - -(game-run! *the-game*) -``` - -**Notes:** -- `*renderer*`, `*font*`, `*title-font*` remain module-level variables (set in `preload:`). Keep their `(define ...)` declarations at the top of `game.scm` as `(define *font* #f)` etc. (uninitialized — they'll be set in `preload:`). -- The engine's input handles Escape → quit via `input-held? input 'quit`; the old `let/cc exit-main-loop!` mechanism and `exit-main-loop!` call in `update-game-state` are no longer needed. **However, do not change `update-game-state` in this milestone** — the `(lambda () #f)` passed as `exit!` means the "Quit" menu item is a no-op for now. Fix it in a later milestone. -- `(game-scene game)` is never set from macroknight, so the engine's `render-scene!` is always a no-op. All rendering happens in the `render:` hook via `render-frame`. This is intentional for this port milestone. - -- [ ] **Step 3: Remove the old SDL2 init block from `game.scm`** - -Delete lines 87–137 — everything from `(sdl2:set-main-ready!)` through `(define *title-font* (ttf:open-font ...))` inclusive. - -This also removes the `on-exit` cleanup handlers (lines 107–108) and the custom exception handler (lines 113–117). **This is intentional for this milestone.** The engine's `game-run!` calls `sdl2:destroy-window!` and `sdl2:quit!` at cleanup, but does not install an exception handler. If macroknight crashes mid-run, SDL2 will not be shut down cleanly. This will be addressed in a later milestone (either by adding error handling to `game-run!` or by macroknight wrapping `game-run!` in a guard). - -After deletion, add these uninitialized stubs near the top of `game.scm` (after the imports) so the rest of the file can still reference the variables. Note: `*window*` only appears inside the deleted range, so its stub is precautionary only. - -```scheme -(define *window* #f) ;; owned by engine; not used after this port -(define *renderer* #f) ;; set in preload: -(define *font* #f) ;; set in preload: -(define *title-font* #f) ;; set in preload: -(define *text-color* (sdl2:make-color 255 255 255)) -``` - -- [ ] **Step 4: Remove the old main loop from `game.scm`** - -Delete lines 659–678 (the `let/cc` game loop through the end of the file including the `"Bye!\n"` print). The engine's `game-run!` replaces it. The new `(define *the-game* ...)` and `(game-run! *the-game*)` from Step 2 are the new end of the file. - -- [ ] **Step 5: Build macroknight to verify it compiles** - -```bash -cd /home/gene/src/macroknight -make clean && make -``` - -Expected: successful compilation. Fix any symbol-not-found or import errors before continuing. - -- [ ] **Step 6: Run macroknight to verify it plays** - -```bash -cd /home/gene/src/macroknight -./bin/game -``` - -Expected: game launches, title screen appears, gameplay works correctly (title → stage select → play a level). Press Escape to quit. - -**Known regression (intentional):** Selecting "Quit" from the main menu is a no-op in this milestone. The old `exit-main-loop!` continuation no longer exists; the `update:` hook passes `(lambda () #f)` in its place. This will be fixed in a later milestone. - -- [ ] **Step 7: Verify game.scm is ≤50 lines** - -```bash -wc -l /home/gene/src/macroknight/game.scm -``` - -If still over 50 lines, extract helper functions that belong in other modules. Game-specific logic (menu rendering, level loading) can stay; SDL2 boilerplate must be gone. - -- [ ] **Step 8: Commit** - -```bash -cd /home/gene/src/macroknight -git add game.scm -git commit -m "feat: port macroknight to use make-game + game-run! (Milestone 8)" -``` - -Then commit the downstroke side: - -```bash -cd /home/gene/src/downstroke -git add -A -git commit -m "feat: Milestone 8 complete — make-game + game-run! engine entry point" -``` - ---- - -## Acceptance Criteria - -- [ ] `make test` in downstroke passes all suites including assets and engine -- [ ] `make` in downstroke builds all modules including `bin/assets.o` and `bin/engine.o` -- [ ] `make && ./bin/game` in macroknight launches and plays correctly -- [ ] macroknight `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate -- [ ] `make-game` + `game-run!` are the sole entry point — no top-level SDL2 calls outside them diff --git a/docs/superpowers/specs/2026-04-05-demos-design.md b/docs/superpowers/specs/2026-04-05-demos-design.md deleted file mode 100644 index f51f260..0000000 --- a/docs/superpowers/specs/2026-04-05-demos-design.md +++ /dev/null @@ -1,223 +0,0 @@ -# Downstroke Demo Games Design - -**Date:** 2026-04-05 -**Status:** Approved -**Scope:** `demo/` folder, 5 demo games, Makefile `make demos` target, CLAUDE.md update - ---- - -## Goal - -Provide 5 small self-contained demo games in `demo/` that collectively exercise every engine system. Each demo compiles to its own executable (`bin/demo-*`). They replace the macroknight integration test for Milestone 8 and serve as living documentation of the engine API. - ---- - -## File Layout - -``` -demo/ - assets/ ← copied from macroknight/assets (not symlinked) - monochrome-transparent.png ← tileset spritesheet - monochrome_transparent.tsx ← tileset metadata (TSX) - level-0.tmx ← level used by platformer, topdown, sandbox - DejaVuSans.ttf ← font for audio demo text - jump.wav ← sound effect (platformer jump, shmup shoot) - theme.ogg ← music (audio demo) - platformer.scm - shmup.scm - topdown.scm - audio.scm - sandbox.scm -``` - -**Omitted from copy:** `prefabs.scm`, `macroknight.tiled-project`, `macroknight.tiled-session` — macroknight-specific files not needed by any demo. - ---- - -## Build - -### Makefile additions - -```makefile -DEMO_NAMES := platformer shmup topdown audio sandbox -DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES)) - -demos: engine $(DEMO_BINS) - -bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin - csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin -``` - -- `make` — builds engine modules only (unchanged) -- `make demos` — builds all 5 demo executables; depends on engine being built first -- Demos are compiled as programs (not units), linked against all engine `.o` files - -### CLAUDE.md update - -Add to the Build & Test section: - -> `make demos` must always succeed. A demo that fails to compile is a build failure. Run `make && make demos` to verify both engine and demos build cleanly. - ---- - -## Demo Code Pattern - -Every demo follows this ~30-line structure: - -```scheme -(import (prefix sdl2 "sdl2:") - (prefix sdl2-ttf "ttf:") - (prefix sdl2-image "img:") - downstroke/engine - downstroke/world - downstroke/tilemap - downstroke/renderer - downstroke/input - downstroke/physics - downstroke/assets) - -(define *game* - (make-game - title: "Demo: <Name>" width: 600 height: 400 - preload: (lambda (game) ...) ;; load tilemap, tileset texture, sounds - create: (lambda (game) ...) ;; build scene, place entities - update: (lambda (game dt) ...) ;; input dispatch, physics calls - render: (lambda (game) ...))) ;; HUD overlay (optional) - -(game-run! *game*) -``` - -Tile IDs in entity plists are placeholder values — to be adjusted visually after first run. - ---- - -## The 5 Demos - -### 1. `demo/platformer.scm` — Platformer - -**Systems exercised:** `input`, `physics` (gravity + tile collision), `renderer` (tilemap + entities), `world`/scene, camera follow, audio (sound effect) - -**Mechanics:** -- Player entity with gravity, left/right movement, jump -- Tile collision via `apply-physics` from `physics.scm` -- Camera follows player horizontally -- Jump sound via macroknight's `sound.scm` (or direct SDL_mixer call) -- Level: `demo/assets/level-0.tmx` -- Tile IDs: placeholder (user adjusts) - -**Key entity plist:** -```scheme -(list #:type 'player - #:x 100 #:y 50 - #:width 16 #:height 16 - #:vx 0 #:vy 0 - #:gravity? #t - #:on-ground? #f - #:tile-id 1) -``` - -**Update logic:** read input → set `#:vx` from left/right → jump sets `#:vy` → call physics step → update camera x to follow player x. - ---- - -### 2. `demo/shmup.scm` — Shoot-em-up - -**Systems exercised:** `entity` (spawning/removal), entity-entity collision via `physics.scm`, `input`, `renderer`, `world`/scene (no tilemap) - -**Mechanics:** -- Player ship at bottom, moves left/right -- Space bar fires bullet upward (new entity added to scene) -- Enemies spawn from top at random x positions every N frames, move downward -- Bullet-enemy collision: both entities removed from scene -- No tilemap — plain background (black/SDL clear) -- No gravity on any entity -- `jump.wav` plays on shoot - -**Key entity plists:** -```scheme -;; player -(list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0 #:tile-id 2) -;; bullet -(list #:type 'bullet #:x px #:y 340 #:width 4 #:height 8 #:vx 0 #:vy -5 #:tile-id 3) -;; enemy -(list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2 #:tile-id 4) -``` - -**Update logic:** move entities by vx/vy each frame → AABB collision check between bullets and enemies → filter dead entities from scene → spawn new enemy every 60 frames → read input for player movement and shoot. - ---- - -### 3. `demo/topdown.scm` — Top-down explorer - -**Systems exercised:** `input` (8-directional), `renderer` (tilemap + entity), `world`/scene, camera follow (both axes), `physics` (no gravity) - -**Mechanics:** -- Player entity moves in 8 directions (WASD or arrows) -- No gravity (`#:gravity? #f`) -- Camera follows player on both x and y axes -- Level: `demo/assets/level-0.tmx` (same tilemap, different movement feel) -- No audio - -**Update logic:** read input → set `#:vx` and `#:vy` from direction keys → apply tile collision (no gravity component) → update camera to center on player. - ---- - -### 4. `demo/audio.scm` — Audio showcase - -**Systems exercised:** audio (sound effects + music), `renderer` (text via `draw-ui-text`), `input`, `assets` - -**Mechanics:** -- Static screen with text instructions rendered via `draw-ui-text` -- Press **J** → play `jump.wav` -- Press **M** → toggle `theme.ogg` music on/off -- Press **Escape** → quit -- No tilemap, no physics, no moving entities -- Uses `DejaVuSans.ttf` for text -- Audio calls via macroknight's `sound.scm` functions (`load-sounds!`, `play-sound!`, `load-music!`, `play-music!`, `stop-music!`) — or direct SDL_mixer FFI if sound.scm is not available - -**Display:** colored rectangle background + text labels for each key binding. - ---- - -### 5. `demo/sandbox.scm` — Physics sandbox - -**Systems exercised:** `physics` (gravity + tile collision + entity-entity collision), `renderer`, `world`/scene, no player input - -**Mechanics:** -- 10 entities spawned at random x positions near the top of the screen -- All have `#:gravity? #t` -- Physics step runs each frame (gravity accelerates, tile collision stops them) -- Entities rest on floor tiles or bounce (depending on physics.scm behavior) -- No player — pure observation of physics pipeline -- Level: `demo/assets/level-0.tmx` -- After all entities settle (or after 10 seconds), loop: despawn all, respawn at new random positions - ---- - -## Systems Coverage Matrix - -| System / Module | platformer | shmup | topdown | audio | sandbox | -|---|---|---|---|---|---| -| `engine` (make-game, game-run!) | ✓ | ✓ | ✓ | ✓ | ✓ | -| `input` | ✓ | ✓ | ✓ | ✓ | — | -| `physics` (gravity) | ✓ | — | — | — | ✓ | -| `physics` (tile collision) | ✓ | — | ✓ | — | ✓ | -| `physics` (entity collision) | — | ✓ | — | — | ✓ | -| `renderer` (tilemap) | ✓ | — | ✓ | — | ✓ | -| `renderer` (entities) | ✓ | ✓ | ✓ | — | ✓ | -| `renderer` (text) | — | — | — | ✓ | — | -| `world` / scene | ✓ | ✓ | ✓ | — | ✓ | -| `assets` registry | ✓ | ✓ | ✓ | ✓ | ✓ | -| audio (sound) | ✓ | ✓ | — | ✓ | — | -| audio (music) | — | — | — | ✓ | — | -| camera follow | ✓ (x) | — | ✓ (xy) | — | — | - ---- - -## Out of Scope - -- Animation state machine (`animation.scm`) — not yet extracted to downstroke -- AI (`ai.scm`) — not yet extracted -- Prefab system — not yet extracted -- Scene transitions — Milestone 9 -- Asset-type-specific load helpers (`game-load-tilemap!` etc.) — Milestone 6 diff --git a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md b/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md deleted file mode 100644 index d6e71be..0000000 --- a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md +++ /dev/null @@ -1,234 +0,0 @@ -# Milestone 8 — The Game Object and Lifecycle API - -**Date:** 2026-04-05 -**Status:** Approved -**Scope:** `engine.scm`, `assets.scm`, macroknight port -**Milestone numbering:** Follows `downstroke.org` / `TODO-engine.org` (Milestone 8). CLAUDE.md refers to the same work as "Milestone 7". - ---- - -## Goal - -Introduce `make-game` and `game-run!` as the public entry point for downstroke. A minimal game becomes ~20 lines of Scheme. This is the "Phaser moment" — the API stabilises around a game struct with lifecycle hooks. - ---- - -## Architecture - -Two new files: - -- **`engine.scm`** — `make-game` struct, `game-run!`, the frame loop, and all public accessors. -- **`assets.scm`** — minimal key→value asset registry. No asset-type-specific logic; that is Milestone 6's domain. - -`engine.scm` imports: `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, the sdl2 egg (prefix `sdl2:`), `sdl2-ttf` (prefix `ttf:`), and `sdl2-image` (prefix `img:`). - -**Audio / mixer is not initialised by the engine in this milestone.** `mixer.scm` and `sound.scm` remain in macroknight and are not yet extracted. macroknight's `preload:` hook calls `init-audio!` directly from `sound.scm` until those modules are extracted. - -`physics.scm` is **not** imported by the engine. Physics is a library; the user requires it if needed and calls physics functions from their `update:` hook. - -### New function in `renderer.scm` - -`render-scene!` does not yet exist. It must be added to `renderer.scm` as part of this milestone. The module uses `export *` so no explicit export update is needed. - -Existing signatures in `renderer.scm` that `render-scene!` calls: -- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args -- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args -- `tilemap-tileset` is a field accessor on the `tilemap` struct (defined in `tilemap.scm`) - -```scheme -(define (render-scene! renderer scene) - (let ((camera (scene-camera scene)) - (tilemap (scene-tilemap scene)) - (tileset-texture (scene-tileset-texture scene)) - (tileset (tilemap-tileset (scene-tilemap scene))) - (entities (scene-entities scene))) - (draw-tilemap renderer camera tileset-texture tilemap) - (draw-entities renderer camera tileset tileset-texture entities))) -``` - -### Build order (Makefile) - -Only modules that currently exist in downstroke are listed. Modules still in macroknight (`animation`, `ai`, `prefabs`, `mixer`, `sound`) are not prerequisites for this milestone. - -``` -entity → tilemap → world → input → renderer → assets → engine -``` - -`physics` is not a link dependency of `engine` and is omitted from this chain. It is a separate build target. - ---- - -## Data Structures - -### `game` struct (`engine.scm`) - -`defstruct` is called with `constructor: make-game*` so the generated raw constructor does not collide with the public keyword constructor `make-game`. - -```scheme -(defstruct (game constructor: make-game*) - title width height - window renderer - input ;; input-state record (accessor: game-input) - input-config ;; input-config record (accessor: game-input-config) - assets ;; asset registry hash table (from assets.scm) - frame-delay - preload-hook ;; (lambda (game) ...) - create-hook ;; (lambda (game) ...) - update-hook ;; (lambda (game dt) ...) - render-hook ;; (lambda (game) ...) — post-render overlay - scene) ;; current scene struct (accessor: game-scene) -``` - -**Note:** There is no separate `camera` field. The camera lives inside the scene (`scene-camera`). `game-camera` is a convenience accessor (see Accessors section). - -### `make-game` keyword constructor - -```scheme -(define (make-game #!key - (title "Downstroke Game") - (width 640) (height 480) - (frame-delay 16) - (input-config *default-input-config*) - (preload #f) (create #f) (update #f) (render #f)) - (make-game* - title: title width: width height: height - window: #f renderer: #f scene: #f - input: (create-input-state input-config) ;; create-input-state takes one arg: config - input-config: input-config - assets: (make-asset-registry) - frame-delay: frame-delay - preload-hook: preload - create-hook: create - update-hook: update - render-hook: render)) -``` - -### Asset registry (`assets.scm`) - -```scheme -(define (make-asset-registry) (make-hash-table)) -(define (asset-set! reg key val) (hash-table-set! reg key val)) -(define (asset-ref reg key) (hash-table-ref reg key (lambda () #f))) -``` - ---- - -## Lifecycle: `game-run!` - -```scheme -(define (game-run! game) - ;; 1. SDL2 init (no audio — mixer not yet extracted, user calls init-audio! in preload:) - (sdl2:init! '(video joystick game-controller)) - (ttf:init!) - (img:init! '(png)) - - ;; 2. Create window + renderer - (game-window-set! game - (sdl2:create-window! (game-title game) 'centered 'centered - (game-width game) (game-height game) '())) - (game-renderer-set! game - (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync))) - - ;; 3. preload hook - (when (game-preload-hook game) ((game-preload-hook game) game)) - - ;; 4. create hook - (when (game-create-hook game) ((game-create-hook game) game)) - - ;; 5. Frame loop - (let loop ((last-ticks (sdl2:get-ticks))) - (let* ((now (sdl2:get-ticks)) - (dt (- now last-ticks)) - (events (sdl2:collect-events!)) - (input (input-state-update (game-input game) events (game-input-config game)))) - (game-input-set! game input) - (unless (input-held? input 'quit) - (when (game-update-hook game) ((game-update-hook game) game dt)) - (sdl2:render-clear! (game-renderer game)) - (when (game-scene game) - (render-scene! (game-renderer game) (game-scene game))) - (when (game-render-hook game) ((game-render-hook game) game)) - (sdl2:render-present! (game-renderer game)) - (sdl2:delay! (game-frame-delay game)) - (loop now)))) - - ;; 6. Cleanup - (sdl2:destroy-window! (game-window game)) - (sdl2:quit!)) -``` - -**Notes:** -- `sdl2:collect-events!` pumps the SDL2 event queue and returns a list of events (from the sdl2 egg). -- `input-state-update` is the existing function in `input.scm`: `(input-state-update state events config)`. It is purely functional — it returns a new `input-state` record and does not mutate the existing one. -- `create-input-state` in `input.scm` takes one argument: `(create-input-state config)` — it initialises all actions to `#f`. -- Quit is detected via `(input-held? input 'quit)` — the `'quit` action is in `*default-input-config*` mapped to `escape` key: `(escape . quit)` in `keyboard-map`. -- `sdl2:render-clear!` and `sdl2:render-present!` are called directly from the sdl2 egg (no wrapper needed). -- The loop exits when `input-held?` returns true for `'quit`. No user-settable `game-running?` flag in this milestone. - ---- - -## `render:` Hook Semantics - -Following Phaser 2.x: the engine draws the full scene first via `render-scene!` (tilemap layers + entities), then calls the user's `render:` hook. The hook is for post-render overlays only — debug info, HUD elements, custom visuals. It does not replace or suppress the engine's render pass. - ---- - -## Physics - -Physics (`physics.scm`) is a library, not a pipeline. `game-run!` does not call any physics function automatically. Users who want physics `(require-extension downstroke/physics)` and call functions from their `update:` hook. - ---- - -## Public Accessors - -All `game-*` readers and `game-*-set!` mutators are generated by `defstruct` and exported. Additional convenience accessors: - -| Accessor | Description | -|---|---| -| `game-renderer` | SDL2 renderer — available from `preload:` onward | -| `game-window` | SDL2 window | -| `game-scene` / `game-scene-set!` | Current scene struct; user sets this in `create:` | -| `game-camera` | Convenience: `(scene-camera (game-scene game))` — derived, not a struct field. Only valid after `create:` sets a scene; calling before that is an error. | -| `game-assets` | Full asset registry hash table | -| `game-input` | Current `input-state` record | -| `game-input-config` | Current `input-config` record | -| `(game-asset game key)` | Convenience: `(asset-ref (game-assets game) key)` | -| `(game-asset-set! game key val)` | Convenience: `(asset-set! (game-assets game) key val)` | - -`game-camera` is not a struct field — it is a derived accessor: -```scheme -(define (game-camera game) - (scene-camera (game-scene game))) -``` - ---- - -## Scene-Level Preloading - -Out of scope for this milestone. A `preload:` slot will be reserved in the scene struct when `scene-loader.scm` is implemented (Milestone 7), and the two-level asset registry (global + per-scene) will be designed then. - ---- - -## macroknight Port - -macroknight's `game.scm` must be ported to use `make-game` + `game-run!` as part of this milestone. It is the primary integration test. - -**What moves to the engine:** SDL2 init block (lines 87–137), the main loop (line 659+), and all frame-level rendering. - -**What stays in macroknight `game.scm`:** Only the three lifecycle hooks and `game-run!`: -- `preload:` — load fonts, sounds, tilemaps (currently scattered across lines 87–137) -- `create:` — build initial scene (currently `make-initial-game-state`, line 196) -- `update:` — game logic, input dispatch, physics calls (currently the per-frame update functions) - -**Acceptance criterion:** macroknight compiles (`make` succeeds), the game runs and plays correctly (title screen → stage select → gameplay), and `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate. - ---- - -## Out of Scope - -- Scene-level preloading (Milestone 7) -- Asset-type-specific load functions (`game-load-tilemap!`, `game-load-font!`, etc.) — Milestone 6 -- `game-running?` quit flag -- Scene state machine (Milestone 9) -- AI tag lookup (Milestone 10) -- Extraction of `animation`, `ai`, `prefabs`, `mixer`, `sound` modules into downstroke diff --git a/downstroke.egg b/downstroke.egg index b28c79d..309c545 100644 --- a/downstroke.egg +++ b/downstroke.egg @@ -18,6 +18,22 @@ (extension downstroke/input (source "input.scm") (component-dependencies downstroke/entity)) + (extension downstroke/assets + (source "assets.scm")) (extension downstroke/renderer (source "renderer.scm") - (component-dependencies downstroke/entity downstroke/tilemap downstroke/world)))) + (component-dependencies downstroke/entity downstroke/tilemap downstroke/world)) + (extension downstroke/engine + (source "engine.scm") + (component-dependencies downstroke/renderer downstroke/world downstroke/input downstroke/assets)) + (extension downstroke/mixer + (source "mixer.scm")) + (extension downstroke/sound + (source "sound.scm") + (component-dependencies downstroke/mixer)) + (extension downstroke/animation + (source "animation.scm") + (component-dependencies downstroke/entity downstroke/world)) + (extension downstroke/ai + (source "ai.scm") + (component-dependencies downstroke/entity downstroke/world)))) @@ -6,6 +6,7 @@ (prefix sdl2 "sdl2:") (prefix sdl2-ttf "ttf:") (prefix sdl2-image "img:") + (srfi 69) defstruct downstroke/world downstroke/input @@ -26,7 +27,9 @@ create-hook ;; (lambda (game) ...) update-hook ;; (lambda (game dt) ...) render-hook ;; (lambda (game) ...) — post-render overlay - scene) ;; current scene struct; #f until create: runs + scene ;; current scene struct; #f until create: runs + states ;; hash-table of name → state-plist + active-state) ;; symbol or #f — currently active state name ;; Store the auto-generated constructor as make-game* (define make-game* make-game) @@ -54,7 +57,9 @@ preload-hook: preload create-hook: create update-hook: update - render-hook: render)) + render-hook: render + states: (make-hash-table) + active-state: #f)) ;; ── Convenience accessors ────────────────────────────────────────────────── @@ -70,6 +75,27 @@ (define (game-asset-set! game key value) (asset-set! (game-assets game) key value)) +;; ── Named scene states ──────────────────────────────────────────────────── + +;; Construct a state plist with lifecycle hooks. +(define (make-game-state #!key (create #f) (update #f) (render #f)) + (list #:create create #:update update #:render render)) + +;; Retrieve a value from a state plist. +(define (state-hook state key) + (get-keyword key state (lambda () #f))) + +;; Register a named state. name is a symbol; state is a make-game-state plist. +(define (game-add-state! game name state) + (hash-table-set! (game-states game) name state)) + +;; Transition to a named state. Calls the state's create: hook if present. +(define (game-start-state! game name) + (game-active-state-set! game name) + (let* ((state (hash-table-ref (game-states game) name)) + (create (state-hook state #:create))) + (when create (create game)))) + ;; ── game-run! ────────────────────────────────────────────────────────────── ;; Main event loop and lifecycle orchestration @@ -93,7 +119,7 @@ (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))) + (sdl2:create-renderer! (game-window game) -1 '(accelerated))) ;; 3. preload: hook — user loads assets here (when (game-preload-hook game) @@ -119,15 +145,19 @@ (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)) + ;; Dispatch to active state hooks, or fall back to game's own hooks + (let* ((active (game-active-state game)) + (state (and active + (hash-table-ref/default (game-states game) active #f))) + (update-fn (or (and state (state-hook state #:update)) + (game-update-hook game))) + (render-fn (or (and state (state-hook state #:render)) + (game-render-hook game)))) + (when update-fn (update-fn game dt)) + (sdl2:render-clear! (game-renderer game)) + (when (game-scene game) + (render-scene! (game-renderer game) (game-scene game))) + (when render-fn (render-fn game))) (sdl2:render-present! (game-renderer game)) (sdl2:delay! (game-frame-delay game)) (loop now))))) diff --git a/mixer.scm b/mixer.scm new file mode 100644 index 0000000..e1c563a --- /dev/null +++ b/mixer.scm @@ -0,0 +1,46 @@ +(module downstroke/mixer * + (import scheme + (chicken base) + (chicken foreign)) + + #> #include "SDL2/SDL_mixer.h" <# + + (define-foreign-type mix-chunk* (c-pointer "Mix_Chunk")) + + (define mix-open-audio! + (foreign-lambda int "Mix_OpenAudio" int unsigned-short int int)) + + (define mix-close-audio! + (foreign-lambda void "Mix_CloseAudio")) + + (define mix-load-chunk + (foreign-lambda* mix-chunk* ((c-string path)) + "C_return(Mix_LoadWAV(path));")) + + (define mix-free-chunk! + (foreign-lambda void "Mix_FreeChunk" mix-chunk*)) + + (define mix-play-channel + (foreign-lambda int "Mix_PlayChannel" int mix-chunk* int)) + + (define mix-default-format + ((foreign-lambda* unsigned-short () + "C_return(MIX_DEFAULT_FORMAT);"))) + + (define-foreign-type mix-music* (c-pointer "Mix_Music")) + + (define mix-load-mus + (foreign-lambda mix-music* "Mix_LoadMUS" c-string)) + + (define mix-play-music + (foreign-lambda int "Mix_PlayMusic" mix-music* int)) + + (define mix-free-music! + (foreign-lambda void "Mix_FreeMusic" mix-music*)) + + (define mix-halt-music + (foreign-lambda int "Mix_HaltMusic")) + + (define mix-volume-music + (foreign-lambda int "Mix_VolumeMusic" int)) +) diff --git a/renderer.scm b/renderer.scm index e2d2c5a..0a82c67 100644 --- a/renderer.scm +++ b/renderer.scm @@ -91,9 +91,11 @@ (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))) + (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)))))) ) ;; end module renderer diff --git a/sound.scm b/sound.scm new file mode 100644 index 0000000..a0bb945 --- /dev/null +++ b/sound.scm @@ -0,0 +1,45 @@ +(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!))) diff --git a/tests/ai-test.scm b/tests/ai-test.scm new file mode 100644 index 0000000..9bb3d28 --- /dev/null +++ b/tests/ai-test.scm @@ -0,0 +1,48 @@ +;; Mock entity module for testing +(module downstroke/entity * + (import scheme (chicken base) (chicken keyword)) + (define (entity-ref entity key #!optional default) + (get-keyword key entity (if (procedure? default) default (lambda () default)))) + (define (entity-set entity key val) + (cons key (cons val (let loop ((lst entity)) + (if (null? lst) '() + (if (eq? (car lst) key) + (cddr lst) + (cons (car lst) (cons (cadr lst) (loop (cddr lst)))))))))) + (define (entity-type e) (entity-ref e #:type #f))) + +;; Mock world module for testing +(module downstroke/world * + (import scheme (chicken base)) + (define (scene-entities s) s) + (define (scene-find-tagged scene tag) #f)) + +(import (srfi 64) + states + downstroke/entity + downstroke/world) + +(include "ai.scm") +(import downstroke/ai) + +(test-begin "ai") + +(test-group "find-player (tag-based)" + (let* ((player (list #:type 'player #:x 100 #:y 100 #:width 16 #:height 16 + #:tags '(player))) + (enemy (list #:type 'enemy #:x 200 #:y 100 #:width 16 #:height 16 + #:tags '(enemy))) + (entities (list enemy player))) + (test-equal "finds player by tags" player (find-player entities)) + (test-equal "returns #f with no player" #f (find-player (list enemy))))) + +(test-group "update-enemy-ai" + (let* ((entity (list #:type 'enemy #:x 0 #:y 0 #:width 16 #:height 16 + #:disabled #t))) + (test-equal "returns entity unchanged when disabled" entity + (update-enemy-ai entity '()))) + (let* ((entity (list #:type 'enemy #:x 0 #:y 0 #:width 16 #:height 16))) + (test-equal "returns entity unchanged when no ai-machine" entity + (update-enemy-ai entity '())))) + +(test-end "ai") diff --git a/tests/animation-test.scm b/tests/animation-test.scm new file mode 100644 index 0000000..fefb77f --- /dev/null +++ b/tests/animation-test.scm @@ -0,0 +1,36 @@ +(import srfi-64) +(include "entity.scm") +(include "world.scm") +(include "animation.scm") +(import downstroke/entity downstroke/world downstroke/animation) + +(test-begin "animation") + +(test-group "frame->tile-id" + (test-equal "first frame, frames (0)" 1 (frame->tile-id '(0) 0)) + (test-equal "wraps around" 1 (frame->tile-id '(0 1) 2)) + (test-equal "frame 1 of (27 28)" 29 (frame->tile-id '(27 28) 1))) + +(test-group "set-animation" + (let ((entity (list #:type 'player #:anim-name 'idle #:anim-frame 5 #:anim-tick 8))) + (test-equal "no-op if already active" entity (set-animation entity 'idle)) + (let ((switched (set-animation entity 'walk))) + (test-equal "switches anim-name" 'walk (entity-ref switched #:anim-name)) + (test-equal "resets frame" 0 (entity-ref switched #:anim-frame)) + (test-equal "resets tick" 0 (entity-ref switched #:anim-tick))))) + +(test-group "animate-entity" + (let* ((anims '((walk . (#:frames (0 1) #:duration 4)))) + (entity (list #:type 'player #:anim-name 'walk #:anim-frame 0 #:anim-tick 0)) + (stepped (animate-entity entity anims))) + (test-equal "increments tick" 1 (entity-ref stepped #:anim-tick)) + (test-equal "sets tile-id on first tick" 1 (entity-ref stepped #:tile-id))) + (let* ((anims '((walk . (#:frames (0 1) #:duration 2)))) + (entity (list #:type 'player #:anim-name 'walk #:anim-frame 0 #:anim-tick 1)) + (advanced (animate-entity entity anims))) + (test-equal "advances frame when tick reaches duration" 1 (entity-ref advanced #:anim-frame)) + (test-equal "resets tick on frame advance" 0 (entity-ref advanced #:anim-tick))) + (let* ((entity (list #:type 'player))) + (test-equal "unchanged entity without anim-name" entity (animate-entity entity '())))) + +(test-end "animation") diff --git a/tests/engine-test.scm b/tests/engine-test.scm index 2c443cc..67b9942 100644 --- a/tests/engine-test.scm +++ b/tests/engine-test.scm @@ -1,4 +1,4 @@ -(import scheme (chicken base) (chicken keyword) srfi-64 defstruct) +(import scheme (chicken base) (chicken keyword) srfi-64 defstruct (srfi 69)) ;; --- Mocks --- @@ -171,4 +171,31 @@ cam (game-camera g)))) +(test-group "make-game-state" + (let ((s (make-game-state create: (lambda (g) 'created) + update: (lambda (g dt) 'updated) + render: (lambda (g) 'rendered)))) + (test-assert "state has create hook" (state-hook s #:create)) + (test-assert "state has update hook" (state-hook s #:update)) + (test-assert "state has render hook" (state-hook s #:render))) + (let ((s (make-game-state))) + (test-equal "default state hooks are #f" #f (state-hook s #:create)) + (test-equal "default state update is #f" #f (state-hook s #:update)))) + +(test-group "game-add-state! and game-start-state!" + (let* ((created? #f) + (game (make-game)) + (state (make-game-state + create: (lambda (g) (set! created? #t))))) + (game-add-state! game 'play state) + (test-equal "active-state defaults to #f" #f (game-active-state game)) + (game-start-state! game 'play) + (test-equal "active-state set after start" 'play (game-active-state game)) + (test-assert "create hook called on start" created?))) + +(test-group "game states defaults" + (let ((game (make-game))) + (test-assert "states is a hash-table" (hash-table? (game-states game))) + (test-equal "active-state defaults to #f" #f (game-active-state game)))) + (test-end "engine") diff --git a/tests/input-test.scm b/tests/input-test.scm index 9153671..7eae12f 100644 --- a/tests/input-test.scm +++ b/tests/input-test.scm @@ -3,7 +3,7 @@ (chicken base) (chicken format) (only srfi-1 any filter fold alist-delete) - (only srfi-13 string-join) + (only srfi-13 string-join string-contains) (only srfi-197 chain) (prefix sdl2 sdl2:) simple-logger diff --git a/tests/world-test.scm b/tests/world-test.scm index 38005b2..c4fd887 100644 --- a/tests/world-test.scm +++ b/tests/world-test.scm @@ -236,4 +236,35 @@ 'player (entity-ref (car (scene-entities scene)) #:type #f)))) + (test-group "camera-follow!" + (let* ((cam (make-camera x: 0 y: 0)) + (entity (list #:type 'player #:x 400 #:y 300 #:width 16 #:height 16))) + (camera-follow! cam entity 600 400) + (test-equal "centers camera x on entity" 100 (camera-x cam)) + (test-equal "centers camera y on entity" 100 (camera-y cam))) + (let* ((cam (make-camera x: 0 y: 0)) + (entity (list #:type 'player #:x 50 #:y 30 #:width 16 #:height 16))) + (camera-follow! cam entity 600 400) + (test-equal "clamps camera x to 0 when entity near origin" 0 (camera-x cam)) + (test-equal "clamps camera y to 0 when entity near origin" 0 (camera-y cam)))) + + (test-group "scene-find-tagged" + (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))) + (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)) + (test-equal "returns #f when tag not found" #f (scene-find-tagged s 'boss)))) + + (test-group "scene-find-all-tagged" + (let* ((p1 (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16 #:tags '(player friendly))) + (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))) + (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)))) + (test-end "world-module") diff --git a/tilemap.scm b/tilemap.scm index 7e9e11c..8af70b9 100644 --- a/tilemap.scm +++ b/tilemap.scm @@ -74,6 +74,34 @@ (maybe-string->number (cdr pair)))) string-alist)) + (define (alist->tileset attrs) + (make-tileset + tilewidth: (alist-ref 'tilewidth attrs eq?) + tileheight: (alist-ref 'tileheight attrs eq?) + spacing: (alist-ref 'spacing attrs eq? 0) + tilecount: (alist-ref 'tilecount attrs eq?) + columns: (alist-ref 'columns attrs eq?) + image-source: "" + image: #f)) + + (define (alist->layer attrs) + (let ((symbol-attrs (string-alist->alist attrs))) + (make-layer + name: (alist-ref 'name symbol-attrs eq?) + width: (alist-ref 'width symbol-attrs eq?) + height: (alist-ref 'height symbol-attrs eq?) + map: '()))) + + (define (alist->object attrs) + (make-object + name: (alist-ref 'name attrs eq?) + type: (alist-ref 'type attrs eq?) + x: (alist-ref 'x attrs eq?) + y: (alist-ref 'y attrs eq?) + width: (alist-ref 'width attrs eq? 0) + height: (alist-ref 'height attrs eq? 0) + properties: '())) + (define (parse-tileset string-tileset) (let ((parser (expat:make-parser)) (tags '()) @@ -135,7 +163,8 @@ ("property" (object-properties-set! object - (cons (cons (alist-ref 'name symbol-attrs) (alist-ref 'value symbol-attrs)) + (cons (cons (string->symbol (alist-ref "name" attrs string=?)) + (alist-ref "value" attrs string=?)) (or (object-properties object) '())))) (_ #f)) (set! tags (cons tag tags))))) @@ -56,4 +56,23 @@ (scene-entities-set! scene (filter pred (scene-entities scene))) scene) + + ;; Center camera on entity. Clamps to >= 0 on both axes. + ;; viewport-w and viewport-h are the game window dimensions (pixels). + (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))))) + + ;; Returns the first entity in scene whose #:tags list contains tag, or #f. + (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)))))) + + ;; Returns all entities in scene whose #:tags list contains tag. + (define (scene-find-all-tagged scene tag) + (filter (lambda (e) (member tag (entity-ref e #:tags '()))) + (scene-entities scene))) ) |
