lecodes-sdk 0.20.2 → 1.1.0

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.
Files changed (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -0,0 +1,378 @@
1
+ // The animation HANDLE (docs/timeline-plan.md §2.2): what `animateTo` / `animateFrom` / `Timeline`
2
+ // return. Owns one spec; every `play()` builds the blob (target ids resolve at play time), hands it
3
+ // to the host (`_creatorUI.tweenCreate`) and gets events back (`registerTweenEvent`: time-callbacks
4
+ // and finish). On a host without the tween core the UI tracks fall back to the old
5
+ // `animateTo` / `animateFrom` bridge pair (single-value tweens only) and the clock is a timer —
6
+ // see `fallback` below.
7
+
8
+ import { CLOCK_GAME, CLOCK_UI, DOM_UI, DOM_VALUE, buildBlob, evaluateTrack, type Track, type TweenSpec } from "./spec"
9
+
10
+ const OP_PLAY = 0
11
+ const OP_PAUSE = 1
12
+ const OP_RESUME = 2
13
+ const OP_CANCEL = 3
14
+ const OP_FINISH = 4
15
+
16
+ const EV_CALL = 0
17
+ const EV_FINISH = 1
18
+ const EV_VALUE = 2 // (id, 2, slot, lane0, lane1, …) — a VALUE track's frame, synchronous from the host
19
+
20
+ /** Playback control shared by element tweens and timelines. Times are **milliseconds**. */
21
+ export interface Animation {
22
+ /** One iteration, ms. */
23
+ readonly duration: number
24
+ /** Position inside the current iteration, ms. Writable = seek. */
25
+ time: number
26
+ /** 0..1 of the whole animation (iterations included). Writable = seek. */
27
+ progress: number
28
+ /** Playback rate; negative runs backwards, 0 freezes. */
29
+ rate: number
30
+ readonly playing: boolean
31
+ /** Resolves `true` when the animation reaches its end, `false` when cancelled, replaced or replayed. Never rejects. */
32
+ readonly finished: Promise<boolean>
33
+ /** Start from t = 0 (a running animation restarts). */
34
+ play(): this
35
+ pause(): this
36
+ resume(): this
37
+ /** Jump to `ms`; playback state is unchanged. */
38
+ seek(ms: number): this
39
+ /** Jump to the end: values land, commits apply, `finished` resolves `true`. */
40
+ finish(): this
41
+ /** Stop where it is — no commit, `finished` resolves `false`. */
42
+ cancel(): this
43
+ onFinish(fn: (done: boolean) => void): this
44
+ }
45
+
46
+ const hasCore = (): boolean => typeof _creatorUI !== "undefined" && typeof (_creatorUI as any).tweenCreate === "function"
47
+
48
+ // ---- host events -------------------------------------------------------------------------------
49
+
50
+ const live = new Map<number, TweenAnimation>()
51
+ let eventsRegistered = false
52
+ const ensureEvents = (): void => {
53
+ if (eventsRegistered) return
54
+ eventsRegistered = true
55
+ ;(_creatorUI as any).registerTweenEvent?.(function (this: unknown, id: number, kind: number, index: number) {
56
+ const a = live.get(id)
57
+ if (!a) return
58
+ if (kind === EV_CALL) a._fireCall(index)
59
+ else if (kind === EV_FINISH) a._hostFinished()
60
+ else if (kind === EV_VALUE) {
61
+ // lanes ride as plain number arguments (no per-frame array on either side)
62
+ const n = arguments.length - 3
63
+ for (let i = 0; i < n; i++) valueScratch[i] = arguments[i + 3] as number
64
+ a._fireValue(index, valueScratch)
65
+ }
66
+ })
67
+ }
68
+
69
+ /** Reused lane buffer for VALUE deliveries (the host's largest kind is 10 lanes). */
70
+ const valueScratch = new Float32Array(16)
71
+
72
+ /** The concrete handle — what the SDK instantiates behind `animateTo` / `animateFrom` / `Timeline`.
73
+ * User code sees the {@link Animation} interface; the class itself must stay in the public
74
+ * declarations because `TimelineImpl` extends it — marking it internal (even mentioning the tag in
75
+ * this comment: the strip is a substring match) drops the timeline's playback members. */
76
+ export class TweenAnimation implements Animation {
77
+ /** @internal */
78
+ _spec: TweenSpec
79
+ /** @internal Time-callbacks by call index (the spec's `calls[i]`). */
80
+ _calls: Array<() => void> = []
81
+ /** @internal VALUE-track deliverers by slot (`animate()` / `Timeline.animate`). */
82
+ _values = new Map<number, (lanes: Float32Array) => void>()
83
+
84
+ private _id = 0 // host id, 0 = not created
85
+ private _started = false // play() ran at least once (the first play keeps the constructor's promise)
86
+ private _endTime = 0 // `time` after the host freed a finished animation
87
+ private _playing = false
88
+ private _rate: number
89
+ private _finished!: Promise<boolean>
90
+ private _resolve!: (done: boolean) => void
91
+ private _settled = true
92
+ private _listeners: Array<(done: boolean) => void> = []
93
+ private _fb?: Fallback
94
+
95
+ constructor(spec: TweenSpec) {
96
+ this._spec = spec
97
+ this._rate = spec.rate
98
+ this._newPromise()
99
+ }
100
+
101
+ private _newPromise(): void {
102
+ this._finished = new Promise<boolean>((r) => { this._resolve = r })
103
+ this._settled = false
104
+ }
105
+
106
+ private _settle(done: boolean): void {
107
+ if (this._settled) return
108
+ this._settled = true
109
+ this._playing = false
110
+ this._resolve(done)
111
+ for (const fn of this._listeners) fn(done)
112
+ }
113
+
114
+ get duration(): number { return this._spec.durationMs }
115
+ get playing(): boolean { return this._playing }
116
+ get finished(): Promise<boolean> { return this._finished }
117
+
118
+ get rate(): number { return this._rate }
119
+ set rate(r: number) {
120
+ this._rate = r
121
+ if (this._id) (_creatorUI as any).tweenSetRate(this._id, r)
122
+ this._fb?.setRate(r)
123
+ }
124
+
125
+ get time(): number {
126
+ if (this._id) return (_creatorUI as any).tweenGetTime?.(this._id) ?? 0
127
+ return this._fb?.time ?? this._endTime
128
+ }
129
+ set time(ms: number) { this.seek(ms) }
130
+
131
+ get progress(): number {
132
+ const total = this._totalMs()
133
+ return total > 0 ? Math.min(1, Math.max(0, this._elapsedMs() / total)) : 1
134
+ }
135
+ set progress(p: number) {
136
+ const total = this._totalMs()
137
+ this._seekTotal(Math.min(1, Math.max(0, p)) * total)
138
+ }
139
+
140
+ private _totalMs(): number {
141
+ const s = this._spec
142
+ const n = s.iterations < 0 ? 1 : s.iterations
143
+ return s.durationMs * n * (s.pingPong && s.iterations !== 1 ? 2 : 1)
144
+ }
145
+ private _elapsedMs(): number {
146
+ if (this._id) return (_creatorUI as any).tweenGetElapsed?.(this._id) ?? this.time
147
+ return this._fb?.elapsed ?? (this._endTime > 0 ? this._totalMs() : 0)
148
+ }
149
+ private _seekTotal(ms: number): void {
150
+ if (hasCore()) {
151
+ this._ensure()
152
+ ;(_creatorUI as any).tweenSeek(this._id, ms)
153
+ } else {
154
+ this._ensureFallback().seek(ms)
155
+ }
156
+ }
157
+
158
+ /** Create the host animation (paused at t = 0) if it doesn't exist. */
159
+ private _ensure(): void {
160
+ if (this._id) return
161
+ ensureEvents()
162
+ const blob = buildBlob(this._spec)
163
+ this._id = (_creatorUI as any).tweenCreate(blob.data, blob.strings, blob.targets)
164
+ if (this._id) live.set(this._id, this)
165
+ if (this._rate !== 1) (_creatorUI as any).tweenSetRate(this._id, this._rate)
166
+ }
167
+
168
+ private _destroy(): void {
169
+ if (this._id) {
170
+ live.delete(this._id)
171
+ ;(_creatorUI as any).tweenControl(this._id, OP_CANCEL)
172
+ this._id = 0
173
+ }
174
+ this._fb?.cancel()
175
+ this._fb = undefined
176
+ }
177
+
178
+ play(): this {
179
+ // A replay: whoever waited on the previous run gets `false` (unless it already ended), the host
180
+ // object is rebuilt (ids may have changed — a screen re-mounts its nodes on every open). The
181
+ // FIRST play keeps the promise handed out before it — unless a `cancel()` before any play has
182
+ // already settled that one, in which case this run gets a fresh promise.
183
+ if (this._started) {
184
+ this._settle(false)
185
+ this._destroy()
186
+ }
187
+ if (this._settled) this._newPromise()
188
+ this._started = true
189
+ this._endTime = 0
190
+ // Commit = the last keyframe becomes the stored state NOW (as `animateTo` always did), so reads
191
+ // and later `.style()` merges see the final state while the host is still tweening.
192
+ for (const t of this._spec.tracks) {
193
+ if (!t.commit || !t.channel.commit) continue
194
+ const last = t.keys[t.keys.length - 1]
195
+ if (last && last.value !== undefined) t.channel.commit(last.raw)
196
+ }
197
+ this._playing = true
198
+ if (hasCore()) {
199
+ this._ensure()
200
+ ;(_creatorUI as any).tweenControl(this._id, OP_PLAY)
201
+ } else {
202
+ this._ensureFallback().play()
203
+ }
204
+ return this
205
+ }
206
+
207
+ pause(): this {
208
+ if (hasCore()) { this._ensure(); (_creatorUI as any).tweenControl(this._id, OP_PAUSE) }
209
+ else this._ensureFallback().pause()
210
+ this._playing = false
211
+ return this
212
+ }
213
+
214
+ resume(): this {
215
+ if (this._settled) return this.play()
216
+ if (hasCore()) { this._ensure(); (_creatorUI as any).tweenControl(this._id, OP_RESUME) }
217
+ else this._ensureFallback().resume()
218
+ this._playing = true
219
+ return this
220
+ }
221
+
222
+ seek(ms: number): this {
223
+ // `time` is inside the current iteration; a seek by time on a looping animation stays in it.
224
+ const s = this._spec
225
+ const period = s.durationMs * (s.pingPong && s.iterations !== 1 ? 2 : 1)
226
+ const iter = period > 0 ? Math.floor(this._elapsedMs() / period) : 0
227
+ this._seekTotal(iter * period + Math.max(0, ms))
228
+ return this
229
+ }
230
+
231
+ finish(): this {
232
+ if (hasCore()) {
233
+ this._ensure()
234
+ ;(_creatorUI as any).tweenControl(this._id, OP_FINISH) // the host answers with EV_FINISH
235
+ } else {
236
+ this._ensureFallback().finish()
237
+ }
238
+ return this
239
+ }
240
+
241
+ cancel(): this {
242
+ this._destroy()
243
+ this._settle(false)
244
+ return this
245
+ }
246
+
247
+ onFinish(fn: (done: boolean) => void): this {
248
+ this._listeners.push(fn)
249
+ return this
250
+ }
251
+
252
+ /** @internal */
253
+ _fireCall(index: number): void { this._calls[index]?.() }
254
+ /** @internal */
255
+ _fireValue(slot: number, lanes: Float32Array): void { this._values.get(slot)?.(lanes) }
256
+
257
+ /** @internal The host reached the end (naturally or via finish()) and has freed the animation. */
258
+ _hostFinished(): void {
259
+ if (this._id) { live.delete(this._id); this._id = 0 }
260
+ // the old-host path's timers / value loop must stop with the run, or an interval outlives it
261
+ if (this._fb) { this._fb.cancel(); this._fb = undefined }
262
+ this._endTime = this._spec.durationMs
263
+ this._settle(true)
264
+ }
265
+
266
+ // ---- old hosts ------------------------------------------------------------------------------
267
+
268
+ private _ensureFallback(): Fallback {
269
+ if (!this._fb) this._fb = new Fallback(this)
270
+ return this._fb
271
+ }
272
+ }
273
+
274
+ /** Old-host path: UI tracks go through the legacy `animateTo` / `animateFrom` pair (one value per
275
+ * prop — the last explicit key for a to-tween, the first for a from-tween; no easing, no seek), a
276
+ * track on any other domain is dropped with one warning, and the clock is a timer that fires the
277
+ * calls and the finish. Good enough for the simple cases on web / Apple until their core lands. */
278
+ class Fallback {
279
+ time = 0
280
+ elapsed = 0
281
+ private _timers: ReturnType<typeof setTimeout>[] = []
282
+ private _startedAt = 0
283
+ private _rate = 1
284
+ private _warned = false
285
+ private _loop?: ReturnType<typeof setInterval>
286
+ private _valueTracks: Track[] = []
287
+ private _scratch = new Float32Array(16)
288
+ private _lanes: number[] = []
289
+
290
+ private a: TweenAnimation
291
+ constructor(a: TweenAnimation) { this.a = a }
292
+
293
+ setRate(r: number): void { this._rate = r }
294
+
295
+ play(): void {
296
+ const s = this.a._spec
297
+ this.cancel()
298
+ this._startedAt = Date.now()
299
+ const byTarget = new Map<Track["target"], { to: Record<string, unknown>, from: Record<string, unknown>, at: number, dur: number, commit: boolean }>()
300
+ this._valueTracks = []
301
+ for (const t of s.tracks) {
302
+ if (t.channel.domain === DOM_VALUE) { this._valueTracks.push(t); continue }
303
+ if (t.channel.domain !== DOM_UI) {
304
+ if (!this._warned) { this._warned = true; console.warn("[tween] this host animates UI elements and values only (no tween core) — 3D / 2D tracks skipped") }
305
+ continue
306
+ }
307
+ const rec = byTarget.get(t.target) ?? { to: {}, from: {}, at: t.atMs, dur: t.durMs, commit: t.commit }
308
+ byTarget.set(t.target, rec)
309
+ const first = t.keys[0]!, last = t.keys[t.keys.length - 1]!
310
+ if (last.value === undefined) rec.from[t.prop] = first.raw // animateFrom shape
311
+ else rec.to[t.prop] = last.raw
312
+ rec.at = Math.min(rec.at, t.atMs)
313
+ rec.dur = Math.max(rec.dur, t.durMs)
314
+ }
315
+ for (const [target, rec] of byTarget) {
316
+ // Sent even for an unmounted element (id 0), exactly as the direct call used to be — the host
317
+ // ignores it; a stub host in tests records it.
318
+ const id = ((target as any)._id as number) ?? 0
319
+ const delay = rec.at + s.delayMs
320
+ const meta: Record<string, unknown> = { duration: rec.dur }
321
+ if (delay > 0) meta.delay = delay
322
+ if (s.iterations !== 1) { meta.loop = s.iterations < 0 ? true : s.iterations; meta.loopMode = s.pingPong ? "ping-pong" : "restart" }
323
+ // The host merges its animate layer into base on commit (the SDK committed its own style already)
324
+ if (Object.keys(rec.to).length) _creatorUI.animateTo?.(id, { ...rec.to, ...meta, ...(rec.commit ? {} : { commit: false }) })
325
+ if (Object.keys(rec.from).length) _creatorUI.animateFrom?.(id, { ...rec.from, ...meta })
326
+ }
327
+ const scale = this._rate > 0 ? 1 / this._rate : 0
328
+ if (scale === 0) return
329
+ for (let i = 0; i < s.calls.length; i++) {
330
+ this._timers.push(setTimeout(() => this.a._fireCall(i), (s.delayMs + s.calls[i]!) * scale))
331
+ }
332
+ if (s.iterations === 1) {
333
+ this._timers.push(setTimeout(() => { this._applyValues(s.durationMs); this.elapsed = this.time = s.durationMs; this.a._hostFinished() }, (s.delayMs + s.durationMs) * scale))
334
+ }
335
+ // VALUE tracks are evaluated here in JS (the C evaluator's twin), ~60 times a second
336
+ if (this._valueTracks.length) {
337
+ this._applyValues(0)
338
+ this._loop = setInterval(() => {
339
+ const total = (Date.now() - this._startedAt) * this._rate - s.delayMs
340
+ if (total < 0) return
341
+ const period = s.durationMs * (s.pingPong && s.iterations !== 1 ? 2 : 1)
342
+ let local = period > 0 ? total % period : 0
343
+ if (s.iterations !== 1 && s.pingPong && local > s.durationMs) local = 2 * s.durationMs - local
344
+ if (s.iterations === 1) local = Math.min(total, s.durationMs)
345
+ this.elapsed = total
346
+ this.time = local
347
+ this._applyValues(local)
348
+ }, 16)
349
+ }
350
+ }
351
+ private _applyValues(local: number): void {
352
+ for (const t of this._valueTracks) {
353
+ evaluateTrack(t, local, this._lanes)
354
+ for (let i = 0; i < t.lanes; i++) this._scratch[i] = this._lanes[i]!
355
+ this.a._fireValue(t.channel.id() as number, this._scratch)
356
+ }
357
+ }
358
+ pause(): void { this.cancel() }
359
+ resume(): void { /* no partial resume on the timer clock: the tween already ran on the host */ }
360
+ seek(ms: number): void { this.elapsed = ms; this.time = ms % Math.max(1, this.a._spec.durationMs); this._applyValues(this.time) }
361
+ finish(): void { this.cancel(); this._applyValues(this.a._spec.durationMs); this.a._hostFinished() }
362
+ cancel(): void {
363
+ for (const t of this._timers) clearTimeout(t)
364
+ this._timers = []
365
+ if (this._loop !== undefined) { clearInterval(this._loop); this._loop = undefined }
366
+ if (this._startedAt) this.elapsed = this.time = Math.min(this.a._spec.durationMs, (Date.now() - this._startedAt) * this._rate)
367
+ }
368
+ }
369
+
370
+ /** @internal Build a one-bag animation for a single target (the `animateTo` / `animateFrom` path). */
371
+ export const singleTargetSpec = (tracks: Track[], clock: number, iterations: number, pingPong: boolean, delayMs: number): TweenSpec => {
372
+ let durationMs = 0
373
+ for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
374
+ // `delay` lives on the tracks (the first key holds through it, §3.1); the spec delay is 0 here
375
+ return { clock, durationMs, delayMs, iterations, pingPong, rate: 1, tracks, calls: [] }
376
+ }
377
+
378
+ export { CLOCK_UI, CLOCK_GAME }
@@ -0,0 +1,175 @@
1
+ // `Timeline` (docs/timeline-plan.md §2.4): choreography over the keyframe core. Tracks on any
2
+ // number of targets (UI elements, 3D / 2D nodes, lights, cameras) at absolute positions, JS calls
3
+ // at times, labels, holds — one clock, one handle. Also the `animateTo` / `animateFrom` bag
4
+ // builder every target class delegates to (`tweenBag`).
5
+
6
+ import { TweenAnimation, type Animation } from "./Animation"
7
+ import { makeValueTrack, type AnimateOptions, type AnimateValue } from "./animateValue"
8
+ import { CLOCK_GAME, CLOCK_UI, bagProps, iterationsOf, makeTrack, type Track, type TweenMeta, type TweenSpec, type TweenTarget } from "./spec"
9
+
10
+ /** Vector-valued props: an array value is ONE keyframe unless it is an array of vectors. */
11
+ const VECTOR_PROPS = new Set(["position", "scale", "quaternion", "eulerAngles", "color"])
12
+
13
+ const clockOf = (clock: TweenMeta["clock"], fallback: number): number =>
14
+ clock === "game" ? CLOCK_GAME : clock === "ui" ? CLOCK_UI : fallback
15
+
16
+ /** @internal Build the tracks of one options bag on one target. `fromCurrent` = animateTo shape. */
17
+ export const bagTracks = (target: TweenTarget, bag: Record<string, unknown>, atMs: number, fromCurrent: boolean): Track[] => {
18
+ const meta = bag as TweenMeta
19
+ const tracks: Track[] = []
20
+ for (const [prop, value] of bagProps(bag)) {
21
+ const track = makeTrack(target, prop, value, meta, atMs, fromCurrent, VECTOR_PROPS.has(prop))
22
+ if (track) tracks.push(track)
23
+ }
24
+ return tracks
25
+ }
26
+
27
+ /** @internal The `animateTo` / `animateFrom` implementation: one bag → one animation, played now. */
28
+ export const tweenBag = (target: TweenTarget, bag: Record<string, unknown>, fromCurrent: boolean): Animation => {
29
+ const meta = bag as TweenMeta
30
+ const tracks = bagTracks(target, bag, 0, fromCurrent)
31
+ let durationMs = 0
32
+ for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
33
+ const spec: TweenSpec = {
34
+ clock: clockOf(meta.clock, target._tweenClock),
35
+ durationMs, delayMs: 0,
36
+ iterations: iterationsOf(meta.loop),
37
+ pingPong: meta.loopMode !== "restart",
38
+ rate: 1, tracks, calls: [],
39
+ }
40
+ return new TweenAnimation(spec).play()
41
+ }
42
+
43
+ export type TimelineOptions = {
44
+ /** `'ui'` (default) = wall time; `'game'` follows `Time.scale` and pauses with the game. */
45
+ clock?: "ui" | "game",
46
+ /** Repeat the whole timeline: `true` = forever, a number = cycles. */
47
+ loop?: boolean | number,
48
+ loopMode?: "restart" | "ping-pong",
49
+ /** Wait before the first play, ms. */
50
+ delay?: number,
51
+ }
52
+
53
+ /** Where a track goes: ms, a label, or `[label, offsetMs]`. Default = the current end (sequencing). */
54
+ export type TimelinePosition = number | string | [string, number]
55
+
56
+ export type TimelineAddOptions = {
57
+ at?: TimelinePosition,
58
+ /** With several targets: each starts this many ms after the previous one. */
59
+ stagger?: number,
60
+ }
61
+
62
+ /** A choreography: tracks on many targets at absolute times, JS calls, labels and holds, driven by
63
+ * one clock. Build it once, `play()` on every entrance — `play()` restarts from t = 0 and the pose
64
+ * at any time is fully defined by the tracks (the first keyframe of a track holds before it
65
+ * starts, the last one after it ends). */
66
+ export class TimelineImpl extends TweenAnimation {
67
+ private _labels = new Map<string, number>()
68
+ private _end = 0
69
+
70
+ constructor(options: TimelineOptions = {}) {
71
+ super({
72
+ clock: clockOf(options.clock, CLOCK_UI),
73
+ durationMs: 0,
74
+ delayMs: options.delay ?? 0,
75
+ iterations: iterationsOf(options.loop),
76
+ pingPong: options.loopMode !== "restart",
77
+ rate: 1, tracks: [], calls: [],
78
+ })
79
+ }
80
+
81
+ private _at(at: TimelinePosition | undefined): number {
82
+ if (at === undefined) return this._end
83
+ if (typeof at === "number") return at
84
+ const name = Array.isArray(at) ? at[0] : at
85
+ const offset = Array.isArray(at) ? at[1] : 0
86
+ const base = this._labels.get(name)
87
+ if (base === undefined) throw new Error(`Timeline: unknown label "${name}"`)
88
+ return base + offset
89
+ }
90
+
91
+ private _grow(endMs: number): void {
92
+ if (endMs > this._end) this._end = endMs
93
+ this._spec.durationMs = this._end
94
+ }
95
+
96
+ /** Animate `props` on one target or several (staggered), like `animateTo` — arrays are keyframes,
97
+ * `duration` / `easing` / `times` / `commit` apply. The bag's `delay` shifts the track after `at`. */
98
+ add(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
99
+ const at = this._at(options.at)
100
+ const targets = Array.isArray(target) ? target : [target]
101
+ const stagger = options.stagger ?? 0
102
+ targets.forEach((t, i) => {
103
+ const tracks = bagTracks(t, props, at + i * stagger, true)
104
+ for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
105
+ })
106
+ return this
107
+ }
108
+
109
+ /** Like `add` with the `animateFrom` shape: the target's own state is the implicit last keyframe. */
110
+ addFrom(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
111
+ const at = this._at(options.at)
112
+ const targets = Array.isArray(target) ? target : [target]
113
+ const stagger = options.stagger ?? 0
114
+ targets.forEach((t, i) => {
115
+ const tracks = bagTracks(t, props, at + i * stagger, false)
116
+ for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
117
+ })
118
+ return this
119
+ }
120
+
121
+ /** A free-value track (see the `animate()` global): the host evaluates it and calls `onUpdate`
122
+ * every frame with the value. */
123
+ animate<T extends AnimateValue>(options: AnimateOptions<T>, at?: TimelinePosition): this {
124
+ const { track, deliver, slot } = makeValueTrack(options, this._at(at))
125
+ this._values.set(slot, deliver)
126
+ this._spec.tracks.push(track)
127
+ this._grow(track.atMs + track.durMs)
128
+ return this
129
+ }
130
+
131
+ /** Call `fn` when playback crosses `at` (forward or backward), in time order, always before
132
+ * finish, never from a previous run. */
133
+ call(at: TimelinePosition, fn: () => void): this {
134
+ const t = this._at(at)
135
+ this._spec.calls.push(t)
136
+ this._calls.push(fn)
137
+ this._grow(t)
138
+ return this
139
+ }
140
+
141
+ /** Name a time — for `at`, `seek` and callers (`tl.labels.landed`). Default = the current end. */
142
+ label(name: string, at?: TimelinePosition): this {
143
+ this._labels.set(name, this._at(at))
144
+ return this
145
+ }
146
+
147
+ /** Named times, ms. */
148
+ get labels(): Record<string, number> {
149
+ const o: Record<string, number> = {}
150
+ for (const [k, v] of this._labels) o[k] = v
151
+ return o
152
+ }
153
+
154
+ /** Extend the timeline with stillness after its current end. */
155
+ hold(ms: number): this {
156
+ this._grow(this._end + Math.max(0, ms))
157
+ return this
158
+ }
159
+
160
+ /** The current end, ms (tracks, calls and holds). */
161
+ get end(): number { return this._end }
162
+
163
+ /** `seek` also accepts a label. */
164
+ override seek(at: number | string): this {
165
+ return super.seek(typeof at === "string" ? this._at(at) : at)
166
+ }
167
+ }
168
+
169
+ /** The timeline handle type (see {@link TimelineImpl} for the members). */
170
+ export type Timeline = TimelineImpl
171
+
172
+ /** Create a timeline — `Timeline()` / `Timeline({ clock: 'game', loop: true })`. */
173
+ export function Timeline(options?: TimelineOptions): Timeline {
174
+ return new TimelineImpl(options)
175
+ }
@@ -0,0 +1,100 @@
1
+ // `animate()` — "animate a VALUE, I apply it" (docs/timeline-plan.md): the free-value tween for
2
+ // anything without a native writer (a material parameter, a volume, a number the Canvas draws).
3
+ // Since 2026-09-14 it runs on the keyframe core like everything else: a VALUE-domain track the host
4
+ // evaluates natively and hands back to JS once per frame, the same easing vocabulary, keyframes,
5
+ // loops and clocks, and the same `Animation` handle. `Timeline.animate` puts one into a timeline.
6
+
7
+ import { TweenAnimation, type Animation } from "./Animation"
8
+ import { Mat4 } from "../../math/mat4"
9
+ import type { Vec2Like, Vec3Like } from "../../math/vec"
10
+ import type { QuatLike } from "../../math/quat"
11
+ import type { Mat4Like } from "../../math/mat4"
12
+ import type { ColorInput } from "../../core/color"
13
+ import {
14
+ CLOCK_GAME, CLOCK_UI, DOM_VALUE, KIND_FLOAT, KIND_MAT4, DEFAULT_DURATION_MS, easingFor, iterationsOf, lanesOf, valueValue,
15
+ type Keyframe, type Track, type TweenChannel, type TweenMeta, type TweenSpec, type TweenTarget,
16
+ } from "./spec"
17
+
18
+ /** What `animate()` tweens: a number, a color (string / packed / rgb(a) array — delivered as rgba
19
+ * 0..1), a 2 / 3-vector, a quaternion (slerp), or a 4x4 matrix (position / rotation / scale). */
20
+ export type AnimateValue = number | string | Vec2Like | Vec3Like | QuatLike | Mat4Like | ColorInput
21
+
22
+ /** `onUpdate` receives a number for a number; every other kind arrives as the SAME `Float32Array`
23
+ * every frame (overwritten in place — copy it if you keep it). A matrix is the 16 floats. */
24
+ export type AnimateOut<T> = T extends number ? number : Float32Array
25
+
26
+ export type AnimateOptions<T extends AnimateValue> = Omit<TweenMeta, "commit" | "layer"> & {
27
+ /** Start value (required unless `values` is given). */
28
+ from?: T,
29
+ /** End value. */
30
+ to?: T,
31
+ /** Keyframes instead of from/to (offsets via `times`). */
32
+ values?: T[],
33
+ onUpdate(value: AnimateOut<T>): void,
34
+ /** After the last frame of a run that reached its end (not on cancel). */
35
+ onComplete?(): void,
36
+ }
37
+
38
+ /** @internal Deliverers turn the host's lanes into the `onUpdate` argument without allocating. */
39
+ const deliverer = (kind: number, lanes: number, onUpdate: (v: any) => void): ((l: Float32Array) => void) => {
40
+ if (kind === KIND_FLOAT) return (l) => onUpdate(l[0])
41
+ if (kind === KIND_MAT4) {
42
+ const out = new Float32Array(16)
43
+ return (l) => {
44
+ const m = Mat4.compose([l[0]!, l[1]!, l[2]!], [l[3]!, l[4]!, l[5]!, l[6]!], [l[7]!, l[8]!, l[9]!]).m
45
+ for (let i = 0; i < 16; i++) out[i] = m[i]!
46
+ onUpdate(out)
47
+ }
48
+ }
49
+ const out = new Float32Array(lanes)
50
+ return (l) => { for (let i = 0; i < lanes; i++) out[i] = l[i]!; onUpdate(out) }
51
+ }
52
+
53
+ /** VALUE slots are unique per world: the host addresses a value track by (animation, slot), and the
54
+ * evaluator must never see two animations naming the same slot (a channel is owned by the newest
55
+ * animation that names it — values have no shared state to own). */
56
+ let nextSlot = 0
57
+
58
+ /** @internal Build a VALUE track from animate options. Returns the track, its deliverer and its slot. */
59
+ export const makeValueTrack = (o: AnimateOptions<any>, atMs: number): { track: Track, deliver: (l: Float32Array) => void, slot: number } => {
60
+ const slot = ++nextSlot
61
+ const raw: unknown[] = o.values ?? (o.from !== undefined && o.to !== undefined ? [o.from, o.to] : [])
62
+ if (raw.length < 1) throw new Error("animate: give `from` + `to`, or `values`")
63
+ const norm = raw.map((v) => valueValue(v))
64
+ if (norm.some((n) => n === null)) throw new Error("animate: a value is not a number, color, vector, quaternion or matrix")
65
+ const kind = norm[0]!.kind
66
+ if (norm.some((n) => n!.kind !== kind)) throw new Error("animate: every value must be the same kind")
67
+ const lanes = lanesOf(kind)
68
+ const easing = easingFor(o.easing, "value")
69
+ const keys: Keyframe[] = norm.map((n, i) => ({
70
+ t: o.times && o.times.length === norm.length ? o.times[i]! : norm.length === 1 ? 1 : i / (norm.length - 1),
71
+ easing,
72
+ value: { lanes: (n as { lanes: number[] }).lanes },
73
+ raw: raw[i],
74
+ }))
75
+ const channel: TweenChannel = { domain: DOM_VALUE, id: () => slot, value: valueValue }
76
+ const target: TweenTarget = { _tweenClock: CLOCK_UI, _tweenChannel: () => channel }
77
+ const track: Track = {
78
+ target, prop: "value", channel, kind, lanes,
79
+ atMs: atMs + (o.delay ?? 0), durMs: Math.max(0, o.duration ?? DEFAULT_DURATION_MS),
80
+ commit: false, keys,
81
+ }
82
+ return { track, deliver: deliverer(kind, lanes, o.onUpdate), slot }
83
+ }
84
+
85
+ /** Tween a free value and apply it yourself in `onUpdate` — the primitive for anything without a
86
+ * native channel (material params, volumes, numbers a Canvas draws). Same easing / keyframes /
87
+ * `loop` / `clock` as `animateTo`; returns the {@link Animation} handle. Default clock: `'ui'`. */
88
+ export const animate = <T extends AnimateValue>(options: AnimateOptions<T>): Animation => {
89
+ const { track, deliver, slot } = makeValueTrack(options, 0)
90
+ const spec: TweenSpec = {
91
+ clock: options.clock === "game" ? CLOCK_GAME : CLOCK_UI,
92
+ durationMs: track.atMs + track.durMs, delayMs: 0,
93
+ iterations: iterationsOf(options.loop), pingPong: options.loopMode !== "restart",
94
+ rate: 1, tracks: [track], calls: [],
95
+ }
96
+ const a = new TweenAnimation(spec)
97
+ a._values.set(slot, deliver)
98
+ if (options.onComplete) a.onFinish((done) => { if (done) options.onComplete!() })
99
+ return a.play()
100
+ }