aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-05 14:17:51 +0100
committerGene Pasquet <dev@etenil.net>2026-04-05 14:17:51 +0100
commit526e6cdcdf1025d5e29680bc99ab910c79789764 (patch)
tree2a91b3e96f2b97cfc81169627f222a5393982830
Initial port of macroknight to an engine
-rw-r--r--.gitignore9
-rw-r--r--CLAUDE.md193
-rw-r--r--Makefile43
-rw-r--r--README.org125
l---------TODO.org1
-rw-r--r--entity.scm45
-rw-r--r--input.scm184
-rw-r--r--physics.scm238
-rw-r--r--renderer.scm88
-rw-r--r--tests/entity-test.scm116
-rw-r--r--tests/input-test.scm174
-rw-r--r--tests/physics-test.scm626
-rw-r--r--tests/renderer-test.scm92
-rw-r--r--tests/tilemap-test.scm204
-rw-r--r--tests/world-test.scm239
-rw-r--r--tilemap.scm235
-rw-r--r--world.scm59
17 files changed, 2671 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..47a40fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+\#*\#
+.\#*
+*~
+/bin/
+*.import.scm
+*.o
+*.log
+logs
+/.agent-shell/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..fb196e5
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,193 @@
+# 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.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..960f42e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+.DEFAULT_GOAL := engine
+
+# Modules listed in dependency order
+MODULE_NAMES := entity tilemap world input physics renderer
+OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES))
+
+# Build all engine modules
+engine: $(OBJECT_FILES)
+
+bin:
+ @mkdir -p $@
+
+# Explicit inter-module dependencies
+bin/entity.o:
+bin/tilemap.o:
+bin/world.o: bin/entity.o bin/tilemap.o
+bin/input.o: bin/entity.o
+bin/physics.o: bin/entity.o bin/world.o bin/tilemap.o
+bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o
+
+# Pattern rule: compile each module as a library unit
+bin/%.o bin/%.import.scm: %.scm | bin
+ csc -c -J -unit $* $*.scm -o bin/$*.o -I bin
+ @if [ -f $*.import.scm ]; then mv $*.import.scm bin/; fi
+
+.PHONY: clean test engine demos
+
+clean:
+ rm -rf bin
+ rm -f *.import.scm
+ rm -f *.log
+
+test:
+ @echo "Running unit tests..."
+ @csi -s tests/entity-test.scm
+ @csi -s tests/world-test.scm
+ @csi -s tests/tilemap-test.scm
+ @csi -s tests/physics-test.scm
+ @csi -s tests/input-test.scm
+ @csi -s tests/renderer-test.scm
+
+demos:
+ @echo "No demos yet."
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..45db408
--- /dev/null
+++ b/README.org
@@ -0,0 +1,125 @@
+* Downstroke
+
+A 2D tile-driven game engine for Chicken Scheme, built on SDL2. Targets old-school platformer and arcade games — NES-style and beyond. The API is inspired by Phaser 2: one call to start, lifecycle hooks to fill in. A minimal game is ~20 lines of Scheme.
+
+#+begin_src scheme
+(define my-game
+ (make-game
+ title: "My Game" width: 320 height: 240
+ preload: (lambda (game) ...) ; load assets
+ create: (lambda (game) ...) ; set up scene
+ update: (lambda (game dt) ...))) ; per-frame logic (physics runs first)
+
+(game-run! my-game)
+#+end_src
+
+** Features
+
+- Tile-based physics: gravity, velocity, AABB collision against TMX tilemaps
+- Built-in update pipeline: input → acceleration → gravity → x-collision → y-collision → ground detection → entity collisions
+- Entities as plists — purely functional, no classes
+- Data-driven prefab/mixin system for composing entity types
+- FSM-based enemy AI via the =states= egg
+- Configurable input: keyboard, joystick, and controller
+- Asset registry with =preload:= lifecycle hook
+- Scene and camera management
+- Sprite animation with frame/tick tracking
+- SDL2_mixer audio via a thin FFI binding
+
+** Status
+
+Early extraction phase. The engine logic is fully working — it powers [[https://genepasquet.itch.io/macroknight][macroknight]], built for Spring Lisp Game Jam 2025. Work is underway to extract it into a standalone, installable Chicken egg.
+
+| Milestone | Description | Status |
+|-----------+------------------------------------+----------|
+| 1 | Zero-risk module extraction | DONE |
+| 2 | Configurable input system | pending |
+| 3 | Data-driven entity rendering | pending |
+| 4 | Renderer abstraction | pending |
+| 5 | Asset preloading | pending |
+| 6 | Scene loading via =create:= hook | pending |
+| 7 | =make-game= / =game-run!= API | pending |
+| 8 | Camera follow | pending |
+| 9 | Named scene states | pending |
+| 10 | AI tag-based lookup | pending |
+| 11 | Package as Chicken egg (v0.1.0) | pending |
+| 12 | Macroknight ported to use the egg | pending |
+
+Milestones 1–6 are pure refactoring. Milestone 7 is the design pivot where the public API stabilises. Milestones 11–12 produce the first installable egg and validate it against macroknight.
+
+** Dependencies
+
+System libraries: =SDL2=, =SDL2_image=, =SDL2_mixer=, =SDL2_ttf=
+
+Chicken eggs:
+#+begin_example
+chicken-install sdl2 sdl2-image expat matchable defstruct states \
+ srfi-197 simple-logger srfi-64
+#+end_example
+
+** Building
+
+#+begin_src sh
+make # compile all modules to bin/
+make test # run SRFI-64 test suites
+make demos # build demo games (verifies they compile)
+make clean # remove bin/
+#+end_src
+
+Single test module:
+#+begin_src sh
+csi -s tests/physics-test.scm
+#+end_src
+
+** Architecture
+
+Modules live at the project root. Each compiles as a Chicken unit (=csc -c -J -unit=). Compile order follows the dependency graph:
+
+: entity, tilemap → world → animation, physics, ai → input → prefabs → mixer → sound → engine
+
+| Module | Responsibility |
+|----------------+---------------------------------------------------|
+| =entity= | Plist accessors: =entity-ref=, =entity-set= |
+| =tilemap= | TMX/TSX parsing (expat), tileset loading |
+| =world= | Scene struct, entity list ops, camera |
+| =physics= | Gravity, velocity, AABB tile + entity collisions |
+| =input= | SDL2 events → action mapping, configurable binds |
+| =animation= | Frame/tick tracking, sprite ID mapping |
+| =ai= | FSM enemy AI: idle → patrol → chase → attack |
+| =prefabs= | Mixin composition, entity instantiation |
+| =renderer= | =draw-sprite=, =draw-tilemap-layer=, =draw-text= |
+| =assets= | Asset registry for the =preload:= hook |
+| =scene-loader= | =game-load-scene!=, =instantiate-prefab= |
+| =mixer= | SDL_mixer FFI (no Scheme deps) |
+| =sound= | Sound registry, music playback |
+| =engine= | =make-game=, =game-run!=, lifecycle orchestration |
+
+Entities are plists — no classes, purely functional:
+
+#+begin_src scheme
+(list #:type 'player
+ #:x 100 #:y 200 #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t #:on-ground? #f
+ #:tile-id 29
+ #:tags '(player)
+ #:anim-name 'walk
+ #:animations '((idle #:frames (28) #:duration 10)
+ (walk #:frames (27 28) #:duration 10)))
+#+end_src
+
+Entity types are composed from mixins declared in a data file:
+
+#+begin_src scheme
+(mixins
+ (physics-body #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f)
+ (has-facing #:facing 1))
+
+(prefabs
+ (player physics-body has-facing #:type player #:tile-id 29))
+#+end_src
+
+** Credits
+
+Engine extracted from [[https://genepasquet.itch.io/macroknight][macroknight]] (Spring Lisp Game Jam 2025).
+Code: Gene Pasquet — Levels: Owen Pasquet — Art: [[https://kenney.nl][Kenney]] (1-bit pack)
diff --git a/TODO.org b/TODO.org
new file mode 120000
index 0000000..d2964da
--- /dev/null
+++ b/TODO.org
@@ -0,0 +1 @@
+/home/gene/Documents/Perso/Projects/downstroke.org \ No newline at end of file
diff --git a/entity.scm b/entity.scm
new file mode 100644
index 0000000..cd467eb
--- /dev/null
+++ b/entity.scm
@@ -0,0 +1,45 @@
+(module entity
+ *
+ (import scheme
+ (chicken base)
+ (chicken keyword)
+ (only srfi-1 fold))
+
+ ;; Entities = plists with shared keys (#:type, #:x, #:y, #:width, #:height, ...).
+
+ (define (entity-ref entity key #!optional default)
+ (get-keyword key entity (if (procedure? default) default (lambda () default))))
+
+ (define (entity-type entity)
+ (entity-ref entity #:type #f))
+
+ (define (entity-set entity key val)
+ (let ((cleaned (let loop ((lst entity) (acc '()))
+ (if (null? lst)
+ (reverse acc)
+ (let ((k (car lst))
+ (v (cadr lst)))
+ (if (eq? k key)
+ (loop (cddr lst) acc)
+ (loop (cddr lst) (cons v (cons k acc)))))))))
+ (cons key (cons val cleaned))))
+
+ (define (entity-update entity key proc #!optional default)
+ (entity-set entity key (proc (entity-ref entity key default))))
+
+ (define (make-player-entity x y width height)
+ (list #:type 'player
+ #:x x
+ #:y y
+ #:width width
+ #:height height
+ #:vx 0
+ #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:tile-id 29
+ #:input-map '((left . (-2 . 0))
+ (right . ( 2 . 0))
+ (down . ( 0 . 2)))))
+)
+
diff --git a/input.scm b/input.scm
new file mode 100644
index 0000000..db581f0
--- /dev/null
+++ b/input.scm
@@ -0,0 +1,184 @@
+(module input
+ *
+
+(import scheme
+ (chicken base)
+ (chicken format)
+ (only srfi-1 any filter fold alist-delete)
+ (only srfi-13 string-join)
+ (only srfi-197 chain)
+ (prefix sdl2 sdl2:)
+ simple-logger
+ entity
+ defstruct)
+
+;; Input configuration record
+(defstruct input-config
+ actions ; list of action symbols
+ keyboard-map ; alist: (sdl2-key-sym . action)
+ joy-button-map ; alist: (button-id . action)
+ controller-button-map ; alist: (button-sym . action)
+ joy-axis-bindings ; list of (axis-id positive-action negative-action)
+ controller-axis-bindings ; list of (axis-sym positive-action negative-action)
+ deadzone) ; integer threshold for analog sticks
+
+;; Default input configuration (capture current hardcoded values)
+(define *default-input-config*
+ (make-input-config
+ actions: '(up down left right a b start select quit)
+ keyboard-map: '((w . up) (up . up)
+ (s . down) (down . down)
+ (a . left) (left . left)
+ (d . right) (right . right)
+ (j . a) (z . a)
+ (k . b) (x . b)
+ (return . start)
+ (escape . quit))
+ joy-button-map: '((0 . a) (1 . b) (7 . start) (6 . select))
+ controller-button-map: '((a . a) (b . b) (start . start) (back . select)
+ (dpad-up . up) (dpad-down . down)
+ (dpad-left . left) (dpad-right . right))
+ joy-axis-bindings: '((0 right left) (1 down up))
+ controller-axis-bindings: '((left-x right left) (left-y down up))
+ deadzone: 8000))
+
+(define-record input-state
+ current ; Alist: (action . bool)
+ previous) ; Alist: (action . bool)
+
+;; Create empty input state from config
+(define (create-input-state config)
+ (let ((initial (map (lambda (a) (cons a #f)) (input-config-actions config))))
+ (make-input-state initial initial)))
+
+;; Helper to update the Alist
+(define (set-action state action value)
+ (let* ((curr (input-state-current state))
+ (new-curr (cons (cons action value) (alist-delete action curr eq?))))
+ (make-input-state new-curr (input-state-previous state))))
+
+(define (input-state-diff state)
+ "Retrieve the difference between current and previous input state maps"
+ (fold (lambda (item agg)
+ (unless (eq? (alist-ref item (input-state-previous state)) item)
+ (cons item agg)))
+ '()
+ (input-state-current state)))
+
+(define (handle-noop state event type config)
+ state)
+
+(define (handle-keyboard state event type config)
+ (let* ((pressed? (eq? type 'key-down))
+ (sym (sdl2:keyboard-event-sym event))
+ (action (alist-ref sym (input-config-keyboard-map config) eq? #f)))
+ (if action (set-action state action pressed?) state)))
+
+(define (apply-axis-to-state state val positive-action negative-action deadzone)
+ (chain state
+ (set-action _ positive-action (> val deadzone))
+ (set-action _ negative-action (< val (- deadzone)))))
+
+(define (handle-joy-button state event type config)
+ (let* ((pressed? (eq? type 'joy-button-down))
+ (btn (sdl2:joy-button-event-button event))
+ (action (alist-ref btn (input-config-joy-button-map config) eqv? #f)))
+ (if action (set-action state action pressed?) state)))
+
+(define (handle-joy-axis state event type config)
+ (let ((axis (sdl2:joy-axis-event-axis event))
+ (val (sdl2:joy-axis-event-value event))
+ (dz (input-config-deadzone config)))
+ (let ((binding (assv axis (input-config-joy-axis-bindings config))))
+ (if binding
+ (apply-axis-to-state state val (cadr binding) (caddr binding) dz)
+ state))))
+
+(define (handle-controller-button state event type config)
+ (let* ((pressed? (eq? type 'controller-button-down))
+ (btn (sdl2:controller-button-event-button event))
+ (action (alist-ref btn (input-config-controller-button-map config) eq? #f)))
+ (if action (set-action state action pressed?) state)))
+
+(define (handle-controller-axis state event type config)
+ (let ((axis (sdl2:controller-axis-event-axis event))
+ (val (sdl2:controller-axis-event-value event))
+ (dz (input-config-deadzone config)))
+ (let ((binding (assv axis (input-config-controller-axis-bindings config))))
+ (if binding
+ (apply-axis-to-state state val (cadr binding) (caddr binding) dz)
+ state))))
+
+(define (handle-controller-device state event type config)
+ (when (eq? type 'controller-device-added)
+ (sdl2:game-controller-open! (sdl2:controller-device-event-which event)))
+ state)
+
+(define (handle-event state event config)
+ (let* ((type (sdl2:event-type event))
+ (handler (case type
+ ((key-down key-up) handle-keyboard)
+ ((joy-button-down joy-button-up) handle-joy-button)
+ ((joy-axis-motion) handle-joy-axis)
+ ((controller-button-down
+ controller-button-up) handle-controller-button)
+ ((controller-axis-motion) handle-controller-axis)
+ ((controller-device-added
+ controller-device-removed) handle-controller-device)
+ (else handle-noop))))
+ (handler state event type config)))
+
+(define (input-state-update state events config)
+ (let ((rolled (make-input-state (input-state-current state)
+ (input-state-current state))))
+ (let* ((new-state (fold (lambda (ev st) (handle-event st ev config)) rolled events))
+ (state-diff (input-state-diff new-state)))
+ (unless (eq? state-diff '())
+ (log-debug "input-state change: ~a" state-diff))
+ new-state)))
+
+;; 5. Simple Getters
+(define (input-held? state action)
+ (alist-ref action (input-state-current state) eq? #f))
+
+(define (input-pressed? state action)
+ (and (input-held? state action)
+ (not (alist-ref action (input-state-previous state) eq? #f))))
+
+(define (input-released? state action)
+ (and (not (input-held? state action))
+ (alist-ref action (input-state-previous state) eq? #f)))
+
+(define (input-any-pressed? state config)
+ (any (lambda (a) (input-pressed? state a)) (input-config-actions config)))
+
+(define (input-state->string state config)
+ (let ((active (filter (lambda (a) (input-held? state a)) (input-config-actions config))))
+ (format #f "[Input: ~a]" (string-join (map symbol->string active) " "))))
+
+(define (set-facing-from-vx entity vx)
+ (cond
+ ((> vx 0) (entity-set entity #:facing 1))
+ ((< vx 0) (entity-set entity #:facing -1))
+ (else entity)))
+
+(define (apply-input-to-entity entity held?)
+ (let ((input-map (entity-ref entity #:input-map #f)))
+ (if (not input-map)
+ entity
+ (let* ((delta (fold (lambda (entry acc)
+ (let* ((action (car entry))
+ (d (cdr entry))
+ (dvx (car d))
+ (dvy (cdr d)))
+ (if (held? action)
+ (cons (+ (car acc) dvx)
+ (+ (cdr acc) dvy))
+ acc)))
+ '(0 . 0)
+ input-map))
+ (speed (entity-ref entity #:move-speed 1))
+ (vx (* speed (car delta))))
+ (set-facing-from-vx (entity-set entity #:vx vx) vx)))))
+
+) ;; end module
diff --git a/physics.scm b/physics.scm
new file mode 100644
index 0000000..83cc85b
--- /dev/null
+++ b/physics.scm
@@ -0,0 +1,238 @@
+(module physics *
+ (import scheme
+ (chicken base)
+ (chicken keyword)
+ (only srfi-1 fold iota)
+ defstruct
+ tilemap
+ entity
+ world
+ simple-logger)
+
+ ;; Gravity constant: pixels per frame per frame
+ (define *gravity* 1)
+
+ ;; Jump force: vertical acceleration applied on jump (one frame)
+ (define *jump-force* 15)
+
+ ;; Consume #:ay into #:vy and clear it (one-shot acceleration)
+ (define (apply-acceleration entity)
+ (if (not (entity-ref entity #:gravity? #f))
+ entity
+ (let ((ay (entity-ref entity #:ay 0))
+ (vy (entity-ref entity #:vy 0)))
+ (entity-set (entity-set entity #:vy (+ vy ay)) #:ay 0))))
+
+ ;; Apply gravity to an entity if it has gravity enabled
+ (define (apply-gravity entity)
+ (if (entity-ref entity #:gravity? #f)
+ (entity-set entity #:vy (+ (entity-ref entity #:vy) *gravity*))
+ entity))
+
+ ;; Update entity's x by its vx velocity
+ (define (apply-velocity-x entity)
+ "Update entity's x by its vx velocity."
+ (let ((x (entity-ref entity #:x 0))
+ (vx (entity-ref entity #:vx 0)))
+ (entity-set entity #:x (+ x vx))))
+
+ ;; Update entity's y by its vy velocity
+ (define (apply-velocity-y entity)
+ "Update entity's y by its vy velocity."
+ (let ((y (entity-ref entity #:y 0))
+ (vy (entity-ref entity #:vy 0)))
+ (entity-set entity #:y (+ y vy))))
+
+ ;; Legacy function: update both x and y by velocities
+ (define (apply-velocity entity)
+ "Legacy function: update both x and y by velocities."
+ (let ((x (entity-ref entity #:x 0))
+ (y (entity-ref entity #:y 0))
+ (vx (entity-ref entity #:vx 0))
+ (vy (entity-ref entity #:vy 0)))
+ (entity-set (entity-set entity #:x (+ x vx)) #:y (+ y vy))))
+
+ ;; Build list of (col . row) pairs to check for collisions
+ (define (build-cell-list col-start col-end row-start row-end)
+ (let loop ((col col-start) (row row-start) (acc '()))
+ (log-debug "Build-cell-list loop with: ~a" (list col row acc))
+ (if (> col col-end)
+ (if (>= row row-end)
+ (reverse acc)
+ (loop col-start (+ row 1) acc))
+ (loop (+ col 1) row (cons (cons col row) acc)))))
+
+ ;; Convert a pixel coordinate to a tile grid index
+ (define (pixel->tile pixel tile-size)
+ (inexact->exact (floor (/ pixel tile-size))))
+
+ ;; Return all tile cells (col . row) overlapping the entity's bounding box
+ (define (entity-tile-cells entity tilemap)
+ (let ((x (entity-ref entity #:x 0))
+ (y (entity-ref entity #:y 0))
+ (w (entity-ref entity #:width 0))
+ (h (entity-ref entity #:height 0))
+ (tw (tilemap-tilewidth tilemap))
+ (th (tilemap-tileheight tilemap)))
+ (build-cell-list
+ (pixel->tile x tw)
+ (pixel->tile (- (+ x w) 1) tw)
+ (pixel->tile y th)
+ (pixel->tile (- (+ y h) 1) th))))
+
+ ;; Snap position to the near or far edge of a tile after collision.
+ ;; Moving forward (v>0): snap entity's leading edge to tile's near edge.
+ ;; Moving backward (v<0): snap entity's trailing edge to tile's far edge.
+ (define (tile-push-pos v coord tile-size entity-size)
+ (if (> v 0)
+ (- (* coord tile-size) entity-size)
+ (* (+ coord 1) tile-size)))
+
+ ;; Resolve collisions with tiles along a single axis.
+ ;; push-fn: (v col row) -> new-pos
+ (define (resolve-tile-collisions-axis entity tilemap vel-key pos-key push-fn)
+ (let ((v (entity-ref entity vel-key 0)))
+ (if (zero? v)
+ entity
+ (fold (lambda (cell acc)
+ (log-debug "resolve-~a: cell=~a acc=~a" vel-key cell acc)
+ (let* ((col (car cell))
+ (row (cdr cell))
+ (tile-id (tilemap-tile-at tilemap col row)))
+ (if (zero? tile-id)
+ acc
+ (entity-set (entity-set acc pos-key (push-fn v col row)) vel-key 0))))
+ entity
+ (entity-tile-cells entity tilemap)))))
+
+ ;; Resolve horizontal collisions with solid tiles
+ (define (resolve-tile-collisions-x entity tilemap)
+ (let ((w (entity-ref entity #:width 0))
+ (tw (tilemap-tilewidth tilemap)))
+ (resolve-tile-collisions-axis entity tilemap #:vx #:x
+ (lambda (v col row) (tile-push-pos v col tw w)))))
+
+ ;; Resolve vertical collisions with solid tiles
+ (define (resolve-tile-collisions-y entity tilemap)
+ (let ((h (entity-ref entity #:height 0))
+ (th (tilemap-tileheight tilemap)))
+ (resolve-tile-collisions-axis entity tilemap #:vy #:y
+ (lambda (v col row) (tile-push-pos v row th h)))))
+
+ ;; Detect if entity is standing on ground by probing 1px below feet
+ (define (detect-ground entity tilemap)
+ (if (not (entity-ref entity #:gravity? #f))
+ entity
+ (let* ((x (entity-ref entity #:x 0))
+ (w (entity-ref entity #:width 0))
+ (tw (tilemap-tilewidth tilemap))
+ (th (tilemap-tileheight tilemap))
+ (probe-y (+ (entity-ref entity #:y 0) (entity-ref entity #:height 0) 1))
+ (row (pixel->tile probe-y th))
+ (col-left (pixel->tile x tw))
+ (col-right (pixel->tile (- (+ x w) 1) tw))
+ (on-ground? (or (not (zero? (tilemap-tile-at tilemap col-left row)))
+ (not (zero? (tilemap-tile-at tilemap col-right row))))))
+ (entity-set entity #:on-ground? on-ground?))))
+
+ ;; Set vertical acceleration for jump (consumed next frame by apply-acceleration)
+ (define (apply-jump entity jump-pressed?)
+ "Set #:ay to jump force if jump pressed and entity is on ground."
+ (if (and jump-pressed? (entity-ref entity #:on-ground? #f))
+ (entity-set entity #:ay (- (entity-ref entity #:jump-force *jump-force*)))
+ entity))
+
+ ;; Replace element at idx in lst with val
+ (define (list-set lst idx val)
+ (let loop ((lst lst) (i 0) (acc '()))
+ (if (null? lst)
+ (reverse acc)
+ (loop (cdr lst) (+ i 1)
+ (cons (if (= i idx) val (car lst)) acc)))))
+
+ ;; Generate all unique (i . j) index pairs where i < j
+ (define (index-pairs n)
+ (if (< n 2) '()
+ (apply append
+ (map (lambda (i)
+ (map (lambda (j) (cons i j))
+ (iota (- n i 1) (+ i 1))))
+ (iota (- n 1))))))
+
+ (define (axis->dimension axis)
+ (case axis
+ ((#:x) #:width)
+ ((#:y) #:height)))
+
+ (define (axis->velocity axis)
+ (case axis
+ ((#:x) #:vx)
+ ((#:y) #:vy)))
+
+ ;; Push entity along one axis by half-overlap, setting velocity in push direction
+ (define (push-entity entity pos-key vel-key pos overlap sign)
+ (entity-set (entity-set entity pos-key (+ pos (* sign (/ overlap 2)))) vel-key sign))
+
+
+ (define (entity-center-on-axis entity axis)
+ (let ((dimension (axis->dimension axis)))
+ (+ (entity-ref entity axis 0) (/ (entity-ref entity dimension 0) 2))))
+
+ (define (aabb-overlap-on-axis axis a b)
+ (let ((dimension (axis->dimension axis)))
+ (- (/ (+ (entity-ref a dimension 0) (entity-ref b dimension 0)) 2)
+ (abs (- (entity-center-on-axis b axis) (entity-center-on-axis a axis))))))
+
+ (define (push-along-axis axis a b overlap)
+ (let* ((a-center (entity-center-on-axis a axis))
+ (b-center (entity-center-on-axis b axis))
+ (delta (if (< a-center b-center) -1 1))
+ (axis-velocity-key (axis->velocity axis)))
+ (cons (push-entity a axis axis-velocity-key (entity-ref a axis 0) overlap delta)
+ (push-entity b axis axis-velocity-key (entity-ref b axis 0) overlap (- delta)))))
+
+ ;; Push two overlapping entities apart along the minimum penetration axis.
+ ;; Returns (a2 . b2) with updated positions and velocities.
+ (define (push-apart a b)
+ (let* ((ovx (aabb-overlap-on-axis #:x a b))
+ (ovy (aabb-overlap-on-axis #:y a b)))
+ (if (<= ovx ovy)
+ (push-along-axis #:x a b ovx)
+ (push-along-axis #:y a b ovy))))
+
+ ;; Check if two axis-aligned bounding boxes overlap.
+ ;; Returns #t if they overlap, #f if they don't (including edge-touching).
+ (define (aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2)
+ (not (or (>= x1 (+ x2 w2))
+ (>= x2 (+ x1 w1))
+ (>= y1 (+ y2 h2))
+ (>= y2 (+ y1 h1)))))
+
+ ;; Resolve AABB collision between two solid entities.
+ ;; Returns (a2 . b2) with positions/velocities adjusted, or #f if no collision.
+ (define (resolve-pair a b)
+ (and (entity-ref a #:solid? #f)
+ (entity-ref b #:solid? #f)
+ (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))
+ (push-apart a b)))
+
+ ;; Detect and resolve AABB overlaps between all pairs of solid entities.
+ ;; Returns a new entity list with collisions resolved.
+ (define (resolve-entity-collisions entities)
+ (fold (lambda (pair ents)
+ (let* ((i (car pair)) (j (cdr pair))
+ (result (resolve-pair (list-ref ents i) (list-ref ents j))))
+ (if result
+ (list-set (list-set ents i (car result)) j (cdr result))
+ ents)))
+ entities
+ (index-pairs (length entities))))
+
+ ;; Wrapper for scene-resolve-collisions
+ (define (scene-resolve-collisions scene)
+ (scene-entities-set! scene
+ (resolve-entity-collisions (scene-entities scene)))
+ scene))
diff --git a/renderer.scm b/renderer.scm
new file mode 100644
index 0000000..ad894d0
--- /dev/null
+++ b/renderer.scm
@@ -0,0 +1,88 @@
+(module renderer
+ *
+ (import scheme
+ (chicken base)
+ (only srfi-1 iota for-each)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ entity
+ tilemap
+ world)
+
+ ;; --- Pure functions (no SDL2, testable) ---
+
+ ;; Returns (x y w h) as a plain list — testable without SDL2
+ (define (entity-screen-coords entity camera)
+ (list (- (inexact->exact (floor (entity-ref entity #:x 0))) (camera-x camera))
+ (- (inexact->exact (floor (entity-ref entity #:y 0))) (camera-y camera))
+ (inexact->exact (floor (entity-ref entity #:width 0)))
+ (inexact->exact (floor (entity-ref entity #:height 0)))))
+
+ ;; Returns sdl2:rect for actual drawing
+ (define (entity->screen-rect entity camera)
+ (apply sdl2:make-rect (entity-screen-coords entity camera)))
+
+ ;; Returns flip list based on #:facing field
+ (define (entity-flip entity)
+ (if (= (entity-ref entity #:facing 1) -1) '(horizontal) '()))
+
+ ;; --- Tilemap drawing ---
+
+ (define (draw-tile renderer camera tileset tileset-texture tile-id row-num col-num)
+ (let ((tile (tileset-tile tileset tile-id)))
+ (sdl2:render-copy! renderer tileset-texture
+ (tile-rect tile)
+ (sdl2:make-rect
+ (- (* col-num (tileset-tilewidth tileset)) (camera-x camera))
+ (- (* row-num (tileset-tileheight tileset)) (camera-y camera))
+ (tileset-tilewidth tileset)
+ (tileset-tileheight tileset)))))
+
+ (define (draw-tilemap-rows draw-fn rows row-num)
+ (unless (null? rows)
+ (for-each
+ (cut draw-fn <> row-num <>)
+ (car rows)
+ (iota (length (car rows))))
+ (draw-tilemap-rows draw-fn (cdr rows) (+ row-num 1))))
+
+ (define (draw-tilemap renderer camera tileset-texture tilemap)
+ (let ((map-layers (tilemap-layers tilemap))
+ (tileset (tilemap-tileset tilemap)))
+ (for-each
+ (lambda (layer)
+ (draw-tilemap-rows
+ (cut draw-tile renderer camera tileset tileset-texture <> <> <>)
+ (layer-map layer)
+ 0))
+ map-layers)))
+
+ ;; --- Entity drawing ---
+
+ (define (draw-entity renderer camera tileset tileset-texture entity)
+ (let ((tile-id (entity-ref entity #:tile-id #f)))
+ (when tile-id
+ (sdl2:render-copy-ex! renderer tileset-texture
+ (tile-rect (tileset-tile tileset tile-id))
+ (entity->screen-rect entity camera)
+ 0.0
+ #f
+ (entity-flip entity)))))
+
+ (define (draw-entities renderer camera tileset tileset-texture entities)
+ (for-each
+ (lambda (e) (draw-entity renderer camera tileset tileset-texture e))
+ entities))
+
+ ;; --- Text drawing ---
+
+ (define (draw-ui-text renderer font text color x y)
+ (let* ((surface (ttf:render-text-solid font text color))
+ (texture (sdl2:create-texture-from-surface renderer surface))
+ (dims (call-with-values (lambda () (ttf:size-utf8 font text)) cons))
+ (w (car dims))
+ (h (cdr dims)))
+ (sdl2:render-copy! renderer texture #f
+ (sdl2:make-rect x y w h))))
+
+) ;; end module renderer
diff --git a/tests/entity-test.scm b/tests/entity-test.scm
new file mode 100644
index 0000000..3e1f85e
--- /dev/null
+++ b/tests/entity-test.scm
@@ -0,0 +1,116 @@
+(import srfi-64)
+(include "entity.scm")
+(import entity)
+
+(test-begin "entity")
+
+;; Test: entity-ref retrieves values from entity plists
+(test-group "entity-ref"
+ (let ((entity '(#:type player #:x 100 #:y 200 #:width 16 #:height 16)))
+ (test-equal "retrieves type" 'player (entity-ref entity #:type))
+ (test-equal "retrieves x" 100 (entity-ref entity #:x))
+ (test-equal "retrieves y" 200 (entity-ref entity #:y))
+ (test-equal "retrieves width" 16 (entity-ref entity #:width))
+ (test-equal "retrieves height" 16 (entity-ref entity #:height)))
+
+ ;; Test with default value
+ (let ((entity '(#:type player)))
+ (test-equal "returns default for missing key"
+ 99
+ (entity-ref entity #:x 99))
+ (test-equal "returns #f as default if not specified"
+ #f
+ (entity-ref entity #:missing-key))))
+
+;; Test: entity-ref with procedure as default
+(test-group "entity-ref-with-procedure-default"
+ (let ((entity '(#:type player)))
+ (test-equal "calls procedure default when key missing"
+ 42
+ (entity-ref entity #:x (lambda () 42)))))
+
+;; Test: entity-type extracts type from entity
+(test-group "entity-type"
+ (let ((player '(#:type player #:x 100))
+ (enemy '(#:type enemy #:x 200)))
+ (test-equal "extracts player type" 'player (entity-type player))
+ (test-equal "extracts enemy type" 'enemy (entity-type enemy)))
+
+ (let ((no-type '(#:x 100 #:y 200)))
+ (test-equal "returns #f for entity without type"
+ #f
+ (entity-type no-type))))
+
+;; Test: make-player-entity creates valid player entity
+(test-group "make-player-entity"
+ (let ((player (make-player-entity 50 75 16 16)))
+ (test-assert "returns a list" (list? player))
+ (test-equal "has correct type" 'player (entity-ref player #:type))
+ (test-equal "has correct x" 50 (entity-ref player #:x))
+ (test-equal "has correct y" 75 (entity-ref player #:y))
+ (test-equal "has correct width" 16 (entity-ref player #:width))
+ (test-equal "has correct height" 16 (entity-ref player #:height))
+ (test-equal "has initial tile-id" 29 (entity-ref player #:tile-id))))
+
+;; Test: complex entity with multiple properties
+(test-group "complex-entity"
+ (let ((entity '(#:type enemy
+ #:x 100
+ #:y 200
+ #:width 16
+ #:height 16
+ #:health 50
+ #:speed 2.5
+ #:ai-state patrol)))
+ (test-equal "retrieves numeric property" 50 (entity-ref entity #:health))
+ (test-equal "retrieves float property" 2.5 (entity-ref entity #:speed))
+ (test-equal "retrieves symbol property" 'patrol (entity-ref entity #:ai-state))))
+
+;; Test: entity-set updates entity properties
+(test-group "entity-set"
+ (test-group "existing key is replaced"
+ (let ((e (entity-set '(#:x 10 #:y 20) #:x 15)))
+ (test-equal "value updated" 15 (entity-ref e #:x))
+ (test-equal "other key untouched" 20 (entity-ref e #:y))
+ ;; plist length should shrink from 4 to 4 (same — one pair removed, one added)
+ ;; stronger: verify the list length stays at 4, not 6
+ (test-equal "no duplicate key: list length unchanged" 4 (length e))))
+ (test-group "new key is added"
+ (let ((e (entity-set '(#:x 10) #:vx 3)))
+ (test-equal "new key present" 3 (entity-ref e #:vx))
+ (test-equal "existing key untouched" 10 (entity-ref e #:x))
+ (test-equal "list grows by one pair" 4 (length e)))))
+
+;; Test: entity-update applies transformations
+(test-group "entity-update"
+ (test-group "transform existing value"
+ (let ((e (entity-update '(#:x 10 #:y 20) #:x (lambda (v) (+ v 5)))))
+ (test-equal "#:x is 15" 15 (entity-ref e #:x))
+ (test-equal "#:y is 20" 20 (entity-ref e #:y))))
+
+ (test-group "missing key uses default"
+ (let ((e (entity-update '(#:x 10) #:health (lambda (v) (+ v 1)) 0)))
+ (test-equal "#:health is 1" 1 (entity-ref e #:health))))
+
+ (test-group "missing key without default"
+ (let ((e (entity-update '(#:x 10) #:z (lambda (v) v))))
+ (test-equal "#:z is #f" #f (entity-ref e #:z))))
+
+ (test-group "no duplicate keys"
+ (let ((e (entity-update '(#:x 10 #:y 20) #:x (lambda (v) (* v 2)))))
+ (test-equal "length is 4" 4 (length e)))))
+
+;; Test: make-player-entity velocity fields
+(test-group "make-player-entity-velocity-fields"
+ (let* ((p (make-player-entity 5 10 16 16))
+ (imap (entity-ref p #:input-map #f)))
+ (test-equal "vx defaults to 0" 0 (entity-ref p #:vx))
+ (test-equal "vy defaults to 0" 0 (entity-ref p #:vy))
+ (test-assert "input-map is present" imap)
+ ;; Each entry is (action . (dvx . dvy)); assq returns (action . (dvx . dvy))
+ (test-equal "left dvx" -2 (car (cdr (assq 'left imap))))
+ (test-equal "left dvy" 0 (cdr (cdr (assq 'left imap))))
+ (test-equal "right dvx" 2 (car (cdr (assq 'right imap))))
+ (test-equal "right dvy" 0 (cdr (cdr (assq 'right imap))))))
+
+(test-end "entity")
diff --git a/tests/input-test.scm b/tests/input-test.scm
new file mode 100644
index 0000000..822875e
--- /dev/null
+++ b/tests/input-test.scm
@@ -0,0 +1,174 @@
+;; Load dependencies first
+(import scheme
+ (chicken base)
+ (chicken format)
+ (only srfi-1 any filter fold alist-delete)
+ (only srfi-13 string-join)
+ (only srfi-197 chain)
+ (prefix sdl2 sdl2:)
+ simple-logger
+ srfi-64
+ defstruct)
+
+;; Load entity first (input imports it)
+(include "entity.scm")
+(import entity)
+
+;; Load the module source directly
+(include "input.scm")
+;; Now import it to access the exported functions
+(import input)
+
+;; Test suite for input module
+(test-begin "input-module")
+
+;; Test: create-input-state initializes correctly
+(test-group "create-input-state"
+ (let ((state (create-input-state *default-input-config*)))
+ (test-assert "returns an input-state record" (input-state? state))
+ (test-assert "has current field" (list? (input-state-current state)))
+ (test-assert "has previous field" (list? (input-state-previous state)))
+
+ ;; All actions should be initialized to #f
+ (test-equal "up action is false" #f (input-held? state 'up))
+ (test-equal "down action is false" #f (input-held? state 'down))
+ (test-equal "left action is false" #f (input-held? state 'left))
+ (test-equal "right action is false" #f (input-held? state 'right))
+ (test-equal "a action is false" #f (input-held? state 'a))
+ (test-equal "b action is false" #f (input-held? state 'b))
+ (test-equal "start action is false" #f (input-held? state 'start))
+ (test-equal "quit action is false" #f (input-held? state 'quit))))
+
+;; Test: input-held? query
+(test-group "input-held?"
+ (let ((state (create-input-state *default-input-config*)))
+ (test-equal "returns false for unheld action" #f (input-held? state 'up))
+ (test-equal "returns false for unknown action" #f (input-held? state 'unknown))))
+
+;; Test: input-pressed? detection
+(test-group "input-pressed?"
+ (let* ((state1 (create-input-state *default-input-config*))
+ ;; Simulate state transition: nothing -> up pressed
+ (state2 (make-input-state
+ (cons (cons 'up #t) (input-state-current state1))
+ (input-state-current state1))))
+
+ ;; In state1, up is not pressed
+ (test-equal "not pressed in initial state" #f (input-pressed? state1 'up))
+
+ ;; In state2, up is held but was not held before -> pressed
+ (test-assert "pressed when current=#t and previous=#f"
+ (input-pressed? state2 'up))))
+
+;; Test: input-released? detection
+(test-group "input-released?"
+ (let* ((state1 (create-input-state *default-input-config*))
+ ;; State with up held
+ (state-held (make-input-state
+ (cons (cons 'up #t) (input-state-current state1))
+ (input-state-current state1)))
+ ;; State with up released (current=#f, previous=#t)
+ (state-released (make-input-state
+ (cons (cons 'up #f) (input-state-current state1))
+ (cons (cons 'up #t) (input-state-current state1)))))
+
+ (test-equal "not released when held" #f (input-released? state-held 'up))
+ (test-assert "released when current=#f and previous=#t"
+ (input-released? state-released 'up))))
+
+;; Test: input-any-pressed?
+(test-group "input-any-pressed?"
+ (let ((state1 (create-input-state *default-input-config*)))
+ (test-equal "no actions pressed in initial state"
+ #f
+ (input-any-pressed? state1 *default-input-config*))))
+
+;; Test: input-state->string formatting
+(test-group "input-state->string"
+ (let* ((state (create-input-state *default-input-config*))
+ (str (input-state->string state *default-input-config*)))
+ (test-assert "returns a string" (string? str))
+ (test-assert "contains [Input:" (string-contains str "[Input:"))
+ (test-assert "empty state shows no actions"
+ (or (string-contains str "[]")
+ (string-contains str "[Input: ]")))))
+
+;; Test: state transitions
+(test-group "state-transitions"
+ (let* ((state1 (create-input-state *default-input-config*))
+ ;; Manually create state2 where 'up' is pressed
+ (state2 (make-input-state
+ (cons (cons 'up #t)
+ (filter (lambda (p) (not (eq? (car p) 'up)))
+ (input-state-current state1)))
+ (input-state-current state1))))
+
+ ;; Verify transition from not-held to held = pressed
+ (test-equal "up not held in state1" #f (input-held? state1 'up))
+ (test-assert "up held in state2" (input-held? state2 'up))
+ (test-assert "up pressed in state2" (input-pressed? state2 'up))
+
+ ;; Now create state3 where up is still held (not pressed anymore)
+ (let ((state3 (make-input-state
+ (input-state-current state2)
+ (input-state-current state2))))
+ (test-assert "up still held in state3" (input-held? state3 'up))
+ (test-equal "up not pressed in state3 (already was pressed)"
+ #f
+ (input-pressed? state3 'up)))))
+
+;; Test: apply-input-to-entity applies input to entity
+(test-group "apply-input-to-entity"
+ (test-group "no input-map: entity unchanged"
+ (let* ((e '(#:type player #:x 5 #:y 10))
+ (out (apply-input-to-entity e (lambda (a) #f))))
+ (test-equal "entity returned as-is" e out)))
+
+ (test-group "no actions held: velocity is zero"
+ (let* ((e (make-player-entity 0 0 16 16))
+ (out (apply-input-to-entity e (lambda (a) #f))))
+ (test-equal "vx is 0" 0 (entity-ref out #:vx))
+ (test-equal "vy is 0" 0 (entity-ref out #:vy))))
+
+ (test-group "right held: vx=2 vy=0"
+ (let* ((e (make-player-entity 0 0 16 16))
+ (out (apply-input-to-entity e (lambda (a) (eq? a 'right)))))
+ (test-equal "vx is 2" 2 (entity-ref out #:vx))
+ (test-equal "vy is 0" 0 (entity-ref out #:vy))))
+
+ (test-group "right+down held: vx=2 vy unchanged"
+ (let* ((e (make-player-entity 0 0 16 16))
+ (out (apply-input-to-entity e (lambda (a) (memv a '(right down))))))
+ (test-equal "vx is 2" 2 (entity-ref out #:vx))
+ (test-equal "vy is unchanged (input handler does not set vy)" 0 (entity-ref out #:vy))))
+
+ (test-group "right held: facing set to 1"
+ (let* ((e (make-player-entity 0 0 16 16))
+ (out (apply-input-to-entity e (lambda (a) (eq? a 'right)))))
+ (test-equal "facing is 1" 1 (entity-ref out #:facing 0))))
+
+ (test-group "left held: facing set to -1"
+ (let* ((e (make-player-entity 0 0 16 16))
+ (out (apply-input-to-entity e (lambda (a) (eq? a 'left)))))
+ (test-equal "facing is -1" -1 (entity-ref out #:facing 0))))
+
+ (test-group "no key held: facing retains previous value"
+ (let* ((e (entity-set (make-player-entity 0 0 16 16) #:facing 1))
+ (out (apply-input-to-entity e (lambda (a) #f))))
+ (test-equal "facing stays 1 when vx=0" 1 (entity-ref out #:facing 0)))))
+
+(test-group "custom-input-config"
+ (let* ((cfg (make-input-config
+ actions: '(jump shoot)
+ keyboard-map: '((space . jump) (f . shoot))
+ joy-button-map: '()
+ controller-button-map: '()
+ joy-axis-bindings: '()
+ controller-axis-bindings: '()
+ deadzone: 8000))
+ (state (create-input-state cfg)))
+ (test-assert "custom config creates valid state" (input-state? state))
+ (test-equal "jump is false" #f (input-held? state 'jump))
+ (test-equal "shoot is false" #f (input-held? state 'shoot))))
+
+(test-end "input-module")
diff --git a/tests/physics-test.scm b/tests/physics-test.scm
new file mode 100644
index 0000000..4c6d4a6
--- /dev/null
+++ b/tests/physics-test.scm
@@ -0,0 +1,626 @@
+;; Load dependencies first
+(import scheme
+ (chicken base)
+ (chicken keyword)
+ defstruct
+ srfi-64
+ (only srfi-1 every member make-list fold iota))
+
+;; Create a mock tilemap module to avoid SDL dependency
+(module tilemap *
+ (import scheme (chicken base) defstruct)
+
+ (defstruct tileset
+ tilewidth
+ tileheight
+ spacing
+ tilecount
+ columns
+ image-source
+ image)
+
+ (defstruct layer
+ name
+ width
+ height
+ map)
+
+ (defstruct tilemap
+ width
+ height
+ tilewidth
+ tileheight
+ tileset-source
+ tileset
+ layers
+ objects))
+
+(import tilemap)
+
+;; Load entity module first (since world now imports entity)
+(include "entity.scm")
+(import entity)
+
+;; Load world module first
+(include "world.scm")
+(import world)
+
+;; Load physics module
+(include "physics.scm")
+(import physics)
+
+;; Load physics module
+(include "input.scm")
+(import input)
+
+;; Test suite for physics module
+(test-begin "physics-module")
+
+;; Helper to reduce tilemap boilerplate in tests
+;; rows: list of lists of tile IDs, tiles are 16x16
+(define (make-test-tilemap rows)
+ (let* ((height (length rows))
+ (width (length (car rows)))
+ (layer (make-layer name: "test" width: width height: height map: rows)))
+ (make-tilemap width: width height: height
+ tilewidth: 16 tileheight: 16
+ tileset-source: "" tileset: #f
+ layers: (list layer) objects: '())))
+
+;; Integration helper: simulate one frame of physics
+(define (tick e tm held?)
+ (let* ((e (apply-input-to-entity e held?))
+ (e (apply-gravity e))
+ (e (apply-velocity-x e))
+ (e (resolve-tile-collisions-x e tm))
+ (e (apply-velocity-y e))
+ (e (resolve-tile-collisions-y e tm))
+ (e (detect-ground e tm)))
+ e))
+
+;; Test: apply-gravity
+(test-group "apply-gravity"
+ (test-group "gravity? true, vy starts at 0"
+ (let* ((e '(#:type rock #:x 0 #:y 0 #:vx 0 #:vy 0 #:gravity? #t))
+ (result (apply-gravity e)))
+ (test-equal "vy increased by gravity" *gravity* (entity-ref result #:vy))
+ (test-equal "x unchanged" 0 (entity-ref result #:x))
+ (test-equal "y unchanged" 0 (entity-ref result #:y))
+ (test-equal "vx unchanged" 0 (entity-ref result #:vx))))
+
+ (test-group "gravity? true, vy already has value"
+ (let* ((e '(#:type rock #:x 0 #:y 0 #:vx 0 #:vy 3 #:gravity? #t))
+ (result (apply-gravity e)))
+ (test-equal "vy increased by gravity" 4 (entity-ref result #:vy))))
+
+ (test-group "gravity? false"
+ (let* ((e '(#:type static #:x 0 #:y 0 #:vx 0 #:vy 0 #:gravity? #f))
+ (result (apply-gravity e)))
+ (test-equal "vy unchanged" 0 (entity-ref result #:vy))))
+
+ (test-group "no gravity? field at all"
+ (let* ((e '(#:type static #:x 5 #:y 5))
+ (result (apply-gravity e)))
+ (test-equal "entity unchanged" e result))))
+
+(test-group "apply-velocity-x"
+ (test-group "basic horizontal movement"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 5 #:vy -2))
+ (result (apply-velocity-x e)))
+ (test-equal "x moved by vx" 15 (entity-ref result #:x))
+ (test-equal "y unchanged" 20 (entity-ref result #:y))
+ (test-equal "vy unchanged" -2 (entity-ref result #:vy))))
+
+ (test-group "zero vx"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 0 #:vy 3))
+ (result (apply-velocity-x e)))
+ (test-equal "x unchanged" 10 (entity-ref result #:x))
+ (test-equal "y unchanged" 20 (entity-ref result #:y)))))
+
+(test-group "apply-velocity-y"
+ (test-group "basic vertical movement"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 3 #:vy -5))
+ (result (apply-velocity-y e)))
+ (test-equal "x unchanged" 10 (entity-ref result #:x))
+ (test-equal "y moved by vy" 15 (entity-ref result #:y))
+ (test-equal "vx unchanged" 3 (entity-ref result #:vx))))
+
+ (test-group "zero vy"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 3 #:vy 0))
+ (result (apply-velocity-y e)))
+ (test-equal "x unchanged" 10 (entity-ref result #:x))
+ (test-equal "y unchanged" 20 (entity-ref result #:y)))))
+
+(test-group "apply-velocity"
+ (test-group "basic movement"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 3 #:vy -2))
+ (result (apply-velocity e)))
+ (test-equal "x moved by vx" 13 (entity-ref result #:x))
+ (test-equal "y moved by vy" 18 (entity-ref result #:y))))
+
+ (test-group "zero velocity"
+ (let* ((e '(#:type rock #:x 10 #:y 20 #:vx 0 #:vy 0))
+ (result (apply-velocity e)))
+ (test-equal "x unchanged" 10 (entity-ref result #:x))
+ (test-equal "y unchanged" 20 (entity-ref result #:y))))
+
+ (test-group "no velocity fields (defaults to 0)"
+ (let* ((e '(#:type static #:x 5 #:y 5))
+ (result (apply-velocity e)))
+ (test-equal "x unchanged" 5 (entity-ref result #:x))
+ (test-equal "y unchanged" 5 (entity-ref result #:y)))))
+
+(test-group "build-cell-list"
+ (test-group "single cell"
+ (let ((cells (build-cell-list 5 5 3 3)))
+ (test-equal "one cell" 1 (length cells))
+ (test-equal "cell is pair" '(5 . 3) (car cells))))
+
+ (test-group "two columns one row"
+ (let ((cells (build-cell-list 11 12 22 22)))
+ (test-equal "two cells" 2 (length cells))
+ (test-assert "all cells are pairs" (every pair? cells))
+ (test-assert "contains (11 . 22)" (member '(11 . 22) cells))
+ (test-assert "contains (12 . 22)" (member '(12 . 22) cells))))
+
+ (test-group "one column two rows"
+ (let ((cells (build-cell-list 5 5 2 3)))
+ (test-equal "two cells" 2 (length cells))
+ (test-assert "all cells are pairs" (every pair? cells))
+ (test-assert "contains (5 . 2)" (member '(5 . 2) cells))
+ (test-assert "contains (5 . 3)" (member '(5 . 3) cells))))
+
+ (test-group "2x2 grid"
+ (let ((cells (build-cell-list 0 1 0 1)))
+ (test-equal "four cells" 4 (length cells))
+ (test-assert "all cells are pairs" (every pair? cells))
+ (test-assert "no #f in list" (not (member #f cells)))))
+
+ (test-group "empty when col-start > col-end"
+ (let ((cells (build-cell-list 5 4 0 0)))
+ (test-equal "empty list" '() cells)))
+
+ (test-group "player-like values (x=182 y=352 w=16 h=16 tw=16 th=16)"
+ (let* ((x 182) (y 352) (w 16) (h 16) (tw 16) (th 16)
+ (col-start (inexact->exact (floor (/ x tw))))
+ (col-end (inexact->exact (floor (/ (- (+ x w) 1) tw))))
+ (row-start (inexact->exact (floor (/ y th))))
+ (row-end (inexact->exact (floor (/ (- (+ y h) 1) th))))
+ (cells (build-cell-list col-start col-end row-start row-end)))
+ (test-equal "col-start" 11 col-start)
+ (test-equal "col-end" 12 col-end)
+ (test-equal "row-start" 22 row-start)
+ (test-equal "row-end" 22 row-end)
+ (test-equal "two cells" 2 (length cells))
+ (test-assert "all cells are pairs" (every pair? cells)))))
+
+(test-group "resolve-tile-collisions-x"
+ (test-group "no collision: entity unchanged"
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 0 #:width 16 #:height 16 #:vx 2 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "x unchanged" 0 (entity-ref result #:x))
+ (test-equal "vx unchanged" 2 (entity-ref result #:vx)))))
+
+ (test-group "zero vx: skipped entirely"
+ (let* ((tm (make-test-tilemap '((0 1 0) (0 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 0)))
+ (test-equal "entity eq? when vx=0" e (resolve-tile-collisions-x e tm))))
+
+ (test-group "collision moving right: push left"
+ ;; solid at col=1 (x=16..31); entity at x=20 overlaps it, vx>0
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 1 0) (0 0 0))))
+ (e '(#:type player #:x 20 #:y 16 #:width 16 #:height 16 #:vx 5 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "pushed left of solid tile" 0 (entity-ref result #:x))
+ (test-equal "vx zeroed" 0 (entity-ref result #:vx)))))
+
+ (test-group "collision moving left: push right"
+ ;; solid at col=1 (x=16..31); entity at x=16 overlaps it, vx<0
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 1 0) (0 0 0))))
+ (e '(#:type player #:x 16 #:y 16 #:width 16 #:height 16 #:vx -5 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "pushed right of solid tile" 32 (entity-ref result #:x))
+ (test-equal "vx zeroed" 0 (entity-ref result #:vx)))))
+
+ (test-group "floating-point x position"
+ ;; solid at col=1; entity at x=20.5 (float), vx>0
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 1 0) (0 0 0))))
+ (e '(#:type player #:x 20.5 #:y 16 #:width 16 #:height 16 #:vx 2 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "pushed left of solid tile" 0 (entity-ref result #:x))
+ (test-equal "vx zeroed" 0 (entity-ref result #:vx)))))
+
+ (test-group "entity spanning two columns: both checked"
+ ;; wall at col=3; 20px-wide entity at x=28 spans cols 1 and 2, no collision
+ (let* ((tm (make-test-tilemap '((0 0 0 1) (0 0 0 1) (0 0 0 1))))
+ (e '(#:type player #:x 28 #:y 0 #:width 20 #:height 16 #:vx 3 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "no collision yet" 28 (entity-ref result #:x))))
+ ;; entity moved to x=34 now spans cols 2 and 3 (solid), pushed left
+ (let* ((tm (make-test-tilemap '((0 0 0 1) (0 0 0 1) (0 0 0 1))))
+ (e '(#:type player #:x 34 #:y 0 #:width 20 #:height 16 #:vx 3 #:vy 0)))
+ (let ((result (resolve-tile-collisions-x e tm)))
+ (test-equal "pushed left of wall" 28 (entity-ref result #:x))
+ (test-equal "vx zeroed" 0 (entity-ref result #:vx))))))
+
+(test-group "resolve-tile-collisions-y"
+ (test-group "no collision: entity unchanged"
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "y unchanged" 0 (entity-ref result #:y))
+ (test-equal "vy unchanged" 2 (entity-ref result #:vy)))))
+
+ (test-group "zero vy: skipped entirely"
+ (let* ((tm (make-test-tilemap '((1 0 0) (0 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 0)))
+ (test-equal "entity eq? when vy=0" e (resolve-tile-collisions-y e tm))))
+
+ (test-group "collision moving down: push up"
+ ;; solid at row=1 (y=16..31); entity at y=20 overlaps it, vy>0
+ (let* ((tm (make-test-tilemap '((0 0 0) (1 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 20 #:width 16 #:height 16 #:vx 0 #:vy 5)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "pushed above solid tile" 0 (entity-ref result #:y))
+ (test-equal "vy zeroed" 0 (entity-ref result #:vy)))))
+
+ (test-group "collision moving up: push down"
+ ;; solid at row=1 (y=16..31); entity at y=16 overlaps it from below, vy<0
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 1 0) (0 0 0))))
+ (e '(#:type player #:x 16 #:y 16 #:width 16 #:height 16 #:vx 0 #:vy -5)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "pushed below solid tile" 32 (entity-ref result #:y))
+ (test-equal "vy zeroed" 0 (entity-ref result #:vy)))))
+
+ (test-group "floating-point y position"
+ ;; solid at row=1; entity at y=20.5 (float), vy>0
+ (let* ((tm (make-test-tilemap '((0 0 0) (1 0 0) (0 0 0))))
+ (e '(#:type player #:x 0 #:y 20.5 #:width 16 #:height 16 #:vx 0 #:vy 3)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "pushed above solid tile" 0 (entity-ref result #:y))
+ (test-equal "vy zeroed" 0 (entity-ref result #:vy)))))
+
+ (test-group "entity spanning two rows: both checked"
+ ;; floor at row=3; 20px-tall entity at y=28 spans rows 1 and 2, no collision
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0) (1 1 1))))
+ (e '(#:type player #:x 0 #:y 28 #:width 16 #:height 20 #:vx 0 #:vy 3)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "no collision yet" 28 (entity-ref result #:y))))
+ ;; entity at y=34 now spans rows 2 and 3 (solid), pushed up
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0) (1 1 1))))
+ (e '(#:type player #:x 0 #:y 34 #:width 16 #:height 20 #:vx 0 #:vy 3)))
+ (let ((result (resolve-tile-collisions-y e tm)))
+ (test-equal "pushed above floor" 28 (entity-ref result #:y))
+ (test-equal "vy zeroed" 0 (entity-ref result #:vy))))))
+
+;; Integration test: simulate the actual game physics loop
+(test-group "multi-frame physics simulation"
+ (test-group "player falls and lands on floor (10 frames)"
+ ;; 3x4 tilemap: air on rows 0-2, solid floor on row 3
+ ;; Player starts at y=0, 16px tall; floor is at y=48
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0) (1 1 1))))
+ (e0 '(#:type player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:input-map ())))
+ (let loop ((e e0) (n 10))
+ (if (= n 0)
+ (begin
+ (test-assert "player rests at or above floor" (<= (entity-ref e #:y) 32))
+ (test-assert "y is non-negative" (>= (entity-ref e #:y) 0)))
+ (loop (tick e tm (lambda (a) #f)) (- n 1))))))
+
+ (test-group "player stable on floor (10 frames of gravity jitter)"
+ ;; Player already on floor, should stay there
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 1 1))))
+ ;; Floor at row 2 (y=32); player at y=16, height=16: bottom at y=32
+ (e0 '(#:type player #:x 0 #:y 16 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:input-map ())))
+ (let loop ((e e0) (n 10))
+ (if (= n 0)
+ (test-assert "player stays on floor" (<= (entity-ref e #:y) 16))
+ (loop (tick e tm (lambda (a) #f)) (- n 1))))))
+
+ (test-group "player with real starting coordinates (x=182 y=350.5) falls 5 frames"
+ ;; Use a large enough tilemap: 15 cols x 25 rows, solid floor at row 24
+ (let* ((empty-row (make-list 15 0))
+ (solid-row (make-list 15 1))
+ (rows (append (make-list 24 empty-row) (list solid-row)))
+ (tm (make-test-tilemap rows))
+ (e0 (make-player-entity 182 350.5 16 16)))
+ ;; Should not crash
+ (let loop ((e e0) (n 5))
+ (if (= n 0)
+ (test-assert "player survived 5 frames" #t)
+ (loop (tick e tm (lambda (a) #f)) (- n 1)))))))
+
+(test-group "resolve-entity-collisions"
+ (define (make-solid x y w h)
+ (list #:type 'block #:x x #:y y #:width w #:height h #:solid? #t))
+
+ (test-group "no overlap: entities unchanged"
+ (let* ((a (make-solid 0 0 16 16))
+ (b (make-solid 100 0 16 16))
+ (result (resolve-entity-collisions (list a b))))
+ (test-equal "a x unchanged" 0 (entity-ref (list-ref result 0) #:x 0))
+ (test-equal "b x unchanged" 100 (entity-ref (list-ref result 1) #:x 0))))
+
+ (test-group "horizontal overlap: pushed apart on x"
+ ;; a at x=0, b at x=10, both 16x16 → overlap-x = (16+16)/2 - 10 = 6, overlap-y = (16+16)/2 - 0 = 16
+ ;; push on x (smaller), each by 3px
+ (let* ((a (make-solid 0 0 16 16))
+ (b (make-solid 10 0 16 16))
+ (result (resolve-entity-collisions (list a b)))
+ (ra (list-ref result 0))
+ (rb (list-ref result 1)))
+ (test-equal "a pushed left by 3" -3 (entity-ref ra #:x 0))
+ (test-equal "b pushed right by 3" 13 (entity-ref rb #:x 0))))
+
+ (test-group "vertical overlap: pushed apart on y"
+ ;; a at y=0, b at y=10, both 16x16 → overlap-x=16, overlap-y=6 → push on y
+ (let* ((a (make-solid 0 0 16 16))
+ (b (make-solid 0 10 16 16))
+ (result (resolve-entity-collisions (list a b)))
+ (ra (list-ref result 0))
+ (rb (list-ref result 1)))
+ (test-equal "a pushed up by 3" -3 (entity-ref ra #:y 0))
+ (test-equal "b pushed down by 3" 13 (entity-ref rb #:y 0))))
+
+ (test-group "non-solid entity ignored"
+ (let* ((a (make-solid 0 0 16 16))
+ (b (list #:type 'goal #:x 5 #:y 5 #:width 16 #:height 16))
+ (result (resolve-entity-collisions (list a b))))
+ (test-equal "a x unchanged" 0 (entity-ref (list-ref result 0) #:x 0))
+ (test-equal "b x unchanged" 5 (entity-ref (list-ref result 1) #:x 0)))))
+
+;; New tests for detect-ground and apply-jump
+(test-group "detect-ground"
+ (test-group "entity standing on solid tile"
+ ;; Tilemap: 3 rows, row 2 is solid (tile=1), rows 0-1 empty (tile=0)
+ ;; tilewidth=tileheight=16
+ ;; Entity standing: y=16, h=16 → bottom at y=32, probe at y=33 → row=2 → solid
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 1 1))))
+ (e (list #:type 'player #:x 0 #:y 16 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
+ (result (detect-ground e tm)))
+ (test-assert "on-ground? is #t" (entity-ref result #:on-ground? #f))))
+
+ (test-group "entity in mid-air"
+ ;; Entity in mid-air: y=0, h=16 → bottom at 16, probe at 17 → row=1 → empty
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 1 1))))
+ (e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #t))
+ (result (detect-ground e tm)))
+ (test-assert "on-ground? is #f" (not (entity-ref result #:on-ground? #f)))))
+
+ (test-group "entity probe spans two tiles, left is solid"
+ ;; Entity at x=0, w=16: left foot at col 0; probe below
+ ;; Row with solid at col 0, empty at col 1: should be on-ground
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 0 0))))
+ (e (list #:type 'player #:x 0 #:y 16 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
+ (result (detect-ground e tm)))
+ (test-assert "on-ground? is #t (left foot on solid)" (entity-ref result #:on-ground? #f))))
+
+ (test-group "entity probe spans two tiles, right is solid"
+ ;; Entity at x=8, w=16: left foot at col 0, right foot at col 1; probe below
+ ;; Row with empty at col 0, solid at col 1: should be on-ground (right foot on solid)
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 1 0))))
+ (e (list #:type 'player #:x 8 #:y 16 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
+ (result (detect-ground e tm)))
+ (test-assert "on-ground? is #t (right foot on solid)" (entity-ref result #:on-ground? #f)))))
+
+(test-group "apply-jump"
+ (test-group "on-ground and pressed → impulse applied"
+ (let* ((e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:on-ground? #t))
+ (result (apply-jump e #t)))
+ (test-equal "ay is -jump-force" (- *jump-force*) (entity-ref result #:ay 0))))
+
+ (test-group "on-ground but not pressed → unchanged"
+ (let* ((e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:on-ground? #t))
+ (result (apply-jump e #f)))
+ (test-equal "vy unchanged" 0 (entity-ref result #:vy 0))))
+
+ (test-group "in-air and pressed → no double jump"
+ (let* ((e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy -5 #:on-ground? #f))
+ (result (apply-jump e #t)))
+ (test-equal "vy unchanged (no double jump)" -5 (entity-ref result #:vy 0))))
+
+ (test-group "in-air and not pressed → unchanged"
+ (let* ((e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
+ #:vx 0 #:vy -5 #:on-ground? #f))
+ (result (apply-jump e #f)))
+ (test-equal "vy unchanged" -5 (entity-ref result #:vy 0)))))
+
+(test-group "apply-acceleration"
+ (test-group "gravity? #t, ay set: consumed into vy and cleared"
+ (let* ((e '(#:type player #:x 0 #:y 0 #:vy 3 #:ay 5 #:gravity? #t))
+ (result (apply-acceleration e)))
+ (test-equal "vy += ay" 8 (entity-ref result #:vy 0))
+ (test-equal "ay cleared" 0 (entity-ref result #:ay 0))))
+
+ (test-group "gravity? #t, ay is 0: vy unchanged"
+ (let* ((e '(#:type player #:x 0 #:y 0 #:vy 3 #:ay 0 #:gravity? #t))
+ (result (apply-acceleration e)))
+ (test-equal "vy unchanged" 3 (entity-ref result #:vy 0))
+ (test-equal "ay still 0" 0 (entity-ref result #:ay 0))))
+
+ (test-group "gravity? #f: entity unchanged"
+ (let* ((e '(#:type player #:x 0 #:y 0 #:vy 3 #:ay 5 #:gravity? #f))
+ (result (apply-acceleration e)))
+ (test-equal "entity unchanged" e result))))
+
+(test-group "pixel->tile"
+ (test-equal "pixel 0 in 16px tile → 0" 0 (pixel->tile 0 16))
+ (test-equal "pixel 15 in 16px tile → 0" 0 (pixel->tile 15 16))
+ (test-equal "pixel 16 in 16px tile → 1" 1 (pixel->tile 16 16))
+ (test-equal "pixel 24 in 16px tile → 1" 1 (pixel->tile 24 16))
+ (test-equal "pixel 24.7 in 16px tile → 1" 1 (pixel->tile 24.7 16))
+ (test-equal "pixel 32 in 16px tile → 2" 2 (pixel->tile 32 16)))
+
+(test-group "entity-tile-cells"
+ (test-group "entity aligned to one tile"
+ (let* ((tm (make-test-tilemap '((0 0) (0 0))))
+ (e '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (cells (entity-tile-cells e tm)))
+ (test-equal "one cell" 1 (length cells))
+ (test-equal "cell is (0 . 0)" '(0 . 0) (car cells))))
+
+ (test-group "entity spanning 2 cols and 2 rows"
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0))))
+ (e '(#:type player #:x 8 #:y 8 #:width 16 #:height 16))
+ (cells (entity-tile-cells e tm)))
+ (test-equal "four cells" 4 (length cells)))))
+
+(test-group "tile-push-pos"
+ (test-group "moving forward (v>0): snap leading edge to near side of tile"
+ ;; coord=3, tile-size=16, entity-size=16 → 3*16 - 16 = 32
+ (test-equal "push pos" 32 (tile-push-pos 1 3 16 16)))
+
+ (test-group "moving backward (v<0): snap trailing edge to far side of tile"
+ ;; coord=3, tile-size=16 → (3+1)*16 = 64
+ (test-equal "push pos" 64 (tile-push-pos -1 3 16 16))))
+
+(test-group "list-set"
+ (test-equal "replace first" '(x b c) (list-set '(a b c) 0 'x))
+ (test-equal "replace middle" '(a x c) (list-set '(a b c) 1 'x))
+ (test-equal "replace last" '(a b x) (list-set '(a b c) 2 'x)))
+
+(test-group "index-pairs"
+ (test-equal "n=0: empty" '() (index-pairs 0))
+ (test-equal "n=1: empty" '() (index-pairs 1))
+ (test-equal "n=2: one pair" '((0 . 1)) (index-pairs 2))
+ (test-group "n=3: three pairs"
+ (let ((pairs (index-pairs 3)))
+ (test-equal "count" 3 (length pairs))
+ (test-assert "(0 . 1)" (member '(0 . 1) pairs))
+ (test-assert "(0 . 2)" (member '(0 . 2) pairs))
+ (test-assert "(1 . 2)" (member '(1 . 2) pairs)))))
+
+(test-group "axis->dimension"
+ (test-equal "#:x → #:width" #:width (axis->dimension #:x))
+ (test-equal "#:y → #:height" #:height (axis->dimension #:y)))
+
+(test-group "axis->velocity"
+ (test-equal "#:x → #:vx" #:vx (axis->velocity #:x))
+ (test-equal "#:y → #:vy" #:vy (axis->velocity #:y)))
+
+(test-group "push-entity"
+ (test-group "push right (sign=1): x += overlap/2, vx=1"
+ (let* ((e '(#:type player #:x 10 #:y 0 #:vx 0 #:vy 0))
+ (result (push-entity e #:x #:vx 10 6 1)))
+ (test-equal "x = 10 + 3" 13 (entity-ref result #:x 0))
+ (test-equal "vx = 1" 1 (entity-ref result #:vx 0))))
+
+ (test-group "push left (sign=-1): x -= overlap/2, vx=-1"
+ (let* ((e '(#:type player #:x 10 #:y 0 #:vx 0 #:vy 0))
+ (result (push-entity e #:x #:vx 10 6 -1)))
+ (test-equal "x = 10 - 3" 7 (entity-ref result #:x 0))
+ (test-equal "vx = -1" -1 (entity-ref result #:vx 0)))))
+
+(test-group "entity-center-on-axis"
+ (let ((e '(#:type player #:x 10 #:y 20 #:width 16 #:height 24)))
+ (test-equal "center-x = 10 + 8 = 18" 18 (entity-center-on-axis e #:x))
+ (test-equal "center-y = 20 + 12 = 32" 32 (entity-center-on-axis e #:y))))
+
+(test-group "aabb-overlap-on-axis"
+ (test-group "x overlap: a at x=0 w=16, b at x=10 w=16 → overlap=6"
+ ;; half-sum of widths = 16, center dist = |18 - 8| = 10, overlap = 16 - 10 = 6
+ (let ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 10 #:y 0 #:width 16 #:height 16)))
+ (test-equal "x overlap = 6" 6 (aabb-overlap-on-axis #:x a b))))
+
+ (test-group "y overlap: a at y=0 h=16, b at y=10 h=16 → overlap=6"
+ (let ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 0 #:y 10 #:width 16 #:height 16)))
+ (test-equal "y overlap = 6" 6 (aabb-overlap-on-axis #:y a b))))
+
+ (test-group "no overlap: negative value"
+ (let ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 100 #:y 0 #:width 16 #:height 16)))
+ (test-assert "x overlap is negative" (< (aabb-overlap-on-axis #:x a b) 0)))))
+
+(test-group "push-along-axis"
+ (test-group "x axis: a left of b, pushed apart"
+ (let* ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 10 #:y 0 #:width 16 #:height 16))
+ (result (push-along-axis #:x a b 6))
+ (ra (car result))
+ (rb (cdr result)))
+ (test-equal "a pushed left to -3" -3 (entity-ref ra #:x 0))
+ (test-equal "b pushed right to 13" 13 (entity-ref rb #:x 0))
+ (test-equal "a vx = -1" -1 (entity-ref ra #:vx 0))
+ (test-equal "b vx = 1" 1 (entity-ref rb #:vx 0))))
+
+ (test-group "y axis: a above b, pushed apart"
+ (let* ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 0 #:y 10 #:width 16 #:height 16))
+ (result (push-along-axis #:y a b 6))
+ (ra (car result))
+ (rb (cdr result)))
+ (test-equal "a pushed up to -3" -3 (entity-ref ra #:y 0))
+ (test-equal "b pushed down to 13" 13 (entity-ref rb #:y 0)))))
+
+(test-group "push-apart"
+ (test-group "x overlap smaller: pushes on x axis"
+ ;; a at (0,0), b at (10,0), both 16x16: ovx=6, ovy=16 → push on x
+ (let* ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 10 #:y 0 #:width 16 #:height 16))
+ (result (push-apart a b)))
+ (test-equal "a pushed left" -3 (entity-ref (car result) #:x 0))
+ (test-equal "b pushed right" 13 (entity-ref (cdr result) #:x 0))))
+
+ (test-group "y overlap smaller: pushes on y axis"
+ ;; a at (0,0), b at (0,10), both 16x16: ovx=16, ovy=6 → push on y
+ (let* ((a '(#:type player #:x 0 #:y 0 #:width 16 #:height 16))
+ (b '(#:type player #:x 0 #:y 10 #:width 16 #:height 16))
+ (result (push-apart a b)))
+ (test-equal "a pushed up" -3 (entity-ref (car result) #:y 0))
+ (test-equal "b pushed down" 13 (entity-ref (cdr result) #:y 0)))))
+
+(test-group "resolve-pair"
+ (define (make-solid x y) (list #:type 'block #:x x #:y y #:width 16 #:height 16 #:solid? #t))
+
+ (test-group "one entity not solid: returns #f"
+ (let ((a (make-solid 0 0))
+ (b '(#:type ghost #:x 5 #:y 5 #:width 16 #:height 16)))
+ (test-assert "returns #f" (not (resolve-pair a b)))))
+
+ (test-group "no overlap: returns #f"
+ (let ((a (make-solid 0 0))
+ (b (make-solid 100 0)))
+ (test-assert "returns #f" (not (resolve-pair a b)))))
+
+ (test-group "overlap: returns (a2 . b2) pair"
+ (let* ((a (make-solid 0 0))
+ (b (make-solid 10 0))
+ (result (resolve-pair a b)))
+ (test-assert "result is a pair" (pair? result))
+ (test-assert "a2 is an entity" (pair? (car result)))
+ (test-assert "b2 is an entity" (pair? (cdr result))))))
+
+(test-group "aabb-overlap?"
+ (test-group "two boxes clearly overlapping"
+ (test-assert "boxes overlap in center"
+ (aabb-overlap? 0 0 10 10 5 5 10 10)))
+
+ (test-group "two boxes not overlapping (separated horizontally)"
+ (test-assert "boxes don't overlap when separated on x-axis"
+ (not (aabb-overlap? 0 0 10 10 20 0 10 10))))
+
+ (test-group "two boxes not overlapping (separated vertically)"
+ (test-assert "boxes don't overlap when separated on y-axis"
+ (not (aabb-overlap? 0 0 10 10 0 20 10 10))))
+
+ (test-group "edge-touching exactly"
+ (test-assert "touching edges are not overlapping"
+ (not (aabb-overlap? 0 0 10 10 10 0 10 10))))
+
+ (test-group "one box fully inside another"
+ (test-assert "inner box overlaps with outer"
+ (aabb-overlap? 0 0 20 20 5 5 10 10))))
+
+(test-end "physics-module")
diff --git a/tests/renderer-test.scm b/tests/renderer-test.scm
new file mode 100644
index 0000000..a8fdeed
--- /dev/null
+++ b/tests/renderer-test.scm
@@ -0,0 +1,92 @@
+;; Load base deps
+(import scheme
+ (chicken base)
+ (chicken keyword)
+ (only srfi-1 fold iota for-each)
+ defstruct
+ srfi-64)
+
+;; Mock tilemap module
+(module tilemap *
+ (import scheme (chicken base) defstruct)
+ (defstruct tileset tilewidth tileheight spacing tilecount columns image-source image)
+ (defstruct layer name width height map)
+ (defstruct tilemap width height tilewidth tileheight tileset-source tileset layers objects)
+ (defstruct tile id rect)
+ (define (tileset-tile ts id) (make-tile id: id rect: #f))
+ (define (tile-rect t) #f))
+(import tilemap)
+
+;; Mock sdl2
+(module sdl2 *
+ (import scheme (chicken base))
+ (define (make-rect x y w h) (list x y w h))
+ (define (render-copy! . args) #f)
+ (define (render-copy-ex! . args) #f)
+ (define (create-texture-from-surface . args) #f))
+(import (prefix sdl2 "sdl2:"))
+
+;; Mock sdl2-ttf
+(module sdl2-ttf *
+ (import scheme (chicken base))
+ (define (render-text-solid . args) #f)
+ (define (size-utf8 . args) (values 0 0)))
+(import (prefix sdl2-ttf "ttf:"))
+
+;; Load entity module
+(include "entity.scm")
+(import entity)
+
+;; Load world module
+(include "world.scm")
+(import world)
+
+;; Load renderer module
+(include "renderer.scm")
+(import renderer)
+
+(test-begin "renderer")
+
+(test-group "entity-screen-coords"
+ (let* ((cam (make-camera x: 10 y: 20))
+ (e (list #:x 50 #:y 80 #:width 16 #:height 16)))
+ (test-equal "subtracts camera offset from x"
+ 40
+ (car (entity-screen-coords e cam)))
+ (test-equal "subtracts camera offset from y"
+ 60
+ (cadr (entity-screen-coords e cam)))
+ (test-equal "preserves width"
+ 16
+ (caddr (entity-screen-coords e cam)))
+ (test-equal "preserves height"
+ 16
+ (cadddr (entity-screen-coords e cam))))
+
+ (let* ((cam (make-camera x: 0 y: 0))
+ (e (list #:x 100.7 #:y 200.3 #:width 16 #:height 16)))
+ (test-equal "floors fractional x"
+ 100
+ (car (entity-screen-coords e cam)))
+ (test-equal "floors fractional y"
+ 200
+ (cadr (entity-screen-coords e cam))))
+
+ (let* ((cam (make-camera x: 0 y: 0))
+ (e (list #:x 0 #:y 0 #:width 32 #:height 32)))
+ (test-equal "zero camera, zero position"
+ '(0 0 32 32)
+ (entity-screen-coords e cam))))
+
+(test-group "entity-flip"
+ (test-equal "facing 1: no flip"
+ '()
+ (entity-flip (list #:facing 1)))
+ (test-equal "facing -1: horizontal flip"
+ '(horizontal)
+ (entity-flip (list #:facing -1)))
+ (test-equal "no facing key: defaults to no flip"
+ '()
+ (entity-flip (list #:x 0))))
+
+(test-end "renderer")
diff --git a/tests/tilemap-test.scm b/tests/tilemap-test.scm
new file mode 100644
index 0000000..a76cff9
--- /dev/null
+++ b/tests/tilemap-test.scm
@@ -0,0 +1,204 @@
+;; Load dependencies first
+(import scheme
+ (chicken base)
+ (chicken io)
+ (chicken file)
+ (chicken format)
+ (chicken string)
+ (chicken pathname)
+ (chicken process-context)
+ (chicken pretty-print)
+ (only srfi-1 filter-map)
+ expat
+ matchable
+ defstruct
+ (prefix sdl2 sdl2:)
+ (prefix sdl2-image img:)
+ srfi-69
+ srfi-64)
+
+;; Load the module source directly
+(include "tilemap.scm")
+;; Now import it to access the exported functions
+(import tilemap)
+
+;; Test suite for tilemap module
+(test-begin "tilemap-module")
+
+;; Test: tileset record creation
+(test-group "tileset-structure"
+ (let ((ts (make-tileset tilewidth: 16
+ tileheight: 16
+ spacing: 1
+ tilecount: 100
+ columns: 10
+ image-source: "test.png"
+ image: #f)))
+ (test-assert "tileset is a record" (tileset? ts))
+ (test-equal "tilewidth is set correctly" 16 (tileset-tilewidth ts))
+ (test-equal "tileheight is set correctly" 16 (tileset-tileheight ts))
+ (test-equal "spacing is set correctly" 1 (tileset-spacing ts))
+ (test-equal "tilecount is set correctly" 100 (tileset-tilecount ts))
+ (test-equal "columns is set correctly" 10 (tileset-columns ts))
+ (test-equal "image-source is set correctly" "test.png" (tileset-image-source ts))))
+
+;; Test: tileset-rows calculation
+(test-group "tileset-rows"
+ (let ((ts (make-tileset tilewidth: 16
+ tileheight: 16
+ spacing: 1
+ tilecount: 100
+ columns: 10
+ image-source: "test.png"
+ image: #f)))
+ (test-equal "100 tiles / 10 columns = 10 rows"
+ 10
+ (tileset-rows ts)))
+
+ (let ((ts (make-tileset tilewidth: 16
+ tileheight: 16
+ spacing: 1
+ tilecount: 105
+ columns: 10
+ image-source: "test.png"
+ image: #f)))
+ (test-equal "105 tiles / 10 columns = 11 rows (ceiling)"
+ 11
+ (tileset-rows ts))))
+
+;; Test: tileset-tile calculates correct tile position
+(test-group "tileset-tile"
+ (let* ((ts (make-tileset tilewidth: 16
+ tileheight: 16
+ spacing: 1
+ tilecount: 100
+ columns: 10
+ image-source: "test.png"
+ image: #f))
+ (tile1 (tileset-tile ts 1))
+ (tile11 (tileset-tile ts 11)))
+
+ (test-assert "tile1 is a tile record" (tile? tile1))
+ (test-equal "tile1 has correct id" 1 (tile-id tile1))
+ (test-assert "tile1 has a rect" (sdl2:rect? (tile-rect tile1)))
+
+ ;; First tile should be at (0, 0)
+ (test-equal "tile1 x position" 0 (sdl2:rect-x (tile-rect tile1)))
+ (test-equal "tile1 y position" 0 (sdl2:rect-y (tile-rect tile1)))
+
+ ;; Tile 11 should be at start of second row (x=0, y=17 with spacing)
+ (test-equal "tile11 x position" 0 (sdl2:rect-x (tile-rect tile11)))
+ (test-equal "tile11 y position" 17 (sdl2:rect-y (tile-rect tile11)))))
+
+;; Test: layer record creation
+(test-group "layer-structure"
+ (let ((layer (make-layer name: "ground"
+ width: 40
+ height: 30
+ map: '())))
+ (test-assert "layer is a record" (layer? layer))
+ (test-equal "name is set correctly" "ground" (layer-name layer))
+ (test-equal "width is set correctly" 40 (layer-width layer))
+ (test-equal "height is set correctly" 30 (layer-height layer))
+ (test-equal "map is empty list" '() (layer-map layer))))
+
+;; Test: object record creation
+(test-group "object-structure"
+ (let ((obj (make-object name: "player"
+ type: "Player"
+ x: 100
+ y: 200
+ width: 16
+ height: 16
+ properties: '((text . "hello")))))
+ (test-assert "object is a record" (object? obj))
+ (test-equal "name is set correctly" "player" (object-name obj))
+ (test-equal "type is set correctly" "Player" (object-type obj))
+ (test-equal "x is set correctly" 100 (object-x obj))
+ (test-equal "y is set correctly" 200 (object-y obj))
+ (test-equal "properties contain text" "hello" (alist-ref 'text (object-properties obj)))))
+
+;; Test: tilemap record creation
+(test-group "tilemap-structure"
+ (let ((tm (make-tilemap width: 40
+ height: 30
+ tilewidth: 16
+ tileheight: 16
+ tileset-source: "test.tsx"
+ tileset: '()
+ layers: '()
+ objects: '())))
+ (test-assert "tilemap is a record" (tilemap? tm))
+ (test-equal "width is set correctly" 40 (tilemap-width tm))
+ (test-equal "height is set correctly" 30 (tilemap-height tm))
+ (test-equal "tilewidth is set correctly" 16 (tilemap-tilewidth tm))
+ (test-equal "tileheight is set correctly" 16 (tilemap-tileheight tm))))
+
+;; Test: tile record creation
+(test-group "tile-structure"
+ (let* ((rect (sdl2:make-rect 0 0 16 16))
+ (tile (make-tile id: 1 rect: rect)))
+ (test-assert "tile is a record" (tile? tile))
+ (test-equal "id is set correctly" 1 (tile-id tile))
+ (test-assert "rect is an SDL rect" (sdl2:rect? (tile-rect tile)))))
+
+;; Test: parse-tileset XML parsing
+(test-group "parse-tileset"
+ (let* ((xml "<?xml version='1.0' encoding='UTF-8'?>
+<tileset version='1.10' tiledversion='1.11.2' name='test' tilewidth='16' tileheight='16' spacing='1' tilecount='100' columns='10'>
+ <image source='test.png' width='160' height='160'/>
+</tileset>")
+ (ts (parse-tileset xml)))
+ (test-assert "returns a tileset" (tileset? ts))
+ (test-equal "parses tilewidth" 16 (tileset-tilewidth ts))
+ (test-equal "parses tileheight" 16 (tileset-tileheight ts))
+ (test-equal "parses spacing" 1 (tileset-spacing ts))
+ (test-equal "parses tilecount" 100 (tileset-tilecount ts))
+ (test-equal "parses columns" 10 (tileset-columns ts))
+ (test-equal "parses image source" "test.png" (tileset-image-source ts))))
+
+;; Test: parse-tilemap XML parsing
+(test-group "parse-tilemap"
+ (let* ((xml "<?xml version='1.0' encoding='UTF-8'?>
+<map version='1.10' orientation='orthogonal' width='10' height='10' tilewidth='16' tileheight='16'>
+ <tileset firstgid='1' source='test.tsx'/>
+ <layer id='1' name='ground' width='10' height='10'>
+ <data encoding='csv'>
+1,2,3,4,5,6,7,8,9,10,
+11,12,13,14,15,16,17,18,19,20
+</data>
+ </layer>
+</map>")
+ (tm (parse-tilemap xml)))
+ (test-assert "returns a tilemap" (tilemap? tm))
+ (test-equal "parses width" 10 (tilemap-width tm))
+ (test-equal "parses height" 10 (tilemap-height tm))
+ (test-equal "parses tilewidth" 16 (tilemap-tilewidth tm))
+ (test-equal "parses tileheight" 16 (tilemap-tileheight tm))
+ (test-equal "parses tileset source" "test.tsx" (tilemap-tileset-source tm))
+ (test-assert "has layers" (not (null? (tilemap-layers tm))))
+ (test-equal "first layer name" "ground" (layer-name (car (tilemap-layers tm))))))
+
+;; Test: parse-tilemap with objects
+(test-group "parse-tilemap-with-objects"
+ (let* ((xml "<?xml version='1.0' encoding='UTF-8'?>
+<map version='1.10' orientation='orthogonal' width='10' height='10' tilewidth='16' tileheight='16'>
+ <tileset firstgid='1' source='test.tsx'/>
+ <objectgroup id='1' name='entities'>
+ <object id='1' name='player' type='Player' x='50' y='50' width='16' height='16'>
+ <properties>
+ <property name='speed' value='5'/>
+ </properties>
+ </object>
+ </objectgroup>
+</map>")
+ (tm (parse-tilemap xml)))
+ (test-assert "has objects" (not (null? (tilemap-objects tm))))
+ (let ((obj (car (tilemap-objects tm))))
+ (test-equal "object name" "player" (object-name obj))
+ (test-equal "object type" "Player" (object-type obj))
+ (test-equal "object x" 50 (object-x obj))
+ (test-equal "object y" 50 (object-y obj))
+ (test-equal "object has properties" "5" (alist-ref 'speed (object-properties obj))))))
+
+(test-end "tilemap-module")
diff --git a/tests/world-test.scm b/tests/world-test.scm
new file mode 100644
index 0000000..c758d2a
--- /dev/null
+++ b/tests/world-test.scm
@@ -0,0 +1,239 @@
+;; Load dependencies first
+(import scheme
+ (chicken base)
+ (chicken keyword)
+ defstruct
+ srfi-64
+ (only srfi-1 every member make-list))
+
+;; Create a mock tilemap module to avoid SDL dependency
+(module tilemap *
+ (import scheme (chicken base) defstruct)
+
+ (defstruct tileset
+ tilewidth
+ tileheight
+ spacing
+ tilecount
+ columns
+ image-source
+ image)
+
+ (defstruct layer
+ name
+ width
+ height
+ map)
+
+ (defstruct tilemap
+ width
+ height
+ tilewidth
+ tileheight
+ tileset-source
+ tileset
+ layers
+ objects))
+
+(import tilemap)
+
+;; Load entity module first (since world now imports entity)
+(include "entity.scm")
+(import entity)
+
+;; Load the module source directly
+(include "world.scm")
+;; Now import it to access the exported functions
+(import world)
+
+;; Test suite for world module
+(test-begin "world-module")
+
+;; Test: tilemap-tile-at retrieves tile IDs
+(test-group "tilemap-tile-at"
+ (test-group "valid positions in a small 3x3 tilemap"
+ (let* ((layer1 (make-layer name: "test" width: 3 height: 3
+ map: '((1 2 3) (4 5 6) (7 8 9))))
+ (tm (make-tilemap width: 3 height: 3
+ tilewidth: 16 tileheight: 16
+ tileset-source: ""
+ tileset: #f
+ layers: (list layer1)
+ objects: '())))
+ (test-equal "top-left corner" 1 (tilemap-tile-at tm 0 0))
+ (test-equal "top-right corner" 3 (tilemap-tile-at tm 2 0))
+ (test-equal "bottom-left corner" 7 (tilemap-tile-at tm 0 2))
+ (test-equal "center" 5 (tilemap-tile-at tm 1 1))))
+
+ (test-group "out-of-bounds returns 0"
+ (let* ((layer1 (make-layer name: "test" width: 3 height: 3
+ map: '((1 2 3) (4 5 6) (7 8 9))))
+ (tm (make-tilemap width: 3 height: 3
+ tilewidth: 16 tileheight: 16
+ tileset-source: ""
+ tileset: #f
+ layers: (list layer1)
+ objects: '())))
+ (test-equal "negative col" 0 (tilemap-tile-at tm -1 0))
+ (test-equal "col beyond width" 0 (tilemap-tile-at tm 3 0))
+ (test-equal "negative row" 0 (tilemap-tile-at tm 0 -1))
+ (test-equal "row beyond height" 0 (tilemap-tile-at tm 0 3))))
+
+ (test-group "zero tiles are skipped to next layer"
+ (let* ((layer1 (make-layer name: "test1" width: 3 height: 3
+ map: '((0 0 0) (0 0 0) (0 0 0))))
+ (layer2 (make-layer name: "test2" width: 3 height: 3
+ map: '((1 2 3) (4 5 6) (7 8 9))))
+ (tm (make-tilemap width: 3 height: 3
+ tilewidth: 16 tileheight: 16
+ tileset-source: ""
+ tileset: #f
+ layers: (list layer1 layer2)
+ objects: '())))
+ (test-equal "skips zero in layer1, finds in layer2"
+ 5 (tilemap-tile-at tm 1 1)))))
+
+;; Test: scene record creation
+(test-group "scene-structure"
+ (let ((scene (make-scene entities: '() tilemap: #f)))
+ (test-assert "scene is a record" (scene? scene))
+ (test-equal "entities list is empty" '() (scene-entities scene))
+ (test-equal "tilemap is #f" #f (scene-tilemap scene))))
+
+;; Test: scene with entities and tilemap
+(test-group "scene-with-data"
+ (let* ((player (make-player-entity 100 100 16 16))
+ (enemy '(#:type enemy #:x 200 #:y 200))
+ (tilemap "mock-tilemap")
+ (scene (make-scene entities: (list player enemy)
+ tilemap: tilemap)))
+ (test-equal "scene has 2 entities"
+ 2
+ (length (scene-entities scene)))
+ (test-equal "first entity is player"
+ 'player
+ (entity-type (car (scene-entities scene))))
+ (test-equal "tilemap is set correctly"
+ "mock-tilemap"
+ (scene-tilemap scene))))
+
+;; Test: scene-add-entity adds entity to scene
+(test-group "scene-add-entity"
+ (let* ((player (make-player-entity 100 100 16 16))
+ (scene (make-scene entities: (list player) tilemap: #f))
+ (enemy '(#:type enemy #:x 200 #:y 200)))
+
+ (test-equal "initial entity count" 1 (length (scene-entities scene)))
+
+ (scene-add-entity scene enemy)
+
+ (test-equal "entity count after add" 2 (length (scene-entities scene)))
+ (test-equal "second entity is enemy"
+ 'enemy
+ (entity-type (cadr (scene-entities scene))))))
+
+;; Test: scene-add-entity appends to end
+(test-group "scene-add-entity-order"
+ (let* ((e1 '(#:type a #:x 1))
+ (e2 '(#:type b #:x 2))
+ (e3 '(#:type c #:x 3))
+ (scene (make-scene entities: (list e1) tilemap: #f)))
+
+ (scene-add-entity scene e2)
+ (scene-add-entity scene e3)
+
+ (test-equal "entities are in order"
+ '(a b c)
+ (map entity-type (scene-entities scene)))))
+
+;; Test: scene-update-entities applies function to all entities
+(test-group "scene-update-entities"
+ (let* ((e1 '(#:type player #:x 100 #:y 100))
+ (e2 '(#:type enemy #:x 200 #:y 200))
+ (scene (make-scene entities: (list e1 e2) tilemap: #f))
+ ;; Function that moves all entities right by 10
+ (move-right (lambda (entity)
+ (let ((x (entity-ref entity #:x))
+ (y (entity-ref entity #:y))
+ (type (entity-ref entity #:type)))
+ (list #:type type #:x (+ x 10) #:y y)))))
+
+ (scene-update-entities scene move-right)
+
+ (test-equal "first entity moved right"
+ 110
+ (entity-ref (car (scene-entities scene)) #:x))
+ (test-equal "second entity moved right"
+ 210
+ (entity-ref (cadr (scene-entities scene)) #:x))
+ (test-equal "y values unchanged"
+ 100
+ (entity-ref (car (scene-entities scene)) #:y))))
+
+;; Test: scene-update-entities with identity function
+(test-group "scene-update-entities-identity"
+ (let* ((e1 '(#:type player #:x 100))
+ (e2 '(#:type enemy #:x 200))
+ (scene (make-scene entities: (list e1 e2) tilemap: #f)))
+
+ (scene-update-entities scene (lambda (e) e))
+
+ (test-equal "entity count unchanged" 2 (length (scene-entities scene)))
+ (test-equal "first entity unchanged"
+ 100
+ (entity-ref (car (scene-entities scene)) #:x))))
+
+;; Test: scene mutation
+(test-group "scene-mutation"
+ (let* ((scene (make-scene entities: '() tilemap: #f))
+ (player (make-player-entity 10 20 16 16)))
+
+ ;; Add entity
+ (scene-add-entity scene player)
+ (test-equal "entity added" 1 (length (scene-entities scene)))
+
+ ;; Update entities
+ (scene-update-entities scene
+ (lambda (e)
+ (let ((x (entity-ref e #:x))
+ (y (entity-ref e #:y))
+ (type (entity-type e)))
+ (list #:type type #:x (* x 2) #:y (* y 2)
+ #:width 16 #:height 16))))
+
+ (test-equal "entity x doubled" 20 (entity-ref (car (scene-entities scene)) #:x))
+ (test-equal "entity y doubled" 40 (entity-ref (car (scene-entities scene)) #:y))))
+
+;; Test: scene-tilemap-set!
+(test-group "scene-tilemap-mutation"
+ (let ((scene (make-scene entities: '() tilemap: #f)))
+ (test-equal "tilemap initially #f" #f (scene-tilemap scene))
+
+ (scene-tilemap-set! scene "new-tilemap")
+ (test-equal "tilemap updated" "new-tilemap" (scene-tilemap scene))))
+
+;; Create a test tilemap for the filter test
+(define test-tilemap
+ (make-tilemap width: 3 height: 3
+ tilewidth: 16 tileheight: 16
+ tileset-source: ""
+ tileset: #f
+ layers: '()
+ objects: '()))
+
+;; Test: scene-filter-entities
+(test-group "scene-filter-entities"
+ (let* ((e1 (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16))
+ (e2 (list #:type 'enemy #:x 0 #:y 0 #:width 16 #:height 16))
+ (scene (make-scene entities: (list e1 e2)
+ tilemap: test-tilemap
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f)))
+ (scene-filter-entities scene
+ (lambda (e) (eq? (entity-ref e #:type #f) 'player)))
+ (test-equal "keeps matching entities" 1 (length (scene-entities scene)))
+ (test-equal "kept entity is player"
+ 'player
+ (entity-ref (car (scene-entities scene)) #:type #f))))
+
+(test-end "world-module")
diff --git a/tilemap.scm b/tilemap.scm
new file mode 100644
index 0000000..6e4dc95
--- /dev/null
+++ b/tilemap.scm
@@ -0,0 +1,235 @@
+(module tilemap
+ *
+ (import scheme
+ (chicken io)
+ (chicken base)
+ (chicken string)
+ (chicken format)
+ (chicken process-context)
+ (chicken pathname)
+ (chicken pretty-print)
+ (srfi 1)
+ matchable
+ expat
+ defstruct
+ (prefix sdl2-image "img:")
+ (prefix sdl2 "sdl2:"))
+
+(+ 1 1)
+
+ (defstruct tileset
+ tilewidth
+ tileheight
+ spacing
+ tilecount
+ columns
+ image-source
+ image)
+
+ (+ 1 1)
+
+ (defstruct layer
+ name
+ width
+ height
+ map)
+
+ (defstruct object
+ name
+ type
+ x
+ y
+ width
+ height
+ properties)
+
+ (defstruct tilemap
+ width
+ height
+ tilewidth
+ tileheight
+ tileset-source
+ tileset
+ layers
+ objects)
+
+ (defstruct tile
+ id
+ rect)
+
+ (define (maybe-do action)
+ (lambda (value)
+ (if (eq? value #f)
+ #f
+ (action value))))
+
+ (define (attempt action)
+ (lambda (value)
+ (or (action value) value)))
+
+ (define maybe-string->number (maybe-do (attempt string->number)))
+
+ (define (string-alist->alist string-alist)
+ (map (lambda (pair) (cons (string->symbol (car pair))
+ (maybe-string->number (cdr pair))))
+ string-alist))
+
+ (define (parse-tileset string-tileset)
+ (let ((parser (expat:make-parser))
+ (tags '())
+ (tileset (make-tileset 0 0 0 0 0 "")))
+ (expat:set-start-handler! parser
+ (lambda (tag attrs)
+ (let ((symbol-attrs (string-alist->alist attrs)))
+ (match tag
+ ("tileset" (set! tileset (alist->tileset symbol-attrs)))
+ ("image" (tileset-image-source-set! tileset (alist-ref 'source symbol-attrs)))
+ (_ #f)))
+ (set! tags (cons tag tags))))
+ (expat:set-end-handler! parser (lambda (tag)
+ (set! tags (cdr tags))))
+ (expat:set-character-data-handler! parser (lambda (line) #f))
+ (expat:parse parser string-tileset)
+ tileset))
+
+ (define (load-tileset file-name)
+ (call-with-input-file file-name
+ (lambda (port)
+ (let* ((tileset (parse-tileset (read-string #f port)))
+ (image-source (tileset-image-source tileset))
+ (base-path (pathname-directory file-name))
+ (img-to-load (if (absolute-pathname? image-source)
+ image-source
+ (pathname-replace-directory
+ image-source
+ (if (pathname-directory image-source)
+ (format "~a/~a" base-path (pathname-directory image-source))
+ base-path)))))
+ (tileset-image-set! tileset (img:load img-to-load))
+ tileset))))
+
+ (define (parse-tilemap string-tilemap)
+ (let ((parser (expat:make-parser))
+ (tags '())
+ (tilemap (make-tilemap width: 0 height: 0 tilewidth: 0 tileheight: 0
+ tileset-source: "" tileset: #f
+ layers: '() objects: '()))
+ (layer '())
+ (object '()))
+ (expat:set-start-handler!
+ parser
+ (lambda (tag attrs)
+ (let ((symbol-attrs (string-alist->alist attrs)))
+ (match tag
+ ("map"
+ (tilemap-width-set! tilemap (alist-ref 'width symbol-attrs))
+ (tilemap-height-set! tilemap (alist-ref 'height symbol-attrs))
+ (tilemap-tilewidth-set! tilemap (alist-ref 'tilewidth symbol-attrs))
+ (tilemap-tileheight-set! tilemap (alist-ref 'tileheight symbol-attrs)))
+ ("tileset"
+ (tilemap-tileset-source-set! tilemap (alist-ref 'source symbol-attrs)))
+ ("layer"
+ (set! layer (alist->layer attrs)))
+ ("object"
+ (set! object (alist->object symbol-attrs)))
+ ("property"
+ (object-properties-set!
+ object
+ (cons (cons (alist-ref 'name symbol-attrs) (alist-ref 'value symbol-attrs))
+ (or (object-properties object) '()))))
+ (_ #f))
+ (set! tags (cons tag tags)))))
+ (expat:set-end-handler!
+ parser
+ (lambda (tag)
+ (match tag
+ ("layer" (begin
+ (tilemap-layers-set! tilemap
+ (cons layer (tilemap-layers tilemap)))
+ (set! layer '())))
+ ("object" (tilemap-objects-set! tilemap (cons object (tilemap-objects tilemap))))
+ (_ #f))
+ (set! tags (cdr tags))))
+ (expat:set-character-data-handler!
+ parser
+ (lambda (line)
+ (when (string=? (car tags) "data")
+ (let ((txt (string-chomp line)))
+ (when (not (string=? txt ""))
+ (layer-map-set! layer (append
+ (or (layer-map layer) '())
+ (list (map string->number
+ (string-split txt ","))))))))))
+ (expat:parse parser string-tilemap)
+ tilemap))
+
+ (define (tileset-rows tileset)
+ "Return the number of rows in the tileset"
+ (inexact->exact (ceiling (/ (tileset-tilecount tileset) (tileset-columns tileset)))))
+
+ (define (tileset-tile tileset tile-id)
+ ;; Use the tileset's columns setting and the tileheight/tilewidth to
+ ;; find the tile's x,y location and create a rect
+ (let* ((tile-num (- tile-id 1)) ; tile-id starts at 1!
+ (tile-width (tileset-tilewidth tileset))
+ (tile-height (tileset-tileheight tileset))
+ (tile-x (modulo tile-num (tileset-columns tileset)))
+ (tile-y (inexact->exact (floor (/ tile-num (tileset-columns tileset)))))
+ (x (+ (* tile-x tile-width) tile-x))
+ (y (+ (* tile-y tile-height) tile-y)))
+ (make-tile
+ id: tile-id
+ rect: (sdl2:make-rect x y tile-width tile-height))))
+
+ (define (load-tilemap file-name)
+ (call-with-input-file file-name
+ (lambda (port)
+ (let* ((tilemap (parse-tilemap (read-string #f port)))
+ (base-path (pathname-directory file-name))
+ (tileset-source (tilemap-tileset-source tilemap)))
+ (tilemap-tileset-set! tilemap (load-tileset
+ (if (absolute-pathname? tileset-source)
+ tileset-source
+ (pathname-replace-directory
+ tileset-source
+ (if (pathname-directory tileset-source)
+ (format "~a/~a" base-path (pathname-directory tileset-source))
+ base-path))
+ )))
+ tilemap))))
+
+ (when #f
+
+ (let ((txt "<?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>
+"))
+ (tileset-image (parse-tileset txt)))
+
+ (let ((txt "<?xml version='1.0' encoding='UTF-8'?>
+<map version='1.10' tiledversion='1.11.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,169,0,0,0,0,
+844,0,0,0,0,0,845,546,546,546,
+</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='560.935' y='288.641' width='16' height='16'/>
+ </objectgroup>
+</map>
+"))
+ (tilemap-tileset (parse-tilemap txt)))
+
+ (tileset-image (tilemap-tileset (load-tilemap "assets/level-0.tmx")))
+ )
+
+ ) ;; End tilemap module
diff --git a/world.scm b/world.scm
new file mode 100644
index 0000000..ee1d60c
--- /dev/null
+++ b/world.scm
@@ -0,0 +1,59 @@
+(module world
+ *
+ (import scheme
+ (chicken base)
+ (only srfi-1 fold filter)
+ defstruct
+ tilemap
+ entity)
+ ;; Scene = current level: tilemap (layers, objects) + list of entities.
+
+ ;; Returns tile-id if the cell at (col, row) in this layer is non-zero, #f otherwise.
+ (define (layer-tile-at layer col row)
+ (let ((rows (layer-map layer)))
+ (and (< row (length rows))
+ (let ((row-data (list-ref rows row)))
+ (and (< col (length row-data))
+ (let ((tile-id (list-ref row-data col)))
+ (and (not (zero? tile-id)) tile-id)))))))
+
+ (define (tilemap-tile-at tilemap col row)
+ "Get the tile ID at grid position (col, row).
+ Returns 0 if out of bounds or if all layers have 0 at that cell."
+ (let ((width (tilemap-width tilemap))
+ (height (tilemap-height tilemap)))
+ (if (or (< col 0) (>= col width) (< row 0) (>= row height))
+ 0
+ (let loop ((layers (tilemap-layers tilemap)))
+ (if (null? layers)
+ 0
+ (or (layer-tile-at (car layers) col row)
+ (loop (cdr layers))))))))
+
+ (defstruct camera x y)
+
+ (defstruct scene
+ entities
+ tilemap
+ camera
+ tileset-texture)
+
+ (define (scene-add-entity scene entity)
+ (scene-entities-set! scene (append (scene-entities scene) (list entity)))
+ scene)
+
+ (define (scene-update-entities scene . procs)
+ "Apply each proc in sequence to the scene's entities; each proc maps over all entities.
+ The scene's entity list is replaced once with the final result."
+ (scene-entities-set! scene
+ (fold (lambda (proc es) (map proc es))
+ (scene-entities scene)
+ procs))
+ scene)
+
+ (define (scene-filter-entities scene pred)
+ "Remove all entities from scene that do not satisfy pred."
+ (scene-entities-set! scene
+ (filter pred (scene-entities scene)))
+ scene)
+)