lecodes-sdk 1.1.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/inject.js +260 -361
- package/dist/types/gl/Light.d.ts +3 -0
- package/dist/types/gl/Particles.d.ts +15 -0
- package/dist/types/gl/animation/Locomotion.d.ts +52 -3
- package/dist/types/gl/terrain/Terrain.d.ts +8 -0
- package/dist/types/runtime/input.d.ts +7 -0
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- 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/src/animate/tween/Animation.ts +378 -378
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/animate/tween/animateValue.ts +100 -100
- package/src/animate/tween/spec.ts +479 -479
- package/src/audio/audio.ts +161 -161
- package/src/bridges.d.ts +10 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +3 -0
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Model.ts +167 -167
- package/src/gl/Particles.ts +19 -0
- package/src/gl/animation/DynamicBone.ts +482 -482
- package/src/gl/animation/Locomotion.ts +72 -8
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/terrain/Terrain.ts +29 -0
- package/src/inject.ts +236 -236
- package/src/runtime/input.ts +11 -0
- package/src/scene/gizmos.ts +148 -148
|
@@ -1,479 +1,479 @@
|
|
|
1
|
-
// The keyframe animation SPEC (docs/timeline-plan.md §4) and its wire form.
|
|
2
|
-
//
|
|
3
|
-
// An animation is a list of TRACKS — (target, prop) → keyframes — plus a clock, iteration and call
|
|
4
|
-
// (time-callback) metadata. The SDK builds the spec from the flat options bags (`animateTo`,
|
|
5
|
-
// `Timeline.add`), and `buildBlob` flattens it into ONE Float32Array + a string table at play time
|
|
6
|
-
// (target ids are only known then: UI nodes get their id at mount, screens re-assign it on every
|
|
7
|
-
// open). creator-pkg parses the blob into creator-tween's structures; the layout below is the
|
|
8
|
-
// contract (creator-tween/include/creator-tween/blob.h mirrors it).
|
|
9
|
-
//
|
|
10
|
-
// Blob layout (all floats; ids and indices are exact below 2^24):
|
|
11
|
-
// header [version=1, clock, durationMs, delayMs, iterations, pingPong, rate, nTracks, nCalls, nTables]
|
|
12
|
-
// calls nCalls × [tMs]
|
|
13
|
-
// tables nTables × EASE_TABLE_SIZE samples
|
|
14
|
-
// tracks nTracks × ( [domain, targetIdx, propStr, kind, lanes, atMs, durMs, commit, nKeys, sigStr]
|
|
15
|
-
// + nKeys × [t01, easeKind, e0, e1, e2, e3, has, lanes × value] )
|
|
16
|
-
// `targetIdx` indexes the `targets` list handed over next to the floats (a host node handle is an
|
|
17
|
-
// opaque object on creator-pkg hosts, a number elsewhere — it can't ride in a Float32Array).
|
|
18
|
-
// A key with has = 0 carries no value: the evaluator CAPTURES the target's current value when the
|
|
19
|
-
// animation first plays (the implicit first key of `animateTo`, the implicit last of `animateFrom`).
|
|
20
|
-
// String-valued lanes (DISCRETE / STRING / TRANSFORM_MATRIX keys) hold a string-table index.
|
|
21
|
-
|
|
22
|
-
import { EASE_TABLE_SIZE, evalEasing, parseEasing, type EasingInput, type EasingSpec } from "./easing"
|
|
23
|
-
import { Color } from "../../core/color"
|
|
24
|
-
import { Mat4 } from "../../math/mat4"
|
|
25
|
-
|
|
26
|
-
export const BLOB_VERSION = 1
|
|
27
|
-
|
|
28
|
-
// ---- domains (who the target is; creator-pkg maps each to a writer) ----------------------------
|
|
29
|
-
export const DOM_UI = 0 // targetId = the element's host node id
|
|
30
|
-
export const DOM_GL_NODE = 1 // targetId = entity: position / quaternion / eulerAngles / scale
|
|
31
|
-
export const DOM_GL_LIGHT = 2 // targetId = entity: intensity / color
|
|
32
|
-
export const DOM_GL_CAMERA = 3 // targetId = scene id: fov / near / far
|
|
33
|
-
export const DOM_NODE2D = 4 // targetId = entity: x / y / position / rotation / scale
|
|
34
|
-
export const DOM_SPRITE2D = 5 // targetId = entity: opacity / color
|
|
35
|
-
export const DOM_VALUE = 6 // no engine target: the host hands the lanes back to JS — animate() / Timeline.animate; targetId = slot + 1
|
|
36
|
-
|
|
37
|
-
// ---- value kinds ------------------------------------------------------------------------------
|
|
38
|
-
export const KIND_FLOAT = 0 // 1 lane
|
|
39
|
-
export const KIND_FLOAT_UNIT = 1 // 1 lane, sig = the unit ("px" | "%" | "vw" | "vh" | "deg" | "em")
|
|
40
|
-
export const KIND_COLOR = 2 // 4 lanes r g b a in 0..1
|
|
41
|
-
export const KIND_TRANSFORM = 3 // n lanes = the function list's args; sig = "translate,rotate,scale,…"
|
|
42
|
-
export const KIND_TRANSFORM_MATRIX = 4 // 1 lane = string index of the raw transform; the host decomposes
|
|
43
|
-
export const KIND_VEC3 = 5 // 3 lanes
|
|
44
|
-
export const KIND_QUAT = 6 // 4 lanes, slerp
|
|
45
|
-
export const KIND_EULER = 7 // 3 lanes, degrees, no shortest arc
|
|
46
|
-
export const KIND_DISCRETE = 8 // 1 lane = string index, steps at the key
|
|
47
|
-
export const KIND_STRING = 9 // 1 lane = string index; the UI host resolves: color prop → COLOR, else DISCRETE
|
|
48
|
-
export const KIND_VEC2 = 10 // 2 lanes
|
|
49
|
-
export const KIND_MAT4 = 11 // 10 lanes: position 3, quaternion 4 (slerp), scale 3 — a decomposed 4x4
|
|
50
|
-
|
|
51
|
-
export const CLOCK_UI = 0
|
|
52
|
-
export const CLOCK_GAME = 1
|
|
53
|
-
|
|
54
|
-
/** Lane counts per kind (TRANSFORM is per-signature, see `transformLanes`). */
|
|
55
|
-
const LANES: Record<number, number> = {
|
|
56
|
-
[KIND_FLOAT]: 1, [KIND_FLOAT_UNIT]: 1, [KIND_COLOR]: 4, [KIND_TRANSFORM_MATRIX]: 1,
|
|
57
|
-
[KIND_VEC3]: 3, [KIND_QUAT]: 4, [KIND_EULER]: 3, [KIND_DISCRETE]: 1, [KIND_STRING]: 1, [KIND_VEC2]: 2, [KIND_MAT4]: 10,
|
|
58
|
-
}
|
|
59
|
-
export const lanesOf = (kind: number): number => LANES[kind] ?? 1
|
|
60
|
-
|
|
61
|
-
/** One normalized value: numeric lanes, or a string (string-lane kinds). */
|
|
62
|
-
export type TweenValue = { lanes: number[] } | { str: string }
|
|
63
|
-
|
|
64
|
-
/** A target's answer to "can you animate `prop`?": the writer domain, the id resolver, the value
|
|
65
|
-
* normalizer, and the commit hook (UI writes the last key into its stored style). */
|
|
66
|
-
export type TweenChannel = {
|
|
67
|
-
domain: number,
|
|
68
|
-
/** Host handle at play time — a number (entity, web node id) or the host's opaque node object
|
|
69
|
-
* (creator-pkg hands `_id` out as one); falsy = not addressable right now (unmounted), the
|
|
70
|
-
* track is skipped. Handed to the host in the blob's `targets` list, never in the float data. */
|
|
71
|
-
id: () => unknown,
|
|
72
|
-
/** Normalize one keyframe value; `null` = not animatable (a warning, the value is committed only). */
|
|
73
|
-
value: (v: unknown) => ({ kind: number, sig?: string } & TweenValue) | null,
|
|
74
|
-
commit?: (v: unknown) => void,
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Implemented by every animatable class (Element, Node, Node2D, Sprite, Light, Camera). */
|
|
78
|
-
export interface TweenTarget {
|
|
79
|
-
/** @internal */
|
|
80
|
-
_tweenChannel(prop: string): TweenChannel | null
|
|
81
|
-
/** @internal Default clock for `animateTo` on this target. */
|
|
82
|
-
readonly _tweenClock: number
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export type Keyframe = {
|
|
86
|
-
/** 0..1 within the track. */
|
|
87
|
-
t: number,
|
|
88
|
-
easing: EasingSpec,
|
|
89
|
-
/** `undefined` = capture at play. */
|
|
90
|
-
value?: TweenValue,
|
|
91
|
-
/** The bag value as written — what the old-host path forwards verbatim. */
|
|
92
|
-
raw?: unknown,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export type Track = {
|
|
96
|
-
target: TweenTarget,
|
|
97
|
-
prop: string,
|
|
98
|
-
channel: TweenChannel,
|
|
99
|
-
kind: number,
|
|
100
|
-
lanes: number,
|
|
101
|
-
sig?: string,
|
|
102
|
-
atMs: number,
|
|
103
|
-
durMs: number,
|
|
104
|
-
commit: boolean,
|
|
105
|
-
keys: Keyframe[],
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export type TweenSpec = {
|
|
109
|
-
clock: number,
|
|
110
|
-
/** One iteration, ms — the last track's end (holds included). */
|
|
111
|
-
durationMs: number,
|
|
112
|
-
delayMs: number,
|
|
113
|
-
/** -1 = forever. */
|
|
114
|
-
iterations: number,
|
|
115
|
-
pingPong: boolean,
|
|
116
|
-
rate: number,
|
|
117
|
-
tracks: Track[],
|
|
118
|
-
/** Time-callbacks, ms; index = the event index the host reports. */
|
|
119
|
-
calls: number[],
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ---- the options bag -----------------------------------------------------------------------------
|
|
123
|
-
|
|
124
|
-
/** Meta keys of an `animateTo` / `Timeline.add` bag — everything that is not a prop target. */
|
|
125
|
-
export const TWEEN_META = new Set(["duration", "delay", "easing", "times", "loop", "loopMode", "commit", "clock", "layer", "at", "stagger"])
|
|
126
|
-
|
|
127
|
-
export type TweenMeta = {
|
|
128
|
-
/** Tween length in **milliseconds** (default 225). */
|
|
129
|
-
duration?: number,
|
|
130
|
-
/** Wait before starting, **milliseconds**. The first keyframe holds through the wait. A loop delays once. */
|
|
131
|
-
delay?: number,
|
|
132
|
-
/** A curve for every prop, or a per-prop map (`{ width: 'outExpo', opacity: 'linear' }`). Names
|
|
133
|
-
* (`outCubic`, `inOutSine`, `outBack`, …), `cubic-bezier(…)` / a `[x1, y1, x2, y2]` tuple,
|
|
134
|
-
* `steps(n)`, or any `Easing` function. Default linear. */
|
|
135
|
-
easing?: EasingInput | Record<string, EasingInput>,
|
|
136
|
-
/** Keyframe offsets 0..1 for array-valued props (default: evenly spaced). */
|
|
137
|
-
times?: number[],
|
|
138
|
-
/** `true` = forever, a number = that many cycles. A loop never commits. */
|
|
139
|
-
loop?: boolean | number,
|
|
140
|
-
/** `"ping-pong"` (default) there and back each cycle; `"restart"` snaps back and replays. */
|
|
141
|
-
loopMode?: "restart" | "ping-pong",
|
|
142
|
-
/** `false` = animate without writing the last keyframe into the element's style. */
|
|
143
|
-
commit?: boolean,
|
|
144
|
-
/** `'ui'` = wall time (menus animate while the game is paused); `'game'` follows `Time.scale`.
|
|
145
|
-
* Default: `'ui'` on UI elements, `'game'` on 3D / 2D nodes. */
|
|
146
|
-
clock?: "ui" | "game",
|
|
147
|
-
/** Legacy escape hatch (old hosts only). */
|
|
148
|
-
layer?: number,
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export const DEFAULT_DURATION_MS = 225
|
|
152
|
-
|
|
153
|
-
/** Warn once per (prop) about a value that cannot be animated — the bag is committed anyway. */
|
|
154
|
-
const warned = new Set<string>()
|
|
155
|
-
export const warnOnce = (key: string, msg: string): void => {
|
|
156
|
-
if (warned.has(key)) return
|
|
157
|
-
warned.add(key)
|
|
158
|
-
console.warn(`[tween] ${msg}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Resolve the per-prop easing from the bag. */
|
|
162
|
-
export const easingFor = (easing: TweenMeta["easing"], prop: string): EasingSpec => {
|
|
163
|
-
if (easing !== undefined && typeof easing === "object" && !Array.isArray(easing) && typeof easing !== "function") {
|
|
164
|
-
return parseEasing((easing as Record<string, EasingInput>)[prop])
|
|
165
|
-
}
|
|
166
|
-
return parseEasing(easing as EasingInput | undefined)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Iteration count from `loop`: 1 = one-shot, -1 = forever, n = cycles. */
|
|
170
|
-
export const iterationsOf = (loop: TweenMeta["loop"]): number => {
|
|
171
|
-
if (loop === true) return -1
|
|
172
|
-
if (typeof loop === "number" && loop > 0) return Math.floor(loop)
|
|
173
|
-
return 1
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ---- value normalizers shared by the domains ----------------------------------------------------
|
|
177
|
-
|
|
178
|
-
const UNIT_RE = /^\s*(-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*(px|%|vw|vh|vmin|vmax|deg|rad|em|rem)?\s*$/i
|
|
179
|
-
|
|
180
|
-
/** A UI style value: number → FLOAT; "12px" / "50%" → FLOAT_UNIT; other strings → STRING (the host
|
|
181
|
-
* decides color vs discrete); booleans → DISCRETE. */
|
|
182
|
-
export const uiValue = (v: unknown): ({ kind: number, sig?: string } & TweenValue) | null => {
|
|
183
|
-
if (typeof v === "number") return { kind: KIND_FLOAT, lanes: [v] }
|
|
184
|
-
if (typeof v === "boolean") return { kind: KIND_DISCRETE, str: v ? "true" : "false" }
|
|
185
|
-
if (typeof v === "string") {
|
|
186
|
-
const m = UNIT_RE.exec(v)
|
|
187
|
-
if (m) {
|
|
188
|
-
const unit = (m[2] ?? "").toLowerCase()
|
|
189
|
-
return unit === "" || unit === "px"
|
|
190
|
-
? { kind: KIND_FLOAT, lanes: [parseFloat(m[1]!)] }
|
|
191
|
-
: { kind: KIND_FLOAT_UNIT, sig: unit, lanes: [parseFloat(m[1]!)] }
|
|
192
|
-
}
|
|
193
|
-
return { kind: KIND_STRING, str: v }
|
|
194
|
-
}
|
|
195
|
-
return null
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Per-function argument counts of a UI transform list (degrees / px are the SDK's units). */
|
|
199
|
-
const TRANSFORM_FNS: Record<string, number> = {
|
|
200
|
-
translate: 2, translateX: 1, translateY: 1, rotate: 1, scale: 2, scaleX: 1, scaleY: 1, skewX: 1, skewY: 1,
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Parse `"translate(10px, 20px) rotate(45deg) scale(1.2)"` into a signature + lanes, or null when a
|
|
204
|
-
* function is unknown (the caller falls back to matrix decomposition on the host). `scale(2)` and
|
|
205
|
-
* `translate(5px)` complete their second argument like CSS. */
|
|
206
|
-
export const parseTransformList = (s: string): { sig: string, lanes: number[] } | null => {
|
|
207
|
-
const sig: string[] = []
|
|
208
|
-
const lanes: number[] = []
|
|
209
|
-
const re = /([a-zA-Z]+)\s*\(([^)]*)\)/g
|
|
210
|
-
let m: RegExpExecArray | null
|
|
211
|
-
let consumed = 0
|
|
212
|
-
const src = s.trim()
|
|
213
|
-
if (src === "" || src === "none") return { sig: "", lanes: [] }
|
|
214
|
-
while ((m = re.exec(src)) !== null) {
|
|
215
|
-
const fn = m[1]!
|
|
216
|
-
const n = TRANSFORM_FNS[fn]
|
|
217
|
-
if (n === undefined) return null
|
|
218
|
-
const args = m[2]!.split(",").map((a) => {
|
|
219
|
-
const u = UNIT_RE.exec(a)
|
|
220
|
-
return u ? parseFloat(u[1]!) : NaN
|
|
221
|
-
})
|
|
222
|
-
if (args.some((a) => Number.isNaN(a)) || args.length === 0 || args.length > n) return null
|
|
223
|
-
if (args.length < n) args.push(fn === "translate" ? 0 : args[0]!)
|
|
224
|
-
sig.push(fn)
|
|
225
|
-
lanes.push(...args)
|
|
226
|
-
consumed = re.lastIndex
|
|
227
|
-
}
|
|
228
|
-
if (src.slice(consumed).trim() !== "") return null
|
|
229
|
-
return { sig: sig.join(","), lanes }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export const transformLanes = (sig: string): number => (sig === "" ? 0 : sig.split(",").reduce((n, fn) => n + (TRANSFORM_FNS[fn] ?? 0), 0))
|
|
233
|
-
|
|
234
|
-
const num3 = (v: unknown): number[] | null => {
|
|
235
|
-
if (typeof v === "number") return [v, v, v]
|
|
236
|
-
if (v && typeof v === "object") {
|
|
237
|
-
const o = v as any
|
|
238
|
-
if (typeof o.length === "number" && o.length >= 3) return [+o[0], +o[1], +o[2]]
|
|
239
|
-
if (typeof o.x === "number") return [o.x, o.y, o.z]
|
|
240
|
-
}
|
|
241
|
-
return null
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const num4 = (v: unknown): number[] | null => {
|
|
245
|
-
if (v && typeof v === "object") {
|
|
246
|
-
const o = v as any
|
|
247
|
-
if (typeof o.length === "number" && o.length >= 4) return [+o[0], +o[1], +o[2], +o[3]]
|
|
248
|
-
if (typeof o.x === "number" && typeof o.w === "number") return [o.x, o.y, o.z, o.w]
|
|
249
|
-
}
|
|
250
|
-
return null
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const num2 = (v: unknown): number[] | null => {
|
|
254
|
-
if (typeof v === "number") return [v, v]
|
|
255
|
-
if (v && typeof v === "object") {
|
|
256
|
-
const o = v as any
|
|
257
|
-
if (typeof o.length === "number" && o.length >= 2) return [+o[0], +o[1]]
|
|
258
|
-
if (typeof o.x === "number") return [o.x, o.y]
|
|
259
|
-
}
|
|
260
|
-
return null
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export const vec3Value = (v: unknown) => { const l = num3(v); return l ? { kind: KIND_VEC3, lanes: l } : null }
|
|
264
|
-
export const quatValue = (v: unknown) => { const l = num4(v); return l ? { kind: KIND_QUAT, lanes: l } : null }
|
|
265
|
-
export const eulerValue = (v: unknown) => { const l = num3(v); return l ? { kind: KIND_EULER, lanes: l } : null }
|
|
266
|
-
export const vec2Value = (v: unknown) => { const l = num2(v); return l ? { kind: KIND_VEC2, lanes: l } : null }
|
|
267
|
-
export const floatValue = (v: unknown) => (typeof v === "number" ? { kind: KIND_FLOAT, lanes: [v] } : null)
|
|
268
|
-
export const colorValue = (v: unknown, toRgba: (c: any) => [number, number, number, number]) => {
|
|
269
|
-
if (typeof v === "string" || typeof v === "number" || Array.isArray(v)) return { kind: KIND_COLOR, lanes: toRgba(v) }
|
|
270
|
-
return null
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** A free value for animate(): number → FLOAT, a color string → COLOR, arrays / vectors by length
|
|
274
|
-
* (2 → VEC2, 3 → VEC3, 4 → QUAT, 16 → MAT4 decomposed into position · quaternion · scale). */
|
|
275
|
-
export const valueValue = (v: unknown): ({ kind: number } & TweenValue) | null => {
|
|
276
|
-
if (typeof v === "number") return { kind: KIND_FLOAT, lanes: [v] }
|
|
277
|
-
if (typeof v === "string") return { kind: KIND_COLOR, lanes: Color.toRgba01(v) }
|
|
278
|
-
if (v && typeof v === "object") {
|
|
279
|
-
const o = v as any
|
|
280
|
-
const arr: ArrayLike<number> | undefined = typeof o.length === "number" ? o : Array.isArray(o.m) && o.m.length === 16 ? o.m : undefined
|
|
281
|
-
if (arr) {
|
|
282
|
-
if (arr.length === 2) return { kind: KIND_VEC2, lanes: [+arr[0]!, +arr[1]!] }
|
|
283
|
-
if (arr.length === 3) return { kind: KIND_VEC3, lanes: [+arr[0]!, +arr[1]!, +arr[2]!] }
|
|
284
|
-
if (arr.length === 4) return { kind: KIND_QUAT, lanes: [+arr[0]!, +arr[1]!, +arr[2]!, +arr[3]!] }
|
|
285
|
-
if (arr.length === 16) {
|
|
286
|
-
const d = new Mat4(arr as any).decompose()
|
|
287
|
-
return { kind: KIND_MAT4, lanes: [d.position.x, d.position.y, d.position.z, d.rotation.x, d.rotation.y, d.rotation.z, d.rotation.w, d.scale.x, d.scale.y, d.scale.z] }
|
|
288
|
-
}
|
|
289
|
-
return null
|
|
290
|
-
}
|
|
291
|
-
if (typeof o.x === "number") {
|
|
292
|
-
if (typeof o.w === "number") return { kind: KIND_QUAT, lanes: [o.x, o.y, o.z, o.w] }
|
|
293
|
-
if (typeof o.z === "number") return { kind: KIND_VEC3, lanes: [o.x, o.y, o.z] }
|
|
294
|
-
return { kind: KIND_VEC2, lanes: [o.x, o.y] }
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return null
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Is this bag value a KEYFRAME ARRAY for a vector prop, as opposed to one vector? A vector value
|
|
301
|
-
* is an array of NUMBERS (`[0, 2, 0]`); anything else in the array is a list of keyframes —
|
|
302
|
-
* `[[0,0,0], [0,2,0]]`, `[new Vec3(), …]`, and the UI's `color: ['#fff', '#e29a22']`. */
|
|
303
|
-
export const isVectorKeyframes = (v: unknown): boolean =>
|
|
304
|
-
Array.isArray(v) && v.length > 0 && typeof v[0] !== "number"
|
|
305
|
-
|
|
306
|
-
// ---- track construction ------------------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
/** Build one track's keyframes from a bag value: a single value = one explicit key (the other end
|
|
309
|
-
* captured), an array = explicit keys spaced by `times` or evenly. `fromCurrent` says which end is
|
|
310
|
-
* implicit (animateTo: the first; animateFrom: the last). Returns null when a value can't be
|
|
311
|
-
* animated (already warned). */
|
|
312
|
-
export const makeTrack = (
|
|
313
|
-
target: TweenTarget, prop: string, raw: unknown, meta: TweenMeta, atMs: number, fromCurrent: boolean, vectorProp: boolean,
|
|
314
|
-
): Track | null => {
|
|
315
|
-
const channel = target._tweenChannel(prop)
|
|
316
|
-
if (!channel) {
|
|
317
|
-
warnOnce(`prop:${prop}`, `"${prop}" is not animatable on this target — set it directly`)
|
|
318
|
-
return null
|
|
319
|
-
}
|
|
320
|
-
const values: unknown[] = Array.isArray(raw) && (!vectorProp || isVectorKeyframes(raw)) ? raw : [raw]
|
|
321
|
-
const easing = easingFor(meta.easing, prop)
|
|
322
|
-
const norm = values.map((v) => channel.value(v))
|
|
323
|
-
if (norm.some((n) => n === null)) {
|
|
324
|
-
warnOnce(`value:${prop}`, `"${prop}" got a value that cannot be animated — set it directly`)
|
|
325
|
-
return null
|
|
326
|
-
}
|
|
327
|
-
const ns = norm as NonNullable<(typeof norm)[number]>[]
|
|
328
|
-
|
|
329
|
-
// One kind per track. Mismatched units / transform signatures degrade to DISCRETE (a flip at the
|
|
330
|
-
// key, like CSS), never to garbage interpolation.
|
|
331
|
-
let kind = ns[0]!.kind
|
|
332
|
-
let sig = ns[0]!.sig
|
|
333
|
-
for (const n of ns) {
|
|
334
|
-
if (n.kind !== kind || n.sig !== sig) {
|
|
335
|
-
// FLOAT vs FLOAT_UNIT(px) can't happen (px normalizes to FLOAT); anything else is discrete
|
|
336
|
-
kind = KIND_DISCRETE
|
|
337
|
-
sig = undefined
|
|
338
|
-
break
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
const lanes = kind === KIND_TRANSFORM ? transformLanes(sig ?? "") : LANES[kind]!
|
|
342
|
-
const toValue = (n: NonNullable<(typeof norm)[number]>): TweenValue =>
|
|
343
|
-
kind === KIND_DISCRETE && "lanes" in n ? { str: String(n.lanes[0]) } : ("str" in n ? { str: n.str } : { lanes: n.lanes })
|
|
344
|
-
|
|
345
|
-
const explicit = ns.length
|
|
346
|
-
const total = explicit + (explicit === 1 ? 1 : 0) // a single value pairs with a captured end
|
|
347
|
-
const keys: Keyframe[] = []
|
|
348
|
-
const times = meta.times
|
|
349
|
-
for (let i = 0; i < total; i++) {
|
|
350
|
-
let t: number
|
|
351
|
-
if (times && times.length === total) t = times[i]!
|
|
352
|
-
else t = total === 1 ? 1 : i / (total - 1)
|
|
353
|
-
let value: TweenValue | undefined
|
|
354
|
-
let raw: unknown
|
|
355
|
-
if (explicit === 1) {
|
|
356
|
-
// animateTo: [captured, v]; animateFrom: [v, captured]
|
|
357
|
-
const isExplicit = fromCurrent ? i === 1 : i === 0
|
|
358
|
-
value = isExplicit ? toValue(ns[0]!) : undefined
|
|
359
|
-
raw = isExplicit ? values[0] : undefined
|
|
360
|
-
} else {
|
|
361
|
-
value = toValue(ns[i]!)
|
|
362
|
-
raw = values[i]
|
|
363
|
-
}
|
|
364
|
-
keys.push({ t, easing, value, raw })
|
|
365
|
-
}
|
|
366
|
-
// animateFrom with an explicit array: the element's own style is the implicit LAST key
|
|
367
|
-
if (!fromCurrent && explicit > 1) {
|
|
368
|
-
for (const k of keys) k.t *= (total) / (total + 1)
|
|
369
|
-
keys.push({ t: 1, easing, value: undefined })
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return {
|
|
373
|
-
target, prop, channel, kind, lanes, sig,
|
|
374
|
-
atMs: atMs + (meta.delay ?? 0),
|
|
375
|
-
durMs: Math.max(0, meta.duration ?? DEFAULT_DURATION_MS),
|
|
376
|
-
// animateFrom never commits (its last key is the element's own state); loops never commit
|
|
377
|
-
commit: fromCurrent && meta.commit !== false && iterationsOf(meta.loop) === 1,
|
|
378
|
-
keys,
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Every (prop → value) entry of a bag, meta keys stripped. */
|
|
383
|
-
export const bagProps = (bag: Record<string, unknown>): [string, unknown][] =>
|
|
384
|
-
Object.entries(bag).filter(([k, v]) => !TWEEN_META.has(k) && v !== undefined)
|
|
385
|
-
|
|
386
|
-
// ---- JS evaluation (the old-host path of VALUE tracks; the C evaluator is the reference) ----------
|
|
387
|
-
|
|
388
|
-
const slerpInto = (out: number[], o: number, a: number[], ai: number, b: number[], bi: number, s: number): void => {
|
|
389
|
-
let bx = b[bi]!, by = b[bi + 1]!, bz = b[bi + 2]!, bw = b[bi + 3]!
|
|
390
|
-
let cosom = a[ai]! * bx + a[ai + 1]! * by + a[ai + 2]! * bz + a[ai + 3]! * bw
|
|
391
|
-
if (cosom < 0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw }
|
|
392
|
-
let k0: number, k1: number
|
|
393
|
-
if (1 - cosom > 1e-6) {
|
|
394
|
-
const omega = Math.acos(Math.min(1, cosom)), sinom = Math.sin(omega)
|
|
395
|
-
k0 = Math.sin((1 - s) * omega) / sinom
|
|
396
|
-
k1 = Math.sin(s * omega) / sinom
|
|
397
|
-
} else { k0 = 1 - s; k1 = s }
|
|
398
|
-
out[o] = k0 * a[ai]! + k1 * bx; out[o + 1] = k0 * a[ai + 1]! + k1 * by; out[o + 2] = k0 * a[ai + 2]! + k1 * bz; out[o + 3] = k0 * a[ai + 3]! + k1 * bw
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** The value of a numeric track at local time `ms` (the C evaluator's rule: first key holds before,
|
|
402
|
-
* last after; eased segment between). `out` receives `track.lanes` numbers. */
|
|
403
|
-
export const evaluateTrack = (track: Track, ms: number, out: number[]): void => {
|
|
404
|
-
const u = track.durMs <= 0 ? (ms >= track.atMs ? 1 : 0) : Math.min(1, Math.max(0, (ms - track.atMs) / track.durMs))
|
|
405
|
-
const keys = track.keys
|
|
406
|
-
const lanesOfKey = (k: Keyframe): number[] => (k.value && "lanes" in k.value ? k.value.lanes : [])
|
|
407
|
-
if (keys.length === 0) return
|
|
408
|
-
if (u <= keys[0]!.t || keys.length === 1) { const l = lanesOfKey(keys[0]!); for (let i = 0; i < track.lanes; i++) out[i] = l[i] ?? 0; return }
|
|
409
|
-
const last = keys[keys.length - 1]!
|
|
410
|
-
if (u >= last.t) { const l = lanesOfKey(last); for (let i = 0; i < track.lanes; i++) out[i] = l[i] ?? 0; return }
|
|
411
|
-
let i = 0
|
|
412
|
-
while (i + 1 < keys.length && keys[i + 1]!.t <= u) i++
|
|
413
|
-
const a = keys[i]!, b = keys[i + 1]!
|
|
414
|
-
const span = b.t - a.t
|
|
415
|
-
const s = evalEasing(a.easing, span > 0 ? (u - a.t) / span : 1)
|
|
416
|
-
const la = lanesOfKey(a), lb = lanesOfKey(b)
|
|
417
|
-
if (track.kind === KIND_QUAT) { slerpInto(out, 0, la, 0, lb, 0, s); return }
|
|
418
|
-
if (track.kind === KIND_MAT4) {
|
|
419
|
-
for (let k = 0; k < 3; k++) out[k] = la[k]! + (lb[k]! - la[k]!) * s
|
|
420
|
-
slerpInto(out, 3, la, 3, lb, 3, s)
|
|
421
|
-
for (let k = 7; k < 10; k++) out[k] = la[k]! + (lb[k]! - la[k]!) * s
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
for (let k = 0; k < track.lanes; k++) out[k] = (la[k] ?? 0) + ((lb[k] ?? 0) - (la[k] ?? 0)) * s
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ---- the blob ----------------------------------------------------------------------------------
|
|
428
|
-
|
|
429
|
-
export type TweenBlob = { data: Float32Array, strings: string[], targets: unknown[] }
|
|
430
|
-
|
|
431
|
-
/** Flatten a spec for `_creatorUI.tweenCreate`. Tracks whose target has no host id right now are
|
|
432
|
-
* dropped (the animation still runs its clock, calls and finish). */
|
|
433
|
-
export const buildBlob = (spec: TweenSpec): TweenBlob => {
|
|
434
|
-
const strings: string[] = []
|
|
435
|
-
const strIndex = new Map<string, number>()
|
|
436
|
-
const str = (s: string): number => {
|
|
437
|
-
let i = strIndex.get(s)
|
|
438
|
-
if (i === undefined) { i = strings.length; strings.push(s); strIndex.set(s, i) }
|
|
439
|
-
return i
|
|
440
|
-
}
|
|
441
|
-
const tables: Float32Array[] = []
|
|
442
|
-
const tableIndex = (e: EasingSpec): number => {
|
|
443
|
-
const i = tables.indexOf(e.table!)
|
|
444
|
-
if (i >= 0) return i
|
|
445
|
-
tables.push(e.table!)
|
|
446
|
-
return tables.length - 1
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const tracks = spec.tracks.map((t) => ({ t, id: t.channel.id() })).filter((x) => !!x.id)
|
|
450
|
-
const targets: unknown[] = []
|
|
451
|
-
const targetIndex = (id: unknown): number => {
|
|
452
|
-
let i = targets.indexOf(id)
|
|
453
|
-
if (i < 0) { i = targets.length; targets.push(id) }
|
|
454
|
-
return i
|
|
455
|
-
}
|
|
456
|
-
const out: number[] = []
|
|
457
|
-
out.push(BLOB_VERSION, spec.clock, spec.durationMs, spec.delayMs, spec.iterations, spec.pingPong ? 1 : 0, spec.rate,
|
|
458
|
-
tracks.length, spec.calls.length, 0 /* nTables, patched */)
|
|
459
|
-
for (const c of spec.calls) out.push(c)
|
|
460
|
-
const tablesAt = out.length
|
|
461
|
-
for (const { t, id } of tracks) {
|
|
462
|
-
out.push(t.channel.domain, targetIndex(id), str(t.prop), t.kind, t.lanes, t.atMs, t.durMs, t.commit ? 1 : 0, t.keys.length, t.sig !== undefined ? str(t.sig) : -1)
|
|
463
|
-
for (const k of t.keys) {
|
|
464
|
-
const e = k.easing
|
|
465
|
-
const p: number[] = [e.p[0], e.p[1], e.p[2], e.p[3]]
|
|
466
|
-
if (e.table) p[0] = tableIndex(e)
|
|
467
|
-
out.push(k.t, e.kind, p[0]!, p[1]!, p[2]!, p[3]!, k.value ? 1 : 0)
|
|
468
|
-
if (!k.value) { for (let i = 0; i < t.lanes; i++) out.push(0) }
|
|
469
|
-
else if ("str" in k.value) { out.push(str(k.value.str)); for (let i = 1; i < t.lanes; i++) out.push(0) }
|
|
470
|
-
else { for (let i = 0; i < t.lanes; i++) out.push(k.value.lanes[i] ?? 0) }
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
// splice the tables in after the calls
|
|
474
|
-
const tableData: number[] = []
|
|
475
|
-
for (const tb of tables) for (let i = 0; i < EASE_TABLE_SIZE; i++) tableData.push(tb[i]!)
|
|
476
|
-
out.splice(tablesAt, 0, ...tableData)
|
|
477
|
-
out[9] = tables.length
|
|
478
|
-
return { data: Float32Array.from(out), strings, targets }
|
|
479
|
-
}
|
|
1
|
+
// The keyframe animation SPEC (docs/timeline-plan.md §4) and its wire form.
|
|
2
|
+
//
|
|
3
|
+
// An animation is a list of TRACKS — (target, prop) → keyframes — plus a clock, iteration and call
|
|
4
|
+
// (time-callback) metadata. The SDK builds the spec from the flat options bags (`animateTo`,
|
|
5
|
+
// `Timeline.add`), and `buildBlob` flattens it into ONE Float32Array + a string table at play time
|
|
6
|
+
// (target ids are only known then: UI nodes get their id at mount, screens re-assign it on every
|
|
7
|
+
// open). creator-pkg parses the blob into creator-tween's structures; the layout below is the
|
|
8
|
+
// contract (creator-tween/include/creator-tween/blob.h mirrors it).
|
|
9
|
+
//
|
|
10
|
+
// Blob layout (all floats; ids and indices are exact below 2^24):
|
|
11
|
+
// header [version=1, clock, durationMs, delayMs, iterations, pingPong, rate, nTracks, nCalls, nTables]
|
|
12
|
+
// calls nCalls × [tMs]
|
|
13
|
+
// tables nTables × EASE_TABLE_SIZE samples
|
|
14
|
+
// tracks nTracks × ( [domain, targetIdx, propStr, kind, lanes, atMs, durMs, commit, nKeys, sigStr]
|
|
15
|
+
// + nKeys × [t01, easeKind, e0, e1, e2, e3, has, lanes × value] )
|
|
16
|
+
// `targetIdx` indexes the `targets` list handed over next to the floats (a host node handle is an
|
|
17
|
+
// opaque object on creator-pkg hosts, a number elsewhere — it can't ride in a Float32Array).
|
|
18
|
+
// A key with has = 0 carries no value: the evaluator CAPTURES the target's current value when the
|
|
19
|
+
// animation first plays (the implicit first key of `animateTo`, the implicit last of `animateFrom`).
|
|
20
|
+
// String-valued lanes (DISCRETE / STRING / TRANSFORM_MATRIX keys) hold a string-table index.
|
|
21
|
+
|
|
22
|
+
import { EASE_TABLE_SIZE, evalEasing, parseEasing, type EasingInput, type EasingSpec } from "./easing"
|
|
23
|
+
import { Color } from "../../core/color"
|
|
24
|
+
import { Mat4 } from "../../math/mat4"
|
|
25
|
+
|
|
26
|
+
export const BLOB_VERSION = 1
|
|
27
|
+
|
|
28
|
+
// ---- domains (who the target is; creator-pkg maps each to a writer) ----------------------------
|
|
29
|
+
export const DOM_UI = 0 // targetId = the element's host node id
|
|
30
|
+
export const DOM_GL_NODE = 1 // targetId = entity: position / quaternion / eulerAngles / scale
|
|
31
|
+
export const DOM_GL_LIGHT = 2 // targetId = entity: intensity / color
|
|
32
|
+
export const DOM_GL_CAMERA = 3 // targetId = scene id: fov / near / far
|
|
33
|
+
export const DOM_NODE2D = 4 // targetId = entity: x / y / position / rotation / scale
|
|
34
|
+
export const DOM_SPRITE2D = 5 // targetId = entity: opacity / color
|
|
35
|
+
export const DOM_VALUE = 6 // no engine target: the host hands the lanes back to JS — animate() / Timeline.animate; targetId = slot + 1
|
|
36
|
+
|
|
37
|
+
// ---- value kinds ------------------------------------------------------------------------------
|
|
38
|
+
export const KIND_FLOAT = 0 // 1 lane
|
|
39
|
+
export const KIND_FLOAT_UNIT = 1 // 1 lane, sig = the unit ("px" | "%" | "vw" | "vh" | "deg" | "em")
|
|
40
|
+
export const KIND_COLOR = 2 // 4 lanes r g b a in 0..1
|
|
41
|
+
export const KIND_TRANSFORM = 3 // n lanes = the function list's args; sig = "translate,rotate,scale,…"
|
|
42
|
+
export const KIND_TRANSFORM_MATRIX = 4 // 1 lane = string index of the raw transform; the host decomposes
|
|
43
|
+
export const KIND_VEC3 = 5 // 3 lanes
|
|
44
|
+
export const KIND_QUAT = 6 // 4 lanes, slerp
|
|
45
|
+
export const KIND_EULER = 7 // 3 lanes, degrees, no shortest arc
|
|
46
|
+
export const KIND_DISCRETE = 8 // 1 lane = string index, steps at the key
|
|
47
|
+
export const KIND_STRING = 9 // 1 lane = string index; the UI host resolves: color prop → COLOR, else DISCRETE
|
|
48
|
+
export const KIND_VEC2 = 10 // 2 lanes
|
|
49
|
+
export const KIND_MAT4 = 11 // 10 lanes: position 3, quaternion 4 (slerp), scale 3 — a decomposed 4x4
|
|
50
|
+
|
|
51
|
+
export const CLOCK_UI = 0
|
|
52
|
+
export const CLOCK_GAME = 1
|
|
53
|
+
|
|
54
|
+
/** Lane counts per kind (TRANSFORM is per-signature, see `transformLanes`). */
|
|
55
|
+
const LANES: Record<number, number> = {
|
|
56
|
+
[KIND_FLOAT]: 1, [KIND_FLOAT_UNIT]: 1, [KIND_COLOR]: 4, [KIND_TRANSFORM_MATRIX]: 1,
|
|
57
|
+
[KIND_VEC3]: 3, [KIND_QUAT]: 4, [KIND_EULER]: 3, [KIND_DISCRETE]: 1, [KIND_STRING]: 1, [KIND_VEC2]: 2, [KIND_MAT4]: 10,
|
|
58
|
+
}
|
|
59
|
+
export const lanesOf = (kind: number): number => LANES[kind] ?? 1
|
|
60
|
+
|
|
61
|
+
/** One normalized value: numeric lanes, or a string (string-lane kinds). */
|
|
62
|
+
export type TweenValue = { lanes: number[] } | { str: string }
|
|
63
|
+
|
|
64
|
+
/** A target's answer to "can you animate `prop`?": the writer domain, the id resolver, the value
|
|
65
|
+
* normalizer, and the commit hook (UI writes the last key into its stored style). */
|
|
66
|
+
export type TweenChannel = {
|
|
67
|
+
domain: number,
|
|
68
|
+
/** Host handle at play time — a number (entity, web node id) or the host's opaque node object
|
|
69
|
+
* (creator-pkg hands `_id` out as one); falsy = not addressable right now (unmounted), the
|
|
70
|
+
* track is skipped. Handed to the host in the blob's `targets` list, never in the float data. */
|
|
71
|
+
id: () => unknown,
|
|
72
|
+
/** Normalize one keyframe value; `null` = not animatable (a warning, the value is committed only). */
|
|
73
|
+
value: (v: unknown) => ({ kind: number, sig?: string } & TweenValue) | null,
|
|
74
|
+
commit?: (v: unknown) => void,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Implemented by every animatable class (Element, Node, Node2D, Sprite, Light, Camera). */
|
|
78
|
+
export interface TweenTarget {
|
|
79
|
+
/** @internal */
|
|
80
|
+
_tweenChannel(prop: string): TweenChannel | null
|
|
81
|
+
/** @internal Default clock for `animateTo` on this target. */
|
|
82
|
+
readonly _tweenClock: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type Keyframe = {
|
|
86
|
+
/** 0..1 within the track. */
|
|
87
|
+
t: number,
|
|
88
|
+
easing: EasingSpec,
|
|
89
|
+
/** `undefined` = capture at play. */
|
|
90
|
+
value?: TweenValue,
|
|
91
|
+
/** The bag value as written — what the old-host path forwards verbatim. */
|
|
92
|
+
raw?: unknown,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type Track = {
|
|
96
|
+
target: TweenTarget,
|
|
97
|
+
prop: string,
|
|
98
|
+
channel: TweenChannel,
|
|
99
|
+
kind: number,
|
|
100
|
+
lanes: number,
|
|
101
|
+
sig?: string,
|
|
102
|
+
atMs: number,
|
|
103
|
+
durMs: number,
|
|
104
|
+
commit: boolean,
|
|
105
|
+
keys: Keyframe[],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TweenSpec = {
|
|
109
|
+
clock: number,
|
|
110
|
+
/** One iteration, ms — the last track's end (holds included). */
|
|
111
|
+
durationMs: number,
|
|
112
|
+
delayMs: number,
|
|
113
|
+
/** -1 = forever. */
|
|
114
|
+
iterations: number,
|
|
115
|
+
pingPong: boolean,
|
|
116
|
+
rate: number,
|
|
117
|
+
tracks: Track[],
|
|
118
|
+
/** Time-callbacks, ms; index = the event index the host reports. */
|
|
119
|
+
calls: number[],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- the options bag -----------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Meta keys of an `animateTo` / `Timeline.add` bag — everything that is not a prop target. */
|
|
125
|
+
export const TWEEN_META = new Set(["duration", "delay", "easing", "times", "loop", "loopMode", "commit", "clock", "layer", "at", "stagger"])
|
|
126
|
+
|
|
127
|
+
export type TweenMeta = {
|
|
128
|
+
/** Tween length in **milliseconds** (default 225). */
|
|
129
|
+
duration?: number,
|
|
130
|
+
/** Wait before starting, **milliseconds**. The first keyframe holds through the wait. A loop delays once. */
|
|
131
|
+
delay?: number,
|
|
132
|
+
/** A curve for every prop, or a per-prop map (`{ width: 'outExpo', opacity: 'linear' }`). Names
|
|
133
|
+
* (`outCubic`, `inOutSine`, `outBack`, …), `cubic-bezier(…)` / a `[x1, y1, x2, y2]` tuple,
|
|
134
|
+
* `steps(n)`, or any `Easing` function. Default linear. */
|
|
135
|
+
easing?: EasingInput | Record<string, EasingInput>,
|
|
136
|
+
/** Keyframe offsets 0..1 for array-valued props (default: evenly spaced). */
|
|
137
|
+
times?: number[],
|
|
138
|
+
/** `true` = forever, a number = that many cycles. A loop never commits. */
|
|
139
|
+
loop?: boolean | number,
|
|
140
|
+
/** `"ping-pong"` (default) there and back each cycle; `"restart"` snaps back and replays. */
|
|
141
|
+
loopMode?: "restart" | "ping-pong",
|
|
142
|
+
/** `false` = animate without writing the last keyframe into the element's style. */
|
|
143
|
+
commit?: boolean,
|
|
144
|
+
/** `'ui'` = wall time (menus animate while the game is paused); `'game'` follows `Time.scale`.
|
|
145
|
+
* Default: `'ui'` on UI elements, `'game'` on 3D / 2D nodes. */
|
|
146
|
+
clock?: "ui" | "game",
|
|
147
|
+
/** Legacy escape hatch (old hosts only). */
|
|
148
|
+
layer?: number,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const DEFAULT_DURATION_MS = 225
|
|
152
|
+
|
|
153
|
+
/** Warn once per (prop) about a value that cannot be animated — the bag is committed anyway. */
|
|
154
|
+
const warned = new Set<string>()
|
|
155
|
+
export const warnOnce = (key: string, msg: string): void => {
|
|
156
|
+
if (warned.has(key)) return
|
|
157
|
+
warned.add(key)
|
|
158
|
+
console.warn(`[tween] ${msg}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resolve the per-prop easing from the bag. */
|
|
162
|
+
export const easingFor = (easing: TweenMeta["easing"], prop: string): EasingSpec => {
|
|
163
|
+
if (easing !== undefined && typeof easing === "object" && !Array.isArray(easing) && typeof easing !== "function") {
|
|
164
|
+
return parseEasing((easing as Record<string, EasingInput>)[prop])
|
|
165
|
+
}
|
|
166
|
+
return parseEasing(easing as EasingInput | undefined)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Iteration count from `loop`: 1 = one-shot, -1 = forever, n = cycles. */
|
|
170
|
+
export const iterationsOf = (loop: TweenMeta["loop"]): number => {
|
|
171
|
+
if (loop === true) return -1
|
|
172
|
+
if (typeof loop === "number" && loop > 0) return Math.floor(loop)
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- value normalizers shared by the domains ----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const UNIT_RE = /^\s*(-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*(px|%|vw|vh|vmin|vmax|deg|rad|em|rem)?\s*$/i
|
|
179
|
+
|
|
180
|
+
/** A UI style value: number → FLOAT; "12px" / "50%" → FLOAT_UNIT; other strings → STRING (the host
|
|
181
|
+
* decides color vs discrete); booleans → DISCRETE. */
|
|
182
|
+
export const uiValue = (v: unknown): ({ kind: number, sig?: string } & TweenValue) | null => {
|
|
183
|
+
if (typeof v === "number") return { kind: KIND_FLOAT, lanes: [v] }
|
|
184
|
+
if (typeof v === "boolean") return { kind: KIND_DISCRETE, str: v ? "true" : "false" }
|
|
185
|
+
if (typeof v === "string") {
|
|
186
|
+
const m = UNIT_RE.exec(v)
|
|
187
|
+
if (m) {
|
|
188
|
+
const unit = (m[2] ?? "").toLowerCase()
|
|
189
|
+
return unit === "" || unit === "px"
|
|
190
|
+
? { kind: KIND_FLOAT, lanes: [parseFloat(m[1]!)] }
|
|
191
|
+
: { kind: KIND_FLOAT_UNIT, sig: unit, lanes: [parseFloat(m[1]!)] }
|
|
192
|
+
}
|
|
193
|
+
return { kind: KIND_STRING, str: v }
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Per-function argument counts of a UI transform list (degrees / px are the SDK's units). */
|
|
199
|
+
const TRANSFORM_FNS: Record<string, number> = {
|
|
200
|
+
translate: 2, translateX: 1, translateY: 1, rotate: 1, scale: 2, scaleX: 1, scaleY: 1, skewX: 1, skewY: 1,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Parse `"translate(10px, 20px) rotate(45deg) scale(1.2)"` into a signature + lanes, or null when a
|
|
204
|
+
* function is unknown (the caller falls back to matrix decomposition on the host). `scale(2)` and
|
|
205
|
+
* `translate(5px)` complete their second argument like CSS. */
|
|
206
|
+
export const parseTransformList = (s: string): { sig: string, lanes: number[] } | null => {
|
|
207
|
+
const sig: string[] = []
|
|
208
|
+
const lanes: number[] = []
|
|
209
|
+
const re = /([a-zA-Z]+)\s*\(([^)]*)\)/g
|
|
210
|
+
let m: RegExpExecArray | null
|
|
211
|
+
let consumed = 0
|
|
212
|
+
const src = s.trim()
|
|
213
|
+
if (src === "" || src === "none") return { sig: "", lanes: [] }
|
|
214
|
+
while ((m = re.exec(src)) !== null) {
|
|
215
|
+
const fn = m[1]!
|
|
216
|
+
const n = TRANSFORM_FNS[fn]
|
|
217
|
+
if (n === undefined) return null
|
|
218
|
+
const args = m[2]!.split(",").map((a) => {
|
|
219
|
+
const u = UNIT_RE.exec(a)
|
|
220
|
+
return u ? parseFloat(u[1]!) : NaN
|
|
221
|
+
})
|
|
222
|
+
if (args.some((a) => Number.isNaN(a)) || args.length === 0 || args.length > n) return null
|
|
223
|
+
if (args.length < n) args.push(fn === "translate" ? 0 : args[0]!)
|
|
224
|
+
sig.push(fn)
|
|
225
|
+
lanes.push(...args)
|
|
226
|
+
consumed = re.lastIndex
|
|
227
|
+
}
|
|
228
|
+
if (src.slice(consumed).trim() !== "") return null
|
|
229
|
+
return { sig: sig.join(","), lanes }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const transformLanes = (sig: string): number => (sig === "" ? 0 : sig.split(",").reduce((n, fn) => n + (TRANSFORM_FNS[fn] ?? 0), 0))
|
|
233
|
+
|
|
234
|
+
const num3 = (v: unknown): number[] | null => {
|
|
235
|
+
if (typeof v === "number") return [v, v, v]
|
|
236
|
+
if (v && typeof v === "object") {
|
|
237
|
+
const o = v as any
|
|
238
|
+
if (typeof o.length === "number" && o.length >= 3) return [+o[0], +o[1], +o[2]]
|
|
239
|
+
if (typeof o.x === "number") return [o.x, o.y, o.z]
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const num4 = (v: unknown): number[] | null => {
|
|
245
|
+
if (v && typeof v === "object") {
|
|
246
|
+
const o = v as any
|
|
247
|
+
if (typeof o.length === "number" && o.length >= 4) return [+o[0], +o[1], +o[2], +o[3]]
|
|
248
|
+
if (typeof o.x === "number" && typeof o.w === "number") return [o.x, o.y, o.z, o.w]
|
|
249
|
+
}
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const num2 = (v: unknown): number[] | null => {
|
|
254
|
+
if (typeof v === "number") return [v, v]
|
|
255
|
+
if (v && typeof v === "object") {
|
|
256
|
+
const o = v as any
|
|
257
|
+
if (typeof o.length === "number" && o.length >= 2) return [+o[0], +o[1]]
|
|
258
|
+
if (typeof o.x === "number") return [o.x, o.y]
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const vec3Value = (v: unknown) => { const l = num3(v); return l ? { kind: KIND_VEC3, lanes: l } : null }
|
|
264
|
+
export const quatValue = (v: unknown) => { const l = num4(v); return l ? { kind: KIND_QUAT, lanes: l } : null }
|
|
265
|
+
export const eulerValue = (v: unknown) => { const l = num3(v); return l ? { kind: KIND_EULER, lanes: l } : null }
|
|
266
|
+
export const vec2Value = (v: unknown) => { const l = num2(v); return l ? { kind: KIND_VEC2, lanes: l } : null }
|
|
267
|
+
export const floatValue = (v: unknown) => (typeof v === "number" ? { kind: KIND_FLOAT, lanes: [v] } : null)
|
|
268
|
+
export const colorValue = (v: unknown, toRgba: (c: any) => [number, number, number, number]) => {
|
|
269
|
+
if (typeof v === "string" || typeof v === "number" || Array.isArray(v)) return { kind: KIND_COLOR, lanes: toRgba(v) }
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A free value for animate(): number → FLOAT, a color string → COLOR, arrays / vectors by length
|
|
274
|
+
* (2 → VEC2, 3 → VEC3, 4 → QUAT, 16 → MAT4 decomposed into position · quaternion · scale). */
|
|
275
|
+
export const valueValue = (v: unknown): ({ kind: number } & TweenValue) | null => {
|
|
276
|
+
if (typeof v === "number") return { kind: KIND_FLOAT, lanes: [v] }
|
|
277
|
+
if (typeof v === "string") return { kind: KIND_COLOR, lanes: Color.toRgba01(v) }
|
|
278
|
+
if (v && typeof v === "object") {
|
|
279
|
+
const o = v as any
|
|
280
|
+
const arr: ArrayLike<number> | undefined = typeof o.length === "number" ? o : Array.isArray(o.m) && o.m.length === 16 ? o.m : undefined
|
|
281
|
+
if (arr) {
|
|
282
|
+
if (arr.length === 2) return { kind: KIND_VEC2, lanes: [+arr[0]!, +arr[1]!] }
|
|
283
|
+
if (arr.length === 3) return { kind: KIND_VEC3, lanes: [+arr[0]!, +arr[1]!, +arr[2]!] }
|
|
284
|
+
if (arr.length === 4) return { kind: KIND_QUAT, lanes: [+arr[0]!, +arr[1]!, +arr[2]!, +arr[3]!] }
|
|
285
|
+
if (arr.length === 16) {
|
|
286
|
+
const d = new Mat4(arr as any).decompose()
|
|
287
|
+
return { kind: KIND_MAT4, lanes: [d.position.x, d.position.y, d.position.z, d.rotation.x, d.rotation.y, d.rotation.z, d.rotation.w, d.scale.x, d.scale.y, d.scale.z] }
|
|
288
|
+
}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
if (typeof o.x === "number") {
|
|
292
|
+
if (typeof o.w === "number") return { kind: KIND_QUAT, lanes: [o.x, o.y, o.z, o.w] }
|
|
293
|
+
if (typeof o.z === "number") return { kind: KIND_VEC3, lanes: [o.x, o.y, o.z] }
|
|
294
|
+
return { kind: KIND_VEC2, lanes: [o.x, o.y] }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Is this bag value a KEYFRAME ARRAY for a vector prop, as opposed to one vector? A vector value
|
|
301
|
+
* is an array of NUMBERS (`[0, 2, 0]`); anything else in the array is a list of keyframes —
|
|
302
|
+
* `[[0,0,0], [0,2,0]]`, `[new Vec3(), …]`, and the UI's `color: ['#fff', '#e29a22']`. */
|
|
303
|
+
export const isVectorKeyframes = (v: unknown): boolean =>
|
|
304
|
+
Array.isArray(v) && v.length > 0 && typeof v[0] !== "number"
|
|
305
|
+
|
|
306
|
+
// ---- track construction ------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/** Build one track's keyframes from a bag value: a single value = one explicit key (the other end
|
|
309
|
+
* captured), an array = explicit keys spaced by `times` or evenly. `fromCurrent` says which end is
|
|
310
|
+
* implicit (animateTo: the first; animateFrom: the last). Returns null when a value can't be
|
|
311
|
+
* animated (already warned). */
|
|
312
|
+
export const makeTrack = (
|
|
313
|
+
target: TweenTarget, prop: string, raw: unknown, meta: TweenMeta, atMs: number, fromCurrent: boolean, vectorProp: boolean,
|
|
314
|
+
): Track | null => {
|
|
315
|
+
const channel = target._tweenChannel(prop)
|
|
316
|
+
if (!channel) {
|
|
317
|
+
warnOnce(`prop:${prop}`, `"${prop}" is not animatable on this target — set it directly`)
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
const values: unknown[] = Array.isArray(raw) && (!vectorProp || isVectorKeyframes(raw)) ? raw : [raw]
|
|
321
|
+
const easing = easingFor(meta.easing, prop)
|
|
322
|
+
const norm = values.map((v) => channel.value(v))
|
|
323
|
+
if (norm.some((n) => n === null)) {
|
|
324
|
+
warnOnce(`value:${prop}`, `"${prop}" got a value that cannot be animated — set it directly`)
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
const ns = norm as NonNullable<(typeof norm)[number]>[]
|
|
328
|
+
|
|
329
|
+
// One kind per track. Mismatched units / transform signatures degrade to DISCRETE (a flip at the
|
|
330
|
+
// key, like CSS), never to garbage interpolation.
|
|
331
|
+
let kind = ns[0]!.kind
|
|
332
|
+
let sig = ns[0]!.sig
|
|
333
|
+
for (const n of ns) {
|
|
334
|
+
if (n.kind !== kind || n.sig !== sig) {
|
|
335
|
+
// FLOAT vs FLOAT_UNIT(px) can't happen (px normalizes to FLOAT); anything else is discrete
|
|
336
|
+
kind = KIND_DISCRETE
|
|
337
|
+
sig = undefined
|
|
338
|
+
break
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const lanes = kind === KIND_TRANSFORM ? transformLanes(sig ?? "") : LANES[kind]!
|
|
342
|
+
const toValue = (n: NonNullable<(typeof norm)[number]>): TweenValue =>
|
|
343
|
+
kind === KIND_DISCRETE && "lanes" in n ? { str: String(n.lanes[0]) } : ("str" in n ? { str: n.str } : { lanes: n.lanes })
|
|
344
|
+
|
|
345
|
+
const explicit = ns.length
|
|
346
|
+
const total = explicit + (explicit === 1 ? 1 : 0) // a single value pairs with a captured end
|
|
347
|
+
const keys: Keyframe[] = []
|
|
348
|
+
const times = meta.times
|
|
349
|
+
for (let i = 0; i < total; i++) {
|
|
350
|
+
let t: number
|
|
351
|
+
if (times && times.length === total) t = times[i]!
|
|
352
|
+
else t = total === 1 ? 1 : i / (total - 1)
|
|
353
|
+
let value: TweenValue | undefined
|
|
354
|
+
let raw: unknown
|
|
355
|
+
if (explicit === 1) {
|
|
356
|
+
// animateTo: [captured, v]; animateFrom: [v, captured]
|
|
357
|
+
const isExplicit = fromCurrent ? i === 1 : i === 0
|
|
358
|
+
value = isExplicit ? toValue(ns[0]!) : undefined
|
|
359
|
+
raw = isExplicit ? values[0] : undefined
|
|
360
|
+
} else {
|
|
361
|
+
value = toValue(ns[i]!)
|
|
362
|
+
raw = values[i]
|
|
363
|
+
}
|
|
364
|
+
keys.push({ t, easing, value, raw })
|
|
365
|
+
}
|
|
366
|
+
// animateFrom with an explicit array: the element's own style is the implicit LAST key
|
|
367
|
+
if (!fromCurrent && explicit > 1) {
|
|
368
|
+
for (const k of keys) k.t *= (total) / (total + 1)
|
|
369
|
+
keys.push({ t: 1, easing, value: undefined })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
target, prop, channel, kind, lanes, sig,
|
|
374
|
+
atMs: atMs + (meta.delay ?? 0),
|
|
375
|
+
durMs: Math.max(0, meta.duration ?? DEFAULT_DURATION_MS),
|
|
376
|
+
// animateFrom never commits (its last key is the element's own state); loops never commit
|
|
377
|
+
commit: fromCurrent && meta.commit !== false && iterationsOf(meta.loop) === 1,
|
|
378
|
+
keys,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Every (prop → value) entry of a bag, meta keys stripped. */
|
|
383
|
+
export const bagProps = (bag: Record<string, unknown>): [string, unknown][] =>
|
|
384
|
+
Object.entries(bag).filter(([k, v]) => !TWEEN_META.has(k) && v !== undefined)
|
|
385
|
+
|
|
386
|
+
// ---- JS evaluation (the old-host path of VALUE tracks; the C evaluator is the reference) ----------
|
|
387
|
+
|
|
388
|
+
const slerpInto = (out: number[], o: number, a: number[], ai: number, b: number[], bi: number, s: number): void => {
|
|
389
|
+
let bx = b[bi]!, by = b[bi + 1]!, bz = b[bi + 2]!, bw = b[bi + 3]!
|
|
390
|
+
let cosom = a[ai]! * bx + a[ai + 1]! * by + a[ai + 2]! * bz + a[ai + 3]! * bw
|
|
391
|
+
if (cosom < 0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw }
|
|
392
|
+
let k0: number, k1: number
|
|
393
|
+
if (1 - cosom > 1e-6) {
|
|
394
|
+
const omega = Math.acos(Math.min(1, cosom)), sinom = Math.sin(omega)
|
|
395
|
+
k0 = Math.sin((1 - s) * omega) / sinom
|
|
396
|
+
k1 = Math.sin(s * omega) / sinom
|
|
397
|
+
} else { k0 = 1 - s; k1 = s }
|
|
398
|
+
out[o] = k0 * a[ai]! + k1 * bx; out[o + 1] = k0 * a[ai + 1]! + k1 * by; out[o + 2] = k0 * a[ai + 2]! + k1 * bz; out[o + 3] = k0 * a[ai + 3]! + k1 * bw
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The value of a numeric track at local time `ms` (the C evaluator's rule: first key holds before,
|
|
402
|
+
* last after; eased segment between). `out` receives `track.lanes` numbers. */
|
|
403
|
+
export const evaluateTrack = (track: Track, ms: number, out: number[]): void => {
|
|
404
|
+
const u = track.durMs <= 0 ? (ms >= track.atMs ? 1 : 0) : Math.min(1, Math.max(0, (ms - track.atMs) / track.durMs))
|
|
405
|
+
const keys = track.keys
|
|
406
|
+
const lanesOfKey = (k: Keyframe): number[] => (k.value && "lanes" in k.value ? k.value.lanes : [])
|
|
407
|
+
if (keys.length === 0) return
|
|
408
|
+
if (u <= keys[0]!.t || keys.length === 1) { const l = lanesOfKey(keys[0]!); for (let i = 0; i < track.lanes; i++) out[i] = l[i] ?? 0; return }
|
|
409
|
+
const last = keys[keys.length - 1]!
|
|
410
|
+
if (u >= last.t) { const l = lanesOfKey(last); for (let i = 0; i < track.lanes; i++) out[i] = l[i] ?? 0; return }
|
|
411
|
+
let i = 0
|
|
412
|
+
while (i + 1 < keys.length && keys[i + 1]!.t <= u) i++
|
|
413
|
+
const a = keys[i]!, b = keys[i + 1]!
|
|
414
|
+
const span = b.t - a.t
|
|
415
|
+
const s = evalEasing(a.easing, span > 0 ? (u - a.t) / span : 1)
|
|
416
|
+
const la = lanesOfKey(a), lb = lanesOfKey(b)
|
|
417
|
+
if (track.kind === KIND_QUAT) { slerpInto(out, 0, la, 0, lb, 0, s); return }
|
|
418
|
+
if (track.kind === KIND_MAT4) {
|
|
419
|
+
for (let k = 0; k < 3; k++) out[k] = la[k]! + (lb[k]! - la[k]!) * s
|
|
420
|
+
slerpInto(out, 3, la, 3, lb, 3, s)
|
|
421
|
+
for (let k = 7; k < 10; k++) out[k] = la[k]! + (lb[k]! - la[k]!) * s
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
for (let k = 0; k < track.lanes; k++) out[k] = (la[k] ?? 0) + ((lb[k] ?? 0) - (la[k] ?? 0)) * s
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- the blob ----------------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
export type TweenBlob = { data: Float32Array, strings: string[], targets: unknown[] }
|
|
430
|
+
|
|
431
|
+
/** Flatten a spec for `_creatorUI.tweenCreate`. Tracks whose target has no host id right now are
|
|
432
|
+
* dropped (the animation still runs its clock, calls and finish). */
|
|
433
|
+
export const buildBlob = (spec: TweenSpec): TweenBlob => {
|
|
434
|
+
const strings: string[] = []
|
|
435
|
+
const strIndex = new Map<string, number>()
|
|
436
|
+
const str = (s: string): number => {
|
|
437
|
+
let i = strIndex.get(s)
|
|
438
|
+
if (i === undefined) { i = strings.length; strings.push(s); strIndex.set(s, i) }
|
|
439
|
+
return i
|
|
440
|
+
}
|
|
441
|
+
const tables: Float32Array[] = []
|
|
442
|
+
const tableIndex = (e: EasingSpec): number => {
|
|
443
|
+
const i = tables.indexOf(e.table!)
|
|
444
|
+
if (i >= 0) return i
|
|
445
|
+
tables.push(e.table!)
|
|
446
|
+
return tables.length - 1
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const tracks = spec.tracks.map((t) => ({ t, id: t.channel.id() })).filter((x) => !!x.id)
|
|
450
|
+
const targets: unknown[] = []
|
|
451
|
+
const targetIndex = (id: unknown): number => {
|
|
452
|
+
let i = targets.indexOf(id)
|
|
453
|
+
if (i < 0) { i = targets.length; targets.push(id) }
|
|
454
|
+
return i
|
|
455
|
+
}
|
|
456
|
+
const out: number[] = []
|
|
457
|
+
out.push(BLOB_VERSION, spec.clock, spec.durationMs, spec.delayMs, spec.iterations, spec.pingPong ? 1 : 0, spec.rate,
|
|
458
|
+
tracks.length, spec.calls.length, 0 /* nTables, patched */)
|
|
459
|
+
for (const c of spec.calls) out.push(c)
|
|
460
|
+
const tablesAt = out.length
|
|
461
|
+
for (const { t, id } of tracks) {
|
|
462
|
+
out.push(t.channel.domain, targetIndex(id), str(t.prop), t.kind, t.lanes, t.atMs, t.durMs, t.commit ? 1 : 0, t.keys.length, t.sig !== undefined ? str(t.sig) : -1)
|
|
463
|
+
for (const k of t.keys) {
|
|
464
|
+
const e = k.easing
|
|
465
|
+
const p: number[] = [e.p[0], e.p[1], e.p[2], e.p[3]]
|
|
466
|
+
if (e.table) p[0] = tableIndex(e)
|
|
467
|
+
out.push(k.t, e.kind, p[0]!, p[1]!, p[2]!, p[3]!, k.value ? 1 : 0)
|
|
468
|
+
if (!k.value) { for (let i = 0; i < t.lanes; i++) out.push(0) }
|
|
469
|
+
else if ("str" in k.value) { out.push(str(k.value.str)); for (let i = 1; i < t.lanes; i++) out.push(0) }
|
|
470
|
+
else { for (let i = 0; i < t.lanes; i++) out.push(k.value.lanes[i] ?? 0) }
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// splice the tables in after the calls
|
|
474
|
+
const tableData: number[] = []
|
|
475
|
+
for (const tb of tables) for (let i = 0; i < EASE_TABLE_SIZE; i++) tableData.push(tb[i]!)
|
|
476
|
+
out.splice(tablesAt, 0, ...tableData)
|
|
477
|
+
out[9] = tables.length
|
|
478
|
+
return { data: Float32Array.from(out), strings, targets }
|
|
479
|
+
}
|