lecodes-sdk 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/global.d.ts +18 -4
  2. package/dist/inject.js +260 -361
  3. package/dist/types/animate/tween/Animation.d.ts +69 -0
  4. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  5. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  6. package/dist/types/animate/tween/easing.d.ts +29 -0
  7. package/dist/types/animate/tween/spec.d.ts +178 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/Foliage.d.ts +47 -0
  12. package/dist/types/gl/Geometry.d.ts +24 -0
  13. package/dist/types/gl/Light.d.ts +28 -7
  14. package/dist/types/gl/Lightmap.d.ts +90 -60
  15. package/dist/types/gl/Material.d.ts +28 -20
  16. package/dist/types/gl/Model.d.ts +7 -5
  17. package/dist/types/gl/Node.d.ts +18 -0
  18. package/dist/types/gl/Particles.d.ts +55 -1
  19. package/dist/types/gl/Scene.d.ts +20 -0
  20. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  21. package/dist/types/gl/animation/Animator.d.ts +27 -0
  22. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  23. package/dist/types/gl/animation/IK.d.ts +86 -30
  24. package/dist/types/gl/animation/Locomotion.d.ts +52 -3
  25. package/dist/types/gl/animation/Warp.d.ts +2 -1
  26. package/dist/types/gl/animation/core.d.ts +35 -4
  27. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  28. package/dist/types/gl/terrain/Terrain.d.ts +12 -2
  29. package/dist/types/inject.d.ts +8 -2
  30. package/dist/types/runtime/input.d.ts +7 -0
  31. package/dist/types/scene/defineScene.d.ts +44 -32
  32. package/dist/types/ui/UIButton.d.ts +3 -1
  33. package/dist/types/ui/UIInput.d.ts +5 -1
  34. package/dist/types/ui/UINode.d.ts +24 -24
  35. package/dist/types.json +1 -1
  36. package/package.json +1 -1
  37. package/prompts/README.md +142 -142
  38. package/prompts/core-design.md +27 -4
  39. package/prompts/core.md +35 -6
  40. package/prompts/dist/2d-game.md +197 -408
  41. package/prompts/dist/3d-app.md +166 -491
  42. package/prompts/dist/ar-app.md +163 -373
  43. package/prompts/dist/design.md +87 -83
  44. package/prompts/dist/ui-app.md +136 -325
  45. package/prompts/select.ts +19 -4
  46. package/src/animate/tween/Animation.ts +378 -0
  47. package/src/animate/tween/Timeline.ts +175 -0
  48. package/src/animate/tween/animateValue.ts +100 -0
  49. package/src/animate/tween/easing.ts +172 -0
  50. package/src/animate/tween/spec.ts +479 -0
  51. package/src/audio/audio.ts +161 -161
  52. package/src/bridges.d.ts +235 -65
  53. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  54. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  55. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  56. package/src/compile/bundler.ts +34 -4
  57. package/src/compile/compileProject.ts +31 -1
  58. package/src/compile/detectEntry.ts +8 -3
  59. package/src/compile/index.ts +2 -0
  60. package/src/compile/serverSplit.ts +9 -3
  61. package/src/g2/Node2D.ts +38 -0
  62. package/src/g2/Sprite.ts +20 -1
  63. package/src/gl/Camera.ts +34 -1
  64. package/src/gl/CameraPlace.ts +52 -52
  65. package/src/gl/Foliage.ts +102 -0
  66. package/src/gl/Geometry.ts +393 -348
  67. package/src/gl/Light.ts +49 -16
  68. package/src/gl/Lightmap.ts +439 -275
  69. package/src/gl/Material.ts +59 -47
  70. package/src/gl/Mesh.ts +120 -120
  71. package/src/gl/Model.ts +23 -12
  72. package/src/gl/Node.ts +39 -0
  73. package/src/gl/Particles.ts +80 -2
  74. package/src/gl/Scene.ts +34 -1
  75. package/src/gl/animation/AnimationClip.ts +52 -0
  76. package/src/gl/animation/Animator.ts +42 -2
  77. package/src/gl/animation/DynamicBone.ts +35 -12
  78. package/src/gl/animation/IK.ts +173 -152
  79. package/src/gl/animation/Locomotion.ts +72 -8
  80. package/src/gl/animation/Playback.ts +5 -4
  81. package/src/gl/animation/Warp.ts +5 -2
  82. package/src/gl/animation/core.ts +65 -4
  83. package/src/gl/physics/Ragdoll.ts +451 -272
  84. package/src/gl/scenarios.ts +291 -291
  85. package/src/gl/terrain/Terrain.ts +33 -2
  86. package/src/inject.ts +236 -226
  87. package/src/runtime/input.ts +11 -0
  88. package/src/scene/defineScene.ts +72 -62
  89. package/src/scene/gizmos.ts +148 -148
  90. package/src/ui/UIButton.ts +2 -2
  91. package/src/ui/UIInput.ts +3 -3
  92. package/src/ui/UINode.ts +61 -36
  93. package/dist/types/animate/animate.d.ts +0 -20
  94. package/src/animate/animate.ts +0 -238
@@ -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
+ }
@@ -46,11 +46,13 @@ import {
46
46
  } from "./terrainMesh"
47
47
 
48
48
  export type TerrainLayer = {
49
- /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. */
49
+ /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. Its ALPHA is the layer's roughness
50
+ * map (`lecodes assets terrain-pack --roughness` puts it there): an opaque albedo reads 1. */
50
51
  albedo?: string | Texture
51
52
  /** Metres per texture repeat. Default 8. */
52
53
  tiling?: number
53
- /** Perceptual roughness of the layer. Default 1. */
54
+ /** Perceptual roughness of the layer — a factor on the albedo's alpha (its roughness map), the whole value when the
55
+ * albedo is opaque. A terrain has no metallic: ground is a dielectric. Default 1. */
54
56
  roughness?: number
55
57
  /** Normal-map strength (0 = the layer's slot of the pack is not read). Default 0 — set 1 when the
56
58
  * layer has a normal map in `normals`. */
@@ -257,6 +259,35 @@ export class Terrain {
257
259
  return new Vec3(n[0], n[1], n[2])
258
260
  }
259
261
 
262
+ /** The LAYER WEIGHTS at a local (x, z) — what the splat shader blends there: the control map read bilinearly
263
+ * between its samples and normalised to sum 1 (`[1, 0, 0, 0]` where the map is empty, as the shader has
264
+ * it). Index = the layer's in `layers`. Outside the grid the edge answers. This is how a game asks WHAT the
265
+ * ground is under a point — footprints in the sand and none on the cobble, a footstep sound per layer, dust
266
+ * by surface — and the blend is already in the numbers, so a transition is a fade and not a line. */
267
+ weightsAt(x: number, z: number): [number, number, number, number] {
268
+ const fx = Math.min(this.sizeX - 1, Math.max(0, x / this.cellSize)), fz = Math.min(this.sizeZ - 1, Math.max(0, z / this.cellSize))
269
+ const x0 = Math.floor(fx), z0 = Math.floor(fz), x1 = Math.min(this.sizeX - 1, x0 + 1), z1 = Math.min(this.sizeZ - 1, z0 + 1)
270
+ const u = fx - x0, v = fz - z0
271
+ const c = this.control
272
+ const w: [number, number, number, number] = [ 0, 0, 0, 0 ]
273
+ for (let k = 0; k < 4; k++) {
274
+ const a = c[(z0 * this.sizeX + x0) * 4 + k], b = c[(z0 * this.sizeX + x1) * 4 + k]
275
+ const d = c[(z1 * this.sizeX + x0) * 4 + k], e = c[(z1 * this.sizeX + x1) * 4 + k]
276
+ w[k] = (a + (b - a) * u) * (1 - v) + (d + (e - d) * u) * v
277
+ }
278
+ const sum = w[0] + w[1] + w[2] + w[3]
279
+ if (sum <= 1e-4 * 255) return [ 1, 0, 0, 0 ]
280
+ return [ w[0] / sum, w[1] / sum, w[2] / sum, w[3] / sum ]
281
+ }
282
+
283
+ /** The heaviest layer's index at a local (x, z) — `weightsAt` when only "which one" is asked. */
284
+ layerAt(x: number, z: number): number {
285
+ const w = this.weightsAt(x, z)
286
+ let best = 0
287
+ for (let k = 1; k < 4; k++) if (w[k] > w[best]) best = k
288
+ return best
289
+ }
290
+
260
291
  /** The smooth (vertex) normal at integer sample (ix, iz). */
261
292
  sampleNormal(ix: number, iz: number): Vec3 {
262
293
  const n = gridNormalAt(this._grid, ix, iz)