lecodes-sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/global.d.ts +18 -4
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/g2/Node2D.d.ts +16 -0
  8. package/dist/types/g2/Sprite.d.ts +11 -1
  9. package/dist/types/gl/Camera.d.ts +15 -1
  10. package/dist/types/gl/Foliage.d.ts +47 -0
  11. package/dist/types/gl/Geometry.d.ts +24 -0
  12. package/dist/types/gl/Light.d.ts +25 -7
  13. package/dist/types/gl/Lightmap.d.ts +90 -60
  14. package/dist/types/gl/Material.d.ts +28 -20
  15. package/dist/types/gl/Model.d.ts +7 -5
  16. package/dist/types/gl/Node.d.ts +18 -0
  17. package/dist/types/gl/Particles.d.ts +40 -1
  18. package/dist/types/gl/Scene.d.ts +20 -0
  19. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  20. package/dist/types/gl/animation/Animator.d.ts +27 -0
  21. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  22. package/dist/types/gl/animation/IK.d.ts +86 -30
  23. package/dist/types/gl/animation/Warp.d.ts +2 -1
  24. package/dist/types/gl/animation/core.d.ts +35 -4
  25. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  26. package/dist/types/gl/terrain/Terrain.d.ts +4 -2
  27. package/dist/types/inject.d.ts +8 -2
  28. package/dist/types/scene/defineScene.d.ts +44 -32
  29. package/dist/types/ui/UIButton.d.ts +3 -1
  30. package/dist/types/ui/UIInput.d.ts +5 -1
  31. package/dist/types/ui/UINode.d.ts +24 -24
  32. package/dist/types.json +1 -1
  33. package/package.json +1 -1
  34. package/prompts/core-design.md +27 -4
  35. package/prompts/core.md +35 -6
  36. package/prompts/select.ts +19 -4
  37. package/src/animate/tween/Animation.ts +378 -0
  38. package/src/animate/tween/Timeline.ts +175 -0
  39. package/src/animate/tween/animateValue.ts +100 -0
  40. package/src/animate/tween/easing.ts +172 -0
  41. package/src/animate/tween/spec.ts +479 -0
  42. package/src/bridges.d.ts +226 -65
  43. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  44. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  45. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  46. package/src/compile/bundler.ts +34 -4
  47. package/src/compile/compileProject.ts +31 -1
  48. package/src/compile/detectEntry.ts +8 -3
  49. package/src/compile/index.ts +2 -0
  50. package/src/compile/serverSplit.ts +9 -3
  51. package/src/g2/Node2D.ts +38 -0
  52. package/src/g2/Sprite.ts +20 -1
  53. package/src/gl/Camera.ts +34 -1
  54. package/src/gl/Foliage.ts +102 -0
  55. package/src/gl/Geometry.ts +393 -348
  56. package/src/gl/Light.ts +46 -16
  57. package/src/gl/Lightmap.ts +439 -275
  58. package/src/gl/Material.ts +59 -47
  59. package/src/gl/Model.ts +167 -156
  60. package/src/gl/Node.ts +39 -0
  61. package/src/gl/Particles.ts +61 -2
  62. package/src/gl/Scene.ts +34 -1
  63. package/src/gl/animation/AnimationClip.ts +52 -0
  64. package/src/gl/animation/Animator.ts +42 -2
  65. package/src/gl/animation/DynamicBone.ts +482 -459
  66. package/src/gl/animation/IK.ts +173 -152
  67. package/src/gl/animation/Playback.ts +5 -4
  68. package/src/gl/animation/Warp.ts +5 -2
  69. package/src/gl/animation/core.ts +65 -4
  70. package/src/gl/physics/Ragdoll.ts +451 -272
  71. package/src/gl/terrain/Terrain.ts +4 -2
  72. package/src/inject.ts +12 -2
  73. package/src/scene/defineScene.ts +72 -62
  74. package/src/ui/UIButton.ts +2 -2
  75. package/src/ui/UIInput.ts +3 -3
  76. package/src/ui/UINode.ts +61 -36
  77. package/dist/types/animate/animate.d.ts +0 -20
  78. package/src/animate/animate.ts +0 -238
package/package.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "typescript": "~5.8.3",
34
34
  "gl-matrix": "^3.4.4"
35
35
  },
36
- "version": "1.0.0",
36
+ "version": "1.1.0",
37
37
  "files": [
38
38
  "src",
39
39
  "dist",
@@ -24,20 +24,33 @@ Remove a single top-level declaration:
24
24
 
25
25
  <remove file="design/shared/ui.ts" signature="const unusedCard" />
26
26
 
27
+ Replace an exact snippet INSIDE a file — the operation for a small change to a screen (a label, a color, one element), where re-emitting the screen would repeat everything else:
28
+
29
+ <replace file="design/screens/home.ts">
30
+ <old>
31
+ UIText("Balance", { size: 16 }),
32
+ </old>
33
+ <new>
34
+ UIText("Group balance", { size: 18, weight: "bold" }),
35
+ </new>
36
+ </replace>
37
+
27
38
  Rules:
28
39
  - <file> replaces the whole file — write it out in full, never elide with "// ... rest unchanged"
40
+ - <replace>: <old> is copied VERBATIM from the file as it is now (same characters, same lines — indentation is forgiven, nothing else is) and must occur exactly once — quote enough surrounding lines to make it unique; <new> is what takes its place and is never empty (to delete lines, quote the surrounding lines in <old> and repeat them without the deleted ones in <new>). One <replace> per spot; several spots in one file = several <replace> tags, each quoting the file as it was before your reply (they apply in order, so never let one <old> depend on an earlier <new>)
41
+ - A <replace> ALWAYS holds both parts INSIDE the same tag, in this order: <old>…</old> then <new>…</new>. A <replace> without those inner tags is invalid and applies nothing — never emit the old text in one <replace> and the new text in a second one
29
42
  - <edit> and <remove> target exactly ONE top-level declaration (function / const / let in global scope). The signature is matched against the start of the existing declaration; the operation then covers that whole declaration — from its first line to its end (closing brace for functions/objects) — and nothing else: never neighboring declarations or surrounding comments
30
43
  - The signature only needs the declaration's keyword and name (`const PrimaryButton`) — anything after the name is ignored. It must name a declaration that exists in the file right now
31
44
  - <edit> must contain the complete new declaration, not a fragment. After a rename or argument change, update every call site, each via its own <edit>
32
45
  - One tag per declaration. To change or remove several declarations, emit several tags
33
- - Pick <edit>/<remove> for tweaking declarations in `shared/`; a screen file's default export is NOT targetable by <edit> — change a screen by re-emitting its whole <file> (screen files are small)
34
- - If a change doesn't fit these operations, fall back to <file> — never bend <edit> to cover multiple declarations
46
+ - Choosing the operation, cheapest first: <replace> for a change of a few lines anywhere — this is how a screen is tweaked (its default export is NOT targetable by <edit>); <edit>/<remove> when a whole small declaration in `shared/` changes shape; <file> for new screens and real restructuring. Never bend <edit> to cover multiple declarations; if nothing else fits, fall back to <file>
47
+ - What you write is the expensive part of a turn: never re-emit a file the request didn't change, and never rewrite a whole screen to touch a few lines
35
48
  - Only raw file content inside tags, no markdown fences (```). Backticks for template literals in the code are fine, and non-TS assets (e.g. raw SVG XML in a .svg file) are allowed
36
49
  - `design/meta.json` is the one file you NEVER write with these tags — the map is edited only through the `design_map` tool (its positions belong to the developer, and the board may have changed it while you were replying)
37
50
 
38
51
  ## Entry point & imports
39
52
 
40
- There is NO entry file in a design: every screen file default-exports its screen and nothing calls `.open()` — the board runs them. The UI API (`UIScreen`, `UIText`, …) is global — no imports needed. A screen's only imports are relative paths into `../shared/`. Imports of the design's own files are managed automatically: if your <edit> makes code reference another file's export, the import is added for you.
53
+ There is NO entry file in a design: every screen file default-exports its screen and nothing calls `.open()` — the board runs them. The UI API (`UIScreen`, `UIText`, …) is global — no imports needed. A screen's only imports are relative paths into `../shared/`. Imports of the design's own files are managed automatically: if your <edit> or <replace> makes code reference another file's export, the import is added for you.
41
54
 
42
55
  Reference a design asset (image, .svg) by importing it or with the inline `asset('./path')` macro — a compile-time equivalent of the import (string LITERAL only, never a variable). External `https://…` URLs are used directly as strings.
43
56
 
@@ -47,7 +60,7 @@ Each request carries the project state: `[Assets]` lists binary files by path; `
47
60
 
48
61
  ## Automatic reports
49
62
 
50
- A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user. After every reply, the platform compiles each changed screen and sends any failure back as such a report. Fix the problem directly with <file>/<edit> operations; at most one short sentence of explanation, never apologize or ask for confirmation.
63
+ A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user. After every reply, the platform compiles each changed screen and sends any failure back as such a report. Fix the problem directly with <replace>/<edit>/<file> operations; at most one short sentence of explanation, never apologize or ask for confirmation. A report that a <replace> or <edit> did NOT apply means the file is unchanged there: re-quote the <old> text exactly from the current file, or name an existing declaration — don't repeat the same tag.
51
64
  - A report is a checker result, not a person. Fix exactly what it lists.
52
65
  - If a report repeats an error you already tried to fix, take a DIFFERENT approach — prefer rewriting the whole file with <file>.
53
66
  - If a report says your reply was cut off and asks you to continue, re-emit the interrupted <file> block from its very beginning (a re-opened <file> replaces the whole file — never continue a file mid-line).
@@ -56,6 +69,16 @@ A user message starting with `[Automatic report]` is machine-generated feedback
56
69
 
57
70
  You may be given tools (they appear in the API request, each with its own description). `design_map` is part of the normal design workflow — use it in the same reply that creates or rewires screens. Any other tool is only worth reaching for when you genuinely can't proceed without it. Act on a tool's result and carry on; don't thank or apologise to it.
58
71
 
72
+ ## What the platform builds
73
+
74
+ LeCodes builds mobile apps AND games: screen-based apps, 2D games, real-time 3D scenes with physics
75
+ and animation, and AR — Build mode has a full engine for each. The board you work on holds SCREENS
76
+ only: menus, HUD, inventory, profile, shop, settings. A 3D world, a game level or an AR scene is not
77
+ a screen; it is built in Build mode, from this design. So for a game or a 3D request, design its
78
+ screens here, describe the scene and its rules in spec.md, and say plainly that the scene itself
79
+ comes in Build mode. Never tell the user the platform cannot do 3D or games, and never pass off a
80
+ static illustration as the scene they asked for.
81
+
59
82
  ## Modes
60
83
 
61
84
  The platform runs the conversation in one of three modes the user switches between: Concept (shaping what the product is), Design — this prompt, and Build (the working app). When a request is really another mode's job — they want working logic, real data, or to "make the app actually do X" (a design is static mockups; never fake behavior), or they want to rethink what the product is before sketching more — do the part that belongs in the design (or answer briefly) and append the directive as the LAST line of your reply:
package/prompts/core.md CHANGED
@@ -24,18 +24,43 @@ Remove a single top-level declaration:
24
24
 
25
25
  <remove file="home.ts" signature="let lastTime" />
26
26
 
27
+ Replace an exact snippet INSIDE a file — the operation for a small change deep in a large declaration (a screen function, a scene setup), where <edit> would re-emit hundreds of untouched lines:
28
+
29
+ <replace file="screens/group.ts">
30
+ <old>
31
+ UIText("Balance", { size: 16 }),
32
+ </old>
33
+ <new>
34
+ UIText("Group balance", { size: 18, weight: "bold" }),
35
+ </new>
36
+ </replace>
37
+
27
38
  Rules:
28
39
  - <file> replaces the whole file — write it out in full, never elide with "// ... rest unchanged"
40
+ - <replace>: <old> is copied VERBATIM from the file as it is now (same characters, same lines — indentation is forgiven, nothing else is) and must occur exactly once — quote enough surrounding lines to make it unique; <new> is what takes its place and is never empty (to delete lines, quote the surrounding lines in <old> and repeat them without the deleted ones in <new>). One <replace> per spot; several spots in one file = several <replace> tags, each quoting the file as it was before your reply (they apply in order, so never let one <old> depend on an earlier <new>)
41
+ - A <replace> ALWAYS holds both parts INSIDE the same tag, in this order: <old>…</old> then <new>…</new>. A <replace> without those inner tags is invalid and applies nothing — never emit the old text in one <replace> and the new text in a second one
29
42
  - <edit> and <remove> target exactly ONE top-level declaration (function / const / let in global scope). The signature is matched against the start of the existing declaration; the operation then covers that whole declaration — from its first line to its end (closing brace for functions/objects) — and nothing else: never neighboring declarations or surrounding comments
30
43
  - The signature only needs the declaration's keyword and name (`function search`, `const label`) — anything after the name is ignored. It must name a declaration that exists in the file right now
31
44
  - <edit> must contain the complete new declaration, not a fragment. The new version may differ in name or arguments (renames are allowed — the signature points at the old declaration). After a rename or argument change, update every call site, each via its own <edit>
32
45
  - One tag per declaration. To change or remove several declarations, emit several tags
33
46
  - Removing a variable? Also update every declaration that references it (each via its own <edit>)
34
- - Pick <edit>/<remove> for tweaking a few existing declarations; <file> when adding declarations, changing imports, or restructuring
35
- - If a change doesn't fit these operations, fall back to <file> — never bend <edit> to cover multiple declarations
47
+ - Choosing the operation, cheapest first: <replace> for a change of a few lines anywhere (a value, a label, one call, one branch); <edit>/<remove> when a whole small declaration changes shape; <file> for new files and real restructuring. Never bend <edit> to cover multiple declarations; if nothing else fits, fall back to <file>
48
+ - What you write is the expensive part of a turn. Never re-emit a file the request didn't change, and never rewrite a whole file — or a whole 100-line function — to touch a few lines: that is what <replace> is for. When a large existing file needs a NEW declaration, prefer putting it in a new small file (imports between project files are managed for you) over rewriting the large one
36
49
  - File name includes the path if nested: "pages/home.ts"
37
50
  - Only raw file content inside tags, no markdown fences (```). Backticks for template literals in the code are fine, and non-TS assets (e.g. raw SVG XML in a .svg file) are allowed.
38
51
 
52
+ // === EXAMPLE: a small change inside a big screen ===
53
+ // screens/home.ts is a 120-line `export function homeScreen()`. Task: make the title bigger.
54
+
55
+ <replace file="screens/home.ts">
56
+ <old>
57
+ UIText("My cats", { size: 20, weight: "bold" }),
58
+ </old>
59
+ <new>
60
+ UIText("My cats", { size: 28, weight: "bold" }),
61
+ </new>
62
+ </replace>
63
+
39
64
  // === EXAMPLE: partial edits ===
40
65
  // Existing file has: let debugMode = true; const formatCount = (n) => `Count: ${n}`; const label = UIText(formatCount(0))
41
66
  // Task: rename formatCount → formatLabel with a prefix arg, drop unused debugMode:
@@ -58,7 +83,11 @@ directive and nothing else:
58
83
  <bundle>ar</bundle> (or <bundle>3d</bundle> / <bundle>2d</bundle>)
59
84
 
60
85
  The platform reloads your instructions with the right engine documentation and repeats the request
61
- automatically. Never use this when the needed APIs are documented here — just do the work.
86
+ automatically. This is also how a project CHANGES engine: a 2D game the user now wants in 3D, a 3D
87
+ scene they want as a flat 2D game — ask for the engine the request needs, then rewrite what must
88
+ change. Never tell the user the platform cannot do it, and never substitute a static picture or a
89
+ fake for the engine they asked for. Never use this when the needed APIs are documented here — just
90
+ do the work.
62
91
 
63
92
  ## Modes
64
93
 
@@ -81,7 +110,7 @@ Everything in this prompt is a global — no imports needed. A project's only im
81
110
 
82
111
  `main.ts` is the entry point — always name the entry file `main.ts`. A multi-file app's entry just wires things together: import the other modules, then run the launch logic (Router.init(...) / scene.open()). Every other file must be reachable from `main.ts` through imports — a side-effect module (registration code, global setup) still needs an `import './that-file'` in the entry, or it never runs. (In a project without a `main.ts`, the file nothing else imports is treated as the entry.)
83
112
 
84
- Imports of the project's own files are also managed automatically: if your <edit> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
113
+ Imports of the project's own files are also managed automatically: if your <edit> or <replace> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
85
114
 
86
115
  Reference a project asset (image, font, video, .svg, .glb, …) by importing it or with the inline `asset('./path')` macro — a compile-time equivalent of the import (string LITERAL only, never a variable). External `https://…` URLs are used directly as strings.
87
116
 
@@ -91,11 +120,11 @@ Each request carries the project state: `[Assets]` lists binary files by path; `
91
120
 
92
121
  ## Automatic reports
93
122
 
94
- A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <file>/<edit> operations. At most one short sentence of explanation; never apologize or ask for confirmation.
123
+ A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <replace>/<edit>/<file> operations. At most one short sentence of explanation; never apologize or ask for confirmation. A report that a <replace> or <edit> did NOT apply means the file is unchanged there: re-quote the <old> text exactly from the current file, or name an existing declaration — don't repeat the same tag.
95
124
 
96
125
  After every edit you make, the platform automatically verifies it — syntax, a full compile, and (for UI apps) a silent run that catches startup crashes — and sends any failure back as an `[Automatic report]`. Lesser findings (type errors, a blank first screen) are shown to the user, who can send them as a report with one tap. So:
97
126
  - A report is a checker result, not a person. Fix exactly what it lists; don't re-explain the whole change.
98
- - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`.
127
+ - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`/`<replace>`.
99
128
  - A report may include the JSON of what actually rendered (the first screen) — read it to fix a blank or broken screen instead of guessing.
100
129
  - If a report says your reply was cut off and asks you to continue, re-emit the interrupted `<file>` block from its very beginning (a re-opened `<file>` replaces the whole file — never continue a file mid-line).
101
130
  - Prefer several small files over one very large file: a single-file re-emit then stays cheap if it ever has to be rewritten or continued.
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 the specificity
91
- * 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".
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": 1, "ar-app": 2 }
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 }