lecodes-sdk 2.4.4 → 2.4.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/gl/Light.d.ts +11 -4
- package/dist/types/runtime/app.d.ts +14 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/bridges/app.d.ts +8 -1
- package/src/core/Aspect.ts +55 -14
- package/src/g2/Scene2D.ts +1 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +274 -257
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Scene.ts +1 -1
- package/src/gl/scenarios.ts +291 -291
- package/src/runtime/app.ts +14 -0
- package/src/version.ts +1 -1
- package/tests/helpers/phases.ts +9 -8
package/src/gl/scenarios.ts
CHANGED
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
// No-code scenario aspects: ready-made behaviors a scene builder attaches in the editor without
|
|
2
|
-
// writing any code — move to a target, travel a waypoint path, spin, face something, start a GLB
|
|
3
|
-
// animation. They are ordinary aspects (work from hand-written code too); in the scene editor the
|
|
4
|
-
// movement ones double as `static editor` generators that draw their paths as editor gizmos
|
|
5
|
-
// (`Gizmos.*` in rebuild() — overlay lines, never scene content; play mode never calls rebuild(),
|
|
6
|
-
// so they cost nothing at runtime).
|
|
7
|
-
//
|
|
8
|
-
// Conventions shared by the family:
|
|
9
|
-
// • targets/waypoints are scene nodes — place an Empty, then pick it in the inspector (`ref()`);
|
|
10
|
-
// • times are seconds, angles degrees; all motion runs in `update(dt)` (play mode only);
|
|
11
|
-
// • orientation is quaternion-composed — never euler-incremented (see math/quat.ts).
|
|
12
|
-
|
|
13
|
-
import { Aspect } from "../core/Aspect"
|
|
14
|
-
import type { FieldMeta } from "../core/fields"
|
|
15
|
-
import type { InspectorUI } from "../core/InspectorUI"
|
|
16
|
-
import { Vec3 } from "../math/vec"
|
|
17
|
-
import { Quat } from "../math/quat"
|
|
18
|
-
import { Node } from "./Node"
|
|
19
|
-
import { Model } from "./Model"
|
|
20
|
-
import type { Playback } from "./animation/Playback"
|
|
21
|
-
import { Gizmos } from "../scene/gizmos"
|
|
22
|
-
|
|
23
|
-
const MOVE_LINE = "#5b8ef0"
|
|
24
|
-
const LOOK_LINE = "#d9a13f"
|
|
25
|
-
const DEG2RAD = Math.PI / 180
|
|
26
|
-
|
|
27
|
-
type LoopMode = "once" | "loop" | "pingpong"
|
|
28
|
-
|
|
29
|
-
/** Normalized phase for elapsed time `t` over one trip of `duration`: once clamps at 1, loop
|
|
30
|
-
* wraps, pingpong triangles 0→1→0. */
|
|
31
|
-
const phase = (t: number, duration: number, mode: LoopMode): number => {
|
|
32
|
-
if (duration <= 0) return 1
|
|
33
|
-
const u = t / duration
|
|
34
|
-
if (mode === "once") return Math.min(u, 1)
|
|
35
|
-
if (mode === "loop") return u - Math.floor(u)
|
|
36
|
-
const v = u % 2
|
|
37
|
-
return v <= 1 ? v : 2 - v
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const ease = (k: number, easing: "smooth" | "linear"): number =>
|
|
41
|
-
easing === "smooth" ? k * k * (3 - 2 * k) : k
|
|
42
|
-
|
|
43
|
-
/** A world point in `node`'s PARENT space — what `node.position` is expressed in. */
|
|
44
|
-
const toParentLocal = (node: Node, world: Vec3): Vec3 => {
|
|
45
|
-
const parent = node.parent
|
|
46
|
-
return parent ? parent.worldMatrix.invert().transformPoint(world) : world
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** A path node's waypoints: its children in scene-FILE order (`_sceneChildOrder`, stamped by
|
|
50
|
-
* defineScene — the engine's live child order is insertion-based and may differ), editor helper
|
|
51
|
-
* nodes (`__`-prefixed) excluded. Runtime-added children append in engine order. */
|
|
52
|
-
const pathWaypoints = (path: Node): Node[] => {
|
|
53
|
-
const children = path.children.filter((c) => !(c.name ?? "").startsWith("__"))
|
|
54
|
-
const order = (path as { _sceneChildOrder?: string[] })._sceneChildOrder
|
|
55
|
-
if (!order) return children
|
|
56
|
-
const byName = new Map(children.map((c) => [ c.name, c ]))
|
|
57
|
-
const sorted: Node[] = []
|
|
58
|
-
for (const n of order) { const c = byName.get(n); if (c) sorted.push(c) }
|
|
59
|
-
const seen = new Set(sorted)
|
|
60
|
-
for (const c of children) if (!seen.has(c)) sorted.push(c)
|
|
61
|
-
return sorted
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ---- the aspects ---------------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
/** Travel from the node's starting position to a target node in `duration` seconds. */
|
|
67
|
-
export class MoveTo extends Aspect<"moveTo"> {
|
|
68
|
-
/** Where to travel — place an Empty node and pick it. */
|
|
69
|
-
target: Node | null = null
|
|
70
|
-
|
|
71
|
-
/** Seconds for the full trip. */
|
|
72
|
-
duration = 2
|
|
73
|
-
|
|
74
|
-
/** Seconds to wait before starting. */
|
|
75
|
-
delay = 0
|
|
76
|
-
|
|
77
|
-
/** once = stop at the target · loop = restart from the start point · pingpong = back and forth. */
|
|
78
|
-
mode: LoopMode = "once"
|
|
79
|
-
|
|
80
|
-
/** Motion curve. */
|
|
81
|
-
easing: "smooth" | "linear" = "smooth"
|
|
82
|
-
|
|
83
|
-
static readonly aspect = "moveTo"
|
|
84
|
-
static editor = { rebuild: true }
|
|
85
|
-
static fields: FieldMeta<MoveTo> = {
|
|
86
|
-
target: { editor: "node" },
|
|
87
|
-
duration: { min: 0.05, max: 120, step: 0.05 },
|
|
88
|
-
delay: { min: 0, max: 120, step: 0.05 },
|
|
89
|
-
mode: { options: [ "once", "loop", "pingpong" ] },
|
|
90
|
-
easing: { options: [ "smooth", "linear" ] },
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private _t = 0
|
|
94
|
-
private _start: Vec3 | null = null
|
|
95
|
-
|
|
96
|
-
update(dt: number): void {
|
|
97
|
-
if (!this.target) return
|
|
98
|
-
this._start ??= this.node.position
|
|
99
|
-
this._t += dt
|
|
100
|
-
const k = ease(phase(Math.max(0, this._t - this.delay), this.duration, this.mode), this.easing)
|
|
101
|
-
const end = toParentLocal(this.node, this.target.worldPosition)
|
|
102
|
-
this.node.position = new Vec3(this._start).add(end.sub(this._start).scale(k))
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Editor-only path line (edit mode; play mode never calls this). */
|
|
106
|
-
rebuild(): void {
|
|
107
|
-
if (!this.target) return
|
|
108
|
-
// the target is an empty — its own anchored marker shows the endpoint
|
|
109
|
-
Gizmos.line(this.node.worldPosition, this.target.worldPosition, { color: MOVE_LINE })
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Travel through the children of a path node (add an Empty per waypoint) in `duration` seconds.
|
|
114
|
-
* `loop` runs the circuit closed (last → first); `pingpong` goes back and forth along the open
|
|
115
|
-
* path; `once` stops at the last waypoint. */
|
|
116
|
-
export class FollowPath extends Aspect<"followPath"> {
|
|
117
|
-
/** A node whose CHILDREN are the waypoints, in file order. */
|
|
118
|
-
path: Node | null = null
|
|
119
|
-
|
|
120
|
-
/** Seconds for one full pass of the path. */
|
|
121
|
-
duration = 6
|
|
122
|
-
|
|
123
|
-
/** Seconds to wait before starting. */
|
|
124
|
-
delay = 0
|
|
125
|
-
|
|
126
|
-
mode: LoopMode = "loop"
|
|
127
|
-
|
|
128
|
-
/** Turn to face the direction of travel. */
|
|
129
|
-
orient = true
|
|
130
|
-
|
|
131
|
-
/** Which local axis leads when orienting. */
|
|
132
|
-
forward: "-z" | "z" | "x" | "-x" = "-z"
|
|
133
|
-
|
|
134
|
-
static readonly aspect = "followPath"
|
|
135
|
-
static editor = { rebuild: true }
|
|
136
|
-
static fields: FieldMeta<FollowPath> = {
|
|
137
|
-
path: { editor: "node" },
|
|
138
|
-
duration: { min: 0.05, max: 300, step: 0.05 },
|
|
139
|
-
delay: { min: 0, max: 120, step: 0.05 },
|
|
140
|
-
mode: { options: [ "loop", "once", "pingpong" ] },
|
|
141
|
-
forward: { options: [ "-z", "z", "x", "-x" ] },
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private _t = 0
|
|
145
|
-
|
|
146
|
-
private _points(): Vec3[] {
|
|
147
|
-
if (!this.path) return []
|
|
148
|
-
const points = pathWaypoints(this.path).map((c) => c.worldPosition)
|
|
149
|
-
if (this.mode === "loop" && points.length > 1) points.push(points[0]!.clone())
|
|
150
|
-
return points
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
update(dt: number): void {
|
|
154
|
-
const points = this._points()
|
|
155
|
-
if (points.length < 2) return
|
|
156
|
-
this._t += dt
|
|
157
|
-
const k = phase(Math.max(0, this._t - this.delay), this.duration, this.mode === "loop" ? "loop" : this.mode)
|
|
158
|
-
|
|
159
|
-
const lengths: number[] = []
|
|
160
|
-
let total = 0
|
|
161
|
-
for (let i = 0; i < points.length - 1; i++) {
|
|
162
|
-
const l = points[i + 1]!.sub(points[i]!).length()
|
|
163
|
-
lengths.push(l)
|
|
164
|
-
total += l
|
|
165
|
-
}
|
|
166
|
-
if (total <= 0) return
|
|
167
|
-
|
|
168
|
-
let dist = k * total
|
|
169
|
-
let i = 0
|
|
170
|
-
while (i < lengths.length - 1 && dist > lengths[i]!) { dist -= lengths[i]!; i++ }
|
|
171
|
-
const a = points[i]!
|
|
172
|
-
const b = points[i + 1]!
|
|
173
|
-
const seg = b.sub(a)
|
|
174
|
-
const u = lengths[i]! > 0 ? Math.min(dist / lengths[i]!, 1) : 0
|
|
175
|
-
const world = a.add(seg.scale(u))
|
|
176
|
-
this.node.position = toParentLocal(this.node, world)
|
|
177
|
-
if (this.orient && seg.lengthSq() > 1e-12) this.node.lookAt(world.add(seg), this.forward)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Editor-only waypoint polyline (edit mode; play mode never calls this). */
|
|
181
|
-
rebuild(): void {
|
|
182
|
-
const points = this._points() // loop mode already appends the first point again
|
|
183
|
-
Gizmos.polyline(points, { color: MOVE_LINE }) // waypoints are empties: they carry their own markers
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Continuous rotation about one of the node's local axes, degrees per second. */
|
|
188
|
-
export class Spin extends Aspect<"spin"> {
|
|
189
|
-
/** Degrees per second. */
|
|
190
|
-
speed = 45
|
|
191
|
-
|
|
192
|
-
/** Local axis to spin around. */
|
|
193
|
-
axis: "x" | "y" | "z" = "y"
|
|
194
|
-
|
|
195
|
-
static readonly aspect = "spin"
|
|
196
|
-
static fields: FieldMeta<Spin> = {
|
|
197
|
-
speed: { min: -720, max: 720, step: 5 },
|
|
198
|
-
axis: { options: [ "x", "y", "z" ] },
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
update(dt: number): void {
|
|
202
|
-
const axis: [number, number, number] = this.axis === "x" ? [ 1, 0, 0 ] : this.axis === "y" ? [ 0, 1, 0 ] : [ 0, 0, 1 ]
|
|
203
|
-
// compose in quaternion space about the LOCAL axis (q ⊗ dq) — never euler-increment
|
|
204
|
-
this.node.quaternion = this.node.quaternion.mul(Quat.fromAxisAngle(axis, this.speed * dt * DEG2RAD))
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Keep the node facing a target node (a turret tracking, a signpost, a camera aimed at a hero).
|
|
209
|
-
* Accessor `node.facing` — NOT `lookAt`, which would shadow the `Node.lookAt()` method. */
|
|
210
|
-
export class LookAt extends Aspect<"facing"> {
|
|
211
|
-
/** The node to face. */
|
|
212
|
-
target: Node | null = null
|
|
213
|
-
|
|
214
|
-
/** Which local axis points at the target. */
|
|
215
|
-
forward: "-z" | "z" | "x" | "-x" = "-z"
|
|
216
|
-
|
|
217
|
-
/** 0 = snap instantly; otherwise seconds of turn lag (bigger = slower, smoother). */
|
|
218
|
-
smoothing = 0
|
|
219
|
-
|
|
220
|
-
static readonly aspect = "facing"
|
|
221
|
-
static editor = { rebuild: true }
|
|
222
|
-
static fields: FieldMeta<LookAt> = {
|
|
223
|
-
target: { editor: "node" },
|
|
224
|
-
forward: { options: [ "-z", "z", "x", "-x" ] },
|
|
225
|
-
smoothing: { min: 0, max: 5, step: 0.05 },
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
update(dt: number): void {
|
|
229
|
-
if (!this.target) return
|
|
230
|
-
const point = this.target.worldPosition
|
|
231
|
-
if (this.smoothing <= 0) { this.node.lookAt(point, this.forward); return }
|
|
232
|
-
const q0 = this.node.quaternion
|
|
233
|
-
this.node.lookAt(point, this.forward)
|
|
234
|
-
const q1 = this.node.quaternion
|
|
235
|
-
this.node.quaternion = q0.slerp(q1, 1 - Math.exp(-dt / this.smoothing))
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Editor-only sight line (edit mode; play mode never calls this). */
|
|
239
|
-
rebuild(): void {
|
|
240
|
-
if (!this.target) return
|
|
241
|
-
Gizmos.line(this.node.worldPosition, this.target.worldPosition, { color: LOOK_LINE })
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Start a GLB animation clip when the scene runs. Attach to a `model:` node; the inspector card
|
|
246
|
-
* lists the model's clips live and can preview them while editing. */
|
|
247
|
-
export class PlayAnimation extends Aspect<"playAnimation", Model> {
|
|
248
|
-
/** Clip name; empty = the model's first clip. */
|
|
249
|
-
clip = ""
|
|
250
|
-
|
|
251
|
-
loop = true
|
|
252
|
-
|
|
253
|
-
/** Playback rate (1 = authored speed). */
|
|
254
|
-
speed = 1
|
|
255
|
-
|
|
256
|
-
static readonly aspect = "playAnimation"
|
|
257
|
-
static fields: FieldMeta<PlayAnimation> = {
|
|
258
|
-
speed: { min: 0.05, max: 5, step: 0.05 },
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private _preview?: Playback
|
|
262
|
-
|
|
263
|
-
onAttach(): void {
|
|
264
|
-
const anim = (this.node as Model | undefined)?.anim
|
|
265
|
-
if (!anim) return
|
|
266
|
-
if (this.loop) anim.playLoop(this.clip === "" ? 0 : this.clip, { speed: this.speed })
|
|
267
|
-
else anim.play(this.clip === "" ? 0 : this.clip, { speed: this.speed })
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
static inspector(ui: InspectorUI, a: PlayAnimation): void {
|
|
271
|
-
const anim = (a.node as Model | undefined)?.anim
|
|
272
|
-
const clips = anim ? anim.clips : []
|
|
273
|
-
if (clips.length === 0) {
|
|
274
|
-
ui.warn("No animation clips — attach this to a GLB model node")
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
ui.select("clip", clips.map((c) => c.name))
|
|
278
|
-
ui.auto("loop", "speed")
|
|
279
|
-
if (anim) {
|
|
280
|
-
const clip = a.clip === "" ? clips[0]?.name : a.clip
|
|
281
|
-
const playing = a._preview?.playing === true
|
|
282
|
-
if (ui.button(playing ? "Restart preview" : "Preview")) {
|
|
283
|
-
if (a.loop) { anim.playLoop(clip, { speed: a.speed }); a._preview = undefined }
|
|
284
|
-
else a._preview = anim.play(clip, { speed: a.speed, restart: true })
|
|
285
|
-
}
|
|
286
|
-
if (playing && ui.button("Stop")) { anim.stop(); a._preview = undefined }
|
|
287
|
-
const duration = clips.find((c) => c.name === clip)?.duration
|
|
288
|
-
ui.info(playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
1
|
+
// No-code scenario aspects: ready-made behaviors a scene builder attaches in the editor without
|
|
2
|
+
// writing any code — move to a target, travel a waypoint path, spin, face something, start a GLB
|
|
3
|
+
// animation. They are ordinary aspects (work from hand-written code too); in the scene editor the
|
|
4
|
+
// movement ones double as `static editor` generators that draw their paths as editor gizmos
|
|
5
|
+
// (`Gizmos.*` in rebuild() — overlay lines, never scene content; play mode never calls rebuild(),
|
|
6
|
+
// so they cost nothing at runtime).
|
|
7
|
+
//
|
|
8
|
+
// Conventions shared by the family:
|
|
9
|
+
// • targets/waypoints are scene nodes — place an Empty, then pick it in the inspector (`ref()`);
|
|
10
|
+
// • times are seconds, angles degrees; all motion runs in `update(dt)` (play mode only);
|
|
11
|
+
// • orientation is quaternion-composed — never euler-incremented (see math/quat.ts).
|
|
12
|
+
|
|
13
|
+
import { Aspect } from "../core/Aspect"
|
|
14
|
+
import type { FieldMeta } from "../core/fields"
|
|
15
|
+
import type { InspectorUI } from "../core/InspectorUI"
|
|
16
|
+
import { Vec3 } from "../math/vec"
|
|
17
|
+
import { Quat } from "../math/quat"
|
|
18
|
+
import { Node } from "./Node"
|
|
19
|
+
import { Model } from "./Model"
|
|
20
|
+
import type { Playback } from "./animation/Playback"
|
|
21
|
+
import { Gizmos } from "../scene/gizmos"
|
|
22
|
+
|
|
23
|
+
const MOVE_LINE = "#5b8ef0"
|
|
24
|
+
const LOOK_LINE = "#d9a13f"
|
|
25
|
+
const DEG2RAD = Math.PI / 180
|
|
26
|
+
|
|
27
|
+
type LoopMode = "once" | "loop" | "pingpong"
|
|
28
|
+
|
|
29
|
+
/** Normalized phase for elapsed time `t` over one trip of `duration`: once clamps at 1, loop
|
|
30
|
+
* wraps, pingpong triangles 0→1→0. */
|
|
31
|
+
const phase = (t: number, duration: number, mode: LoopMode): number => {
|
|
32
|
+
if (duration <= 0) return 1
|
|
33
|
+
const u = t / duration
|
|
34
|
+
if (mode === "once") return Math.min(u, 1)
|
|
35
|
+
if (mode === "loop") return u - Math.floor(u)
|
|
36
|
+
const v = u % 2
|
|
37
|
+
return v <= 1 ? v : 2 - v
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ease = (k: number, easing: "smooth" | "linear"): number =>
|
|
41
|
+
easing === "smooth" ? k * k * (3 - 2 * k) : k
|
|
42
|
+
|
|
43
|
+
/** A world point in `node`'s PARENT space — what `node.position` is expressed in. */
|
|
44
|
+
const toParentLocal = (node: Node, world: Vec3): Vec3 => {
|
|
45
|
+
const parent = node.parent
|
|
46
|
+
return parent ? parent.worldMatrix.invert().transformPoint(world) : world
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A path node's waypoints: its children in scene-FILE order (`_sceneChildOrder`, stamped by
|
|
50
|
+
* defineScene — the engine's live child order is insertion-based and may differ), editor helper
|
|
51
|
+
* nodes (`__`-prefixed) excluded. Runtime-added children append in engine order. */
|
|
52
|
+
const pathWaypoints = (path: Node): Node[] => {
|
|
53
|
+
const children = path.children.filter((c) => !(c.name ?? "").startsWith("__"))
|
|
54
|
+
const order = (path as { _sceneChildOrder?: string[] })._sceneChildOrder
|
|
55
|
+
if (!order) return children
|
|
56
|
+
const byName = new Map(children.map((c) => [ c.name, c ]))
|
|
57
|
+
const sorted: Node[] = []
|
|
58
|
+
for (const n of order) { const c = byName.get(n); if (c) sorted.push(c) }
|
|
59
|
+
const seen = new Set(sorted)
|
|
60
|
+
for (const c of children) if (!seen.has(c)) sorted.push(c)
|
|
61
|
+
return sorted
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- the aspects ---------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** Travel from the node's starting position to a target node in `duration` seconds. */
|
|
67
|
+
export class MoveTo extends Aspect<"moveTo"> {
|
|
68
|
+
/** Where to travel — place an Empty node and pick it. */
|
|
69
|
+
target: Node | null = null
|
|
70
|
+
|
|
71
|
+
/** Seconds for the full trip. */
|
|
72
|
+
duration = 2
|
|
73
|
+
|
|
74
|
+
/** Seconds to wait before starting. */
|
|
75
|
+
delay = 0
|
|
76
|
+
|
|
77
|
+
/** once = stop at the target · loop = restart from the start point · pingpong = back and forth. */
|
|
78
|
+
mode: LoopMode = "once"
|
|
79
|
+
|
|
80
|
+
/** Motion curve. */
|
|
81
|
+
easing: "smooth" | "linear" = "smooth"
|
|
82
|
+
|
|
83
|
+
static readonly aspect = "moveTo"
|
|
84
|
+
static editor = { rebuild: true }
|
|
85
|
+
static fields: FieldMeta<MoveTo> = {
|
|
86
|
+
target: { editor: "node" },
|
|
87
|
+
duration: { min: 0.05, max: 120, step: 0.05 },
|
|
88
|
+
delay: { min: 0, max: 120, step: 0.05 },
|
|
89
|
+
mode: { options: [ "once", "loop", "pingpong" ] },
|
|
90
|
+
easing: { options: [ "smooth", "linear" ] },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private _t = 0
|
|
94
|
+
private _start: Vec3 | null = null
|
|
95
|
+
|
|
96
|
+
update(dt: number): void {
|
|
97
|
+
if (!this.target) return
|
|
98
|
+
this._start ??= this.node.position
|
|
99
|
+
this._t += dt
|
|
100
|
+
const k = ease(phase(Math.max(0, this._t - this.delay), this.duration, this.mode), this.easing)
|
|
101
|
+
const end = toParentLocal(this.node, this.target.worldPosition)
|
|
102
|
+
this.node.position = new Vec3(this._start).add(end.sub(this._start).scale(k))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Editor-only path line (edit mode; play mode never calls this). */
|
|
106
|
+
rebuild(): void {
|
|
107
|
+
if (!this.target) return
|
|
108
|
+
// the target is an empty — its own anchored marker shows the endpoint
|
|
109
|
+
Gizmos.line(this.node.worldPosition, this.target.worldPosition, { color: MOVE_LINE })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Travel through the children of a path node (add an Empty per waypoint) in `duration` seconds.
|
|
114
|
+
* `loop` runs the circuit closed (last → first); `pingpong` goes back and forth along the open
|
|
115
|
+
* path; `once` stops at the last waypoint. */
|
|
116
|
+
export class FollowPath extends Aspect<"followPath"> {
|
|
117
|
+
/** A node whose CHILDREN are the waypoints, in file order. */
|
|
118
|
+
path: Node | null = null
|
|
119
|
+
|
|
120
|
+
/** Seconds for one full pass of the path. */
|
|
121
|
+
duration = 6
|
|
122
|
+
|
|
123
|
+
/** Seconds to wait before starting. */
|
|
124
|
+
delay = 0
|
|
125
|
+
|
|
126
|
+
mode: LoopMode = "loop"
|
|
127
|
+
|
|
128
|
+
/** Turn to face the direction of travel. */
|
|
129
|
+
orient = true
|
|
130
|
+
|
|
131
|
+
/** Which local axis leads when orienting. */
|
|
132
|
+
forward: "-z" | "z" | "x" | "-x" = "-z"
|
|
133
|
+
|
|
134
|
+
static readonly aspect = "followPath"
|
|
135
|
+
static editor = { rebuild: true }
|
|
136
|
+
static fields: FieldMeta<FollowPath> = {
|
|
137
|
+
path: { editor: "node" },
|
|
138
|
+
duration: { min: 0.05, max: 300, step: 0.05 },
|
|
139
|
+
delay: { min: 0, max: 120, step: 0.05 },
|
|
140
|
+
mode: { options: [ "loop", "once", "pingpong" ] },
|
|
141
|
+
forward: { options: [ "-z", "z", "x", "-x" ] },
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private _t = 0
|
|
145
|
+
|
|
146
|
+
private _points(): Vec3[] {
|
|
147
|
+
if (!this.path) return []
|
|
148
|
+
const points = pathWaypoints(this.path).map((c) => c.worldPosition)
|
|
149
|
+
if (this.mode === "loop" && points.length > 1) points.push(points[0]!.clone())
|
|
150
|
+
return points
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
update(dt: number): void {
|
|
154
|
+
const points = this._points()
|
|
155
|
+
if (points.length < 2) return
|
|
156
|
+
this._t += dt
|
|
157
|
+
const k = phase(Math.max(0, this._t - this.delay), this.duration, this.mode === "loop" ? "loop" : this.mode)
|
|
158
|
+
|
|
159
|
+
const lengths: number[] = []
|
|
160
|
+
let total = 0
|
|
161
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
162
|
+
const l = points[i + 1]!.sub(points[i]!).length()
|
|
163
|
+
lengths.push(l)
|
|
164
|
+
total += l
|
|
165
|
+
}
|
|
166
|
+
if (total <= 0) return
|
|
167
|
+
|
|
168
|
+
let dist = k * total
|
|
169
|
+
let i = 0
|
|
170
|
+
while (i < lengths.length - 1 && dist > lengths[i]!) { dist -= lengths[i]!; i++ }
|
|
171
|
+
const a = points[i]!
|
|
172
|
+
const b = points[i + 1]!
|
|
173
|
+
const seg = b.sub(a)
|
|
174
|
+
const u = lengths[i]! > 0 ? Math.min(dist / lengths[i]!, 1) : 0
|
|
175
|
+
const world = a.add(seg.scale(u))
|
|
176
|
+
this.node.position = toParentLocal(this.node, world)
|
|
177
|
+
if (this.orient && seg.lengthSq() > 1e-12) this.node.lookAt(world.add(seg), this.forward)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Editor-only waypoint polyline (edit mode; play mode never calls this). */
|
|
181
|
+
rebuild(): void {
|
|
182
|
+
const points = this._points() // loop mode already appends the first point again
|
|
183
|
+
Gizmos.polyline(points, { color: MOVE_LINE }) // waypoints are empties: they carry their own markers
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Continuous rotation about one of the node's local axes, degrees per second. */
|
|
188
|
+
export class Spin extends Aspect<"spin"> {
|
|
189
|
+
/** Degrees per second. */
|
|
190
|
+
speed = 45
|
|
191
|
+
|
|
192
|
+
/** Local axis to spin around. */
|
|
193
|
+
axis: "x" | "y" | "z" = "y"
|
|
194
|
+
|
|
195
|
+
static readonly aspect = "spin"
|
|
196
|
+
static fields: FieldMeta<Spin> = {
|
|
197
|
+
speed: { min: -720, max: 720, step: 5 },
|
|
198
|
+
axis: { options: [ "x", "y", "z" ] },
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
update(dt: number): void {
|
|
202
|
+
const axis: [number, number, number] = this.axis === "x" ? [ 1, 0, 0 ] : this.axis === "y" ? [ 0, 1, 0 ] : [ 0, 0, 1 ]
|
|
203
|
+
// compose in quaternion space about the LOCAL axis (q ⊗ dq) — never euler-increment
|
|
204
|
+
this.node.quaternion = this.node.quaternion.mul(Quat.fromAxisAngle(axis, this.speed * dt * DEG2RAD))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Keep the node facing a target node (a turret tracking, a signpost, a camera aimed at a hero).
|
|
209
|
+
* Accessor `node.facing` — NOT `lookAt`, which would shadow the `Node.lookAt()` method. */
|
|
210
|
+
export class LookAt extends Aspect<"facing"> {
|
|
211
|
+
/** The node to face. */
|
|
212
|
+
target: Node | null = null
|
|
213
|
+
|
|
214
|
+
/** Which local axis points at the target. */
|
|
215
|
+
forward: "-z" | "z" | "x" | "-x" = "-z"
|
|
216
|
+
|
|
217
|
+
/** 0 = snap instantly; otherwise seconds of turn lag (bigger = slower, smoother). */
|
|
218
|
+
smoothing = 0
|
|
219
|
+
|
|
220
|
+
static readonly aspect = "facing"
|
|
221
|
+
static editor = { rebuild: true }
|
|
222
|
+
static fields: FieldMeta<LookAt> = {
|
|
223
|
+
target: { editor: "node" },
|
|
224
|
+
forward: { options: [ "-z", "z", "x", "-x" ] },
|
|
225
|
+
smoothing: { min: 0, max: 5, step: 0.05 },
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
update(dt: number): void {
|
|
229
|
+
if (!this.target) return
|
|
230
|
+
const point = this.target.worldPosition
|
|
231
|
+
if (this.smoothing <= 0) { this.node.lookAt(point, this.forward); return }
|
|
232
|
+
const q0 = this.node.quaternion
|
|
233
|
+
this.node.lookAt(point, this.forward)
|
|
234
|
+
const q1 = this.node.quaternion
|
|
235
|
+
this.node.quaternion = q0.slerp(q1, 1 - Math.exp(-dt / this.smoothing))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Editor-only sight line (edit mode; play mode never calls this). */
|
|
239
|
+
rebuild(): void {
|
|
240
|
+
if (!this.target) return
|
|
241
|
+
Gizmos.line(this.node.worldPosition, this.target.worldPosition, { color: LOOK_LINE })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Start a GLB animation clip when the scene runs. Attach to a `model:` node; the inspector card
|
|
246
|
+
* lists the model's clips live and can preview them while editing. */
|
|
247
|
+
export class PlayAnimation extends Aspect<"playAnimation", Model> {
|
|
248
|
+
/** Clip name; empty = the model's first clip. */
|
|
249
|
+
clip = ""
|
|
250
|
+
|
|
251
|
+
loop = true
|
|
252
|
+
|
|
253
|
+
/** Playback rate (1 = authored speed). */
|
|
254
|
+
speed = 1
|
|
255
|
+
|
|
256
|
+
static readonly aspect = "playAnimation"
|
|
257
|
+
static fields: FieldMeta<PlayAnimation> = {
|
|
258
|
+
speed: { min: 0.05, max: 5, step: 0.05 },
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private _preview?: Playback
|
|
262
|
+
|
|
263
|
+
onAttach(): void {
|
|
264
|
+
const anim = (this.node as Model | undefined)?.anim
|
|
265
|
+
if (!anim) return
|
|
266
|
+
if (this.loop) anim.playLoop(this.clip === "" ? 0 : this.clip, { speed: this.speed })
|
|
267
|
+
else anim.play(this.clip === "" ? 0 : this.clip, { speed: this.speed })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
static inspector(ui: InspectorUI, a: PlayAnimation): void {
|
|
271
|
+
const anim = (a.node as Model | undefined)?.anim
|
|
272
|
+
const clips = anim ? anim.clips : []
|
|
273
|
+
if (clips.length === 0) {
|
|
274
|
+
ui.warn("No animation clips — attach this to a GLB model node")
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
ui.select("clip", clips.map((c) => c.name))
|
|
278
|
+
ui.auto("loop", "speed")
|
|
279
|
+
if (anim) {
|
|
280
|
+
const clip = a.clip === "" ? clips[0]?.name : a.clip
|
|
281
|
+
const playing = a._preview?.playing === true
|
|
282
|
+
if (ui.button(playing ? "Restart preview" : "Preview")) {
|
|
283
|
+
if (a.loop) { anim.playLoop(clip, { speed: a.speed }); a._preview = undefined }
|
|
284
|
+
else a._preview = anim.play(clip, { speed: a.speed, restart: true })
|
|
285
|
+
}
|
|
286
|
+
if (playing && ui.button("Stop")) { anim.stop(); a._preview = undefined }
|
|
287
|
+
const duration = clips.find((c) => c.name === clip)?.duration
|
|
288
|
+
ui.info(playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/runtime/app.ts
CHANGED
|
@@ -113,6 +113,20 @@ export const app = {
|
|
|
113
113
|
quit(): void {
|
|
114
114
|
_creatorApp.quit?.()
|
|
115
115
|
},
|
|
116
|
+
/** Collect garbage now: what the app no longer references — a finished level's nodes, its
|
|
117
|
+
* textures and materials — is freed before this returns, not whenever the collector next runs.
|
|
118
|
+
* Call it where a pause is invisible (a loading screen, between levels), after dropping the
|
|
119
|
+
* references; never during play — a collection walks the whole heap. It also puts the next
|
|
120
|
+
* automatic collection at half again the heap it leaves: the room the next stretch of play has.
|
|
121
|
+
* `headroomMB` asks for more room when the play ahead allocates more than that — a drive that
|
|
122
|
+
* streams in hundreds of megabytes of terrain buffers: `app.gc({ headroomMB: 512 })` keeps the
|
|
123
|
+
* next collection at least 512 MB away (until it comes; then the engine's rule again). The log's
|
|
124
|
+
* `[gc]` line says what it took, what it freed and where the next one is. On iOS a request the
|
|
125
|
+
* engine serves on its own schedule, without the headroom; a no-op in the browser, where a page
|
|
126
|
+
* cannot ask for a collection. */
|
|
127
|
+
gc(options?: { headroomMB?: number }): void {
|
|
128
|
+
_creatorApp.gc?.(options?.headroomMB)
|
|
129
|
+
},
|
|
116
130
|
/** Global back-press fallback (Android back button/gesture — iOS has no system back). Fires
|
|
117
131
|
* only when nothing else consumed the press: no open widget/modal claimed it, the current
|
|
118
132
|
* screen has no `onBack`, every pager is at its tab root and the router is at its
|
package/src/version.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
|
|
5
5
|
// and a host's embedded version must agree on the major for the host to run the bundle; minor
|
|
6
6
|
// and patch never gate anything (features are detected, never versioned).
|
|
7
|
-
export const SDK_VERSION = "2.4.
|
|
7
|
+
export const SDK_VERSION = "2.4.6"
|
|
8
8
|
|
|
9
9
|
/** The major of a semver string, or null when it is not one. */
|
|
10
10
|
export const sdkMajor = (version: string | null | undefined): number | null => {
|
package/tests/helpers/phases.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// The aspect frame source for every test file, installed by the preload (bunfig.toml) BEFORE any
|
|
2
|
-
// SDK module runs. core/Aspect.ts wires its
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// SDK module runs. core/Aspect.ts wires its sources ONCE per process, at the first attached updater
|
|
3
|
+
// (`_installAspectFrames`: this one, then each engine layer's at its first Scene / Scene2D — or, with
|
|
4
|
+
// none wired, the `setLoop` of that moment). This source has no `presented` and so always drives;
|
|
5
|
+
// the engine layers' sources are never wired here, since the host stubs carry no setEarlyUpdate.
|
|
6
|
+
// bun test shares one module registry across files, so a file's own `_creator.setEarlyUpdate` stub
|
|
7
|
+
// only ever saw the runners if that file happened to attach first (a DynamicBone attached with no
|
|
8
|
+
// Scene, in an earlier file, sent them into its `setLoop = () => 1`). A file that drives the phases
|
|
9
|
+
// by hand ticks through `tick` here instead.
|
|
9
10
|
//
|
|
10
11
|
// No fixed hook is installed: the SDK's own 1/60 accumulator steps `updateFixed` inside the early
|
|
11
12
|
// phase, as on a host without setFixedUpdate. Files that compile a bundle (aspect-update,
|
|
12
|
-
// aspect-lifecycle) run their own copy of the SDK and
|
|
13
|
+
// aspect-lifecycle, aspect-sources) run their own copy of the SDK and capture the bridges' hooks themselves.
|
|
13
14
|
import { _installAspectFrames } from "../../src/core/Aspect"
|
|
14
15
|
|
|
15
16
|
type PhaseFn = (dt: number) => void
|