lecodes-sdk 0.20.0 → 1.0.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 (114) hide show
  1. package/dist/global.d.ts +48 -5
  2. package/dist/inject.js +361 -260
  3. package/dist/types/audio/Bus.d.ts +45 -0
  4. package/dist/types/audio/Sound.d.ts +28 -0
  5. package/dist/types/audio/Voice.d.ts +27 -0
  6. package/dist/types/audio/audio.d.ts +83 -0
  7. package/dist/types/audio/support.d.ts +1 -0
  8. package/dist/types/canvas/Canvas.d.ts +2 -0
  9. package/dist/types/gl/DecalSet.d.ts +148 -0
  10. package/dist/types/gl/Geometry.d.ts +17 -0
  11. package/dist/types/gl/Light.d.ts +7 -0
  12. package/dist/types/gl/Lightmap.d.ts +9 -0
  13. package/dist/types/gl/Material.d.ts +90 -2
  14. package/dist/types/gl/Mesh.d.ts +18 -1
  15. package/dist/types/gl/Model.d.ts +34 -0
  16. package/dist/types/gl/Particles.d.ts +13 -0
  17. package/dist/types/gl/Scene.d.ts +23 -0
  18. package/dist/types/gl/Texture.d.ts +29 -1
  19. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  20. package/dist/types/gl/animation/DynamicBone.d.ts +173 -0
  21. package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
  22. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  23. package/dist/types/gl/animation/core.d.ts +15 -10
  24. package/dist/types/gl/audio/AudioSource.d.ts +60 -0
  25. package/dist/types/gl/audio/AudioZone.d.ts +32 -0
  26. package/dist/types/gl/audio/SceneAudio.d.ts +11 -0
  27. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  28. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  29. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  30. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  31. package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
  32. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  33. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  34. package/dist/types/gl/state.d.ts +0 -1
  35. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
  36. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  37. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  38. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  39. package/dist/types/inject.d.ts +33 -21
  40. package/dist/types/runtime/files.d.ts +24 -1
  41. package/dist/types/runtime/input.d.ts +11 -0
  42. package/dist/types/scene/defineScene.d.ts +10 -3
  43. package/dist/types/ui/UIImage.d.ts +15 -5
  44. package/dist/types.json +1 -1
  45. package/package.json +1 -1
  46. package/prompts/README.md +142 -142
  47. package/prompts/dist/2d-game.md +408 -197
  48. package/prompts/dist/3d-app.md +491 -166
  49. package/prompts/dist/ar-app.md +373 -163
  50. package/prompts/dist/design.md +83 -87
  51. package/prompts/dist/ui-app.md +325 -136
  52. package/src/audio/Bus.ts +102 -0
  53. package/src/audio/Sound.ts +96 -0
  54. package/src/audio/Voice.ts +102 -0
  55. package/src/audio/audio.ts +161 -0
  56. package/src/audio/support.ts +6 -0
  57. package/src/bridges.d.ts +279 -32
  58. package/src/canvas/Canvas.ts +21 -0
  59. package/src/compile/__tests__/compile.test.ts +11 -0
  60. package/src/compile/compileProject.ts +30 -15
  61. package/src/compile/header.ts +6 -3
  62. package/src/compile/index.ts +4 -0
  63. package/src/compile/sceneEditor.ts +42 -1
  64. package/src/core/Aspect.ts +33 -8
  65. package/src/g2/Scene2D.ts +7 -0
  66. package/src/gl/CameraPlace.ts +52 -52
  67. package/src/gl/DecalSet.ts +360 -0
  68. package/src/gl/Geometry.ts +348 -279
  69. package/src/gl/Light.ts +16 -0
  70. package/src/gl/Lightmap.ts +35 -7
  71. package/src/gl/Material.ts +173 -4
  72. package/src/gl/Mesh.ts +120 -83
  73. package/src/gl/Model.ts +33 -1
  74. package/src/gl/Node.ts +1 -1
  75. package/src/gl/Particles.ts +21 -3
  76. package/src/gl/Scene.ts +41 -7
  77. package/src/gl/Texture.ts +43 -3
  78. package/src/gl/animation/AnimationClip.ts +43 -20
  79. package/src/gl/animation/Animator.ts +4 -3
  80. package/src/gl/animation/DynamicBone.ts +459 -0
  81. package/src/gl/{IK.ts → animation/IK.ts} +4 -4
  82. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  83. package/src/gl/animation/core.ts +20 -15
  84. package/src/gl/audio/AudioSource.ts +113 -0
  85. package/src/gl/audio/AudioZone.ts +75 -0
  86. package/src/gl/audio/SceneAudio.ts +26 -0
  87. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  88. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  89. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  90. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  91. package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +272 -270
  92. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  93. package/src/gl/{Trigger.ts → physics/Trigger.ts} +2 -2
  94. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  95. package/src/gl/scenarios.ts +291 -291
  96. package/src/gl/state.ts +1 -1
  97. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
  98. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  99. package/src/gl/vehicle/Vehicle.ts +666 -0
  100. package/src/gl/vehicle/Wheel.ts +290 -0
  101. package/src/inject.ts +226 -212
  102. package/src/runtime/files.ts +32 -2
  103. package/src/runtime/input.ts +6 -1
  104. package/src/scene/defineScene.ts +26 -10
  105. package/src/scene/gizmos.ts +148 -148
  106. package/src/scene/level.ts +2 -2
  107. package/src/ui/UIImage.ts +21 -7
  108. package/dist/types/gl/Gearbox.d.ts +0 -86
  109. package/dist/types/gl/Vehicle.d.ts +0 -191
  110. package/dist/types/gl/Wheel.d.ts +0 -95
  111. package/src/gl/Gearbox.ts +0 -212
  112. package/src/gl/Vehicle.ts +0 -473
  113. package/src/gl/Wheel.ts +0 -240
  114. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -63,13 +63,17 @@ export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
63
63
  export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
64
64
  export {
65
65
  compileProject,
66
+ compileProjectWithMap,
67
+ entriesToFiles,
66
68
  type CompileEntry,
67
69
  type CompileOptions,
68
70
  type CompileFileType,
71
+ type CompileResult,
69
72
  } from "./compileProject"
70
73
  export {
71
74
  loadSceneHarness,
72
75
  sceneEditorEntries,
76
+ sceneImportsOf,
73
77
  SCENE_ENTRY_PATH,
74
78
  SCENE_HARNESS_DIR,
75
79
  } from "./sceneEditor"
@@ -47,10 +47,44 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
47
47
  * the project's file list — `.editor.ts` entries are picked out of it. Compile with
48
48
  * `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
49
49
  */
50
+ /** Resolve a relative import specifier against a project path ("./src/map.scene.ts" + "./map/x.scene"
51
+ * → "./src/map/x.scene"). */
52
+ const resolveRelative = (fromPath: string, spec: string): string => {
53
+ const segs = (p: string) => p.split("/").filter((s) => s !== "" && s !== ".")
54
+ const out = segs(fromPath).slice(0, -1)
55
+ for (const seg of segs(spec)) {
56
+ if (seg === "..") out.pop()
57
+ else out.push(seg)
58
+ }
59
+ return "./" + out.join("/")
60
+ }
61
+
62
+ /**
63
+ * The other scene files a scene file imports as prefabs (`import x from './x.scene'`), as project
64
+ * paths present in `projectPaths`. The edit bundle registers their handles BY PATH
65
+ * (`__lecodesSceneFiles`), so the editor can live-patch a `prefab:` node — add, duplicate, undo —
66
+ * without a compile: the doc sees `prefab: X` as an identifier, the host maps it through the
67
+ * file's import table to this path, and the harness resolves the path to the running handle.
68
+ */
69
+ export const sceneImportsOf = (sceneText: string, scenePath: string, projectPaths: string[]): string[] => {
70
+ const known = new Set(projectPaths)
71
+ const out: string[] = []
72
+ const re = /^\s*import\s+[A-Za-z_$][\w$]*\s+from\s+["']([^"']+\.scene(?:\.ts)?)["']/gm
73
+ for (const m of sceneText.matchAll(re)) {
74
+ const spec = m[1]
75
+ if (!spec.startsWith(".")) continue
76
+ let p = resolveRelative(scenePath, spec)
77
+ if (!p.endsWith(".ts")) p += ".ts"
78
+ if (known.has(p) && !out.includes(p)) out.push(p)
79
+ }
80
+ return out
81
+ }
82
+
50
83
  export const sceneEditorEntries = (
51
84
  scenePath: string,
52
85
  harness: Record<string, string>,
53
86
  projectPaths: string[] = [],
87
+ sceneImports: string[] = [],
54
88
  ): CompileEntry[] => {
55
89
  const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
56
90
  path: `${SCENE_HARNESS_DIR}/${name}`,
@@ -74,15 +108,22 @@ export const sceneEditorEntries = (
74
108
  const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
75
109
  const materialRegister = materialPaths.length === 0 ? ""
76
110
  : `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
111
+ // The scene's prefab imports, registered by project path (see sceneImportsOf) — the same module
112
+ // instances the scene itself imported, so a live-patched prefab node is the same handle.
113
+ const sceneImportLines = sceneImports.map((p, i) => `import __scn${i} from "${p}"\n`).join("")
114
+ const sceneRegister = sceneImports.length === 0 ? ""
115
+ : `;(globalThis as any).__lecodesSceneFiles = { ${sceneImports.map((p, i) => `${JSON.stringify(p)}: __scn${i}`).join(", ")} }\n`
77
116
  entries.push({
78
117
  path: SCENE_ENTRY_PATH,
79
118
  type: "text",
80
119
  text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
81
120
  + materialImports
121
+ + sceneImportLines
82
122
  + `import "${scenePath}"\n`
83
123
  + editorImports
84
124
  + `import "${SCENE_HARNESS_DIR}/main.ts"\n`
85
- + materialRegister,
125
+ + materialRegister
126
+ + sceneRegister,
86
127
  })
87
128
  return entries
88
129
  }
@@ -96,14 +96,27 @@ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
96
96
  type FixedInstaller = (fixed: PhaseFn) => boolean
97
97
  let frameInstaller: FrameInstaller | undefined
98
98
  let fixedInstaller: FixedInstaller | undefined
99
- /** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer. */
100
- export const _installAspectFrames = (fn: FrameInstaller, fixed?: FixedInstaller): void => { frameInstaller = fn; fixedInstaller = fixed }
99
+ /** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer.
100
+ * The FIRST engine layer wins: a 3D game that later builds a Scene2D for a UIImage minimap keeps its dispatch on the
101
+ * 3D engine (the one it presents); the 2D engine's phases stay unwired there, so nothing ticks twice. */
102
+ export const _installAspectFrames = (fn: FrameInstaller, fixed?: FixedInstaller): void => {
103
+ if (frameInstaller) return
104
+ frameInstaller = fn; fixedInstaller = fixed
105
+ }
101
106
 
102
107
  /** @internal Engine-level phase hooks (the Net system, sdk/src/net): FIRST in the early and fixed
103
108
  * phases, LAST in the late one, ahead of / behind every game aspect regardless of ordering
104
109
  * constraints. Single slot each; raw dt. */
105
110
  export const _phaseHooks: { early: PhaseFn | null; fixed: PhaseFn | null; late: PhaseFn | null } = { early: null, fixed: null, late: null }
106
111
 
112
+ /** @internal Advances at the start of EVERY phase pass — early, each fixed step, late — on the
113
+ * render-synced path and the setLoop fallback alike. The key for "read the engine once per phase"
114
+ * caches (gl/Vehicle's state block): between two passes the engine rewrites state natively (the
115
+ * physics steps, the transform sync), so a value cached under an older stamp is stale. Once per
116
+ * FRAME is not enough — the early and late phases of one frame straddle the physics step, and a
117
+ * value first read in `updateBefore` must not be what `update` sees. */
118
+ export const _phaseStamp = { value: 0 }
119
+
107
120
  let attachSeq = 0
108
121
  const warned = new Set<string>()
109
122
  const warnOnce = (msg: string): void => { if (!warned.has(msg)) { warned.add(msg); console.warn(msg) } }
@@ -214,7 +227,11 @@ let fixedFallbackAcc = 0
214
227
  // engine drives it from inside its substep loop when it has the hook; otherwise (older wasm / Apple /
215
228
  // Android builds, the setLoop path) the SDK steps its own accumulator right after the early phase -
216
229
  // same step, same 4-substep cap, same drop-the-backlog rule as creator-physics' cphysFrameBegin.
217
- const fixedStep: PhaseFn = (dt) => { _phaseHooks.fixed?.(dt); fixedUpdaters.run("updateFixed", dt) }
230
+ const fixedStep: PhaseFn = (dt) => {
231
+ _phaseStamp.value++ // a substep moved the bodies natively since the last pass
232
+ _phaseHooks.fixed?.(dt)
233
+ fixedUpdaters.run("updateFixed", dt)
234
+ }
218
235
  const fixedFallback = (): void => {
219
236
  const step = Time.fixedDt
220
237
  fixedFallbackAcc += Time.dt // scaled: paused -> no steps, slow-motion -> fewer steps, never a shorter dt
@@ -229,12 +246,17 @@ const ensureDispatch = (): void => {
229
246
  // Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
230
247
  // drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
231
248
  const early: PhaseFn = (dt) => {
249
+ _phaseStamp.value++
232
250
  Time._beginFrame(dt)
233
251
  _phaseHooks.early?.(dt)
234
252
  earlyUpdaters.run("updateBefore", Time.dt)
235
253
  if (fixedFallbackOn) fixedFallback()
236
254
  }
237
- const late: PhaseFn = (dt) => { lateUpdaters.run("update", Time._phaseDt(dt)); _phaseHooks.late?.(dt) }
255
+ const late: PhaseFn = (dt) => {
256
+ _phaseStamp.value++ // the physics step + transform sync ran since the early pass
257
+ lateUpdaters.run("update", Time._phaseDt(dt))
258
+ _phaseHooks.late?.(dt)
259
+ }
238
260
  // render-synced source if an engine layer installed one; else one host loop (early then late).
239
261
  if (!(frameInstaller && frameInstaller(early, late))) {
240
262
  setLoop((dt) => { early(dt); late(dt) })
@@ -266,10 +288,13 @@ const registerUpdater = (inst: Aspect<any, any, any>): void => {
266
288
  }
267
289
  const unregisterUpdater = (inst: Aspect<any, any, any>): void => {
268
290
  const a = internals(inst)
269
- if (a._phases === 0) return
270
- earlyUpdaters.remove(a)
271
- lateUpdaters.remove(a)
272
- fixedUpdaters.remove(a)
291
+ const phases = a._phases
292
+ if (phases === 0) return
293
+ // `_phases` records which lists the aspect is in (1 early / 2 late / 4 fixed) — only those are
294
+ // scanned, so detaching a late-only aspect never walks the early or fixed lists.
295
+ if (phases & 1) earlyUpdaters.remove(a)
296
+ if (phases & 2) lateUpdaters.remove(a)
297
+ if (phases & 4) fixedUpdaters.remove(a)
273
298
  a._phases = 0
274
299
  }
275
300
 
package/src/g2/Scene2D.ts CHANGED
@@ -167,6 +167,13 @@ export class Scene2D implements Presentable {
167
167
  return this
168
168
  }
169
169
 
170
+ /** @internal UIImage source marker (UIImage.toSrc): `UIImage(scene)` shows this scene live — the
171
+ * host draws it into the image node's box every frame with the scene's own camera (a minimap, a
172
+ * portrait, any 2D view inside the UI). Needs no open(); the scene keeps its aspects and animations. */
173
+ _imageSrc(): { scene2d: number } {
174
+ return { scene2d: this.id }
175
+ }
176
+
170
177
  /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
171
178
  _viewDesc(): object {
172
179
  return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
@@ -1,52 +1,52 @@
1
- // CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
2
- // a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
3
- // side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
4
- // empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
5
- //
6
- // • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
7
- // `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
8
- // • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
9
- // owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
10
- // or anchors on it (`inst.alignTo(inst.nodes.eye)`).
11
- // • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
12
- // releases.
13
- // • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
14
- // See docs/scene-camera-place-plan.md.
15
-
16
- import { Aspect } from "../core/Aspect"
17
- import type { FieldMeta } from "../core/fields"
18
- import { Gizmos } from "../scene/gizmos"
19
- import type { Camera } from "./Camera"
20
- import type { Node } from "./Node"
21
-
22
- export class CameraPlace extends Aspect<"cameraPlace", Node> {
23
- static readonly aspect = "cameraPlace"
24
- /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
25
- fov = 60
26
- /** Near clip distance. */
27
- near = 0.01
28
- /** Far clip distance = view range. */
29
- far = 1000
30
- /** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
31
- * ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
32
- active = false
33
- /** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
34
- static fields: FieldMeta<CameraPlace> = {
35
- fov: { label: "FOV", min: 1, max: 179, step: 1 },
36
- near: { min: 0.001, max: 10, step: 0.01 },
37
- far: { min: 1, max: 100000, step: 1 },
38
- active: { label: "Active camera" },
39
- }
40
- static editor = { rebuild: true }
41
-
42
- /** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
43
- applyTo(camera: Camera): void {
44
- camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
45
- }
46
-
47
- /** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
48
- * clicking it selects the node. Play mode never calls this. */
49
- rebuild(): void {
50
- Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
51
- }
52
- }
1
+ // CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
2
+ // a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
3
+ // side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
4
+ // empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
5
+ //
6
+ // • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
7
+ // `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
8
+ // • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
9
+ // owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
10
+ // or anchors on it (`inst.alignTo(inst.nodes.eye)`).
11
+ // • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
12
+ // releases.
13
+ // • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
14
+ // See docs/scene-camera-place-plan.md.
15
+
16
+ import { Aspect } from "../core/Aspect"
17
+ import type { FieldMeta } from "../core/fields"
18
+ import { Gizmos } from "../scene/gizmos"
19
+ import type { Camera } from "./Camera"
20
+ import type { Node } from "./Node"
21
+
22
+ export class CameraPlace extends Aspect<"cameraPlace", Node> {
23
+ static readonly aspect = "cameraPlace"
24
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
25
+ fov = 60
26
+ /** Near clip distance. */
27
+ near = 0.01
28
+ /** Far clip distance = view range. */
29
+ far = 1000
30
+ /** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
31
+ * ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
32
+ active = false
33
+ /** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
34
+ static fields: FieldMeta<CameraPlace> = {
35
+ fov: { label: "FOV", min: 1, max: 179, step: 1 },
36
+ near: { min: 0.001, max: 10, step: 0.01 },
37
+ far: { min: 1, max: 100000, step: 1 },
38
+ active: { label: "Active camera" },
39
+ }
40
+ static editor = { rebuild: true }
41
+
42
+ /** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
43
+ applyTo(camera: Camera): void {
44
+ camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
45
+ }
46
+
47
+ /** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
48
+ * clicking it selects the node. Play mode never calls this. */
49
+ rebuild(): void {
50
+ Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
51
+ }
52
+ }
@@ -0,0 +1,360 @@
1
+ // Projected decals — bullet holes, footprints, dirt, blood. A DecalSet is one node that owns up to
2
+ // `max` decals drawn as ONE native renderable: each decal is a box that projects its image onto the
3
+ // opaque scene inside it (the engine reads the scene depth — creator-gl src/decals.cpp +
4
+ // materials/src/decal.mat), so it wraps floor-wall corners, stairs and curved surfaces without any
5
+ // geometry access. It lands on opaque surfaces only: never on particles, glass or other decals.
6
+ //
7
+ // Coordinates are in the SET's space: a set added at the scene root with no transform takes world
8
+ // coordinates (the usual case); a set parented to a door rides with it and takes door-local ones.
9
+ // Slots are a ring — a full set recycles its oldest decal, so `max` is the on-screen budget.
10
+
11
+ import { Node } from "./Node"
12
+ import { Material } from "./Material"
13
+ import type { Texture } from "./Texture"
14
+ import { Vec3, type Vec3Like } from "../math/vec"
15
+ import { Quat, type QuatLike } from "../math/quat"
16
+ import { Color, type ColorInput } from "../core/color"
17
+
18
+ /** Per-decal look and life — defaults come from the set's options. */
19
+ export type DecalOptions = {
20
+ /** Image width and height on the surface (world units); a number = square. Default 0.2. */
21
+ size?: number | [number, number]
22
+ /** Projection depth (world units): how far in front of and behind the hit point the decal still
23
+ * lands. Default = the smaller of width / height. */
24
+ depth?: number
25
+ /** Atlas cell (row-major from the top-left) for a set with a `sheet`; `"random"` picks one.
26
+ * Default 0. */
27
+ frame?: number | "random"
28
+ /** Tint multiplied into the image. Default white. */
29
+ tint?: ColorInput
30
+ /** 0..1 on top of the tint's alpha. Default 1. */
31
+ opacity?: number
32
+ /** Seconds until the decal is gone; 0 = stays until recycled. Default 0. */
33
+ life?: number
34
+ /** Seconds to fade in after spawning. Default 0. */
35
+ fadeIn?: number
36
+ /** Seconds of fade at the end of `life` (ignored with life 0). Default 0. */
37
+ fadeOut?: number
38
+ /** Opacity multipliers at the image's BOTTOM and TOP edge (−Y / +Y), interpolated along it — a
39
+ * tire mark that darkens as the slide deepens. Default `[1, 1]`. */
40
+ gradient?: [number, number]
41
+ /** Tilt of the image's bottom and top edge, as a unit-space slope (Δy per Δx across the box):
42
+ * the edge pivots on the corner of the side it keeps and cuts INTO the box on the other, so two
43
+ * boxes can meet on one shared line — what `DecalTrail` uses to mitre its joints. `0` = square.
44
+ * Default `[0, 0]`. */
45
+ caps?: [number, number]
46
+ }
47
+
48
+ export type DecalSetOptions = DecalOptions & {
49
+ /** The material — `Material.decal({ map })` by default (`map` below is its shortcut). A custom
50
+ * material must keep decal.mat's vertex contract. */
51
+ material?: Material
52
+ /** Atlas texture for the default material. */
53
+ map?: Texture
54
+ /** Tangent-space normal atlas (same cell grid) → a RELIEF decal that bends the surface's
55
+ * lighting instead of painting a colour (`Material.decal` `normalMap`). Footprints and dents
56
+ * need only this; a bullet hole gives `map` too and its colour multiplies in. */
57
+ normalMap?: Texture
58
+ /** Relief strength for `normalMap`. Default 1. Default material only. */
59
+ bump?: number
60
+ /** Atlas grid of the map: columns, or [columns, rows]. Default 1 (the whole texture). */
61
+ sheet?: number | [number, number]
62
+ /** Slot budget — the most decals alive at once. Default 256. */
63
+ max?: number
64
+ /** Soft fraction (0..1) of the box's half depth at both ends, so an oblique surface leaves the
65
+ * box gently. Default 0.3. Default material only. */
66
+ edge?: number
67
+ /** Surfaces turned more than this away from the projection axis fade out — the cosine of the
68
+ * angle (0.3 ≈ 72°) keeps a floor hit off the wall it meets; 0 = project onto anything.
69
+ * Default 0.3. Default material only. */
70
+ angleFade?: number
71
+ /** HDR boost of the image (0 = none). Default material only. */
72
+ emissive?: number
73
+ name?: string
74
+ /** Coarse draw order, 0 (first) … 7 (last) — see `Mesh.renderPriority`. Default 3: a decal is
75
+ * part of the surface it sits on, so it draws before every other blended thing (meshes 4,
76
+ * particles 5) — the engine's depth sort compares object centres, and a set spread over the
77
+ * level has no useful centre. */
78
+ renderPriority?: number
79
+ }
80
+
81
+ /** `spawn` placement: where the image sits on the surface. */
82
+ export type DecalSpawnOptions = DecalOptions & {
83
+ /** World direction the image's top points along the surface (projected onto it) — a footprint's
84
+ * travel direction. Default: a random spin. */
85
+ up?: Vec3Like
86
+ /** Extra spin around the normal, radians. Default: random when `up` is not given, else 0. */
87
+ rotation?: number
88
+ }
89
+
90
+ /** `place` / `update` placement: a full frame, like a node looking INTO the surface (its −Z is the
91
+ * projection direction, +Y the image's top). */
92
+ export type DecalPlacement = DecalOptions & {
93
+ position: Vec3Like
94
+ /** A quaternion, or Euler degrees (YXZ) like `Node.eulerAngles`. Default identity = projecting
95
+ * down −Z. */
96
+ rotation?: QuatLike | Vec3Like
97
+ }
98
+
99
+ const RECORD = 27
100
+ const NO_SLOT = 0xFFFFFFFF
101
+
102
+ export class DecalSet extends Node {
103
+ private _material: Material
104
+ private readonly _max: number
105
+ private readonly _cols: number
106
+ private readonly _rows: number
107
+ private readonly _defaults: DecalOptions
108
+ private readonly _rec = new Float32Array(RECORD)
109
+ private readonly _slots = new Map<number, DecalPlacement>()
110
+ private static _warned = false
111
+
112
+ constructor(options: DecalSetOptions = {}) {
113
+ super()
114
+ if (options.name) this.name = options.name
115
+ this._material = options.material ?? Material.decal({
116
+ map: options.map, normalMap: options.normalMap, bump: options.bump,
117
+ edge: options.edge, angleFade: options.angleFade, emissive: options.emissive,
118
+ })
119
+ this._max = Math.max(1, Math.round(options.max ?? 256))
120
+ const sheet = options.sheet ?? 1
121
+ this._cols = Math.max(1, Array.isArray(sheet) ? sheet[0] : sheet)
122
+ this._rows = Math.max(1, Array.isArray(sheet) ? sheet[1] : 1)
123
+ this._defaults = {
124
+ size: options.size ?? 0.2, depth: options.depth, frame: options.frame ?? 0, tint: options.tint,
125
+ opacity: options.opacity ?? 1, life: options.life ?? 0, fadeIn: options.fadeIn ?? 0, fadeOut: options.fadeOut ?? 0,
126
+ }
127
+ if (_creator.createDecalSet) {
128
+ _creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
129
+ this.renderPriority = options.renderPriority ?? 3
130
+ } else if (!DecalSet._warned) {
131
+ DecalSet._warned = true
132
+ console.warn("[decals] this host has no createDecalSet — decals are not drawn")
133
+ }
134
+ }
135
+
136
+ get material(): Material { return this._material }
137
+ /** Decals alive right now. */
138
+ get count(): number { return _creator.decalCount ? _creator.decalCount(this.id) : 0 }
139
+ /** The slot budget the set was created with. */
140
+ get max(): number { return this._max }
141
+
142
+ /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
143
+ set renderPriority(v: number) {
144
+ if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
145
+ }
146
+
147
+ /** Stamp a decal on a surface: `point` on it, `normal` out of it (a raycast hit, a foot plant
148
+ * with `Vec3.up`). Returns the slot for `update` / `remove` (−1 when the host draws none). */
149
+ spawn(point: Vec3Like, normal: Vec3Like, options: DecalSpawnOptions = {}): number {
150
+ const n = new Vec3(normal).normalize()
151
+ // Image up on the surface: the hint projected onto the plane, or any perpendicular.
152
+ let up = options.up ? new Vec3(options.up) : null
153
+ if (up) { up = up.sub(n.scale(up.dot(n))); if (up.length() < 1e-4) up = null }
154
+ if (!up) {
155
+ const helper = Math.abs(n.y) < 0.99 ? Vec3.up : Vec3.right
156
+ up = helper.sub(n.scale(helper.dot(n)))
157
+ }
158
+ up = up.normalize()
159
+ // right = up × normal keeps the frame right-handed (right × up = normal), so the image reads
160
+ // unmirrored from the normal's side; then the spin around the normal.
161
+ let right = up.cross(n)
162
+ const spin = options.rotation ?? (options.up ? 0 : Math.random() * Math.PI * 2)
163
+ if (spin !== 0) {
164
+ const c = Math.cos(spin), s = Math.sin(spin)
165
+ const r2 = right.scale(c).add(up.scale(s))
166
+ up = up.scale(c).sub(right.scale(s))
167
+ right = r2
168
+ }
169
+ return this._write(NO_SLOT, right, up, n, new Vec3(point), options)
170
+ }
171
+
172
+ /** Place a decal by a full frame (an editor-placed stain, a moving marker). Returns the slot. */
173
+ place(placement: DecalPlacement): number {
174
+ const slot = this._writePlacement(NO_SLOT, placement)
175
+ if (slot >= 0) this._slots.set(slot, placement)
176
+ return slot
177
+ }
178
+
179
+ /** Move / restyle a placed decal; fields left out keep the values it was placed with. Its birth
180
+ * time (the life clock) is kept. */
181
+ update(slot: number, placement: Partial<DecalPlacement>): this {
182
+ const prev = this._slots.get(slot)
183
+ if (!prev) return this
184
+ const next = { ...prev, ...placement }
185
+ this._slots.set(slot, next)
186
+ this._writePlacement(slot, next)
187
+ return this
188
+ }
189
+
190
+ remove(slot: number): this {
191
+ this._slots.delete(slot)
192
+ if (_creator.removeDecal && slot >= 0) _creator.removeDecal(this.id, slot)
193
+ return this
194
+ }
195
+
196
+ clear(): this {
197
+ this._slots.clear()
198
+ if (_creator.clearDecals) _creator.clearDecals(this.id)
199
+ return this
200
+ }
201
+
202
+ private _writePlacement(slot: number, p: DecalPlacement): number {
203
+ let q: Quat
204
+ if (p.rotation === undefined) q = Quat.identity
205
+ else if (p.rotation instanceof Quat || (p.rotation as any).w !== undefined || (p.rotation as any).length === 4) q = new Quat(p.rotation as QuatLike)
206
+ else { const e = new Vec3(p.rotation as Vec3Like); q = Quat.fromEuler(e.x, e.y, e.z) }
207
+ const right = q.rotateVec3(Vec3.right)
208
+ const up = q.rotateVec3(Vec3.up)
209
+ const normal = right.cross(up) // +Z of the frame: the node's −Z looks into the surface
210
+ return this._write(slot, right, up, normal, new Vec3(p.position), p)
211
+ }
212
+
213
+ /** @internal One decal's frame → record → host (`DecalTrail` writes its segments through here). */
214
+ _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
215
+ const d = this._defaults
216
+ const size = o.size ?? d.size!
217
+ const w = typeof size === "number" ? size : size[0]
218
+ const h = typeof size === "number" ? size : size[1]
219
+ const depth = o.depth ?? d.depth ?? Math.min(w, h)
220
+ const r = this._rec
221
+ r[0] = right.x * w; r[1] = right.y * w; r[2] = right.z * w
222
+ r[3] = up.x * h; r[4] = up.y * h; r[5] = up.z * h
223
+ r[6] = normal.x * depth; r[7] = normal.y * depth; r[8] = normal.z * depth
224
+ r[9] = centre.x; r[10] = centre.y; r[11] = centre.z
225
+ // atlas cell → rect (v grows downward: v0 is the cell's top)
226
+ const cells = this._cols * this._rows
227
+ const frameOpt = o.frame ?? d.frame!
228
+ const frame = frameOpt === "random" ? Math.floor(Math.random() * cells) : ((Math.floor(frameOpt) % cells) + cells) % cells
229
+ const col = frame % this._cols, row = Math.floor(frame / this._cols)
230
+ r[12] = col / this._cols; r[13] = row / this._rows; r[14] = (col + 1) / this._cols; r[15] = (row + 1) / this._rows
231
+ const tint = o.tint ?? d.tint
232
+ const [tr, tg, tb, ta] = tint === undefined ? [1, 1, 1, 1] : Color.toRgba01(tint)
233
+ const opacity = o.opacity ?? d.opacity!
234
+ r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
235
+ r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
236
+ const caps = o.caps ?? d.caps
237
+ r[23] = caps ? caps[0] : 0; r[24] = caps ? caps[1] : 0
238
+ const gradient = o.gradient ?? d.gradient
239
+ r[25] = gradient ? gradient[0] : 1; r[26] = gradient ? gradient[1] : 1
240
+ if (slot === NO_SLOT) {
241
+ if (!_creator.addDecal) return -1
242
+ const s = _creator.addDecal(this.id, r)
243
+ return s === NO_SLOT ? -1 : s
244
+ }
245
+ if (_creator.updateDecal) _creator.updateDecal(this.id, slot, r)
246
+ return slot
247
+ }
248
+ }
249
+
250
+ /** A `DecalTrail`'s look: the strip's width, and what every segment carries. */
251
+ export type DecalTrailOptions = Omit<DecalOptions, "size" | "caps" | "gradient"> & {
252
+ /** Width of the strip on the surface, world units. */
253
+ width: number
254
+ }
255
+
256
+ // The mitre stops making sense past this turn between two segments (a sharp reversal would need a
257
+ // box longer than the strip is wide); the trail breaks there and starts afresh.
258
+ const TRAIL_MAX_SLOPE = 1.75 // tan 60°
259
+
260
+ /**
261
+ * A continuous strip of decals along a path — a tire's skid mark, a dragged body, a tread track.
262
+ * Feed it points (`add`) as the thing moves; every new point becomes one box-decal SEGMENT from the
263
+ * previous point, its image's up along the travel, and the joints between segments are MITRED: each
264
+ * box is cut along the bisector it shares with its neighbour (`caps`), so the strip has no overlaps
265
+ * darkening the outside of a bend and no wedges of gap on the inside. The opacity given with each
266
+ * point is interpolated along the segment (`gradient`), so a mark can darken as a slide deepens and
267
+ * fade as it ends. Segments are the set's decals — same ring, same `max` budget: a set of 900 holds
268
+ * 900 segments across every trail drawn from it.
269
+ *
270
+ * The previous segment is REWRITTEN when the next point arrives (its far cap becomes the shared
271
+ * mitre), through `DecalSet.update`'s path — a recycled slot is simply left alone by the engine.
272
+ */
273
+ export class DecalTrail {
274
+ private _prev: { point: Vec3, opacity: number } | null = null
275
+ private _seg: {
276
+ slot: number, start: Vec3, end: Vec3, dir: Vec3, right: Vec3, normal: Vec3,
277
+ slopeStart: number, a0: number, a1: number,
278
+ } | null = null
279
+
280
+ readonly set: DecalSet
281
+ readonly options: DecalTrailOptions
282
+
283
+ constructor(set: DecalSet, options: DecalTrailOptions) {
284
+ this.set = set
285
+ this.options = options
286
+ }
287
+
288
+ /** Extend the strip to `point` on a surface with `normal`, `opacity` (0..1) at that point. The
289
+ * first call after a start / `end()` only anchors the strip. Returns the new segment's slot, or
290
+ * −1 when nothing was drawn (the anchor, a point that did not move, no host support). */
291
+ add(point: Vec3Like, normal: Vec3Like, opacity = 1): number {
292
+ const p = new Vec3(point)
293
+ const n = new Vec3(normal).normalize()
294
+ const prev = this._prev
295
+ this._prev = { point: p, opacity }
296
+ if (!prev) return -1
297
+ // The segment, flattened onto the surface plane.
298
+ let d = p.sub(prev.point)
299
+ d = d.sub(n.scale(d.dot(n)))
300
+ const len = d.length()
301
+ if (len < 1e-3) { this._prev = prev; return -1 } // not moved: wait for a real step
302
+ const dir = d.scale(1 / len)
303
+ const right = dir.cross(n) // DecalSet.spawn's frame: right × up = normal, image unmirrored from the normal's side
304
+ const w = this.options.width
305
+
306
+ // Mitre with the previous segment: the joint's bisector is the sum of the two right normals,
307
+ // and each box takes it as a cap — the previous one at its END (rewritten now), this one at its
308
+ // START — with the slopes measured in each box's own frame. A trail's first segment, or one
309
+ // after a break, starts square.
310
+ let slopeStart = 0
311
+ const last = this._seg
312
+ if (last) {
313
+ const lastDir = last.dir.sub(n.scale(last.dir.dot(n))).normalize()
314
+ const lastRight = lastDir.cross(n)
315
+ const m = lastRight.add(right)
316
+ const denom = m.dot(right)
317
+ const slopeEnd = Math.abs(denom) > 1e-4 ? m.dot(lastDir) / denom : Infinity
318
+ if (Math.abs(slopeEnd) <= TRAIL_MAX_SLOPE) {
319
+ this._rewrite(last, slopeEnd)
320
+ slopeStart = -slopeEnd
321
+ } else {
322
+ this._rewrite(last, 0) // too sharp to mitre: leave the last one square, start afresh
323
+ }
324
+ }
325
+ const seg = { slot: -1, start: prev.point, end: p, dir, right, normal: n, slopeStart, a0: prev.opacity, a1: opacity }
326
+ seg.slot = this._box(seg, 0, -1)
327
+ this._seg = seg
328
+ return seg.slot
329
+ }
330
+
331
+ /** Finish the strip. With a `point`, one last segment runs out to it at opacity 0 — a mark that
332
+ * tapers away instead of stopping dead. The next `add` anchors a new strip. */
333
+ end(point?: Vec3Like, normal: Vec3Like = Vec3.up): this {
334
+ if (point && this._prev) this.add(point, normal, 0)
335
+ this._prev = null
336
+ this._seg = null
337
+ return this
338
+ }
339
+
340
+ private _rewrite(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number): void {
341
+ if (seg.slot >= 0) this._box(seg, slopeEnd, seg.slot)
342
+ }
343
+
344
+ /** Write one segment's box: the strip between its joints, extended past each mitred joint by the
345
+ * mitre's reach (w/2 · |slope|) so the tilted cap still passes through the joint's centre. */
346
+ private _box(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number, slot: number): number {
347
+ const w = this.options.width
348
+ const L = seg.end.sub(seg.start).length()
349
+ const extStart = 0.5 * w * Math.abs(seg.slopeStart)
350
+ const extEnd = 0.5 * w * Math.abs(slopeEnd)
351
+ const boxLen = L + extStart + extEnd
352
+ const centre = seg.start.add(seg.dir.scale(0.5 * boxLen - extStart))
353
+ // World slopes → unit-space slopes: x is measured in widths, y in box lengths.
354
+ const caps: [number, number] = [ seg.slopeStart * (w / boxLen), slopeEnd * (w / boxLen) ]
355
+ const { width: _w, ...rest } = this.options
356
+ const s = this.set._write(slot < 0 ? NO_SLOT : slot, seg.right, seg.dir, seg.normal, centre,
357
+ { ...rest, size: [ w, boxLen ], caps, gradient: [ seg.a0, seg.a1 ] })
358
+ return slot < 0 ? s : slot
359
+ }
360
+ }