aboutsummaryrefslogtreecommitdiff
path: root/docs/animation.org
blob: 4a3939eb20a27fa214f5b0172f1ccde173b72da9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
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]].