aboutsummaryrefslogtreecommitdiff
path: root/docs/animation.org
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-18 02:47:10 +0100
committerGene Pasquet <dev@etenil.net>2026-04-18 02:47:10 +0100
commit38eee24832fe6da4f135cae455881ab97953b23a (patch)
treecffc2bb3b45ac11d90f4a2de3e207f65862fb6fd /docs/animation.org
parenta02b892e2ad1e1605ff942c63afdd618daa48be4 (diff)
Refresh docs and re-indent
Diffstat (limited to 'docs/animation.org')
-rw-r--r--docs/animation.org369
1 files changed, 369 insertions, 0 deletions
diff --git a/docs/animation.org b/docs/animation.org
new file mode 100644
index 0000000..4a3939e
--- /dev/null
+++ b/docs/animation.org
@@ -0,0 +1,369 @@
+#+TITLE: Animation
+
+Downstroke animates sprites by swapping an entity's =#:tile-id= on a
+timed cycle. You declare a table of named animations on the entity
+under =#:animations=, pick one by name with =#:anim-name=, and the
+engine's last pipeline stage — =apply-animation= — advances the frame
+counter every tick.
+
+There is no interpolation, no blending, no tween layer: each frame is
+a whole tile from the tileset, selected by index, held for a fixed
+number of ticks. The model is small enough to describe in one line of
+data and small enough to disable per-entity when you want to drive
+=#:tile-id= by hand.
+
+This file assumes you already know how entities are shaped (see
+[[file:entities.org][entities.org]]) and how the update pipeline runs
+(see [[file:physics.org][physics.org]]).
+
+* The minimum you need
+
+An animated entity needs three things:
+
+1. A =#:tile-id= (so there is something to render before the first
+ tick).
+2. A non-empty =#:animations= list.
+3. A =#:anim-name= that names one of those animations.
+
+The simplest hand-built entity looks like this:
+
+#+begin_src scheme
+(list (cons #:type 'player)
+ (cons #:x 80) (cons #:y 80)
+ (cons #:width 16) (cons #:height 16)
+ (cons #:tile-id 28)
+ (cons #:anim-name 'walk)
+ (cons #:anim-frame 0)
+ (cons #:anim-tick 0)
+ (cons #:animations
+ (list (list (cons #:name 'walk)
+ (cons #:frames '(28 29))
+ (cons #:duration 10)))))
+#+end_src
+
+In practice you never write this by hand. You declare the animation
+in a prefab data file and let =load-prefabs= + =instantiate-prefab=
+build the alist for you:
+
+#+begin_src scheme
+;; demo/assets/animation-prefabs.scm
+((mixins)
+ (prefabs
+ (hero animated
+ #:type hero #:anim-name walk
+ #:animations ((#:name walk #:frames (28 29) #:duration 10)))))
+#+end_src
+
+Two things are happening here that are not obvious:
+
+- The =animated= mixin (from =engine-mixins=) supplies the
+ =#:anim-frame 0=, =#:anim-tick 0=, and =#:tile-id 0= defaults so you
+ don't have to.
+- The prefab loader deep-converts the plist-shaped =#:animations=
+ value into a list of alists (see
+ [[file:entities.org][entities.org]] for why prefab data is written
+ as plists but entities are alists). =#:animations= is one of the
+ keys in =+nested-plist-list-keys+= that gets this treatment.
+
+Once the prefab is in a scene, the engine does the rest — you don't
+need to call anything per frame just to make the animation play.
+
+* Core concepts
+
+** Animation data shape
+
+An animation is an alist with three keys:
+
+| Key | Required | Meaning |
+|--------------+----------+---------------------------------------------------|
+| =#:name= | yes | Symbol used to select this animation. |
+| =#:frames= | yes | List of frames (see below). |
+| =#:duration= | no | Default ticks-per-frame. Used when a frame |
+| | | does not carry its own duration. Defaults to 10. |
+
+=#:frames= can take two forms, and you can mix them inside one
+animation only if you're careful about what each element means.
+
+*Simple form* — a list of bare tile ids. Each frame holds for the
+animation's =#:duration= ticks (or 10 if no duration is given):
+
+#+begin_src scheme
+(#:name attack #:frames (28 29) #:duration 10)
+#+end_src
+
+*Timed form* — a list of =(tile-id duration-ticks)= pairs. Each frame
+carries its own duration; the animation's top-level =#:duration= is
+ignored for that frame:
+
+#+begin_src scheme
+(#:name walk #:frames ((28 10) (29 1000)))
+#+end_src
+
+In the timed form above, tile 28 shows for 10 ticks and tile 29 shows
+for 1000 ticks — a deliberately uneven cycle useful for things like
+blink-idle animations.
+
+The =#:animations= entity key holds a *list* of these animation
+records, one per named animation:
+
+#+begin_src scheme
+#:animations ((#:name idle #:frames (10))
+ (#:name walk #:frames (28 29) #:duration 10)
+ (#:name jump #:frames (30)))
+#+end_src
+
+For a real working example of both forms side by side, see the
+=timed-frames= and =std-frames= prefabs in
+=demo/assets/animation-prefabs.scm=.
+
+** The apply-animation pipeline step
+
+=apply-animation= is the *last* stage of =default-engine-update=,
+after physics, ground detection, entity collisions, and group
+sync. You can see the wiring in =engine.scm=:
+
+#+begin_src scheme
+(scene-map-entities _ (cut apply-animation <> <> dt))
+#+end_src
+
+It is defined with =define-pipeline= under the pipeline name
+=animation=:
+
+#+begin_src scheme
+(define-pipeline (apply-animation animation) (scene entity dt)
+ guard: (entity-ref entity #:animations #f)
+ ...)
+#+end_src
+
+Two things fall out of this definition:
+
+1. *The guard.* If an entity has no =#:animations= value, the
+ pipeline returns the entity unchanged. You do not have to opt out
+ of animation for non-animated entities — not declaring the key is
+ the opt-out.
+2. *Skip support.* Because the step is registered under the pipeline
+ name =animation=, any entity with =#:skip-pipelines= containing
+ =animation= is also returned unchanged. Same mechanism as the
+ physics skips described in [[file:physics.org][physics.org]].
+
+When the step does run, =animate-entity= looks up the current
+animation by =#:anim-name= in the entity's =#:animations= list, and
+=advance-animation= does the actual work:
+
+- Increment =#:anim-tick= by 1.
+- If tick exceeds the current frame's duration, advance
+ =#:anim-frame= (modulo the number of frames), reset =#:anim-tick=
+ to 0, and write the new frame's =#:tile-id= onto the entity.
+- Otherwise, just write the current frame's =#:tile-id= and the
+ incremented tick.
+
+The renderer reads =#:tile-id= directly — so as long as the pipeline
+ran, what ends up on screen is always the current frame's tile.
+
+Running the =animated= pipeline after physics means your visual state
+reflects the entity's *post-physics* position and flags for the
+frame. If you want to swap animations based on state (e.g. walking vs
+jumping), your user =update:= hook — which runs after physics too —
+is the right place.
+
+** Switching animations
+
+You change what an entity is playing by setting =#:anim-name=. The
+helper for this is =set-animation=:
+
+#+begin_src scheme
+(set-animation entity 'walk)
+#+end_src
+
+=set-animation= has one important subtlety: *if the requested name
+matches the entity's current =#:anim-name=, it is a no-op*. The
+entity is returned unchanged — tick and frame counters keep their
+values. This is what you want almost all the time: calling
+=(set-animation entity 'walk)= every frame while the player is
+walking should *not* restart the walk cycle on frame 0 each tick.
+
+If the name is different, =set-animation= resets both =#:anim-frame=
+and =#:anim-tick= to 0 so the new animation plays from its first
+frame.
+
+The typical usage pattern is in your =update:= hook, branching on
+input or physics state:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (p0 (car (scene-entities scene)))
+ (anim (cond ((not (entity-ref p0 #:on-ground? #f)) 'jump)
+ ((input-held? input 'left) 'walk)
+ ((input-held? input 'right) 'walk)
+ (else 'idle)))
+ (p1 (set-animation p0 anim)))
+ (game-scene-set! game (update-scene scene entities: (list p1)))))
+#+end_src
+
+Because =set-animation= returns a fresh entity alist (it calls
+=entity-set= three times internally), the result must be written
+back into the scene. See [[file:entities.org][entities.org]] for the
+immutable-update convention.
+
+** Interaction with the =animated= mixin
+
+=engine-mixins= in =prefabs.scm= includes a convenience mixin named
+=animated=:
+
+#+begin_src scheme
+(animated #:anim-name idle
+ #:anim-frame 0
+ #:anim-tick 0
+ #:tile-id 0
+ #:animations #t)
+#+end_src
+
+Listing =animated= in a prefab gets you all the bookkeeping keys for
+free. You almost always want this — it saves repeating
+=#:anim-frame 0 #:anim-tick 0= on every animated prefab.
+
+Two of these defaults are booby-traps:
+
+1. *=#:animations #t=* — a placeholder. The pipeline guard only
+ checks that the value is truthy, so =#t= is enough to make the
+ step run; but the =#t= itself is obviously not a valid animation
+ list. You must override =#:animations= with a real list on any
+ prefab that uses =animated=. If you forget, =animation-by-name=
+ will fail (it calls =filter= on a non-list) the first time the
+ step runs.
+2. *=#:anim-name idle=* — also a default. The pipeline will then look
+ up an animation named =idle= in your =#:animations= list. If you
+ don't define one, =animation-by-name= returns =#f=,
+ =animate-entity= returns the entity unchanged, and *=#:tile-id=
+ is never written* by the pipeline. Your entity will render with
+ whatever static =#:tile-id= it happens to have — which, since the
+ mixin sets =#:tile-id 0=, is usually tile 0 (a blank or unexpected
+ tile). The symptom is "my sprite is the wrong tile and never
+ animates" with no error.
+
+The animation demo's prefabs show both ways to avoid this. Neither
+defines an =idle= animation; both override =#:anim-name= to match
+the animation they do define:
+
+#+begin_src scheme
+(timed-frames animated #:type timed-frames #:anim-name walk
+ #:animations ((#:name walk #:frames ((28 10) (29 1000)))))
+(std-frames animated #:type std-frames #:anim-name attack
+ #:animations ((#:name attack #:frames (28 29) #:duration 10)))
+#+end_src
+
+The rule is simple: either define an =idle= animation, or override
+=#:anim-name= to a name you actually defined.
+
+* Common patterns
+
+** Declaring walk/idle/jump in a prefab
+
+The usual player or enemy prefab declares all the animations it
+might switch between in one =#:animations= list:
+
+#+begin_src scheme
+(player animated physics-body
+ #:type player
+ #:width 16 #:height 16
+ #:tile-id 28
+ #:anim-name idle
+ #:animations ((#:name idle #:frames (28))
+ (#:name walk #:frames (28 29) #:duration 8)
+ (#:name jump #:frames (30))))
+#+end_src
+
+Note that this prefab *does* define an =idle= animation, so the
+=animated= mixin's default =#:anim-name idle= works as-is — no
+override needed. If the player is standing still and =update:= never
+calls =set-animation=, the engine will happily play the single-frame
+=idle= cycle forever.
+
+A single-frame animation (like =jump= above) is the idiomatic way to
+hold a static pose: the cycle advances, the modulo wraps back to
+frame 0 every tick, and =#:tile-id= stays pinned to the one tile.
+
+** Switching animation based on input
+
+Put the decision in your =update:= hook, after you've read input
+state but before you hand the entity back to the scene:
+
+#+begin_src scheme
+(define (choose-anim entity input)
+ (cond ((not (entity-ref entity #:on-ground? #f)) 'jump)
+ ((or (input-held? input 'left)
+ (input-held? input 'right)) 'walk)
+ (else 'idle)))
+
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (p0 (car (scene-entities scene)))
+ (p1 (set-animation p0 (choose-anim p0 input))))
+ (game-scene-set! game (update-scene scene entities: (list p1)))))
+#+end_src
+
+Two details worth noticing:
+
+- =set-animation= is safe to call every frame with the same name; it
+ won't reset the cycle.
+- =apply-animation= runs as part of =default-engine-update=, which
+ runs *before* your user =update:= hook. An =#:anim-name= change you
+ make in =update:= takes effect on the next frame's
+ =apply-animation= call, not this one — a one-frame latency that is
+ imperceptible in practice.
+
+** Per-frame durations for non-uniform timing
+
+Use the =(tile-id duration)= frame form when you want a cycle where
+one frame lingers and another flashes past. The canonical example
+(from =demo/assets/animation-prefabs.scm=) is a blink-heavy idle:
+
+#+begin_src scheme
+(#:name idle #:frames ((28 10) (29 1000)))
+#+end_src
+
+Tile 28 shows for 10 ticks (a quick flash), then tile 29 holds for
+1000 ticks (a long eye-open pose), then the cycle repeats. You can
+mix this with the simple form across different animations in the
+same entity — it's the frame shape that matters, not the animation.
+
+Per-frame durations override any top-level =#:duration= on the
+animation; in fact the top-level =#:duration= is only consulted when
+=advance-animation= falls back to the default of 10 (see
+=frame->duration= in =animation.scm=).
+
+** Disabling animation on an entity without touching =#:animations=
+
+If you want to freeze an animated entity on its current frame — for
+example, pausing an enemy during a cutscene — the cheapest way is
+to list =animation= in its =#:skip-pipelines=:
+
+#+begin_src scheme
+(entity-set entity #:skip-pipelines '(animation))
+#+end_src
+
+This leaves =#:animations=, =#:anim-name=, =#:anim-frame=, and
+=#:anim-tick= untouched. When you remove =animation= from
+=#:skip-pipelines= later, the cycle resumes exactly where it left
+off. Contrast with =(entity-set entity #:animations #f)=, which
+strips the animation data entirely and would require you to rebuild
+it to resume.
+
+You can combine =animation= with any of the physics pipeline names
+in the same skip list — they all share one =#:skip-pipelines= key.
+See [[file:physics.org][physics.org]] for the full list of step
+names.
+
+* See also
+
+- [[file:guide.org][guide.org]] — the minimal-game walkthrough.
+- [[file:entities.org][entities.org]] — entity alists, the prefab
+ system, and how =#:animations= is deep-converted from plist data.
+- [[file:physics.org][physics.org]] — the rest of the update
+ pipeline and the =#:skip-pipelines= mechanism.
+- [[file:tweens.org][tweens.org]] — when you want interpolation
+ rather than discrete frame-swapping.
+- [[file:../demo/animation.scm][Animation]] (=bin/demo-animation=) — prefab data in [[file:../demo/assets/animation-prefabs.scm][demo/assets/animation-prefabs.scm]].