@solidrt/2d 0.0.55 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ // Sprite frame animation: a clip (frames + fps) with a shared clock that
2
+ // steps every attached sprite's frame through setSprite. One timer per
3
+ // playing clip and one frame write per attached sprite per STEP (not per
4
+ // display frame), so an 8fps walk cycle generates 8 publishes a second and
5
+ // a paused clip costs nothing - the demand-gate story unchanged. The clip
6
+ // does not own its sprites (removeSprite prunes lazily on the next step),
7
+ // and a sprite belongs to at most one animation. Plain JS over setSprite,
8
+ // so it works on both layer kinds and composes with <Sprite> via ref (leave
9
+ // the frame prop off - the clip owns that field).
10
+ import { getOwner, onCleanup } from "@solidrt/core"
11
+ import type { Frame } from "./frames.ts"
12
+ import type { Sprite } from "./layer.ts"
13
+ import { setSprite } from "./layer.ts"
14
+
15
+ // Timer period as a fraction of the clip's frame duration: the step index
16
+ // is computed from the wall clock (drift-free), so the timer only decides
17
+ // how late after a frame boundary the step lands - half a frame bounds
18
+ // that lateness at half a frame, where a full-frame period could show a
19
+ // frame almost a whole frame late.
20
+ const TICK_RATIO = 0.5
21
+
22
+ export type AnimationOptions = {
23
+ /** Wrap around (default) or play once and hold the last frame. */
24
+ loop?: boolean
25
+ /** Skip the owner-scoped auto-dispose (the core resource contract). */
26
+ autoFree?: boolean
27
+ }
28
+
29
+ export type SpriteAnimation = {
30
+ /** Current frame index into the clip. */
31
+ readonly frame: number
32
+ /** True while the clock advances: born playing, false after pause() and
33
+ * after a one-shot ends. */
34
+ readonly playing: boolean
35
+ /**
36
+ * Attach a sprite: it shows the clip's current frame immediately and
37
+ * steps with the shared clock (every attached sprite shows the SAME
38
+ * index). A sprite belongs to at most one animation - attaching detaches
39
+ * it from its previous one.
40
+ */
41
+ add(sprite: Sprite): void
42
+ /** Detach; the sprite keeps the frame it is showing. */
43
+ remove(sprite: Sprite): void
44
+ /** Resume a paused clip (phase kept), or replay a finished one-shot
45
+ * from frame 0. */
46
+ play(): void
47
+ /** Freeze the clock; attached sprites hold the current frame. */
48
+ pause(): void
49
+ /** One-shot clips (loop: false): the last frame finished its display
50
+ * time. Fires once per play-through, never on pause or dispose. */
51
+ onFinish?: () => void
52
+ /** Stop the clock and detach every sprite. Owner-scoped like a layer
53
+ * (opt out with autoFree: false). */
54
+ dispose(): void
55
+ }
56
+
57
+ // A sprite's current animation - add() moves the sprite here, so two clips
58
+ // never fight over one sprite's frame field.
59
+ let attachedTo = new WeakMap<Sprite, SpriteAnimation>()
60
+
61
+ /**
62
+ * A frame animation clip: `frames` (usually a slice of a `grid()` result)
63
+ * played at `fps`. Sprites attach with `add`; the clip steps their `frame`
64
+ * on its own wall-clock timer, independent of display rate.
65
+ */
66
+ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOptions): SpriteAnimation {
67
+ if (frames.length === 0) throw new Error("createAnimation: frames is empty")
68
+ if (!(Number.isFinite(fps) && fps > 0)) {
69
+ throw new Error(`createAnimation: fps must be positive, got ${fps}`)
70
+ }
71
+ let loop = opts?.loop !== false
72
+ let sprites = new Set<Sprite>()
73
+ let current = 0
74
+ let playing = true
75
+ let ended = false
76
+ let disposed = false
77
+ let timer: number | null = null
78
+ let elapsedBase = 0 // ms of clip time accumulated before the running stretch
79
+ let runStart = 0 // performance.now() when the clock last started
80
+
81
+ function startClock(): void {
82
+ if (timer !== null || !playing || sprites.size === 0) return
83
+ // A looping single-frame clip never changes; a one-shot still needs
84
+ // the clock once, to end.
85
+ if (loop && frames.length === 1) return
86
+ runStart = performance.now()
87
+ timer = setInterval(tick, (1000 / fps) * TICK_RATIO)
88
+ }
89
+
90
+ function stopClock(): void {
91
+ if (timer === null) return
92
+ elapsedBase += performance.now() - runStart
93
+ clearInterval(timer)
94
+ timer = null
95
+ }
96
+
97
+ function writeAll(): void {
98
+ let frame = frames[current]!
99
+ for (let sprite of sprites) {
100
+ if (sprite.layer === null) {
101
+ // Removed sprite: prune (deleting during for..of is safe on a Set).
102
+ sprites.delete(sprite)
103
+ attachedTo.delete(sprite)
104
+ } else setSprite(sprite, { frame })
105
+ }
106
+ if (sprites.size === 0) stopClock()
107
+ }
108
+
109
+ function setIndex(idx: number): void {
110
+ if (idx === current) return
111
+ current = idx
112
+ writeAll()
113
+ }
114
+
115
+ function tick(): void {
116
+ let raw = Math.floor(((elapsedBase + performance.now() - runStart) * fps) / 1000)
117
+ if (!loop && raw >= frames.length) {
118
+ // The last frame has shown for its full duration: hold it and stop.
119
+ setIndex(frames.length - 1)
120
+ stopClock()
121
+ playing = false
122
+ ended = true
123
+ handle.onFinish?.()
124
+ return
125
+ }
126
+ setIndex(loop ? raw % frames.length : Math.min(raw, frames.length - 1))
127
+ }
128
+
129
+ let handle: SpriteAnimation = {
130
+ get frame() {
131
+ return current
132
+ },
133
+ get playing() {
134
+ return playing
135
+ },
136
+ onFinish: undefined,
137
+ add(sprite) {
138
+ if (disposed) throw new Error("createAnimation: add on a disposed animation")
139
+ attachedTo.get(sprite)?.remove(sprite)
140
+ attachedTo.set(sprite, handle)
141
+ sprites.add(sprite)
142
+ setSprite(sprite, { frame: frames[current]! })
143
+ startClock()
144
+ },
145
+ remove(sprite) {
146
+ if (!sprites.delete(sprite)) return
147
+ attachedTo.delete(sprite)
148
+ if (sprites.size === 0) stopClock()
149
+ },
150
+ play() {
151
+ if (disposed || playing) return
152
+ if (ended) {
153
+ ended = false
154
+ elapsedBase = 0
155
+ current = 0
156
+ writeAll()
157
+ }
158
+ playing = true
159
+ startClock()
160
+ },
161
+ pause() {
162
+ if (!playing) return
163
+ stopClock()
164
+ playing = false
165
+ },
166
+ dispose() {
167
+ if (disposed) return
168
+ stopClock()
169
+ playing = false
170
+ disposed = true
171
+ for (let sprite of sprites) attachedTo.delete(sprite)
172
+ sprites.clear()
173
+ },
174
+ }
175
+
176
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => handle.dispose())
177
+ return handle
178
+ }