aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-08 01:05:50 +0100
committerGene Pasquet <dev@etenil.net>2026-04-08 01:05:50 +0100
commit995342fb74fdd1ba5aeaa172a428538e7dd0dcdc (patch)
tree82a60034eaa097191d360fe07e4ef3a52dae9c2a
parent0c3a700aa94a0256c5e5b1a14819f10b3d3e869b (diff)
Code cleanup
-rw-r--r--animation.scm39
-rw-r--r--demo/sandbox.scm58
-rw-r--r--demo/tweens.scm175
-rw-r--r--docs/api.org20
-rw-r--r--docs/entities.org3
-rw-r--r--docs/tweens.org59
-rw-r--r--engine.scm112
-rw-r--r--input.scm24
-rw-r--r--physics.scm74
-rw-r--r--renderer.scm33
-rw-r--r--tests/tween-test.scm136
-rw-r--r--tween.scm104
-rw-r--r--world.scm31
13 files changed, 474 insertions, 394 deletions
diff --git a/animation.scm b/animation.scm
index a152753..4caf1fe 100644
--- a/animation.scm
+++ b/animation.scm
@@ -32,24 +32,25 @@
;; Pass the animation table for this entity's type.
;; Entities without #:anim-name are returned unchanged.
+ (define (advance-animation entity anim)
+ (let* ((tick (+ 1 (entity-ref entity #:anim-tick 0)))
+ (duration (animation-duration anim))
+ (frames (animation-frames anim))
+ (frame (entity-ref entity #:anim-frame 0)))
+ (if (>= tick duration)
+ (let ((new-frame (modulo (+ frame 1) (length frames))))
+ (entity-set (entity-set (entity-set entity
+ #:anim-tick 0)
+ #:anim-frame new-frame)
+ #:tile-id (frame->tile-id frames new-frame)))
+ (entity-set (entity-set entity #:anim-tick tick)
+ #:tile-id (frame->tile-id frames frame)))))
+
(define (animate-entity entity animations)
- (let ((anim-name (entity-ref entity #:anim-name #f)))
- (if (not anim-name)
- entity
- (let* ((entry (assq anim-name animations))
- (anim (and entry (cdr entry))))
- (if (not anim)
- entity
- (let* ((tick (+ 1 (entity-ref entity #:anim-tick 0)))
- (duration (animation-duration anim))
- (frames (animation-frames anim))
- (frame (entity-ref entity #:anim-frame 0)))
- (if (>= tick duration)
- (let ((new-frame (modulo (+ frame 1) (length frames))))
- (entity-set (entity-set (entity-set entity
- #:anim-tick 0)
- #:anim-frame new-frame)
- #:tile-id (frame->tile-id frames new-frame)))
- (entity-set (entity-set entity #:anim-tick tick)
- #:tile-id (frame->tile-id frames frame)))))))))
+ (let* ((anim-name (entity-ref entity #:anim-name #f))
+ (entry (and anim-name (assq anim-name animations)))
+ (anim (and entry (cdr entry))))
+ (if anim
+ (advance-animation entity anim)
+ entity)))
) ;; End of animation module
diff --git a/demo/sandbox.scm b/demo/sandbox.scm
index d7bd53f..f757c7f 100644
--- a/demo/sandbox.scm
+++ b/demo/sandbox.scm
@@ -27,41 +27,7 @@
;; ── Mutable demo state ──────────────────────────────────────────────────────
-(define *demo-t* 0.0)
-(define *shelf-endpoints* #f)
-(define *shelf-tween* #f)
-(define *shelf-origin* #f)
-
-;; ── Tween helpers ────────────────────────────────────────────────────────────
-
-(define (other-endpoint x endpoints)
- (let ((lo (car endpoints))
- (hi (cdr endpoints)))
- (if (< (abs (- x lo)) (abs (- x hi))) hi lo)))
-
-(define (make-ping-pong-tween leader endpoints)
- (make-tween leader
- props: `((#:x . ,(other-endpoint (entity-ref leader #:x 0) endpoints)))
- duration: 3500 ease: 'sine-in-out))
-
-(define (scene-replace-group-origin! scene gid new-origin)
- (scene-entities-set! scene
- (map (lambda (e)
- (if (and (entity-ref e #:group-origin? #f)
- (eq? (entity-ref e #:group-id) gid))
- new-origin
- e))
- (scene-entities scene))))
-
-(define (advance-shelf-tween! scene dt)
- (when (and *shelf-tween* *shelf-origin*)
- (let ((gid (entity-ref *shelf-origin* #:group-id)))
- (receive (tw2 e0) (tween-step *shelf-tween* *shelf-origin* dt)
- (set! *shelf-tween* (if (tween-finished? tw2)
- (make-ping-pong-tween e0 *shelf-endpoints*)
- tw2))
- (set! *shelf-origin* e0)
- (scene-replace-group-origin! scene gid e0)))))
+(define *demo-t* 0.0)
;; ── Tilemap builder ──────────────────────────────────────────────────────────
@@ -143,13 +109,16 @@
;; ── Scene builder ───────────────────────────────────────────────────────────
-(define (init-shelf-tween! shelf-list tw)
+(define (attach-shelf-tween! shelf-list tw)
(let* ((origin (car shelf-list))
(x-left (entity-ref origin #:x 0))
- (x-right (+ x-left (* 6 tw))))
- (set! *shelf-origin* origin)
- (set! *shelf-endpoints* (cons x-left x-right))
- (set! *shelf-tween* (make-ping-pong-tween origin *shelf-endpoints*))))
+ (x-right (+ x-left (* 6 tw)))
+ (tweened (entity-set origin #:tween
+ (make-tween origin
+ props: `((#:x . ,x-right))
+ duration: 3500 ease: 'sine-in-out
+ repeat: -1 yoyo?: #t))))
+ (cons tweened (cdr shelf-list))))
(define (make-sandbox-scene game)
(let* ((reg (load-prefabs "demo/assets/sandbox-groups.scm" (engine-mixins) '()))
@@ -160,14 +129,15 @@
(gw (game-width game))
(gh (game-height game))
(tm (make-sandbox-tilemap ts tw th gw gh))
- (shelf-list (instantiate-group-prefab reg 'shelf-platform
- (* 10 tw) (- gh (* 6 th))))
+ (shelf-list (attach-shelf-tween!
+ (instantiate-group-prefab reg 'shelf-platform
+ (* 10 tw) (- gh (* 6 th)))
+ tw))
(raft-list (instantiate-group-prefab reg 'collision-raft
120 (- gh (* 14 th))))
(bots (list (make-demo-bot 80 80 tw th 0)
(make-demo-bot 220 60 tw th 1)
(make-demo-bot 380 100 tw th 2))))
- (init-shelf-tween! shelf-list tw)
(make-scene
entities: (append shelf-list raft-list (spawn-boxes tw th) bots)
tilemap: tm
@@ -191,7 +161,7 @@
(set! *demo-t* (+ *demo-t* dt))
(let* ((scene (game-scene game))
(tm (scene-tilemap scene)))
- (advance-shelf-tween! scene dt)
+ (scene-update-entities scene (lambda (e) (step-tweens e dt)))
(scene-update-entities scene (lambda (e) (integrate-entity e dt tm)))
(scene-sync-groups! scene)
(scene-resolve-collisions scene)
diff --git a/demo/tweens.scm b/demo/tweens.scm
index 34c7759..a286a49 100644
--- a/demo/tweens.scm
+++ b/demo/tweens.scm
@@ -1,23 +1,17 @@
(import scheme
(chicken base)
(only srfi-1 iota)
- (only srfi-197 chain)
(prefix sdl2 "sdl2:")
(prefix sdl2-ttf "ttf:")
downstroke-engine
downstroke-world
downstroke-renderer
- downstroke-physics
downstroke-entity
downstroke-tween)
;; ── Constants ────────────────────────────────────────────────────────────────
(define +ease-duration+ 2600)
-(define +knock-cooldown-ms+ 3200)
-(define +knock-distance+ 88)
-(define +knock-duration+ 650)
-(define +knock-skip+ '(jump acceleration gravity velocity-x velocity-y))
(define +ease-syms+
'(linear quad-in quad-out quad-in-out cubic-in cubic-out cubic-in-out
@@ -30,127 +24,39 @@
;; ── State ────────────────────────────────────────────────────────────────────
-(define *ease-cells* #f) ; vector of #(ent tw left right ease-sym to-right?)
-(define *knock-ent* #f)
-(define *knock-tw* #f)
-(define *knock-cd* 0)
(define *label-font* #f)
(define *title-font* #f)
;; ── Ease grid ────────────────────────────────────────────────────────────────
-(define (make-ease-cell ease-sym y rgb)
+(define (make-ease-entity ease-sym y rgb)
(let* ((left 20)
(right (+ left 120))
- (ent (list #:type 'tween-demo #:x left #:y y
- #:width 14 #:height 14
- #:vx 0 #:vy 0 #:gravity? #f #:solid? #f #:color rgb))
- (tw (make-tween ent props: `((#:x . ,right))
- duration: +ease-duration+ ease: ease-sym)))
- (vector ent tw left right ease-sym #t)))
-
-(define (advance-ease-cell! cell dt)
- (let ((ent (vector-ref cell 0))
- (tw (vector-ref cell 1))
- (left (vector-ref cell 2))
- (right (vector-ref cell 3))
- (ease (vector-ref cell 4))
- (to-right? (vector-ref cell 5)))
- (receive (tw2 ent2) (tween-step tw ent dt)
- (vector-set! cell 0 ent2)
- (if (tween-finished? tw2)
- (let* ((next-dir (not to-right?))
- (target (if next-dir right left)))
- (vector-set! cell 1 (make-tween ent2 props: `((#:x . ,target))
- duration: +ease-duration+ ease: ease))
- (vector-set! cell 5 next-dir))
- (vector-set! cell 1 tw2)))))
-
-;; ── Knockback crate ──────────────────────────────────────────────────────────
-
-(define (run-physics e tm)
- (chain e
- (apply-jump _ #f)
- (apply-acceleration _)
- (apply-gravity _)
- (apply-velocity-x _)
- (resolve-tile-collisions-x _ tm)
- (apply-velocity-y _)
- (resolve-tile-collisions-y _ tm)
- (detect-on-solid _ tm)))
-
-(define (clamp-entity-to-screen e gw gh)
- (let* ((w (entity-ref e #:width 0))
- (h (entity-ref e #:height 0))
- (x (entity-ref e #:x 0))
- (y (entity-ref e #:y 0))
- (nx (max 0 (min (- gw w) x)))
- (ny (max 0 (min (- gh h) y)))
- (on-floor? (and (entity-ref e #:gravity? #f) (= ny (- gh h)))))
- (chain e
- (entity-set _ #:x nx)
- (entity-set _ #:y ny)
- (entity-set _ #:vx (if (= nx x) (entity-ref e #:vx 0) 0))
- (entity-set _ #:vy (if (= ny y) (entity-ref e #:vy 0) 0))
- (entity-set _ #:on-ground? on-floor?))))
-
-(define (run-physics-no-tilemap e gw gh)
- (chain e
- (apply-jump _ #f)
- (apply-acceleration _)
- (apply-gravity _)
- (apply-velocity-x _)
- (apply-velocity-y _)
- (clamp-entity-to-screen _ gw gh)))
-
-(define (maybe-start-knockback!)
- (when (and *knock-ent* (not *knock-tw*) (>= *knock-cd* +knock-cooldown-ms+))
- (set! *knock-cd* 0)
- (let ((x (entity-ref *knock-ent* #:x 0)))
- (set! *knock-ent* (entity-set *knock-ent* #:skip-pipelines +knock-skip+))
- (set! *knock-tw*
- (make-tween *knock-ent*
- props: `((#:x . ,(+ x +knock-distance+)))
- duration: +knock-duration+
- ease: 'back-out
- on-complete: (lambda (e)
- (set! *knock-ent* (entity-set e #:skip-pipelines '()))))))))
-
-(define (advance-knockback-tween! dt)
- (when *knock-tw*
- (receive (t2 e2) (tween-step *knock-tw* *knock-ent* dt)
- (set! *knock-tw* (if (tween-finished? t2) #f t2))
- (set! *knock-ent* e2))))
-
-(define (update-knockback! dt tm gw gh)
- (set! *knock-cd* (+ *knock-cd* dt))
- (maybe-start-knockback!)
- (advance-knockback-tween! dt)
- (when *knock-ent*
- (set! *knock-ent*
- (if tm
- (run-physics *knock-ent* tm)
- (run-physics-no-tilemap *knock-ent* gw gh)))))
+ (base (list #:x left #:y y)))
+ (list #:type 'tween-demo #:x left #:y y
+ #:width 14 #:height 14
+ #:vx 0 #:vy 0 #:gravity? #f #:solid? #f
+ #:color rgb
+ #:ease-name ease-sym
+ #:tween (make-tween base props: `((#:x . ,right))
+ duration: +ease-duration+ ease: ease-sym
+ repeat: -1 yoyo?: #t))))
;; ── Rendering ────────────────────────────────────────────────────────────────
-(define (draw-ease-labels! renderer)
+(define (draw-ease-labels! renderer entities)
(let ((white (sdl2:make-color 255 255 255 255)))
(draw-ui-text renderer *title-font*
- "Tween demo - easing rows + knockback / skip-pipelines" white 12 6)
+ "Tween demo - easing curves" white 12 6)
(draw-ui-text renderer *label-font*
- "Each box loops on X; bottom crate tweens right with physics skipped, screen bounds only."
- white 12 32)
- (do ((i 0 (+ i 1))) ((>= i (vector-length *ease-cells*)))
- (let* ((cell (vector-ref *ease-cells* i))
- (ent (vector-ref cell 0))
- (lab (symbol->string (vector-ref cell 4))))
- (draw-ui-text renderer *label-font* lab white
- 158 (- (entity-ref ent #:y 0) 2))))))
-
-(define (ease-cell-entities)
- (map (lambda (i) (vector-ref (vector-ref *ease-cells* i) 0))
- (iota (vector-length *ease-cells*))))
+ "Each box ping-pongs with repeat: -1 yoyo?: #t" white 12 32)
+ (for-each
+ (lambda (e)
+ (let ((name (entity-ref e #:ease-name #f)))
+ (when name
+ (draw-ui-text renderer *label-font* (symbol->string name) white
+ 158 (- (entity-ref e #:y 0) 2)))))
+ entities)))
;; ── Game ─────────────────────────────────────────────────────────────────────
@@ -163,37 +69,24 @@
(set! *label-font* (ttf:open-font "demo/assets/DejaVuSans.ttf" 13)))
create: (lambda (game)
- (set! *ease-cells*
- (list->vector
- (map (lambda (ease i)
- (make-ease-cell ease (+ 52 (* i 20)) (list-ref +ease-colors+ i)))
- +ease-syms+ (iota (length +ease-syms+)))))
- (set! *knock-ent*
- (list #:type 'knock-crate #:x 200 #:y 80
- #:width 18 #:height 18
- #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f #:solid? #f
- #:color '(140 110 70)))
- (set! *knock-tw* #f)
- (set! *knock-cd* 2500)
(game-scene-set! game
- (make-scene entities: (append (ease-cell-entities) (list *knock-ent*))
- tilemap: #f
- camera: (make-camera x: 0 y: 0)
- tileset-texture: #f
- camera-target: #f
- background: '(26 28 34))))
+ (make-scene
+ entities: (map (lambda (ease i)
+ (make-ease-entity ease (+ 52 (* i 20))
+ (list-ref +ease-colors+ i)))
+ +ease-syms+ (iota (length +ease-syms+)))
+ tilemap: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f
+ camera-target: #f
+ background: '(26 28 34))))
update: (lambda (game dt)
- (let ((tm (scene-tilemap (game-scene game)))
- (gw (game-width game))
- (gh (game-height game)))
- (do ((i 0 (+ i 1))) ((>= i (vector-length *ease-cells*)))
- (advance-ease-cell! (vector-ref *ease-cells* i) dt))
- (update-knockback! dt tm gw gh)
- (scene-entities-set! (game-scene game)
- (append (ease-cell-entities) (list *knock-ent*)))))
+ (scene-update-entities (game-scene game)
+ (lambda (e) (step-tweens e dt))))
render: (lambda (game)
- (draw-ease-labels! (game-renderer game)))))
+ (draw-ease-labels! (game-renderer game)
+ (scene-entities (game-scene game))))))
(game-run! *game*)
diff --git a/docs/api.org b/docs/api.org
index c418ec8..d4dc074 100644
--- a/docs/api.org
+++ b/docs/api.org
@@ -430,7 +430,8 @@ All entities can have these keys. Not all are required:
| ~#:on-ground?~ | boolean | Is entity touching a solid tile below? |
| ~#:facing~ | integer | 1 (right) or -1 (left) |
| ~#:solid?~ | boolean | Participate in AABB entity collisions? |
-| ~#:skip-pipelines~ | list | Symbols naming pipeline steps to skip; physics defines the built-in set (~docs/physics.org~) |
+| ~#:skip-pipelines~ | list | Symbols naming pipeline steps to skip; physics defines the built-in set (~docs/physics.org~); ~'tweens~ skips ~step-tweens~ |
+| ~#:tween~ | tween/false | Active tween struct, auto-advanced by ~step-tweens~ |
| ~#:anim-name~ | symbol | Current animation name |
| ~#:anim-frame~ | integer | Current frame index |
| ~#:anim-tick~ | integer | Ticks in current frame |
@@ -1046,16 +1047,19 @@ Time-based interpolation of numeric entity properties. Library-only — call fro
** ~make-tween~
#+begin_src scheme
-(make-tween entity #!key props duration (delay 0) ease (on-complete #f))
+(make-tween entity #!key props duration (delay 0) ease
+ (on-complete #f) (repeat 0) (yoyo? #f))
#+end_src
| Keyword | Description |
|---------+-------------|
| ~props~ | Alist ~((#:key . target-number) ...)~ |
| ~duration~ | Milliseconds of interpolation after ~delay~ |
-| ~delay~ | Initial wait in ms (default 0) |
+| ~delay~ | Initial wait in ms (default 0, first cycle only) |
| ~ease~ | Symbol (e.g. ~quad-in-out~) or ~(lambda (t) ...)~ with ~t~ in [0,1] |
-| ~on-complete~ | Optional ~(lambda (entity) ...)~ once at completion |
+| ~on-complete~ | Optional ~(lambda (entity) ...)~ once at final completion (not called with ~repeat: -1~) |
+| ~repeat~ | ~0~ = play once (default), ~N~ = replay N extra times, ~-1~ = loop forever |
+| ~yoyo?~ | ~#f~ (default) = same direction, ~#t~ = reverse each cycle |
** ~tween-step~
@@ -1067,6 +1071,14 @@ Returns two values: updated tween struct and updated entity. ~dt~ is elapsed mil
** ~tween-finished?~ / ~tween-active?~
+** ~step-tweens~
+
+#+begin_src scheme
+(step-tweens entity dt)
+#+end_src
+
+Pipeline step: auto-advances ~#:tween~ on an entity. No-op if ~#:tween~ is absent. Removes ~#:tween~ when the tween finishes. Skipped when ~'tweens~ is in ~#:skip-pipelines~. See ~docs/tweens.org~ for patterns.
+
** Easing exports
~ease-linear~, ~ease-quad-in~, ~ease-quad-out~, ~ease-quad-in-out~, ~ease-cubic-in~, ~ease-cubic-out~, ~ease-cubic-in-out~, ~ease-sine-in-out~, ~ease-expo-in~, ~ease-expo-out~, ~ease-expo-in-out~, ~ease-back-out~, ~ease-named~, ~ease-resolve~.
diff --git a/docs/entities.org b/docs/entities.org
index 3cdae6e..048cd8e 100644
--- a/docs/entities.org
+++ b/docs/entities.org
@@ -114,7 +114,8 @@ The engine recognizes these standard keys. Use them to integrate with the physic
| ~#:on-ground?~ | boolean | Whether the entity is supported from below (set by ~detect-on-solid~): solid tile under the feet and/or standing on another solid entity when you pass the scene entity list. Use this to gate jump input. |
| ~#:solid?~ | boolean | Whether this entity participates in entity-entity collision. If ~#t~, ~resolve-entity-collisions~ will check it against other solid entities. |
| ~#:immovable?~ | boolean | If ~#t~ with ~#:solid? #t~, entity–entity resolution only moves the *other* entity (static platforms). Two overlapping immovable solids are not separated. |
-| ~#:skip-pipelines~ | list of symbols | Optional. Each symbol names a physics step to skip for this entity (e.g. ~gravity~, ~velocity-x~). See ~docs/physics.org~. |
+| ~#:skip-pipelines~ | list of symbols | Optional. Each symbol names a pipeline step to skip (e.g. ~gravity~, ~velocity-x~, ~tweens~). See ~docs/physics.org~ and ~docs/tweens.org~. |
+| ~#:tween~ | tween struct or ~#f~ | Optional. When present, ~step-tweens~ auto-advances the tween each frame. Removed automatically when the tween finishes. See ~docs/tweens.org~. |
| ~#:tile-id~ | integer | Sprite index in the tileset (1-indexed). Used by ~render-scene!~ when the scene has a tileset texture and tile metadata (from the tilemap or ~scene-tileset~). Updated automatically by animation (~animate-entity~). |
| ~#:color~ | list | Optional ~(r g b)~ or ~(r g b a)~ (0–255 each). When ~#:tile-id~ is not drawn as a sprite (missing ~#:tile-id~, or no tileset texture), ~render-scene!~ fills the entity rect with this color. |
| ~#:facing~ | number | Horizontal flip direction: ~1~ = right (default), ~-1~ = left. Used by renderer to flip sprite horizontally. Update when changing direction. |
diff --git a/docs/tweens.org b/docs/tweens.org
index 9ac87cd..213b3ee 100644
--- a/docs/tweens.org
+++ b/docs/tweens.org
@@ -18,31 +18,79 @@ Durations and delays are in **milliseconds**, matching the =dt= argument to =upd
** ~make-tween~
#+begin_src scheme
-(make-tween entity #!key props duration (delay 0) ease (on-complete #f))
+(make-tween entity #!key props duration (delay 0) ease
+ (on-complete #f) (repeat 0) (yoyo? #f))
#+end_src
| Keyword | Meaning |
|---------+---------|
| ~props~ | Alist =((#:x . 200) (#:y . 40))= — keyword keys, numeric targets |
| ~duration~ | Positive integer, milliseconds of interpolation (after ~delay~) |
-| ~delay~ | Non-negative integer ms before interpolation starts |
+| ~delay~ | Non-negative integer ms before interpolation starts (first cycle only) |
| ~ease~ | Easing symbol (see table below) or ~(lambda (t) ...)= with ~t~ in $[0,1]$ |
-| ~on-complete~ | Optional ~(lambda (entity) ...)=, called **once** when the tween reaches its targets |
+| ~on-complete~ | Optional ~(lambda (entity) ...)=, called **once** when the tween fully ends (not called with ~repeat: -1~) |
+| ~repeat~ | ~0~ = play once (default), ~N~ = replay N additional times, ~-1~ = loop forever |
+| ~yoyo?~ | ~#f~ (default) = replay same direction, ~#t~ = reverse direction each cycle |
Start values are captured from ~entity~ at construction time. While the tween runs, intermediate values may be **inexact** (flonums) even if starts and ends are integers.
+*** Repeat and yoyo
+
+~repeat:~ controls how many extra times the tween replays after the initial play. ~yoyo?: #t~ swaps start and end values on each cycle, creating a ping-pong effect.
+
+| ~repeat~ | ~yoyo?~ | Behavior |
+|------+-------+----------|
+| ~0~ | either | Play once and finish (default) |
+| ~1~ | ~#f~ | Play forward twice, then finish |
+| ~1~ | ~#t~ | Play forward, then backward, then finish |
+| ~-1~ | ~#f~ | Play forward forever |
+| ~-1~ | ~#t~ | Ping-pong forever |
+
+~delay:~ only applies before the first cycle. ~on-complete~ fires once when the last repeat finishes; it never fires with ~repeat: -1~. Overflow time from a completed cycle carries into the next cycle for smooth transitions.
+
** ~tween-step~
#+begin_src scheme
(tween-step tween entity dt)
#+end_src
-Returns ~(values new-tween new-entity)~. Advance time by ~dt~ (ms). Before ~delay~ elapses, ~entity~ is unchanged. After completion, further steps return the same values (idempotent). When the tween completes, ~on-complete~ runs with the **final** entity (targets applied), then the callback slot is cleared.
+Returns ~(values new-tween new-entity)~. Advance time by ~dt~ (ms). Before ~delay~ elapses, ~entity~ is unchanged. After completion, further steps return the same values (idempotent). When the tween completes, ~on-complete~ runs with the **final** entity (targets applied), then the callback slot is cleared. With ~repeat:~ / ~yoyo?:~, the tween automatically resets for the next cycle.
** ~tween-finished?~ / ~tween-active?~
Predicates on the tween struct.
+** ~step-tweens~ (pipeline step)
+
+#+begin_src scheme
+(step-tweens entity dt)
+#+end_src
+
+Auto-advances ~#:tween~ on an entity. If the entity has no ~#:tween~ key, returns the entity unchanged. When the tween finishes (no more repeats), ~#:tween~ is removed from the entity. Respects ~#:skip-pipelines~: skipped when ~'tweens~ is in the list.
+
+This is the recommended way to run tweens in most games. Attach a tween to an entity and include ~step-tweens~ in your per-entity pipeline:
+
+#+begin_src scheme
+(scene-update-entities scene
+ (lambda (e) (step-tweens e dt)))
+#+end_src
+
+** Entity key: ~#:tween~
+
+Attach a tween directly to an entity for automatic advancement by ~step-tweens~:
+
+#+begin_src scheme
+(list #:type 'platform
+ #:x 100 #:y 300 #:width 48 #:height 16
+ #:solid? #t #:immovable? #t #:gravity? #f
+ #:tween (make-tween (list #:x 100)
+ props: '((#:x . 300))
+ duration: 3000 ease: 'sine-in-out
+ repeat: -1 yoyo?: #t))
+#+end_src
+
+The platform ping-pongs between x=100 and x=300 forever. No manual tween management needed.
+
* Easing
Each ease maps normalized time ~t ∈ [0,1]~ to an interpolation factor (usually in ~[0,1]~; ~back-out~ may exceed ~1~ briefly).
@@ -85,6 +133,5 @@ Example skip list for “kinematic shove” while keeping tile collisions:
* Limitations (current version)
-- Single segment per tween (no built-in chains or yoyo).
+- Single segment per tween (no built-in chains or sequences).
- Numeric properties only.
-- No engine integration — you wire ~tween-step~ yourself.
diff --git a/engine.scm b/engine.scm
index d88a94b..1d2c14f 100644
--- a/engine.scm
+++ b/engine.scm
@@ -115,25 +115,59 @@
(set! (sdl2:render-draw-color renderer) (sdl2:make-color r g b a)))
(set! (sdl2:render-draw-color renderer) (sdl2:make-color 0 0 0 255)))))
+;; ── game-run! helpers ─────────────────────────────────────────────────────
+
+(define (collect-sdl-events)
+ (sdl2:pump-events!)
+ (let collect ((lst '()))
+ (if (not (sdl2:has-events?))
+ (reverse lst)
+ (let ((e (sdl2:make-event)))
+ (sdl2:poll-event! e)
+ (collect (cons e lst))))))
+
+(define (resolve-hooks game)
+ (let* ((active (game-active-state game))
+ (state (and active
+ (hash-table-ref/default (game-states game) active #f))))
+ (values (or (and state (state-hook state #:update)) (game-update-hook game))
+ (or (and state (state-hook state #:render)) (game-render-hook game)))))
+
+(define (update-camera-follow! game)
+ (when (game-scene game)
+ (let ((target-tag (scene-camera-target (game-scene game))))
+ (when target-tag
+ (let ((target (scene-find-tagged (game-scene game) target-tag)))
+ (when target
+ (camera-follow! (scene-camera (game-scene game))
+ target
+ (game-width game)
+ (game-height game))))))))
+
+(define (game-render! game render-fn)
+ (renderer-set-clear-color! (game-renderer game) (game-scene game))
+ (sdl2:render-clear! (game-renderer game))
+ (when (game-scene game)
+ (render-scene! (game-renderer game) (game-scene game)))
+ (when (and (game-debug? game) (game-scene game))
+ (render-debug-scene! (game-renderer game) (game-scene game)))
+ (when render-fn (render-fn game))
+ (sdl2:render-present! (game-renderer game)))
+
;; ── game-run! ──────────────────────────────────────────────────────────────
-;; Main event loop and lifecycle orchestration
(define (game-run! game)
- ;; 1. SDL2 init (audio excluded — mixer.scm not yet extracted;
- ;; user calls init-audio! in their preload: hook)
(sdl2:set-main-ready!)
(sdl2:init! '(video joystick game-controller))
(ttf:init!)
(img:init! '(png))
- ;; Open any already-connected game controllers
(let init-controllers ((i 0))
(when (< i (sdl2:num-joysticks))
(when (sdl2:is-game-controller? i)
(sdl2:game-controller-open! i))
(init-controllers (+ i 1))))
- ;; 2. Create window + renderer (window size = logical size × scale)
(let ((scale (game-scale game)))
(game-window-set! game
(sdl2:create-window! (game-title game) 'centered 'centered
@@ -146,61 +180,23 @@
(game-renderer game)
(list (game-width game) (game-height game)))))
- ;; 3. preload: hook — user loads assets here
- (when (game-preload-hook game)
- ((game-preload-hook game) game))
-
- ;; 4. create: hook — user builds initial scene here
- (when (game-create-hook game)
- ((game-create-hook game) game))
+ (when (game-preload-hook game) ((game-preload-hook game) game))
+ (when (game-create-hook game) ((game-create-hook game) game))
- ;; 5. Frame loop
(let loop ((last-ticks (sdl2:get-ticks)))
- (let* ((now (sdl2:get-ticks))
- (dt (- now last-ticks)))
- ;; Collect all pending SDL2 events
- (sdl2:pump-events!)
- (let* ((events (let collect ((lst '()))
- (if (not (sdl2:has-events?))
- (reverse lst)
- (let ((e (sdl2:make-event)))
- (sdl2:poll-event! e)
- (collect (cons e lst))))))
- (input (input-state-update (game-input game) events
- (game-input-config game))))
- (game-input-set! game input)
- (unless (input-held? input 'quit)
- ;; Dispatch to active state hooks, or fall back to game's own hooks
- (let* ((active (game-active-state game))
- (state (and active
- (hash-table-ref/default (game-states game) active #f)))
- (update-fn (or (and state (state-hook state #:update))
- (game-update-hook game)))
- (render-fn (or (and state (state-hook state #:render))
- (game-render-hook game))))
- (when update-fn (update-fn game dt))
- ;; Auto camera-follow: if scene has a camera-target tag, follow it
- (when (game-scene game)
- (let ((target-tag (scene-camera-target (game-scene game))))
- (when target-tag
- (let ((target (scene-find-tagged (game-scene game) target-tag)))
- (when target
- (camera-follow! (scene-camera (game-scene game))
- target
- (game-width game)
- (game-height game)))))))
- (renderer-set-clear-color! (game-renderer game) (game-scene game))
- (sdl2:render-clear! (game-renderer game))
- (when (game-scene game)
- (render-scene! (game-renderer game) (game-scene game)))
- (when (and (game-debug? game) (game-scene game))
- (render-debug-scene! (game-renderer game) (game-scene game)))
- (when render-fn (render-fn game)))
- (sdl2:render-present! (game-renderer game))
- (sdl2:delay! (game-frame-delay game))
- (loop now)))))
-
- ;; 6. Cleanup
+ (let* ((now (sdl2:get-ticks))
+ (dt (- now last-ticks))
+ (input (input-state-update (game-input game) (collect-sdl-events)
+ (game-input-config game))))
+ (game-input-set! game input)
+ (unless (input-held? input 'quit)
+ (receive (update-fn render-fn) (resolve-hooks game)
+ (when update-fn (update-fn game dt))
+ (update-camera-follow! game)
+ (game-render! game render-fn))
+ (sdl2:delay! (game-frame-delay game))
+ (loop now))))
+
(sdl2:destroy-window! (game-window game))
(sdl2:quit!))
diff --git a/input.scm b/input.scm
index 94b29ed..eaaade4 100644
--- a/input.scm
+++ b/input.scm
@@ -162,23 +162,21 @@
((< vx 0) (entity-set entity #:facing -1))
(else entity)))
+(define (compute-input-delta input-map held?)
+ (fold (lambda (entry acc)
+ (if (held? (car entry))
+ (cons (+ (car acc) (cadr entry))
+ (+ (cdr acc) (cddr entry)))
+ acc))
+ '(0 . 0)
+ input-map))
+
(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))))
+ (let* ((delta (compute-input-delta input-map held?))
+ (vx (* (entity-ref entity #:move-speed 1) (car delta))))
(set-facing-from-vx (entity-set entity #:vx vx) vx)))))
) ;; end module
diff --git a/physics.scm b/physics.scm
index 083046d..56966c2 100644
--- a/physics.scm
+++ b/physics.scm
@@ -163,33 +163,28 @@
(<= (abs (- bottom oy)) *entity-ground-contact-tolerance*)))))
others))))
- ;; Standing on ground = solid tile 1px below feet and/or feet on top of another solid.
- ;; Optional ~other-entities~: when non-#f, must be a list of scene entities (include movers).
- ;; Call after tile and entity-entity collision so positions and ~#:vy~ are settled.
- ;; Name ends in ~?~ for call-site readability; it still returns an updated entity (not a boolean).
+ (define (tile-ground-below? entity tilemap)
+ (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)))
+ (or (not (zero? (tilemap-tile-at tilemap col-left row)))
+ (not (zero? (tilemap-tile-at tilemap col-right row))))))
+
(define-pipeline (detect-on-solid on-solid)
(entity tilemap #!optional (other-entities #f))
(if (not (entity-ref entity #:gravity? #f))
entity
- (let* ((tile-ground?
- (and tilemap
- (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)))
- (or (not (zero? (tilemap-tile-at tilemap col-left row)))
- (not (zero? (tilemap-tile-at tilemap col-right row)))))))
- (entity-ground?
- (and other-entities
- (entity-solid-support-below? entity other-entities)))
- (on-ground? (or tile-ground? entity-ground?)))
- (entity-set entity #:on-ground? on-ground?))))
+ (let* ((on-tile? (and tilemap (tile-ground-below? entity tilemap)))
+ (on-entity? (and other-entities
+ (entity-solid-support-below? entity other-entities))))
+ (entity-set entity #:on-ground? (or on-tile? on-entity?)))))
;; Set vertical acceleration for jump (consumed next frame by apply-acceleration)
(define-pipeline (apply-jump jump) (entity jump-pressed?)
@@ -262,27 +257,24 @@
;; horizontal (narrow overlap in X but deeper in Y), which shoves the mover
;; sideways instead of resting it on the platform. Prefer vertical separation
;; whenever ~m~'s center is still above ~s~'s center (landing contact).
+ (define (push-movable-along-axis m s axis overlap)
+ (let* ((mc (entity-center-on-axis m axis))
+ (sc (entity-center-on-axis s axis))
+ (dir (if (< mc sc) -1 1))
+ (pos (entity-ref m axis 0))
+ (vel (axis->velocity axis)))
+ (entity-set (entity-set m axis (+ pos (* dir overlap))) vel 0)))
+
(define (separate-movable-from-static m s)
(let* ((ovx (aabb-overlap-on-axis #:x m s))
(ovy (aabb-overlap-on-axis #:y m s))
- (m-cy (entity-center-on-axis m #:y))
- (s-cy (entity-center-on-axis s #:y))
- (land-on-top? (and (< m-cy s-cy) (> ovy 0))))
- (if land-on-top?
- (let* ((dir (if (< m-cy s-cy) -1 1))
- (my (entity-ref m #:y 0)))
- (entity-set (entity-set m #:y (+ my (* dir ovy))) #:vy 0))
- (if (<= ovx ovy)
- (let* ((mc (entity-center-on-axis m #:x))
- (sc (entity-center-on-axis s #:x))
- (dir (if (< mc sc) -1 1))
- (mx (entity-ref m #:x 0)))
- (entity-set (entity-set m #:x (+ mx (* dir ovx))) #:vx 0))
- (let* ((mc (entity-center-on-axis m #:y))
- (sc (entity-center-on-axis s #:y))
- (dir (if (< mc sc) -1 1))
- (my (entity-ref m #:y 0)))
- (entity-set (entity-set m #:y (+ my (* dir ovy))) #:vy 0))))))
+ (land-on-top? (and (< (entity-center-on-axis m #:y)
+ (entity-center-on-axis s #:y))
+ (> ovy 0))))
+ (cond
+ (land-on-top? (push-movable-along-axis m s #:y ovy))
+ ((<= ovx ovy) (push-movable-along-axis m s #:x ovx))
+ (else (push-movable-along-axis m s #:y ovy)))))
;; Check if two axis-aligned bounding boxes overlap.
;; Returns #t if they overlap, #f if they don't (including edge-touching).
diff --git a/renderer.scm b/renderer.scm
index 48698f7..b6ba238 100644
--- a/renderer.scm
+++ b/renderer.scm
@@ -209,6 +209,18 @@
(row-loop (cdr rows) (+ row 1)))))
(tilemap-layers tilemap))))
+ (define (draw-attack-hitbox renderer e tw cx cy)
+ (when (> (entity-ref e #:attack-timer 0) 0)
+ (let* ((ex (inexact->exact (floor (entity-ref e #:x 0))))
+ (ey (inexact->exact (floor (entity-ref e #:y 0))))
+ (ew (inexact->exact (floor (entity-ref e #:width 0))))
+ (eh (inexact->exact (floor (entity-ref e #:height 0))))
+ (facing (entity-ref e #:facing 1))
+ (ax (if (> facing 0) (+ ex ew) (- ex tw))))
+ (set! (sdl2:render-draw-color renderer) +debug-attack-color+)
+ (sdl2:render-fill-rect! renderer
+ (sdl2:make-rect (- ax cx) (- ey cy) tw eh)))))
+
(define (draw-debug-entities renderer camera scene)
(let* ((tilemap (scene-tilemap scene))
(tw (tilemap-tilewidth tilemap))
@@ -222,28 +234,11 @@
((eq? type 'player)
(set! (sdl2:render-draw-color renderer) +debug-player-color+)
(sdl2:render-fill-rect! renderer rect)
- (when (> (entity-ref e #:attack-timer 0) 0)
- (let* ((px (inexact->exact (floor (entity-ref e #:x 0))))
- (py (inexact->exact (floor (entity-ref e #:y 0))))
- (pw (inexact->exact (floor (entity-ref e #:width 0))))
- (ph (inexact->exact (floor (entity-ref e #:height 0))))
- (facing (entity-ref e #:facing 1))
- (ax (if (> facing 0) (+ px pw) (- px tw))))
- (set! (sdl2:render-draw-color renderer) +debug-attack-color+)
- (sdl2:render-fill-rect! renderer
- (sdl2:make-rect (- ax cx) (- py cy) tw ph)))))
+ (draw-attack-hitbox renderer e tw cx cy))
((eq? type 'enemy)
(set! (sdl2:render-draw-color renderer) +debug-enemy-color+)
(sdl2:render-fill-rect! renderer rect)
- (when (> (entity-ref e #:attack-timer 0) 0)
- (let* ((ex (inexact->exact (floor (entity-ref e #:x 0))))
- (ey (inexact->exact (floor (entity-ref e #:y 0))))
- (eh (inexact->exact (floor (entity-ref e #:height 0))))
- (facing (entity-ref e #:facing 1))
- (ax (if (> facing 0) (+ ex tw) (- ex tw))))
- (set! (sdl2:render-draw-color renderer) +debug-attack-color+)
- (sdl2:render-fill-rect! renderer
- (sdl2:make-rect (- ax cx) (- ey cy) tw eh))))))))
+ (draw-attack-hitbox renderer e tw cx cy)))))
(scene-entities scene))))
(define (render-debug-scene! renderer scene)
diff --git a/tests/tween-test.scm b/tests/tween-test.scm
index ebe62e0..4420c94 100644
--- a/tests/tween-test.scm
+++ b/tests/tween-test.scm
@@ -79,4 +79,140 @@
(test-assert (tween-finished? tw3))
(test-equal "x stays" 20.0 (entity-ref e3 #:x)))))))
+(test-group "repeat"
+ (test-group "repeat: 1 plays twice"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 100)) duration: 100
+ ease: 'linear repeat: 1)))
+ (receive (tw2 e2) (tween-step tw ent 100)
+ (test-assert "not finished after first play" (not (tween-finished? tw2)))
+ (test-equal "x at target" 100.0 (entity-ref e2 #:x))
+ (receive (tw3 e3) (tween-step tw2 e2 100)
+ (test-assert "finished after second play" (tween-finished? tw3))
+ (test-equal "x at target again" 100.0 (entity-ref e3 #:x))))))
+
+ (test-group "repeat: -1 never finishes"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 10)) duration: 10
+ ease: 'linear repeat: -1)))
+ (let loop ((tw tw) (ent ent) (i 0))
+ (if (>= i 5) (test-assert "still active after 5 cycles" (tween-active? tw))
+ (receive (tw2 e2) (tween-step tw ent 10)
+ (test-assert "not finished" (not (tween-finished? tw2)))
+ (loop tw2 e2 (+ i 1)))))))
+
+ (test-group "repeat: 0 is default (no repeat)"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 50)) duration: 50 ease: 'linear)))
+ (receive (tw2 _e2) (tween-step tw ent 50)
+ (test-assert "finished immediately" (tween-finished? tw2)))))
+
+ (test-group "on-complete fires after last repeat"
+ (let ((calls 0))
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 10)) duration: 10
+ ease: 'linear repeat: 1
+ on-complete: (lambda (_) (set! calls (+ calls 1))))))
+ (receive (tw2 e2) (tween-step tw ent 10)
+ (test-equal "no call after first play" 0 calls)
+ (receive (tw3 e3) (tween-step tw2 e2 10)
+ (test-equal "one call after last repeat" 1 calls))))))
+
+ (test-group "on-complete does not fire with repeat: -1"
+ (let ((calls 0))
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 10)) duration: 10
+ ease: 'linear repeat: -1
+ on-complete: (lambda (_) (set! calls (+ calls 1))))))
+ (let loop ((tw tw) (ent ent) (i 0))
+ (if (>= i 5) (test-equal "never called" 0 calls)
+ (receive (tw2 e2) (tween-step tw ent 10)
+ (loop tw2 e2 (+ i 1)))))))))
+
+(test-group "yoyo"
+ (test-group "yoyo: #t with repeat: 1 reverses"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 100)) duration: 100
+ ease: 'linear repeat: 1 yoyo?: #t)))
+ (receive (tw2 e2) (tween-step tw ent 100)
+ (test-equal "x at target after forward" 100.0 (entity-ref e2 #:x))
+ (receive (tw3 e3) (tween-step tw2 e2 50)
+ (test-equal "x halfway back" 50.0 (entity-ref e3 #:x))
+ (receive (tw4 e4) (tween-step tw3 e3 50)
+ (test-assert "finished after reverse" (tween-finished? tw4))
+ (test-equal "x back to start" 0.0 (entity-ref e4 #:x)))))))
+
+ (test-group "yoyo: #t with repeat: -1 ping-pongs forever"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 100)) duration: 100
+ ease: 'linear repeat: -1 yoyo?: #t)))
+ ;; Forward
+ (receive (tw2 e2) (tween-step tw ent 100)
+ (test-equal "at target" 100.0 (entity-ref e2 #:x))
+ ;; Reverse
+ (receive (tw3 e3) (tween-step tw2 e2 100)
+ (test-equal "back to start" 0.0 (entity-ref e3 #:x))
+ ;; Forward again
+ (receive (tw4 e4) (tween-step tw3 e3 100)
+ (test-equal "at target again" 100.0 (entity-ref e4 #:x))
+ (test-assert "still active" (tween-active? tw4)))))))
+
+ (test-group "yoyo: #f with repeat: 1 replays same direction"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 100)) duration: 100
+ ease: 'linear repeat: 1 yoyo?: #f)))
+ (receive (tw2 e2) (tween-step tw ent 100)
+ (test-equal "x at target" 100.0 (entity-ref e2 #:x))
+ ;; Second play starts from same starts (0→100), but entity is at 100
+ ;; The tween replays 0→100 using original start values
+ (receive (tw3 e3) (tween-step tw2 e2 50)
+ (test-assert "not finished mid-repeat" (not (tween-finished? tw3)))))))
+
+ (test-group "yoyo: #t without repeat has no effect"
+ (let* ((ent (list #:type 'a #:x 0))
+ (tw (make-tween ent props: '((#:x . 100)) duration: 100
+ ease: 'linear repeat: 0 yoyo?: #t)))
+ (receive (tw2 e2) (tween-step tw ent 100)
+ (test-assert "finishes normally" (tween-finished? tw2))
+ (test-equal "x at target" 100.0 (entity-ref e2 #:x))))))
+
+(test-group "step-tweens pipeline"
+ (test-group "advances #:tween on entity"
+ (let* ((ent (list #:type 'a #:x 0
+ #:tween (make-tween (list #:x 0) props: '((#:x . 100))
+ duration: 100 ease: 'linear)))
+ (e2 (step-tweens ent 50)))
+ (test-equal "x moved to midpoint" 50.0 (entity-ref e2 #:x))
+ (test-assert "tween still attached" (entity-ref e2 #:tween #f))))
+
+ (test-group "removes #:tween when finished"
+ (let* ((ent (list #:type 'a #:x 0
+ #:tween (make-tween (list #:x 0) props: '((#:x . 100))
+ duration: 100 ease: 'linear)))
+ (e2 (step-tweens ent 100)))
+ (test-equal "x at target" 100.0 (entity-ref e2 #:x))
+ (test-equal "tween removed" #f (entity-ref e2 #:tween #f))))
+
+ (test-group "no-op without #:tween"
+ (let* ((ent (list #:type 'a #:x 42))
+ (e2 (step-tweens ent 100)))
+ (test-equal "x unchanged" 42 (entity-ref e2 #:x))))
+
+ (test-group "keeps repeating tween attached"
+ (let* ((ent (list #:type 'a #:x 0
+ #:tween (make-tween (list #:x 0) props: '((#:x . 100))
+ duration: 100 ease: 'linear repeat: -1 yoyo?: #t)))
+ (e2 (step-tweens ent 100)))
+ (test-equal "x at target" 100.0 (entity-ref e2 #:x))
+ (test-assert "tween still attached (repeating)" (entity-ref e2 #:tween #f))))
+
+ (test-group "respects #:skip-pipelines"
+ (let* ((ent (list #:type 'a #:x 0
+ #:skip-pipelines '(tweens)
+ #:tween (make-tween (list #:x 0) props: '((#:x . 100))
+ duration: 100 ease: 'linear)))
+ (e2 (step-tweens ent 100)))
+ (test-equal "x unchanged (skipped)" 0 (entity-ref e2 #:x))
+ (test-assert "tween still there" (entity-ref e2 #:tween #f)))))
+
(test-end "tween")
diff --git a/tween.scm b/tween.scm
index eb8abc3..3475a83 100644
--- a/tween.scm
+++ b/tween.scm
@@ -86,7 +86,9 @@
ease-fn ;; number → number
elapsed ;; ms since tween started (includes delay period)
done? ;; boolean
- callback) ;; (entity → unspecified) or #f; invoked once at completion
+ callback ;; (entity → unspecified) or #f; invoked once at completion
+ repeat ;; -1 = infinite, 0 = no more repeats, N = N repeats remaining
+ yoyo?) ;; swap starts/ends on each repeat cycle
;; ── Public API ────────────────────────────────────────────────────────────
@@ -96,13 +98,15 @@
;; props: alist of (keyword . target-number), e.g. ((#:x . 200) (#:y . 40))
(define (make-tween entity #!key props (duration 500) (delay 0) (ease 'linear)
- (on-complete #f))
+ (on-complete #f) (repeat 0) (yoyo? #f))
(unless (and (integer? duration) (> duration 0))
(error "make-tween: duration must be a positive integer (ms)" duration))
(unless (and (integer? delay) (>= delay 0))
(error "make-tween: delay must be a non-negative integer (ms)" delay))
(unless (pair? props)
(error "make-tween: props must be a non-empty alist" props))
+ (unless (and (integer? repeat) (>= repeat -1))
+ (error "make-tween: repeat must be -1 (infinite) or a non-negative integer" repeat))
(let ((ease-fn (ease-resolve ease))
(starts (map (lambda (p)
(let ((k (car p)))
@@ -117,7 +121,9 @@
ease-fn: ease-fn
elapsed: 0
done?: #f
- callback: on-complete)))
+ callback: on-complete
+ repeat: repeat
+ yoyo?: yoyo?)))
;; Linear interpolation with eased factor u in [0,1]
(define (lerp a b u)
@@ -132,37 +138,71 @@
entity
ends))
+ (define (tw-with-elapsed tw elapsed)
+ (make-tw starts: (tw-starts tw) ends: (tw-ends tw)
+ duration: (tw-duration tw) delay: (tw-delay tw)
+ ease-fn: (tw-ease-fn tw) elapsed: elapsed
+ done?: #f callback: (tw-callback tw)
+ repeat: (tw-repeat tw) yoyo?: (tw-yoyo? tw)))
+
+ (define (tw-finish tw elapsed)
+ (make-tw starts: (tw-starts tw) ends: (tw-ends tw)
+ duration: (tw-duration tw) delay: (tw-delay tw)
+ ease-fn: (tw-ease-fn tw) elapsed: elapsed
+ done?: #t callback: #f
+ repeat: 0 yoyo?: (tw-yoyo? tw)))
+
+ (define (tw-next-cycle tw overflow)
+ (let* ((yoyo? (tw-yoyo? tw))
+ (starts (tw-starts tw))
+ (ends (tw-ends tw)))
+ (make-tw starts: (if yoyo? ends starts)
+ ends: (if yoyo? starts ends)
+ duration: (tw-duration tw) delay: 0
+ ease-fn: (tw-ease-fn tw) elapsed: overflow
+ done?: #f callback: (tw-callback tw)
+ repeat: (let ((r (tw-repeat tw))) (if (= r -1) -1 (- r 1)))
+ yoyo?: yoyo?)))
+
+ (define (tween-complete tw entity elapsed)
+ (let ((final (apply-props entity (tw-starts tw) (tw-ends tw) 1.0)))
+ (if (zero? (tw-repeat tw))
+ (begin
+ (when (tw-callback tw) ((tw-callback tw) final))
+ (values (tw-finish tw elapsed) final))
+ (let ((overflow (- (- elapsed (tw-delay tw)) (tw-duration tw))))
+ (values (tw-next-cycle tw overflow) final)))))
+
+ (define (tween-interpolate tw entity elapsed)
+ (let* ((t0 (- elapsed (tw-delay tw)))
+ (u (min 1.0 (max 0.0 (/ t0 (tw-duration tw)))))
+ (eased ((tw-ease-fn tw) u))
+ (ent2 (apply-props entity (tw-starts tw) (tw-ends tw) eased)))
+ (if (>= u 1.0)
+ (tween-complete tw entity elapsed)
+ (values (tw-with-elapsed tw elapsed) ent2))))
+
(define (tween-step tw entity dt)
(unless (tw? tw) (error "tween-step: expected tween struct" tw))
(if (tw-done? tw)
(values tw entity)
- (let* ((elapsed (+ (tw-elapsed tw) dt))
- (delay (tw-delay tw))
- (duration (tw-duration tw))
- (ease-fn (tw-ease-fn tw))
- (starts (tw-starts tw))
- (ends (tw-ends tw)))
- (cond ((< elapsed delay)
- (values (make-tw starts: starts ends: ends duration: duration
- delay: delay ease-fn: ease-fn
- elapsed: elapsed done?: #f callback: (tw-callback tw))
- entity))
- (else
- (let* ((t0 (- elapsed delay))
- (u-raw (/ t0 duration))
- (u (min 1.0 (max 0.0 u-raw)))
- (eased (ease-fn u))
- (ent2 (apply-props entity starts ends eased)))
- (if (>= u 1.0)
- (let* ((final (apply-props entity starts ends 1.0))
- (cb (tw-callback tw))
- (_ (when cb (cb final)))
- (tw2 (make-tw starts: starts ends: ends duration: duration
- delay: delay ease-fn: ease-fn
- elapsed: elapsed done?: #t callback: #f)))
- (values tw2 final))
- (values (make-tw starts: starts ends: ends duration: duration
- delay: delay ease-fn: ease-fn
- elapsed: elapsed done?: #f callback: (tw-callback tw))
- ent2))))))))
+ (let ((elapsed (+ (tw-elapsed tw) dt)))
+ (if (< elapsed (tw-delay tw))
+ (values (tw-with-elapsed tw elapsed) entity)
+ (tween-interpolate tw entity elapsed)))))
+
+ ;; ── Pipeline step ──────────────────────────────────────────────────────────
+ ;; Auto-advance #:tween on an entity. Call from update: as part of the
+ ;; per-entity pipeline, e.g. (step-tweens entity dt). Removes #:tween
+ ;; when the tween finishes.
+
+ (define-pipeline (step-tweens tweens) (entity dt)
+ (let ((tw (entity-ref entity #:tween #f)))
+ (if (not tw)
+ entity
+ (receive (tw2 ent2) (tween-step tw entity dt)
+ (if (tween-finished? tw2)
+ (entity-set ent2 #:tween #f)
+ (entity-set ent2 #:tween tw2))))))
+
) ;; end module
diff --git a/world.scm b/world.scm
index 1f1b457..e637e90 100644
--- a/world.scm
+++ b/world.scm
@@ -95,24 +95,23 @@
(loop (cdr es) (cons (cons gid e) acc))))
(loop (cdr es) acc))))))
+ (define (sync-member-to-origin e origins)
+ (let* ((gid (entity-ref e #:group-id #f))
+ (o (and gid (not (entity-ref e #:group-origin? #f))
+ (assq gid origins))))
+ (if o
+ (let ((origin (cdr o)))
+ (entity-set (entity-set e #:x (+ (entity-ref origin #:x 0)
+ (entity-ref e #:group-local-x 0)))
+ #:y (+ (entity-ref origin #:y 0)
+ (entity-ref e #:group-local-y 0))))
+ e)))
+
;; Snap member #:x/#:y to origin + #:group-local-x/y. Call after moving origins (tweens, etc.).
(define (scene-sync-groups! scene)
- (let* ((ents (scene-entities scene))
- (origins (group-origin-alist ents)))
+ (let ((origins (group-origin-alist (scene-entities scene))))
(scene-entities-set! scene
- (map (lambda (e)
- (if (and (entity-ref e #:group-id #f)
- (not (entity-ref e #:group-origin? #f)))
- (let* ((gid (entity-ref e #:group-id))
- (o (assq gid origins)))
- (if o
- (let ((origin (cdr o)))
- (entity-set (entity-set e #:x (+ (entity-ref origin #:x 0)
- (entity-ref e #:group-local-x 0)))
- #:y (+ (entity-ref origin #:y 0)
- (entity-ref e #:group-local-y 0))))
- e))
- e))
- ents))
+ (map (lambda (e) (sync-member-to-origin e origins))
+ (scene-entities scene)))
scene))
)