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.
- package/dist/global.d.ts +18 -4
- package/dist/types/animate/tween/Animation.d.ts +69 -0
- package/dist/types/animate/tween/Timeline.d.ts +55 -0
- package/dist/types/animate/tween/animateValue.d.ts +27 -0
- package/dist/types/animate/tween/easing.d.ts +29 -0
- package/dist/types/animate/tween/spec.d.ts +178 -0
- package/dist/types/g2/Node2D.d.ts +16 -0
- package/dist/types/g2/Sprite.d.ts +11 -1
- package/dist/types/gl/Camera.d.ts +15 -1
- package/dist/types/gl/Foliage.d.ts +47 -0
- package/dist/types/gl/Geometry.d.ts +24 -0
- package/dist/types/gl/Light.d.ts +25 -7
- package/dist/types/gl/Lightmap.d.ts +90 -60
- package/dist/types/gl/Material.d.ts +28 -20
- package/dist/types/gl/Model.d.ts +7 -5
- package/dist/types/gl/Node.d.ts +18 -0
- package/dist/types/gl/Particles.d.ts +40 -1
- package/dist/types/gl/Scene.d.ts +20 -0
- package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
- package/dist/types/gl/animation/Animator.d.ts +27 -0
- package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
- package/dist/types/gl/animation/IK.d.ts +86 -30
- package/dist/types/gl/animation/Warp.d.ts +2 -1
- package/dist/types/gl/animation/core.d.ts +35 -4
- package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
- package/dist/types/gl/terrain/Terrain.d.ts +4 -2
- package/dist/types/inject.d.ts +8 -2
- package/dist/types/scene/defineScene.d.ts +44 -32
- package/dist/types/ui/UIButton.d.ts +3 -1
- package/dist/types/ui/UIInput.d.ts +5 -1
- package/dist/types/ui/UINode.d.ts +24 -24
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/core-design.md +27 -4
- package/prompts/core.md +35 -6
- package/prompts/select.ts +19 -4
- package/src/animate/tween/Animation.ts +378 -0
- package/src/animate/tween/Timeline.ts +175 -0
- package/src/animate/tween/animateValue.ts +100 -0
- package/src/animate/tween/easing.ts +172 -0
- package/src/animate/tween/spec.ts +479 -0
- package/src/bridges.d.ts +226 -65
- package/src/compile/__tests__/assetMacro.test.ts +26 -0
- package/src/compile/__tests__/detectEntry.test.ts +19 -0
- package/src/compile/__tests__/serverSplit.test.ts +27 -0
- package/src/compile/bundler.ts +34 -4
- package/src/compile/compileProject.ts +31 -1
- package/src/compile/detectEntry.ts +8 -3
- package/src/compile/index.ts +2 -0
- package/src/compile/serverSplit.ts +9 -3
- package/src/g2/Node2D.ts +38 -0
- package/src/g2/Sprite.ts +20 -1
- package/src/gl/Camera.ts +34 -1
- package/src/gl/Foliage.ts +102 -0
- package/src/gl/Geometry.ts +393 -348
- package/src/gl/Light.ts +46 -16
- package/src/gl/Lightmap.ts +439 -275
- package/src/gl/Material.ts +59 -47
- package/src/gl/Model.ts +167 -156
- package/src/gl/Node.ts +39 -0
- package/src/gl/Particles.ts +61 -2
- package/src/gl/Scene.ts +34 -1
- package/src/gl/animation/AnimationClip.ts +52 -0
- package/src/gl/animation/Animator.ts +42 -2
- package/src/gl/animation/DynamicBone.ts +482 -459
- package/src/gl/animation/IK.ts +173 -152
- package/src/gl/animation/Playback.ts +5 -4
- package/src/gl/animation/Warp.ts +5 -2
- package/src/gl/animation/core.ts +65 -4
- package/src/gl/physics/Ragdoll.ts +451 -272
- package/src/gl/terrain/Terrain.ts +4 -2
- package/src/inject.ts +12 -2
- package/src/scene/defineScene.ts +72 -62
- package/src/ui/UIButton.ts +2 -2
- package/src/ui/UIInput.ts +3 -3
- package/src/ui/UINode.ts +61 -36
- package/dist/types/animate/animate.d.ts +0 -20
- package/src/animate/animate.ts +0 -238
package/package.json
CHANGED
package/prompts/core-design.md
CHANGED
|
@@ -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
|
-
-
|
|
34
|
-
-
|
|
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 <
|
|
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
|
-
-
|
|
35
|
-
-
|
|
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.
|
|
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 <
|
|
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
|
|
91
|
-
* ladder (ui → 2d
|
|
92
|
-
* stable while a project grows from "empty" to "3D game with a HUD".
|
|
90
|
+
* Sticky per-conversation upgrade from CODE DETECTION: once a bundle is chosen, only ever move UP
|
|
91
|
+
* the ladder (ui → 2d → 3d → ar) mid-conversation — never down, so the prompt-cache prefix stays
|
|
92
|
+
* stable while a project grows from "empty" to "3D game with a HUD". The ladder mirrors
|
|
93
|
+
* detectBundle's priority (AR > 3D > 2D > UI): a 2D project that gains 3D code moves to the 3D
|
|
94
|
+
* bundle. (2D and 3D used to share a rank, which made 2D → 3D unreachable by any route — a project
|
|
95
|
+
* that started as a 2D game could never become a 3D one.)
|
|
93
96
|
*/
|
|
94
97
|
export function upgradeBundle(current: Bundle, detected: Bundle): Bundle {
|
|
95
|
-
const rank: Record<Bundle, number> = { "ui-app": 0, "2d-game": 1, "3d-app":
|
|
98
|
+
const rank: Record<Bundle, number> = { "ui-app": 0, "2d-game": 1, "3d-app": 2, "ar-app": 3 }
|
|
96
99
|
return rank[detected] > rank[current] ? detected : current
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Whether to honor the model's own `<bundle>` directive (parseBundleDirective). Unlike detection,
|
|
104
|
+
* an explicit request may move in ANY direction: the model is saying the APIs the request needs
|
|
105
|
+
* are not documented in its current bundle — turning a 2D game into 3D, a 3D app into AR, or a 3D
|
|
106
|
+
* scene back into a 2D game are all legitimate. The old code it rewrites is in its context; it
|
|
107
|
+
* needs no documentation to replace it. Only a no-op (asking for the bundle it already has) is
|
|
108
|
+
* refused, which also rules out a re-send loop.
|
|
109
|
+
*/
|
|
110
|
+
export function canSwitchBundle(current: Bundle, requested: Bundle): boolean {
|
|
111
|
+
return requested !== current
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
const DIRECTIVE_WORD: Record<string, Bundle> = { "2d": "2d-game", "3d": "3d-app", "ar": "ar-app" }
|
|
100
115
|
|
|
101
116
|
/**
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// The animation HANDLE (docs/timeline-plan.md §2.2): what `animateTo` / `animateFrom` / `Timeline`
|
|
2
|
+
// return. Owns one spec; every `play()` builds the blob (target ids resolve at play time), hands it
|
|
3
|
+
// to the host (`_creatorUI.tweenCreate`) and gets events back (`registerTweenEvent`: time-callbacks
|
|
4
|
+
// and finish). On a host without the tween core the UI tracks fall back to the old
|
|
5
|
+
// `animateTo` / `animateFrom` bridge pair (single-value tweens only) and the clock is a timer —
|
|
6
|
+
// see `fallback` below.
|
|
7
|
+
|
|
8
|
+
import { CLOCK_GAME, CLOCK_UI, DOM_UI, DOM_VALUE, buildBlob, evaluateTrack, type Track, type TweenSpec } from "./spec"
|
|
9
|
+
|
|
10
|
+
const OP_PLAY = 0
|
|
11
|
+
const OP_PAUSE = 1
|
|
12
|
+
const OP_RESUME = 2
|
|
13
|
+
const OP_CANCEL = 3
|
|
14
|
+
const OP_FINISH = 4
|
|
15
|
+
|
|
16
|
+
const EV_CALL = 0
|
|
17
|
+
const EV_FINISH = 1
|
|
18
|
+
const EV_VALUE = 2 // (id, 2, slot, lane0, lane1, …) — a VALUE track's frame, synchronous from the host
|
|
19
|
+
|
|
20
|
+
/** Playback control shared by element tweens and timelines. Times are **milliseconds**. */
|
|
21
|
+
export interface Animation {
|
|
22
|
+
/** One iteration, ms. */
|
|
23
|
+
readonly duration: number
|
|
24
|
+
/** Position inside the current iteration, ms. Writable = seek. */
|
|
25
|
+
time: number
|
|
26
|
+
/** 0..1 of the whole animation (iterations included). Writable = seek. */
|
|
27
|
+
progress: number
|
|
28
|
+
/** Playback rate; negative runs backwards, 0 freezes. */
|
|
29
|
+
rate: number
|
|
30
|
+
readonly playing: boolean
|
|
31
|
+
/** Resolves `true` when the animation reaches its end, `false` when cancelled, replaced or replayed. Never rejects. */
|
|
32
|
+
readonly finished: Promise<boolean>
|
|
33
|
+
/** Start from t = 0 (a running animation restarts). */
|
|
34
|
+
play(): this
|
|
35
|
+
pause(): this
|
|
36
|
+
resume(): this
|
|
37
|
+
/** Jump to `ms`; playback state is unchanged. */
|
|
38
|
+
seek(ms: number): this
|
|
39
|
+
/** Jump to the end: values land, commits apply, `finished` resolves `true`. */
|
|
40
|
+
finish(): this
|
|
41
|
+
/** Stop where it is — no commit, `finished` resolves `false`. */
|
|
42
|
+
cancel(): this
|
|
43
|
+
onFinish(fn: (done: boolean) => void): this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasCore = (): boolean => typeof _creatorUI !== "undefined" && typeof (_creatorUI as any).tweenCreate === "function"
|
|
47
|
+
|
|
48
|
+
// ---- host events -------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const live = new Map<number, TweenAnimation>()
|
|
51
|
+
let eventsRegistered = false
|
|
52
|
+
const ensureEvents = (): void => {
|
|
53
|
+
if (eventsRegistered) return
|
|
54
|
+
eventsRegistered = true
|
|
55
|
+
;(_creatorUI as any).registerTweenEvent?.(function (this: unknown, id: number, kind: number, index: number) {
|
|
56
|
+
const a = live.get(id)
|
|
57
|
+
if (!a) return
|
|
58
|
+
if (kind === EV_CALL) a._fireCall(index)
|
|
59
|
+
else if (kind === EV_FINISH) a._hostFinished()
|
|
60
|
+
else if (kind === EV_VALUE) {
|
|
61
|
+
// lanes ride as plain number arguments (no per-frame array on either side)
|
|
62
|
+
const n = arguments.length - 3
|
|
63
|
+
for (let i = 0; i < n; i++) valueScratch[i] = arguments[i + 3] as number
|
|
64
|
+
a._fireValue(index, valueScratch)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reused lane buffer for VALUE deliveries (the host's largest kind is 10 lanes). */
|
|
70
|
+
const valueScratch = new Float32Array(16)
|
|
71
|
+
|
|
72
|
+
/** The concrete handle — what the SDK instantiates behind `animateTo` / `animateFrom` / `Timeline`.
|
|
73
|
+
* User code sees the {@link Animation} interface; the class itself must stay in the public
|
|
74
|
+
* declarations because `TimelineImpl` extends it — marking it internal (even mentioning the tag in
|
|
75
|
+
* this comment: the strip is a substring match) drops the timeline's playback members. */
|
|
76
|
+
export class TweenAnimation implements Animation {
|
|
77
|
+
/** @internal */
|
|
78
|
+
_spec: TweenSpec
|
|
79
|
+
/** @internal Time-callbacks by call index (the spec's `calls[i]`). */
|
|
80
|
+
_calls: Array<() => void> = []
|
|
81
|
+
/** @internal VALUE-track deliverers by slot (`animate()` / `Timeline.animate`). */
|
|
82
|
+
_values = new Map<number, (lanes: Float32Array) => void>()
|
|
83
|
+
|
|
84
|
+
private _id = 0 // host id, 0 = not created
|
|
85
|
+
private _started = false // play() ran at least once (the first play keeps the constructor's promise)
|
|
86
|
+
private _endTime = 0 // `time` after the host freed a finished animation
|
|
87
|
+
private _playing = false
|
|
88
|
+
private _rate: number
|
|
89
|
+
private _finished!: Promise<boolean>
|
|
90
|
+
private _resolve!: (done: boolean) => void
|
|
91
|
+
private _settled = true
|
|
92
|
+
private _listeners: Array<(done: boolean) => void> = []
|
|
93
|
+
private _fb?: Fallback
|
|
94
|
+
|
|
95
|
+
constructor(spec: TweenSpec) {
|
|
96
|
+
this._spec = spec
|
|
97
|
+
this._rate = spec.rate
|
|
98
|
+
this._newPromise()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private _newPromise(): void {
|
|
102
|
+
this._finished = new Promise<boolean>((r) => { this._resolve = r })
|
|
103
|
+
this._settled = false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private _settle(done: boolean): void {
|
|
107
|
+
if (this._settled) return
|
|
108
|
+
this._settled = true
|
|
109
|
+
this._playing = false
|
|
110
|
+
this._resolve(done)
|
|
111
|
+
for (const fn of this._listeners) fn(done)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get duration(): number { return this._spec.durationMs }
|
|
115
|
+
get playing(): boolean { return this._playing }
|
|
116
|
+
get finished(): Promise<boolean> { return this._finished }
|
|
117
|
+
|
|
118
|
+
get rate(): number { return this._rate }
|
|
119
|
+
set rate(r: number) {
|
|
120
|
+
this._rate = r
|
|
121
|
+
if (this._id) (_creatorUI as any).tweenSetRate(this._id, r)
|
|
122
|
+
this._fb?.setRate(r)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get time(): number {
|
|
126
|
+
if (this._id) return (_creatorUI as any).tweenGetTime?.(this._id) ?? 0
|
|
127
|
+
return this._fb?.time ?? this._endTime
|
|
128
|
+
}
|
|
129
|
+
set time(ms: number) { this.seek(ms) }
|
|
130
|
+
|
|
131
|
+
get progress(): number {
|
|
132
|
+
const total = this._totalMs()
|
|
133
|
+
return total > 0 ? Math.min(1, Math.max(0, this._elapsedMs() / total)) : 1
|
|
134
|
+
}
|
|
135
|
+
set progress(p: number) {
|
|
136
|
+
const total = this._totalMs()
|
|
137
|
+
this._seekTotal(Math.min(1, Math.max(0, p)) * total)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private _totalMs(): number {
|
|
141
|
+
const s = this._spec
|
|
142
|
+
const n = s.iterations < 0 ? 1 : s.iterations
|
|
143
|
+
return s.durationMs * n * (s.pingPong && s.iterations !== 1 ? 2 : 1)
|
|
144
|
+
}
|
|
145
|
+
private _elapsedMs(): number {
|
|
146
|
+
if (this._id) return (_creatorUI as any).tweenGetElapsed?.(this._id) ?? this.time
|
|
147
|
+
return this._fb?.elapsed ?? (this._endTime > 0 ? this._totalMs() : 0)
|
|
148
|
+
}
|
|
149
|
+
private _seekTotal(ms: number): void {
|
|
150
|
+
if (hasCore()) {
|
|
151
|
+
this._ensure()
|
|
152
|
+
;(_creatorUI as any).tweenSeek(this._id, ms)
|
|
153
|
+
} else {
|
|
154
|
+
this._ensureFallback().seek(ms)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Create the host animation (paused at t = 0) if it doesn't exist. */
|
|
159
|
+
private _ensure(): void {
|
|
160
|
+
if (this._id) return
|
|
161
|
+
ensureEvents()
|
|
162
|
+
const blob = buildBlob(this._spec)
|
|
163
|
+
this._id = (_creatorUI as any).tweenCreate(blob.data, blob.strings, blob.targets)
|
|
164
|
+
if (this._id) live.set(this._id, this)
|
|
165
|
+
if (this._rate !== 1) (_creatorUI as any).tweenSetRate(this._id, this._rate)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private _destroy(): void {
|
|
169
|
+
if (this._id) {
|
|
170
|
+
live.delete(this._id)
|
|
171
|
+
;(_creatorUI as any).tweenControl(this._id, OP_CANCEL)
|
|
172
|
+
this._id = 0
|
|
173
|
+
}
|
|
174
|
+
this._fb?.cancel()
|
|
175
|
+
this._fb = undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
play(): this {
|
|
179
|
+
// A replay: whoever waited on the previous run gets `false` (unless it already ended), the host
|
|
180
|
+
// object is rebuilt (ids may have changed — a screen re-mounts its nodes on every open). The
|
|
181
|
+
// FIRST play keeps the promise handed out before it — unless a `cancel()` before any play has
|
|
182
|
+
// already settled that one, in which case this run gets a fresh promise.
|
|
183
|
+
if (this._started) {
|
|
184
|
+
this._settle(false)
|
|
185
|
+
this._destroy()
|
|
186
|
+
}
|
|
187
|
+
if (this._settled) this._newPromise()
|
|
188
|
+
this._started = true
|
|
189
|
+
this._endTime = 0
|
|
190
|
+
// Commit = the last keyframe becomes the stored state NOW (as `animateTo` always did), so reads
|
|
191
|
+
// and later `.style()` merges see the final state while the host is still tweening.
|
|
192
|
+
for (const t of this._spec.tracks) {
|
|
193
|
+
if (!t.commit || !t.channel.commit) continue
|
|
194
|
+
const last = t.keys[t.keys.length - 1]
|
|
195
|
+
if (last && last.value !== undefined) t.channel.commit(last.raw)
|
|
196
|
+
}
|
|
197
|
+
this._playing = true
|
|
198
|
+
if (hasCore()) {
|
|
199
|
+
this._ensure()
|
|
200
|
+
;(_creatorUI as any).tweenControl(this._id, OP_PLAY)
|
|
201
|
+
} else {
|
|
202
|
+
this._ensureFallback().play()
|
|
203
|
+
}
|
|
204
|
+
return this
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pause(): this {
|
|
208
|
+
if (hasCore()) { this._ensure(); (_creatorUI as any).tweenControl(this._id, OP_PAUSE) }
|
|
209
|
+
else this._ensureFallback().pause()
|
|
210
|
+
this._playing = false
|
|
211
|
+
return this
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
resume(): this {
|
|
215
|
+
if (this._settled) return this.play()
|
|
216
|
+
if (hasCore()) { this._ensure(); (_creatorUI as any).tweenControl(this._id, OP_RESUME) }
|
|
217
|
+
else this._ensureFallback().resume()
|
|
218
|
+
this._playing = true
|
|
219
|
+
return this
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
seek(ms: number): this {
|
|
223
|
+
// `time` is inside the current iteration; a seek by time on a looping animation stays in it.
|
|
224
|
+
const s = this._spec
|
|
225
|
+
const period = s.durationMs * (s.pingPong && s.iterations !== 1 ? 2 : 1)
|
|
226
|
+
const iter = period > 0 ? Math.floor(this._elapsedMs() / period) : 0
|
|
227
|
+
this._seekTotal(iter * period + Math.max(0, ms))
|
|
228
|
+
return this
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
finish(): this {
|
|
232
|
+
if (hasCore()) {
|
|
233
|
+
this._ensure()
|
|
234
|
+
;(_creatorUI as any).tweenControl(this._id, OP_FINISH) // the host answers with EV_FINISH
|
|
235
|
+
} else {
|
|
236
|
+
this._ensureFallback().finish()
|
|
237
|
+
}
|
|
238
|
+
return this
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
cancel(): this {
|
|
242
|
+
this._destroy()
|
|
243
|
+
this._settle(false)
|
|
244
|
+
return this
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
onFinish(fn: (done: boolean) => void): this {
|
|
248
|
+
this._listeners.push(fn)
|
|
249
|
+
return this
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @internal */
|
|
253
|
+
_fireCall(index: number): void { this._calls[index]?.() }
|
|
254
|
+
/** @internal */
|
|
255
|
+
_fireValue(slot: number, lanes: Float32Array): void { this._values.get(slot)?.(lanes) }
|
|
256
|
+
|
|
257
|
+
/** @internal The host reached the end (naturally or via finish()) and has freed the animation. */
|
|
258
|
+
_hostFinished(): void {
|
|
259
|
+
if (this._id) { live.delete(this._id); this._id = 0 }
|
|
260
|
+
// the old-host path's timers / value loop must stop with the run, or an interval outlives it
|
|
261
|
+
if (this._fb) { this._fb.cancel(); this._fb = undefined }
|
|
262
|
+
this._endTime = this._spec.durationMs
|
|
263
|
+
this._settle(true)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- old hosts ------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
private _ensureFallback(): Fallback {
|
|
269
|
+
if (!this._fb) this._fb = new Fallback(this)
|
|
270
|
+
return this._fb
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Old-host path: UI tracks go through the legacy `animateTo` / `animateFrom` pair (one value per
|
|
275
|
+
* prop — the last explicit key for a to-tween, the first for a from-tween; no easing, no seek), a
|
|
276
|
+
* track on any other domain is dropped with one warning, and the clock is a timer that fires the
|
|
277
|
+
* calls and the finish. Good enough for the simple cases on web / Apple until their core lands. */
|
|
278
|
+
class Fallback {
|
|
279
|
+
time = 0
|
|
280
|
+
elapsed = 0
|
|
281
|
+
private _timers: ReturnType<typeof setTimeout>[] = []
|
|
282
|
+
private _startedAt = 0
|
|
283
|
+
private _rate = 1
|
|
284
|
+
private _warned = false
|
|
285
|
+
private _loop?: ReturnType<typeof setInterval>
|
|
286
|
+
private _valueTracks: Track[] = []
|
|
287
|
+
private _scratch = new Float32Array(16)
|
|
288
|
+
private _lanes: number[] = []
|
|
289
|
+
|
|
290
|
+
private a: TweenAnimation
|
|
291
|
+
constructor(a: TweenAnimation) { this.a = a }
|
|
292
|
+
|
|
293
|
+
setRate(r: number): void { this._rate = r }
|
|
294
|
+
|
|
295
|
+
play(): void {
|
|
296
|
+
const s = this.a._spec
|
|
297
|
+
this.cancel()
|
|
298
|
+
this._startedAt = Date.now()
|
|
299
|
+
const byTarget = new Map<Track["target"], { to: Record<string, unknown>, from: Record<string, unknown>, at: number, dur: number, commit: boolean }>()
|
|
300
|
+
this._valueTracks = []
|
|
301
|
+
for (const t of s.tracks) {
|
|
302
|
+
if (t.channel.domain === DOM_VALUE) { this._valueTracks.push(t); continue }
|
|
303
|
+
if (t.channel.domain !== DOM_UI) {
|
|
304
|
+
if (!this._warned) { this._warned = true; console.warn("[tween] this host animates UI elements and values only (no tween core) — 3D / 2D tracks skipped") }
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
const rec = byTarget.get(t.target) ?? { to: {}, from: {}, at: t.atMs, dur: t.durMs, commit: t.commit }
|
|
308
|
+
byTarget.set(t.target, rec)
|
|
309
|
+
const first = t.keys[0]!, last = t.keys[t.keys.length - 1]!
|
|
310
|
+
if (last.value === undefined) rec.from[t.prop] = first.raw // animateFrom shape
|
|
311
|
+
else rec.to[t.prop] = last.raw
|
|
312
|
+
rec.at = Math.min(rec.at, t.atMs)
|
|
313
|
+
rec.dur = Math.max(rec.dur, t.durMs)
|
|
314
|
+
}
|
|
315
|
+
for (const [target, rec] of byTarget) {
|
|
316
|
+
// Sent even for an unmounted element (id 0), exactly as the direct call used to be — the host
|
|
317
|
+
// ignores it; a stub host in tests records it.
|
|
318
|
+
const id = ((target as any)._id as number) ?? 0
|
|
319
|
+
const delay = rec.at + s.delayMs
|
|
320
|
+
const meta: Record<string, unknown> = { duration: rec.dur }
|
|
321
|
+
if (delay > 0) meta.delay = delay
|
|
322
|
+
if (s.iterations !== 1) { meta.loop = s.iterations < 0 ? true : s.iterations; meta.loopMode = s.pingPong ? "ping-pong" : "restart" }
|
|
323
|
+
// The host merges its animate layer into base on commit (the SDK committed its own style already)
|
|
324
|
+
if (Object.keys(rec.to).length) _creatorUI.animateTo?.(id, { ...rec.to, ...meta, ...(rec.commit ? {} : { commit: false }) })
|
|
325
|
+
if (Object.keys(rec.from).length) _creatorUI.animateFrom?.(id, { ...rec.from, ...meta })
|
|
326
|
+
}
|
|
327
|
+
const scale = this._rate > 0 ? 1 / this._rate : 0
|
|
328
|
+
if (scale === 0) return
|
|
329
|
+
for (let i = 0; i < s.calls.length; i++) {
|
|
330
|
+
this._timers.push(setTimeout(() => this.a._fireCall(i), (s.delayMs + s.calls[i]!) * scale))
|
|
331
|
+
}
|
|
332
|
+
if (s.iterations === 1) {
|
|
333
|
+
this._timers.push(setTimeout(() => { this._applyValues(s.durationMs); this.elapsed = this.time = s.durationMs; this.a._hostFinished() }, (s.delayMs + s.durationMs) * scale))
|
|
334
|
+
}
|
|
335
|
+
// VALUE tracks are evaluated here in JS (the C evaluator's twin), ~60 times a second
|
|
336
|
+
if (this._valueTracks.length) {
|
|
337
|
+
this._applyValues(0)
|
|
338
|
+
this._loop = setInterval(() => {
|
|
339
|
+
const total = (Date.now() - this._startedAt) * this._rate - s.delayMs
|
|
340
|
+
if (total < 0) return
|
|
341
|
+
const period = s.durationMs * (s.pingPong && s.iterations !== 1 ? 2 : 1)
|
|
342
|
+
let local = period > 0 ? total % period : 0
|
|
343
|
+
if (s.iterations !== 1 && s.pingPong && local > s.durationMs) local = 2 * s.durationMs - local
|
|
344
|
+
if (s.iterations === 1) local = Math.min(total, s.durationMs)
|
|
345
|
+
this.elapsed = total
|
|
346
|
+
this.time = local
|
|
347
|
+
this._applyValues(local)
|
|
348
|
+
}, 16)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
private _applyValues(local: number): void {
|
|
352
|
+
for (const t of this._valueTracks) {
|
|
353
|
+
evaluateTrack(t, local, this._lanes)
|
|
354
|
+
for (let i = 0; i < t.lanes; i++) this._scratch[i] = this._lanes[i]!
|
|
355
|
+
this.a._fireValue(t.channel.id() as number, this._scratch)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
pause(): void { this.cancel() }
|
|
359
|
+
resume(): void { /* no partial resume on the timer clock: the tween already ran on the host */ }
|
|
360
|
+
seek(ms: number): void { this.elapsed = ms; this.time = ms % Math.max(1, this.a._spec.durationMs); this._applyValues(this.time) }
|
|
361
|
+
finish(): void { this.cancel(); this._applyValues(this.a._spec.durationMs); this.a._hostFinished() }
|
|
362
|
+
cancel(): void {
|
|
363
|
+
for (const t of this._timers) clearTimeout(t)
|
|
364
|
+
this._timers = []
|
|
365
|
+
if (this._loop !== undefined) { clearInterval(this._loop); this._loop = undefined }
|
|
366
|
+
if (this._startedAt) this.elapsed = this.time = Math.min(this.a._spec.durationMs, (Date.now() - this._startedAt) * this._rate)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @internal Build a one-bag animation for a single target (the `animateTo` / `animateFrom` path). */
|
|
371
|
+
export const singleTargetSpec = (tracks: Track[], clock: number, iterations: number, pingPong: boolean, delayMs: number): TweenSpec => {
|
|
372
|
+
let durationMs = 0
|
|
373
|
+
for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
|
|
374
|
+
// `delay` lives on the tracks (the first key holds through it, §3.1); the spec delay is 0 here
|
|
375
|
+
return { clock, durationMs, delayMs, iterations, pingPong, rate: 1, tracks, calls: [] }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export { CLOCK_UI, CLOCK_GAME }
|