@solidrt/2d 0.0.54 → 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.
- package/AGENTS.md +241 -57
- package/examples/README.md +22 -5
- package/examples/anim.tsx +86 -0
- package/examples/camera-probe.tsx +200 -0
- package/examples/camera.tsx +190 -0
- package/examples/parity.tsx +4 -4
- package/examples/pick.tsx +50 -33
- package/examples/tiles.tsx +19 -10
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/camera-motion.ts +532 -0
- package/src/camera.ts +86 -0
- package/src/camera2d.ts +140 -0
- package/src/components.tsx +330 -76
- package/src/dispatch.ts +231 -0
- package/src/index.ts +30 -5
- package/src/layer.ts +454 -186
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +132 -27
- package/src/shaders.ts +29 -12
- package/src/tiles.ts +79 -24
package/src/animation.ts
ADDED
|
@@ -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
|
+
}
|