diff options
| author | Gene Pasquet <dev@etenil.net> | 2026-04-18 02:47:10 +0100 |
|---|---|---|
| committer | Gene Pasquet <dev@etenil.net> | 2026-04-18 02:47:10 +0100 |
| commit | 38eee24832fe6da4f135cae455881ab97953b23a (patch) | |
| tree | cffc2bb3b45ac11d90f4a2de3e207f65862fb6fd /docs/animation.org | |
| parent | a02b892e2ad1e1605ff942c63afdd618daa48be4 (diff) | |
Refresh docs and re-indent
Diffstat (limited to 'docs/animation.org')
| -rw-r--r-- | docs/animation.org | 369 |
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]]. |
