lecodes-sdk 2.2.5 → 2.2.6
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/types/ui/UINode.d.ts +8 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/core.md +1 -1
- package/prompts/ui-design.md +6 -6
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/compile/assetMacro.ts +81 -13
- package/src/compile/resourceSlots.ts +45 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +257 -257
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/animation/DynamicBone.ts +482 -482
- package/src/gl/scenarios.ts +291 -291
- package/src/ui/UINode.ts +9 -1
- package/src/version.ts +1 -1
- package/prompts/dist/2d-game.md +0 -1567
- package/prompts/dist/3d-app.md +0 -1712
- package/prompts/dist/ar-app.md +0 -1585
- package/prompts/dist/concept.md +0 -49
- package/prompts/dist/design.md +0 -521
- package/prompts/dist/ui-app.md +0 -1175
package/package.json
CHANGED
package/prompts/core.md
CHANGED
|
@@ -112,7 +112,7 @@ Everything in this prompt is a global — no imports needed. A project's only im
|
|
|
112
112
|
|
|
113
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.
|
|
114
114
|
|
|
115
|
-
Reference a project asset (image, font, video, .svg, .glb, …) by its path as a string LITERAL — `UIImage("images/hero.png")`, `"./x.png"` from the file, `"/x.png"` from the root — or with the inline `asset('./path')` macro; both are a compile-time equivalent of the import (a literal, never a variable
|
|
115
|
+
Reference a project asset (image, font, video, .svg, .glb, …) by its path as a string LITERAL — `UIImage("images/hero.png")`, `"./x.png"` from the file, `"/x.png"` from the root — or with the inline `asset('./path')` macro; both are a compile-time equivalent of the import (a literal, never a variable). A path that names a file is that file anywhere — a table of rows `{ photo: "images/x.jpg" }` too; one that names no file fails the compile where the SDK takes a file (UIImage, bgImage, a tab icon, Model.load, Sound.load, a scene's model …) and stays a plain string elsewhere (a path in the app's own files, a storage key). A `.json` path stays a string. External `https://…` URLs are used directly as strings.
|
|
116
116
|
|
|
117
117
|
## Project context
|
|
118
118
|
|
package/prompts/ui-design.md
CHANGED
|
@@ -51,8 +51,8 @@ UIText(text)
|
|
|
51
51
|
UIImage(src) // src: string url | SvgSource | imported asset
|
|
52
52
|
// extra styles: objectFit ("cover"|"contain"|"fill"), tintColor (SVG only), borderRadius (number px only on UIImage)
|
|
53
53
|
// Note: tintColor applies only to SVG sources — it replaces ALL fill and stroke colors (monochrome icons only)
|
|
54
|
-
// Note: a
|
|
55
|
-
//
|
|
54
|
+
// Note: a project file by its path as a string — UIImage("images/photo.jpg") — is the bundled asset (or
|
|
55
|
+
// asset('./images/photo.jpg')); a path that names no file fails the compile; https:// strings are remote URLs
|
|
56
56
|
|
|
57
57
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
58
58
|
// Anything clickable in the prototype (a card, a list row, an icon) — wrap it in a UIButton.
|
|
@@ -191,10 +191,10 @@ UIRow(isSelected ? check : UIColumn()) // WRONG
|
|
|
191
191
|
// ✅ null is skipped in children — no phantom element
|
|
192
192
|
UIRow(isSelected ? check : null)
|
|
193
193
|
|
|
194
|
-
// ❌ pointing UIImage / bgImage at a project file
|
|
195
|
-
UIImage("
|
|
196
|
-
// ✅
|
|
197
|
-
UIImage(
|
|
194
|
+
// ❌ pointing UIImage / bgImage at a project file that is not there — the compile fails: file not found
|
|
195
|
+
UIImage("assets/avatars/nobody.jpg") // WRONG (no such file in the project)
|
|
196
|
+
// ✅ the path of a file the project has (or the same path in asset()) — the bundled asset
|
|
197
|
+
UIImage("assets/avatars/alina.jpg")
|
|
198
198
|
|
|
199
199
|
// ❌ setting a UIImage's content through bgImage
|
|
200
200
|
// bgImage is a container background, not image content — pass the source to UIImage(...)
|
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
// `Timeline` (docs/timeline-plan.md §2.4): choreography over the keyframe core. Tracks on any
|
|
2
|
-
// number of targets (UI elements, 3D / 2D nodes, lights, cameras) at absolute positions, JS calls
|
|
3
|
-
// at times, labels, holds — one clock, one handle. Also the `animateTo` / `animateFrom` bag
|
|
4
|
-
// builder every target class delegates to (`tweenBag`).
|
|
5
|
-
|
|
6
|
-
import { TweenAnimation, type Animation } from "./Animation"
|
|
7
|
-
import { makeValueTrack, type AnimateOptions, type AnimateValue } from "./animateValue"
|
|
8
|
-
import { CLOCK_GAME, CLOCK_UI, bagProps, iterationsOf, makeTrack, type Track, type TweenMeta, type TweenSpec, type TweenTarget } from "./spec"
|
|
9
|
-
|
|
10
|
-
/** Vector-valued props: an array value is ONE keyframe unless it is an array of vectors. */
|
|
11
|
-
const VECTOR_PROPS = new Set(["position", "scale", "quaternion", "eulerAngles", "color"])
|
|
12
|
-
|
|
13
|
-
const clockOf = (clock: TweenMeta["clock"], fallback: number): number =>
|
|
14
|
-
clock === "game" ? CLOCK_GAME : clock === "ui" ? CLOCK_UI : fallback
|
|
15
|
-
|
|
16
|
-
/** @internal Build the tracks of one options bag on one target. `fromCurrent` = animateTo shape. */
|
|
17
|
-
export const bagTracks = (target: TweenTarget, bag: Record<string, unknown>, atMs: number, fromCurrent: boolean): Track[] => {
|
|
18
|
-
const meta = bag as TweenMeta
|
|
19
|
-
const tracks: Track[] = []
|
|
20
|
-
for (const [prop, value] of bagProps(bag)) {
|
|
21
|
-
const track = makeTrack(target, prop, value, meta, atMs, fromCurrent, VECTOR_PROPS.has(prop))
|
|
22
|
-
if (track) tracks.push(track)
|
|
23
|
-
}
|
|
24
|
-
return tracks
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** @internal The `animateTo` / `animateFrom` implementation: one bag → one animation, played now. */
|
|
28
|
-
export const tweenBag = (target: TweenTarget, bag: Record<string, unknown>, fromCurrent: boolean): Animation => {
|
|
29
|
-
const meta = bag as TweenMeta
|
|
30
|
-
const tracks = bagTracks(target, bag, 0, fromCurrent)
|
|
31
|
-
let durationMs = 0
|
|
32
|
-
for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
|
|
33
|
-
const spec: TweenSpec = {
|
|
34
|
-
clock: clockOf(meta.clock, target._tweenClock),
|
|
35
|
-
durationMs, delayMs: 0,
|
|
36
|
-
iterations: iterationsOf(meta.loop),
|
|
37
|
-
pingPong: meta.loopMode !== "restart",
|
|
38
|
-
rate: 1, tracks, calls: [],
|
|
39
|
-
}
|
|
40
|
-
return new TweenAnimation(spec).play()
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type TimelineOptions = {
|
|
44
|
-
/** `'ui'` (default) = wall time; `'game'` follows `Time.scale` and pauses with the game. */
|
|
45
|
-
clock?: "ui" | "game",
|
|
46
|
-
/** Repeat the whole timeline: `true` = forever, a number = cycles. */
|
|
47
|
-
loop?: boolean | number,
|
|
48
|
-
loopMode?: "restart" | "ping-pong",
|
|
49
|
-
/** Wait before the first play, ms. */
|
|
50
|
-
delay?: number,
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Where a track goes: ms, a label, or `[label, offsetMs]`. Default = the current end (sequencing). */
|
|
54
|
-
export type TimelinePosition = number | string | [string, number]
|
|
55
|
-
|
|
56
|
-
export type TimelineAddOptions = {
|
|
57
|
-
at?: TimelinePosition,
|
|
58
|
-
/** With several targets: each starts this many ms after the previous one. */
|
|
59
|
-
stagger?: number,
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** A choreography: tracks on many targets at absolute times, JS calls, labels and holds, driven by
|
|
63
|
-
* one clock. Build it once, `play()` on every entrance — `play()` restarts from t = 0 and the pose
|
|
64
|
-
* at any time is fully defined by the tracks (the first keyframe of a track holds before it
|
|
65
|
-
* starts, the last one after it ends). */
|
|
66
|
-
export class TimelineImpl extends TweenAnimation {
|
|
67
|
-
private _labels = new Map<string, number>()
|
|
68
|
-
private _end = 0
|
|
69
|
-
|
|
70
|
-
constructor(options: TimelineOptions = {}) {
|
|
71
|
-
super({
|
|
72
|
-
clock: clockOf(options.clock, CLOCK_UI),
|
|
73
|
-
durationMs: 0,
|
|
74
|
-
delayMs: options.delay ?? 0,
|
|
75
|
-
iterations: iterationsOf(options.loop),
|
|
76
|
-
pingPong: options.loopMode !== "restart",
|
|
77
|
-
rate: 1, tracks: [], calls: [],
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
private _at(at: TimelinePosition | undefined): number {
|
|
82
|
-
if (at === undefined) return this._end
|
|
83
|
-
if (typeof at === "number") return at
|
|
84
|
-
const name = Array.isArray(at) ? at[0] : at
|
|
85
|
-
const offset = Array.isArray(at) ? at[1] : 0
|
|
86
|
-
const base = this._labels.get(name)
|
|
87
|
-
if (base === undefined) throw new Error(`Timeline: unknown label "${name}"`)
|
|
88
|
-
return base + offset
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private _grow(endMs: number): void {
|
|
92
|
-
if (endMs > this._end) this._end = endMs
|
|
93
|
-
this._spec.durationMs = this._end
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Animate `props` on one target or several (staggered), like `animateTo` — arrays are keyframes,
|
|
97
|
-
* `duration` / `easing` / `times` / `commit` apply. The bag's `delay` shifts the track after `at`. */
|
|
98
|
-
add(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
|
|
99
|
-
const at = this._at(options.at)
|
|
100
|
-
const targets = Array.isArray(target) ? target : [target]
|
|
101
|
-
const stagger = options.stagger ?? 0
|
|
102
|
-
targets.forEach((t, i) => {
|
|
103
|
-
const tracks = bagTracks(t, props, at + i * stagger, true)
|
|
104
|
-
for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
|
|
105
|
-
})
|
|
106
|
-
return this
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Like `add` with the `animateFrom` shape: the target's own state is the implicit last keyframe. */
|
|
110
|
-
addFrom(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
|
|
111
|
-
const at = this._at(options.at)
|
|
112
|
-
const targets = Array.isArray(target) ? target : [target]
|
|
113
|
-
const stagger = options.stagger ?? 0
|
|
114
|
-
targets.forEach((t, i) => {
|
|
115
|
-
const tracks = bagTracks(t, props, at + i * stagger, false)
|
|
116
|
-
for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
|
|
117
|
-
})
|
|
118
|
-
return this
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** A free-value track (see the `animate()` global): the host evaluates it and calls `onUpdate`
|
|
122
|
-
* every frame with the value. */
|
|
123
|
-
animate<T extends AnimateValue>(options: AnimateOptions<T>, at?: TimelinePosition): this {
|
|
124
|
-
const { track, deliver, slot } = makeValueTrack(options, this._at(at))
|
|
125
|
-
this._values.set(slot, deliver)
|
|
126
|
-
this._spec.tracks.push(track)
|
|
127
|
-
this._grow(track.atMs + track.durMs)
|
|
128
|
-
return this
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Call `fn` when playback crosses `at` (forward or backward), in time order, always before
|
|
132
|
-
* finish, never from a previous run. */
|
|
133
|
-
call(at: TimelinePosition, fn: () => void): this {
|
|
134
|
-
const t = this._at(at)
|
|
135
|
-
this._spec.calls.push(t)
|
|
136
|
-
this._calls.push(fn)
|
|
137
|
-
this._grow(t)
|
|
138
|
-
return this
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Name a time — for `at`, `seek` and callers (`tl.labels.landed`). Default = the current end. */
|
|
142
|
-
label(name: string, at?: TimelinePosition): this {
|
|
143
|
-
this._labels.set(name, this._at(at))
|
|
144
|
-
return this
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Named times, ms. */
|
|
148
|
-
get labels(): Record<string, number> {
|
|
149
|
-
const o: Record<string, number> = {}
|
|
150
|
-
for (const [k, v] of this._labels) o[k] = v
|
|
151
|
-
return o
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Extend the timeline with stillness after its current end. */
|
|
155
|
-
hold(ms: number): this {
|
|
156
|
-
this._grow(this._end + Math.max(0, ms))
|
|
157
|
-
return this
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The current end, ms (tracks, calls and holds). */
|
|
161
|
-
get end(): number { return this._end }
|
|
162
|
-
|
|
163
|
-
/** `seek` also accepts a label. */
|
|
164
|
-
override seek(at: number | string): this {
|
|
165
|
-
return super.seek(typeof at === "string" ? this._at(at) : at)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** The timeline handle type (see {@link TimelineImpl} for the members). */
|
|
170
|
-
export type Timeline = TimelineImpl
|
|
171
|
-
|
|
172
|
-
/** Create a timeline — `Timeline()` / `Timeline({ clock: 'game', loop: true })`. */
|
|
173
|
-
export function Timeline(options?: TimelineOptions): Timeline {
|
|
174
|
-
return new TimelineImpl(options)
|
|
175
|
-
}
|
|
1
|
+
// `Timeline` (docs/timeline-plan.md §2.4): choreography over the keyframe core. Tracks on any
|
|
2
|
+
// number of targets (UI elements, 3D / 2D nodes, lights, cameras) at absolute positions, JS calls
|
|
3
|
+
// at times, labels, holds — one clock, one handle. Also the `animateTo` / `animateFrom` bag
|
|
4
|
+
// builder every target class delegates to (`tweenBag`).
|
|
5
|
+
|
|
6
|
+
import { TweenAnimation, type Animation } from "./Animation"
|
|
7
|
+
import { makeValueTrack, type AnimateOptions, type AnimateValue } from "./animateValue"
|
|
8
|
+
import { CLOCK_GAME, CLOCK_UI, bagProps, iterationsOf, makeTrack, type Track, type TweenMeta, type TweenSpec, type TweenTarget } from "./spec"
|
|
9
|
+
|
|
10
|
+
/** Vector-valued props: an array value is ONE keyframe unless it is an array of vectors. */
|
|
11
|
+
const VECTOR_PROPS = new Set(["position", "scale", "quaternion", "eulerAngles", "color"])
|
|
12
|
+
|
|
13
|
+
const clockOf = (clock: TweenMeta["clock"], fallback: number): number =>
|
|
14
|
+
clock === "game" ? CLOCK_GAME : clock === "ui" ? CLOCK_UI : fallback
|
|
15
|
+
|
|
16
|
+
/** @internal Build the tracks of one options bag on one target. `fromCurrent` = animateTo shape. */
|
|
17
|
+
export const bagTracks = (target: TweenTarget, bag: Record<string, unknown>, atMs: number, fromCurrent: boolean): Track[] => {
|
|
18
|
+
const meta = bag as TweenMeta
|
|
19
|
+
const tracks: Track[] = []
|
|
20
|
+
for (const [prop, value] of bagProps(bag)) {
|
|
21
|
+
const track = makeTrack(target, prop, value, meta, atMs, fromCurrent, VECTOR_PROPS.has(prop))
|
|
22
|
+
if (track) tracks.push(track)
|
|
23
|
+
}
|
|
24
|
+
return tracks
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal The `animateTo` / `animateFrom` implementation: one bag → one animation, played now. */
|
|
28
|
+
export const tweenBag = (target: TweenTarget, bag: Record<string, unknown>, fromCurrent: boolean): Animation => {
|
|
29
|
+
const meta = bag as TweenMeta
|
|
30
|
+
const tracks = bagTracks(target, bag, 0, fromCurrent)
|
|
31
|
+
let durationMs = 0
|
|
32
|
+
for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
|
|
33
|
+
const spec: TweenSpec = {
|
|
34
|
+
clock: clockOf(meta.clock, target._tweenClock),
|
|
35
|
+
durationMs, delayMs: 0,
|
|
36
|
+
iterations: iterationsOf(meta.loop),
|
|
37
|
+
pingPong: meta.loopMode !== "restart",
|
|
38
|
+
rate: 1, tracks, calls: [],
|
|
39
|
+
}
|
|
40
|
+
return new TweenAnimation(spec).play()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TimelineOptions = {
|
|
44
|
+
/** `'ui'` (default) = wall time; `'game'` follows `Time.scale` and pauses with the game. */
|
|
45
|
+
clock?: "ui" | "game",
|
|
46
|
+
/** Repeat the whole timeline: `true` = forever, a number = cycles. */
|
|
47
|
+
loop?: boolean | number,
|
|
48
|
+
loopMode?: "restart" | "ping-pong",
|
|
49
|
+
/** Wait before the first play, ms. */
|
|
50
|
+
delay?: number,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Where a track goes: ms, a label, or `[label, offsetMs]`. Default = the current end (sequencing). */
|
|
54
|
+
export type TimelinePosition = number | string | [string, number]
|
|
55
|
+
|
|
56
|
+
export type TimelineAddOptions = {
|
|
57
|
+
at?: TimelinePosition,
|
|
58
|
+
/** With several targets: each starts this many ms after the previous one. */
|
|
59
|
+
stagger?: number,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A choreography: tracks on many targets at absolute times, JS calls, labels and holds, driven by
|
|
63
|
+
* one clock. Build it once, `play()` on every entrance — `play()` restarts from t = 0 and the pose
|
|
64
|
+
* at any time is fully defined by the tracks (the first keyframe of a track holds before it
|
|
65
|
+
* starts, the last one after it ends). */
|
|
66
|
+
export class TimelineImpl extends TweenAnimation {
|
|
67
|
+
private _labels = new Map<string, number>()
|
|
68
|
+
private _end = 0
|
|
69
|
+
|
|
70
|
+
constructor(options: TimelineOptions = {}) {
|
|
71
|
+
super({
|
|
72
|
+
clock: clockOf(options.clock, CLOCK_UI),
|
|
73
|
+
durationMs: 0,
|
|
74
|
+
delayMs: options.delay ?? 0,
|
|
75
|
+
iterations: iterationsOf(options.loop),
|
|
76
|
+
pingPong: options.loopMode !== "restart",
|
|
77
|
+
rate: 1, tracks: [], calls: [],
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private _at(at: TimelinePosition | undefined): number {
|
|
82
|
+
if (at === undefined) return this._end
|
|
83
|
+
if (typeof at === "number") return at
|
|
84
|
+
const name = Array.isArray(at) ? at[0] : at
|
|
85
|
+
const offset = Array.isArray(at) ? at[1] : 0
|
|
86
|
+
const base = this._labels.get(name)
|
|
87
|
+
if (base === undefined) throw new Error(`Timeline: unknown label "${name}"`)
|
|
88
|
+
return base + offset
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private _grow(endMs: number): void {
|
|
92
|
+
if (endMs > this._end) this._end = endMs
|
|
93
|
+
this._spec.durationMs = this._end
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Animate `props` on one target or several (staggered), like `animateTo` — arrays are keyframes,
|
|
97
|
+
* `duration` / `easing` / `times` / `commit` apply. The bag's `delay` shifts the track after `at`. */
|
|
98
|
+
add(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
|
|
99
|
+
const at = this._at(options.at)
|
|
100
|
+
const targets = Array.isArray(target) ? target : [target]
|
|
101
|
+
const stagger = options.stagger ?? 0
|
|
102
|
+
targets.forEach((t, i) => {
|
|
103
|
+
const tracks = bagTracks(t, props, at + i * stagger, true)
|
|
104
|
+
for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
|
|
105
|
+
})
|
|
106
|
+
return this
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Like `add` with the `animateFrom` shape: the target's own state is the implicit last keyframe. */
|
|
110
|
+
addFrom(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
|
|
111
|
+
const at = this._at(options.at)
|
|
112
|
+
const targets = Array.isArray(target) ? target : [target]
|
|
113
|
+
const stagger = options.stagger ?? 0
|
|
114
|
+
targets.forEach((t, i) => {
|
|
115
|
+
const tracks = bagTracks(t, props, at + i * stagger, false)
|
|
116
|
+
for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
|
|
117
|
+
})
|
|
118
|
+
return this
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A free-value track (see the `animate()` global): the host evaluates it and calls `onUpdate`
|
|
122
|
+
* every frame with the value. */
|
|
123
|
+
animate<T extends AnimateValue>(options: AnimateOptions<T>, at?: TimelinePosition): this {
|
|
124
|
+
const { track, deliver, slot } = makeValueTrack(options, this._at(at))
|
|
125
|
+
this._values.set(slot, deliver)
|
|
126
|
+
this._spec.tracks.push(track)
|
|
127
|
+
this._grow(track.atMs + track.durMs)
|
|
128
|
+
return this
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Call `fn` when playback crosses `at` (forward or backward), in time order, always before
|
|
132
|
+
* finish, never from a previous run. */
|
|
133
|
+
call(at: TimelinePosition, fn: () => void): this {
|
|
134
|
+
const t = this._at(at)
|
|
135
|
+
this._spec.calls.push(t)
|
|
136
|
+
this._calls.push(fn)
|
|
137
|
+
this._grow(t)
|
|
138
|
+
return this
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Name a time — for `at`, `seek` and callers (`tl.labels.landed`). Default = the current end. */
|
|
142
|
+
label(name: string, at?: TimelinePosition): this {
|
|
143
|
+
this._labels.set(name, this._at(at))
|
|
144
|
+
return this
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Named times, ms. */
|
|
148
|
+
get labels(): Record<string, number> {
|
|
149
|
+
const o: Record<string, number> = {}
|
|
150
|
+
for (const [k, v] of this._labels) o[k] = v
|
|
151
|
+
return o
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Extend the timeline with stillness after its current end. */
|
|
155
|
+
hold(ms: number): this {
|
|
156
|
+
this._grow(this._end + Math.max(0, ms))
|
|
157
|
+
return this
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The current end, ms (tracks, calls and holds). */
|
|
161
|
+
get end(): number { return this._end }
|
|
162
|
+
|
|
163
|
+
/** `seek` also accepts a label. */
|
|
164
|
+
override seek(at: number | string): this {
|
|
165
|
+
return super.seek(typeof at === "string" ? this._at(at) : at)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The timeline handle type (see {@link TimelineImpl} for the members). */
|
|
170
|
+
export type Timeline = TimelineImpl
|
|
171
|
+
|
|
172
|
+
/** Create a timeline — `Timeline()` / `Timeline({ clock: 'game', loop: true })`. */
|
|
173
|
+
export function Timeline(options?: TimelineOptions): Timeline {
|
|
174
|
+
return new TimelineImpl(options)
|
|
175
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// compile-time `asset('./x.png')` macro is desugared into a real import so it goes through the same
|
|
3
3
|
// loader/resolver as a hand-written import.
|
|
4
4
|
|
|
5
|
+
import { resourceSlots } from "./resourceSlots"
|
|
6
|
+
|
|
5
7
|
// Matches `asset("./img.png")` / `asset('./img.png')` calls whose argument is a single string
|
|
6
8
|
// literal (handling escaped chars inside the literal). The lookbehind excludes property access —
|
|
7
9
|
// a METHOD named asset (`ui.asset("model")`, InspectorUI) is not the macro.
|
|
@@ -104,6 +106,63 @@ const normalizePath = (path: string): string => {
|
|
|
104
106
|
return "/" + out.join("/")
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
// ---- the resource slots (resourceSlots.ts): is a literal what the SDK takes a file in? ----------
|
|
110
|
+
|
|
111
|
+
const OPENERS = "([{"
|
|
112
|
+
const CLOSERS = ")]}"
|
|
113
|
+
// The callee before a `(`: `UIImage`, `Model.load`, `new AudioPlayer` (whitespace allowed around the dots).
|
|
114
|
+
const CALLEE = /(?:\b(new)\s+)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*$/
|
|
115
|
+
// An object literal's entry up to its value: `bgImage: `, `"icon": `.
|
|
116
|
+
const ENTRY_KEY = /^\s*(["']?)([A-Za-z_$][\w$]*)\1\s*:/
|
|
117
|
+
// The target of an assignment: `img.src = `.
|
|
118
|
+
const SET_TARGET = /\.\s*([A-Za-z_$][\w$]*)\s*$/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the literal at `start` sits in a resource slot: an argument of a call or a constructor of
|
|
122
|
+
* the list at its position, the value of a listed key of an object literal, or the right side of a
|
|
123
|
+
* listed property set. Walks OUT from the literal over `mask` (maskNonCode's: strings and comments
|
|
124
|
+
* blanked, so their brackets do not count) and lets the innermost call or object entry decide.
|
|
125
|
+
* Arrays (`Sound.load([…])`, `normals: […]`), grouping parens, ternaries and an arrow's body (its
|
|
126
|
+
* statements, `return "…"`) are seen through; an assignment to anything else ends the walk — the
|
|
127
|
+
* literal is that variable's or that property's, not a slot's.
|
|
128
|
+
*/
|
|
129
|
+
const inResourceSlot = (src: string, mask: string, start: number): boolean => {
|
|
130
|
+
let depth = 0 // brackets closed between the literal and the walk (theirs, not its own)
|
|
131
|
+
let commas = 0 // the literal's argument / entry position in the bracket it sits in
|
|
132
|
+
let entry = -1 // the closest of those commas: where the literal's entry starts
|
|
133
|
+
for (let i = start - 1; i >= 0; i--) {
|
|
134
|
+
const c = mask[i]
|
|
135
|
+
if (CLOSERS.includes(c)) { depth++; continue }
|
|
136
|
+
if (OPENERS.includes(c)) {
|
|
137
|
+
if (depth > 0) { depth--; continue }
|
|
138
|
+
if (c === "(") {
|
|
139
|
+
// A call — the innermost decides; a keyword (`if (`, `return (`) is no slot of the list.
|
|
140
|
+
const m = CALLEE.exec(mask.slice(Math.max(0, i - 128), i))
|
|
141
|
+
if (m) {
|
|
142
|
+
const slots = m[1] ? resourceSlots.constructors[m[2]] : resourceSlots.calls[m[2].replace(/\s+/g, "")]
|
|
143
|
+
return !!slots && slots.includes(commas)
|
|
144
|
+
}
|
|
145
|
+
} else if (c === "{") {
|
|
146
|
+
// An object literal's entry — its key decides; no key: a block (an arrow's body), seen through.
|
|
147
|
+
const key = ENTRY_KEY.exec(src.slice((entry >= 0 ? entry : i) + 1, start))
|
|
148
|
+
if (key) return resourceSlots.keys.includes(key[2])
|
|
149
|
+
}
|
|
150
|
+
commas = 0
|
|
151
|
+
entry = -1
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (depth > 0) continue
|
|
155
|
+
if (c === ",") { commas++; if (entry < 0) entry = i; continue }
|
|
156
|
+
if (c === ";") { commas = 0; entry = -1; continue } // a statement of a block: the block decides
|
|
157
|
+
// an assignment (not `==`, `!=`, `<=`, `>=`, `=>`): `img.src = "…"` is a slot, any other target is not
|
|
158
|
+
if (c === "=" && mask[i + 1] !== "=" && mask[i + 1] !== ">" && !"=!<>".includes(mask[i - 1] ?? "")) {
|
|
159
|
+
const target = SET_TARGET.exec(mask.slice(Math.max(0, i - 128), i))
|
|
160
|
+
return !!target && resourceSlots.assignments.includes(target[1])
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
107
166
|
/**
|
|
108
167
|
* Every string literal in `src` that is a PATH to a resource file of the project — `"images/hero.png"`,
|
|
109
168
|
* `"./hero.png"`, `"/sounds/tap.mp3"` — as the asset references they stand for: a bare path is the
|
|
@@ -111,37 +170,46 @@ const normalizePath = (path: string): string => {
|
|
|
111
170
|
* not the macro; the two now mean the same). Resolution: `./` and `../` from the file first, then
|
|
112
171
|
* from the project root (a model writes `./images/x.png` in `views/cart.ts` meaning the root);
|
|
113
172
|
* `/x` from the root; `a/b.png` from the root first, then the file. `exists(rootPath)` says whether
|
|
114
|
-
* the project has that file.
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
173
|
+
* the project has that file.
|
|
174
|
+
*
|
|
175
|
+
* A literal that names a file is its asset wherever it stands — a table of rows (`photo:
|
|
176
|
+
* "images/espresso.jpg"`) the screens draw and a seed stores. One with a resource extension that
|
|
177
|
+
* starts with `./`, `../`, `/` or holds a `/` and names NO file comes back `missing` only in a
|
|
178
|
+
* resource slot (resourceSlots.ts: `UIImage("…")`, `.style({ bgImage: "…" })`, `Model.load("…")` …) —
|
|
179
|
+
* the bundler reports it when the module is reached, the way it reports a missing `asset()`;
|
|
180
|
+
* anywhere else it is the string it says (2026-10-12: `files.write("photos/a.jpg")`, a storage key,
|
|
181
|
+
* `endsWith("/thumb.jpg")`). A bare name (`"hero.png"`) that names no file is left as it is.
|
|
182
|
+
* Import specifiers, `asset()` arguments (`taken`: the ranges already claimed) and `.json` paths are
|
|
183
|
+
* never touched.
|
|
119
184
|
*/
|
|
120
185
|
export const collectPathLiterals = (
|
|
121
186
|
src: string, filePath: string, exists: (rootPath: string) => boolean, taken: AssetMacroRef[] = [],
|
|
122
187
|
): AssetMacroRef[] => {
|
|
123
188
|
const refs: AssetMacroRef[] = []
|
|
124
189
|
const dir = filePath.slice(0, filePath.lastIndexOf("/"))
|
|
125
|
-
|
|
190
|
+
const literals: [ number, number ][] = []
|
|
191
|
+
const mask = maskNonCode(src, (start, end) => { literals.push([ start, end ]) })
|
|
192
|
+
for (const [ start, end ] of literals) {
|
|
126
193
|
const value = src.slice(start + 1, end - 1)
|
|
127
|
-
if (value.length === 0 || value.length > 256)
|
|
128
|
-
if (!BARE_PATH_EXT.test(value) || CODE_EXT.test(value))
|
|
129
|
-
if (/[\s:*?"'`<>|\\]/.test(value))
|
|
130
|
-
if (taken.some((t) => start >= t.index && start < t.index + t.length))
|
|
131
|
-
if (IMPORT_BEFORE.test(src.slice(Math.max(0, start - 32), start)))
|
|
194
|
+
if (value.length === 0 || value.length > 256) continue
|
|
195
|
+
if (!BARE_PATH_EXT.test(value) || CODE_EXT.test(value)) continue
|
|
196
|
+
if (/[\s:*?"'`<>|\\]/.test(value)) continue // a url, an icon id, a sentence, a glob
|
|
197
|
+
if (taken.some((t) => start >= t.index && start < t.index + t.length)) continue
|
|
198
|
+
if (IMPORT_BEFORE.test(src.slice(Math.max(0, start - 32), start))) continue
|
|
132
199
|
const relative = value.startsWith("./") || value.startsWith("../")
|
|
133
200
|
const rooted = value.startsWith("/")
|
|
134
201
|
const candidates = rooted ? [ normalizePath(value) ]
|
|
135
202
|
: relative ? [ normalizePath(`${dir}/${value}`), normalizePath(value) ]
|
|
136
203
|
: [ normalizePath(value), normalizePath(`${dir}/${value}`) ]
|
|
137
204
|
const found = candidates.find(exists)
|
|
138
|
-
if (found === undefined && !relative && !rooted && !value.includes("/"))
|
|
205
|
+
if (found === undefined && !relative && !rooted && !value.includes("/")) continue
|
|
206
|
+
if (found === undefined && !inResourceSlot(src, mask, start)) continue
|
|
139
207
|
let line = 1
|
|
140
208
|
for (let k = 0; k < start; k++) if (src[k] === "\n") line++
|
|
141
209
|
refs.push(found !== undefined
|
|
142
210
|
? { path: found, index: start, length: end - start, line }
|
|
143
211
|
: { path: value, index: start, length: end - start, line, missing: true })
|
|
144
|
-
}
|
|
212
|
+
}
|
|
145
213
|
return refs
|
|
146
214
|
}
|
|
147
215
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resource SLOTS: where the SDK takes a reference to a file of the project — an image, a sound,
|
|
3
|
+
* a model, a font — the places `asset('…')` or an `https://` url is passed to.
|
|
4
|
+
*
|
|
5
|
+
* compile/assetMacro.ts reads them for the bare path literals (2026-10-12): a literal that names a
|
|
6
|
+
* file of the project is its asset ANYWHERE (a table of rows names its pictures that way — the
|
|
7
|
+
* screens draw them, a seed stores them); a path-looking literal that names NO file fails the
|
|
8
|
+
* compile only in a slot. Anywhere else it is the string it says: a path in the app's own files, a
|
|
9
|
+
* storage key, a suffix test, a test's expected text.
|
|
10
|
+
*
|
|
11
|
+
* Plain data, the names user code writes. A rename in the SDK is an edit here
|
|
12
|
+
* (tests/compile/resourceSlots.test.ts fails until it is made).
|
|
13
|
+
*/
|
|
14
|
+
export const resourceSlots = {
|
|
15
|
+
/** A call as written — a global, or `Global.method` — and the argument positions that take a file. */
|
|
16
|
+
calls: {
|
|
17
|
+
UIImage: [ 0 ],
|
|
18
|
+
registerFont: [ 1 ],
|
|
19
|
+
fetchLocal: [ 0 ],
|
|
20
|
+
"Texture2D.load": [ 0 ],
|
|
21
|
+
"Texture.load": [ 0 ],
|
|
22
|
+
"Model.load": [ 0 ],
|
|
23
|
+
"AnimationClip.load": [ 0 ],
|
|
24
|
+
"AnimationClip.loadAll": [ 0 ],
|
|
25
|
+
"Geometry.load": [ 0 ],
|
|
26
|
+
"Material.load": [ 0 ],
|
|
27
|
+
"Terrain.load": [ 0 ],
|
|
28
|
+
"Sound.load": [ 0 ],
|
|
29
|
+
} as Record<string, number[]>,
|
|
30
|
+
/** `new X(…)` and the argument positions that take a file. */
|
|
31
|
+
constructors: {
|
|
32
|
+
AudioPlayer: [ 0 ],
|
|
33
|
+
VideoPlayer: [ 0 ],
|
|
34
|
+
} as Record<string, number[]>,
|
|
35
|
+
/** `x.<name> = "…"`: `img.src`, `scene.postProcessing.lut`. */
|
|
36
|
+
assignments: [ "src", "lut" ],
|
|
37
|
+
/** The keys of an object literal whose value is a file: `.style({ bgImage })`, `UITabs({ id: { icon } })`,
|
|
38
|
+
* `defineSpriteSheet({ image })`, `defineScene({ nodes: { x: { model } }, env: { ibl, skybox: { texture },
|
|
39
|
+
* lightmap: { data, light, … } } })`, `Terrain.create({ layers: [{ albedo }], normals })`, a material's
|
|
40
|
+
* `map` / `shader`, `Level.place({ build })`. The value may sit in an array, a ternary or an arrow's body. */
|
|
41
|
+
keys: [
|
|
42
|
+
"bgImage", "backgroundImage", "icon", "image", "texture", "model", "ibl", "lut", "albedo", "normals",
|
|
43
|
+
"map", "shader", "build", "data", "light", "aux", "direct", "indirect", "probes", "volume",
|
|
44
|
+
],
|
|
45
|
+
}
|