lecodes-sdk 1.0.0 → 1.2.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.
- package/dist/global.d.ts +18 -4
- package/dist/inject.js +260 -361
- package/dist/types/animate/tween/Animation.d.ts +69 -0
- package/dist/types/animate/tween/Timeline.d.ts +55 -0
- package/dist/types/animate/tween/animateValue.d.ts +27 -0
- package/dist/types/animate/tween/easing.d.ts +29 -0
- package/dist/types/animate/tween/spec.d.ts +178 -0
- package/dist/types/g2/Node2D.d.ts +16 -0
- package/dist/types/g2/Sprite.d.ts +11 -1
- package/dist/types/gl/Camera.d.ts +15 -1
- package/dist/types/gl/Foliage.d.ts +47 -0
- package/dist/types/gl/Geometry.d.ts +24 -0
- package/dist/types/gl/Light.d.ts +28 -7
- package/dist/types/gl/Lightmap.d.ts +90 -60
- package/dist/types/gl/Material.d.ts +28 -20
- package/dist/types/gl/Model.d.ts +7 -5
- package/dist/types/gl/Node.d.ts +18 -0
- package/dist/types/gl/Particles.d.ts +55 -1
- package/dist/types/gl/Scene.d.ts +20 -0
- package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
- package/dist/types/gl/animation/Animator.d.ts +27 -0
- package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
- package/dist/types/gl/animation/IK.d.ts +86 -30
- package/dist/types/gl/animation/Locomotion.d.ts +52 -3
- package/dist/types/gl/animation/Warp.d.ts +2 -1
- package/dist/types/gl/animation/core.d.ts +35 -4
- package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
- package/dist/types/gl/terrain/Terrain.d.ts +12 -2
- package/dist/types/inject.d.ts +8 -2
- package/dist/types/runtime/input.d.ts +7 -0
- package/dist/types/scene/defineScene.d.ts +44 -32
- package/dist/types/ui/UIButton.d.ts +3 -1
- package/dist/types/ui/UIInput.d.ts +5 -1
- package/dist/types/ui/UINode.d.ts +24 -24
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- package/prompts/core-design.md +27 -4
- package/prompts/core.md +35 -6
- package/prompts/dist/2d-game.md +197 -408
- package/prompts/dist/3d-app.md +166 -491
- package/prompts/dist/ar-app.md +163 -373
- package/prompts/dist/design.md +87 -83
- package/prompts/dist/ui-app.md +136 -325
- package/prompts/select.ts +19 -4
- package/src/animate/tween/Animation.ts +378 -0
- package/src/animate/tween/Timeline.ts +175 -0
- package/src/animate/tween/animateValue.ts +100 -0
- package/src/animate/tween/easing.ts +172 -0
- package/src/animate/tween/spec.ts +479 -0
- package/src/audio/audio.ts +161 -161
- package/src/bridges.d.ts +235 -65
- package/src/compile/__tests__/assetMacro.test.ts +26 -0
- package/src/compile/__tests__/detectEntry.test.ts +19 -0
- package/src/compile/__tests__/serverSplit.test.ts +27 -0
- package/src/compile/bundler.ts +34 -4
- package/src/compile/compileProject.ts +31 -1
- package/src/compile/detectEntry.ts +8 -3
- package/src/compile/index.ts +2 -0
- package/src/compile/serverSplit.ts +9 -3
- package/src/g2/Node2D.ts +38 -0
- package/src/g2/Sprite.ts +20 -1
- package/src/gl/Camera.ts +34 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Foliage.ts +102 -0
- package/src/gl/Geometry.ts +393 -348
- package/src/gl/Light.ts +49 -16
- package/src/gl/Lightmap.ts +439 -275
- package/src/gl/Material.ts +59 -47
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Model.ts +23 -12
- package/src/gl/Node.ts +39 -0
- package/src/gl/Particles.ts +80 -2
- package/src/gl/Scene.ts +34 -1
- package/src/gl/animation/AnimationClip.ts +52 -0
- package/src/gl/animation/Animator.ts +42 -2
- package/src/gl/animation/DynamicBone.ts +35 -12
- package/src/gl/animation/IK.ts +173 -152
- package/src/gl/animation/Locomotion.ts +72 -8
- package/src/gl/animation/Playback.ts +5 -4
- package/src/gl/animation/Warp.ts +5 -2
- package/src/gl/animation/core.ts +65 -4
- package/src/gl/physics/Ragdoll.ts +451 -272
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/terrain/Terrain.ts +33 -2
- package/src/inject.ts +236 -226
- package/src/runtime/input.ts +11 -0
- package/src/scene/defineScene.ts +72 -62
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIButton.ts +2 -2
- package/src/ui/UIInput.ts +3 -3
- package/src/ui/UINode.ts +61 -36
- package/dist/types/animate/animate.d.ts +0 -20
- package/src/animate/animate.ts +0 -238
package/prompts/select.ts
CHANGED
|
@@ -87,15 +87,30 @@ export function detectBundle(files: ProjectFile[]): Bundle | null {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Sticky per-conversation upgrade: once a bundle is chosen, only ever move UP
|
|
91
|
-
* ladder (ui → 2d
|
|
92
|
-
* stable while a project grows from "empty" to "3D game with a HUD".
|
|
90
|
+
* Sticky per-conversation upgrade from CODE DETECTION: once a bundle is chosen, only ever move UP
|
|
91
|
+
* the ladder (ui → 2d → 3d → ar) mid-conversation — never down, so the prompt-cache prefix stays
|
|
92
|
+
* stable while a project grows from "empty" to "3D game with a HUD". The ladder mirrors
|
|
93
|
+
* detectBundle's priority (AR > 3D > 2D > UI): a 2D project that gains 3D code moves to the 3D
|
|
94
|
+
* bundle. (2D and 3D used to share a rank, which made 2D → 3D unreachable by any route — a project
|
|
95
|
+
* that started as a 2D game could never become a 3D one.)
|
|
93
96
|
*/
|
|
94
97
|
export function upgradeBundle(current: Bundle, detected: Bundle): Bundle {
|
|
95
|
-
const rank: Record<Bundle, number> = { "ui-app": 0, "2d-game": 1, "3d-app":
|
|
98
|
+
const rank: Record<Bundle, number> = { "ui-app": 0, "2d-game": 1, "3d-app": 2, "ar-app": 3 }
|
|
96
99
|
return rank[detected] > rank[current] ? detected : current
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Whether to honor the model's own `<bundle>` directive (parseBundleDirective). Unlike detection,
|
|
104
|
+
* an explicit request may move in ANY direction: the model is saying the APIs the request needs
|
|
105
|
+
* are not documented in its current bundle — turning a 2D game into 3D, a 3D app into AR, or a 3D
|
|
106
|
+
* scene back into a 2D game are all legitimate. The old code it rewrites is in its context; it
|
|
107
|
+
* needs no documentation to replace it. Only a no-op (asking for the bundle it already has) is
|
|
108
|
+
* refused, which also rules out a re-send loop.
|
|
109
|
+
*/
|
|
110
|
+
export function canSwitchBundle(current: Bundle, requested: Bundle): boolean {
|
|
111
|
+
return requested !== current
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
const DIRECTIVE_WORD: Record<string, Bundle> = { "2d": "2d-game", "3d": "3d-app", "ar": "ar-app" }
|
|
100
115
|
|
|
101
116
|
/**
|
|
@@ -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
|
+
}
|