lecodes-sdk 0.19.2 → 0.20.2

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 (67) hide show
  1. package/dist/global.d.ts +62 -0
  2. package/dist/host.d.ts +3 -0
  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/gl/AudioSource.d.ts +60 -0
  9. package/dist/types/gl/AudioZone.d.ts +32 -0
  10. package/dist/types/gl/DecalSet.d.ts +103 -0
  11. package/dist/types/gl/Geometry.d.ts +5 -0
  12. package/dist/types/gl/Light.d.ts +7 -0
  13. package/dist/types/gl/Locomotion.d.ts +3 -1
  14. package/dist/types/gl/Material.d.ts +86 -2
  15. package/dist/types/gl/Mesh.d.ts +11 -0
  16. package/dist/types/gl/Scene.d.ts +23 -0
  17. package/dist/types/gl/SceneAudio.d.ts +11 -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/Animator.d.ts +51 -183
  21. package/dist/types/gl/animation/Feet.d.ts +85 -0
  22. package/dist/types/gl/animation/Warp.d.ts +53 -0
  23. package/dist/types/gl/animation/core.d.ts +61 -17
  24. package/dist/types/gl/state.d.ts +0 -1
  25. package/dist/types/inject.d.ts +17 -2
  26. package/dist/types/plugins/map.d.ts +174 -0
  27. package/dist/types/runtime/input.d.ts +11 -0
  28. package/dist/types/ui/UIImage.d.ts +15 -5
  29. package/dist/types.json +1 -1
  30. package/package.json +1 -1
  31. package/src/audio/Bus.ts +102 -0
  32. package/src/audio/Sound.ts +96 -0
  33. package/src/audio/Voice.ts +102 -0
  34. package/src/audio/audio.ts +161 -0
  35. package/src/audio/support.ts +6 -0
  36. package/src/bridges.d.ts +1481 -1345
  37. package/src/compile/__tests__/compile.test.ts +12 -0
  38. package/src/compile/compileProject.ts +35 -15
  39. package/src/compile/index.ts +3 -0
  40. package/src/core/Aspect.ts +34 -9
  41. package/src/g2/Scene2D.ts +7 -0
  42. package/src/gl/AudioSource.ts +113 -0
  43. package/src/gl/AudioZone.ts +75 -0
  44. package/src/gl/DecalSet.ts +233 -0
  45. package/src/gl/Geometry.ts +5 -0
  46. package/src/gl/Light.ts +16 -0
  47. package/src/gl/Lightmap.ts +3 -2
  48. package/src/gl/Locomotion.ts +7 -5
  49. package/src/gl/Material.ts +152 -4
  50. package/src/gl/Mesh.ts +20 -1
  51. package/src/gl/Particles.ts +3 -3
  52. package/src/gl/Scene.ts +42 -8
  53. package/src/gl/SceneAudio.ts +26 -0
  54. package/src/gl/Texture.ts +43 -3
  55. package/src/gl/Vehicle.ts +5 -5
  56. package/src/gl/animation/AnimationClip.ts +43 -20
  57. package/src/gl/animation/Animator.ts +138 -329
  58. package/src/gl/animation/Feet.ts +134 -0
  59. package/src/gl/animation/Loop.ts +3 -1
  60. package/src/gl/animation/Warp.ts +96 -0
  61. package/src/gl/animation/core.ts +741 -670
  62. package/src/gl/state.ts +6 -6
  63. package/src/host.d.ts +3 -0
  64. package/src/inject.ts +23 -2
  65. package/src/plugins/map.ts +396 -0
  66. package/src/runtime/input.ts +6 -1
  67. package/src/ui/UIImage.ts +21 -7
@@ -285,3 +285,15 @@ test("buildHeader / detectProjectKind signal the engine from bundled code", () =
285
285
  expect(detectProjectKind("const x = 1")).toBe("ui")
286
286
  expect(detectProjectKind("_creator.createARController()")).toBe("ar")
287
287
  })
288
+
289
+ test("a .json project file is data — asset()/import gives the parsed value", async () => {
290
+ const js = await compileProject({
291
+ name: "Data", publicUrl: "https://le.codes", header: false,
292
+ entries: [
293
+ { path: "/main.ts", type: "text", text: 'toast(asset("./style.json").name)\n' },
294
+ { path: "/style.json", type: "text", text: '{ "name": "OSM Bright", "version": 8 }\n' },
295
+ ],
296
+ })
297
+ expect(js).toContain("OSM Bright")
298
+ expect(js).not.toContain('"./style.json"') // desugared to the import, not left as a string
299
+ })
@@ -69,21 +69,22 @@ export type CompileOptions = {
69
69
  serverUrl?: string
70
70
  }
71
71
 
72
- export const compileProject = async (opts: CompileOptions): Promise<string> => {
73
- // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
74
- const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
75
- const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
76
- if (!entry) {
77
- throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
78
- }
72
+ /** The bundle + its map SEPARATELY (the platform deploy writes the map as a sibling `.js.map`; the
73
+ * preview inlines it) plus the entrypoint the compile used. `code` carries the metadata header
74
+ * unless `header: false`; `map` is already shifted past that header. */
75
+ export type CompileResult = { code: string, map: string | null, entry: string }
79
76
 
77
+ /** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
78
+ * bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
79
+ * data, code verbatim). Exported for callers that build a `libraries` map from entries. */
80
+ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
80
81
  const files: Record<string, string> = {}
81
82
  for (const e of entries) {
82
83
  if (e.type === "resource") {
83
84
  if (opts.localAssets && !e.remote) {
84
85
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
85
86
  // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
86
- const name = e.path.split(/[\\/]/).pop() ?? e.path
87
+ const name = e.path.split(/[\/]/).pop() ?? e.path
87
88
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
88
89
  } else {
89
90
  files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
@@ -98,7 +99,7 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
98
99
  // desktop host reports "desktop" on Windows and "opengl" on Linux, iOS "metal".
99
100
  // Only entries the CLI actually compiled+staged carry `shadersLocal`; server artifacts
100
101
  // (URLs) and un-staged ones keep the URL forms below.
101
- const name = shaders[0].src.split(/[\\/]/).pop() ?? ""
102
+ const name = shaders[0].src.split(/[\/]/).pop() ?? ""
102
103
  const [prefix, suffix = ".filamat"] = name.split("_opengl")
103
104
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(prefix + "_")} + _creator.backend + ${JSON.stringify(suffix)}))`
104
105
  } else if (shaders.length > 0) {
@@ -108,20 +109,39 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
108
109
  }
109
110
  } else if (e.path.toLowerCase().endsWith(".svg")) {
110
111
  files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
112
+ } else if (e.path.toLowerCase().endsWith(".json")) {
113
+ // A .json project file is DATA, not code: `import data from "./x.json"` — and `asset("./x.json")`,
114
+ // which desugars to that import — yields the parsed value. The bundler would otherwise try to
115
+ // parse the file as JS. JSON is an expression, so a default export is the whole translation.
116
+ files[e.path] = `export default ${e.text?.trim() || "null"}`
111
117
  } else {
112
118
  files[e.path] = e.text ?? ""
113
119
  }
114
120
  }
121
+ return files
122
+ }
115
123
 
124
+ export const compileProjectWithMap = async (opts: CompileOptions): Promise<CompileResult> => {
125
+ // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
126
+ const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
127
+ const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
128
+ if (!entry) {
129
+ throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
130
+ }
131
+
132
+ const files = entriesToFiles(entries, opts)
116
133
  const { code, map } = await bundleProjectWithMap(
117
134
  entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
118
135
  )
119
- if (opts.header === false) {
120
- return map ? `${code}\n${inlineSourceMapComment(map)}` : code
121
- }
136
+ if (opts.header === false) return { code, map, entry }
122
137
  const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
123
- if (!map) return header + code
124
- // Shift the map down past the header (prepended before the code); the inline comment lands last.
138
+ // Shift the map down past the header (prepended before the code).
125
139
  const headerLines = (header.match(/\n/g) ?? []).length
126
- return `${header}${code}\n${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`
140
+ return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry }
141
+ }
142
+
143
+ /** The bundle as ONE string, the map inlined at its end (the shipped/preview form). */
144
+ export const compileProject = async (opts: CompileOptions): Promise<string> => {
145
+ const { code, map } = await compileProjectWithMap(opts)
146
+ return map ? `${code}\n${inlineSourceMapComment(map)}` : code
127
147
  }
@@ -63,9 +63,12 @@ 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,
@@ -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) } }
@@ -213,8 +226,12 @@ let fixedFallbackAcc = 0
213
226
  // The FIXED phase: dt = Time.fixedDt (1/60) every call, once per physics substep, on every host. The
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
- // same step, same 4-substep cap, same drop-the-backlog rule as creator-gl's physics/world.cpp.
217
- const fixedStep: PhaseFn = (dt) => { _phaseHooks.fixed?.(dt); fixedUpdaters.run("updateFixed", dt) }
229
+ // same step, same 4-substep cap, same drop-the-backlog rule as creator-physics' cphysFrameBegin.
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 }
@@ -0,0 +1,113 @@
1
+ // A 3D sound emitter, as an aspect on a Node (docs/audio-plan.md §2). The engine follows the node's
2
+ // world pose every frame (position, facing for the cone, velocity for doppler); voices played
3
+ // through it are spatialized against the scene's listener (scene.audio.listener, the camera by
4
+ // default). Parented, animated and physics-driven nodes just work.
5
+ //
6
+ // const gun = new Node().aspect(AudioSource, { minDistance: 1, maxDistance: 40 })
7
+ // gun.audio.play(shot, { pitch: 0.95 + Math.random() * 0.1 })
8
+ // const hum = car.audio.play(engineLoop, { loop: true }); hum.pitch = 0.8 + rpm * 0.6
9
+ // speaker.aspect(AudioSource, { cone: { inner: 60, outer: 120, outerGain: 0.3 } }) // facing −Z
10
+ // enemy.aspect(AudioSource, { occlusion: true }) // a wall between it and the listener muffles it
11
+ //
12
+ // Without host audio the aspect is inert: `play` returns an inert Voice, nothing throws.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import type { FieldMeta } from "../core/fields"
16
+ import { audio, sourceParams, type PlaySoundOptions, type Rolloff } from "../audio/audio"
17
+ import type { Sound } from "../audio/Sound"
18
+ import { audioSupported } from "../audio/support"
19
+ import type { Voice } from "../audio/Voice"
20
+ import type { Node } from "./Node"
21
+
22
+ export type AudioCone = {
23
+ /** Degrees of full volume around the node's −Z. */
24
+ inner: number
25
+ /** Degrees where the volume has fallen to `outerGain`. */
26
+ outer: number
27
+ /** Volume behind the source, 0 … 1. */
28
+ outerGain?: number
29
+ }
30
+
31
+ export class AudioSource extends Aspect<"audio", Node> {
32
+ static readonly aspect = "audio"
33
+
34
+ static fields: FieldMeta<AudioSource> = {
35
+ minDistance: { min: 0.05, max: 100, step: 0.05 },
36
+ maxDistance: { min: 0.5, max: 1000, step: 0.5 },
37
+ doppler: { min: 0, max: 1, step: 0.05 },
38
+ spread: { min: 0, max: 1, step: 0.05 },
39
+ }
40
+
41
+ private _minDistance = 1
42
+ private _maxDistance = 50
43
+ private _rolloff: Rolloff = "inverse"
44
+ private _cone: AudioCone | null = null
45
+ private _doppler = 0
46
+ private _spread = 0
47
+ private _occlusion = false
48
+ private _bus = "sfx"
49
+ /** @internal engine source id (0 = not attached / no audio). */
50
+ _id = 0
51
+
52
+ /** Metres of full volume around the node. Default 1. */
53
+ get minDistance(): number { return this._minDistance }
54
+ set minDistance(v: number) { this._minDistance = v; this._push() }
55
+ /** Metres beyond which the source no longer gets quieter. Default 50. */
56
+ get maxDistance(): number { return this._maxDistance }
57
+ set maxDistance(v: number) { this._maxDistance = v; this._push() }
58
+ /** `inverse` (default), `linear`, `exponential`, `none`. */
59
+ get rolloff(): Rolloff { return this._rolloff }
60
+ set rolloff(v: Rolloff) { this._rolloff = v; this._push() }
61
+ /** Directional source along the node's −Z; null = omnidirectional (default). */
62
+ get cone(): AudioCone | null { return this._cone }
63
+ set cone(v: AudioCone | null) { this._cone = v; this._push() }
64
+ /** Doppler amount 0 … 1 (0 = off, the default) — needs the node to actually move. */
65
+ get doppler(): number { return this._doppler }
66
+ set doppler(v: number) { this._doppler = v; this._push() }
67
+ /** 0 = pin-point panning (default) … 1 = the same on every speaker (a big, close source). */
68
+ get spread(): number { return this._spread }
69
+ set spread(v: number) { this._spread = v; this._push() }
70
+ /** The engine raycasts listener → source (against solid bodies) and muffles the voices when
71
+ * something is in the way. Default false. */
72
+ get occlusion(): boolean { return this._occlusion }
73
+ set occlusion(v: boolean) { this._occlusion = v; this._push() }
74
+ /** Default bus for voices on this source. Default `sfx`. */
75
+ get bus(): string { return this._bus }
76
+ set bus(v: string) { this._bus = v; this._push() }
77
+
78
+ /** Live voices on this source. */
79
+ get voices(): number { return this._id && audioSupported ? _creatorAudio.sourceVoices(this._id) : 0 }
80
+
81
+ onAttach(): void {
82
+ if (!audioSupported) return
83
+ this._id = _creatorAudio.createSource()
84
+ if (!this._id) return
85
+ _creatorAudio.attachSource(this._id, this.node.id)
86
+ this._push()
87
+ }
88
+
89
+ onDetach(): void {
90
+ if (this._id && audioSupported) _creatorAudio.destroySource(this._id)
91
+ this._id = 0
92
+ }
93
+
94
+ /** Play a clip from this node. */
95
+ play(sound: Sound, options: PlaySoundOptions = {}): Voice {
96
+ return audio._play(sound, this._id, options)
97
+ }
98
+
99
+ /** Stop every voice on this source (fade in seconds). */
100
+ stopAll(fade = 0): void {
101
+ if (this._id && audioSupported) _creatorAudio.stopSource(this._id, fade)
102
+ }
103
+
104
+ private _push(): void {
105
+ if (!this._id || !audioSupported) return
106
+ const cone = this._cone
107
+ _creatorAudio.setSourceParams(this._id, sourceParams({
108
+ minDistance: this._minDistance, maxDistance: this._maxDistance, rolloff: this._rolloff,
109
+ coneInner: cone ? cone.inner : 360, coneOuter: cone ? cone.outer : 360, coneOuterGain: cone ? cone.outerGain ?? 0.25 : 1,
110
+ doppler: this._doppler, spread: this._spread, occlusion: this._occlusion, bus: audio._busId(this._bus),
111
+ }))
112
+ }
113
+ }
@@ -0,0 +1,75 @@
1
+ // A reverb zone, as an aspect on a Node (docs/audio-plan.md §2): a box or sphere volume that
2
+ // follows the node; while the listener is inside, the zone's reverb blends onto a bus (the bus's
3
+ // own reverb outside it). Zones nest: the smallest one containing the listener wins. `blend` is
4
+ // how many metres inside the border the crossfade takes — walking through a doorway is smooth.
5
+ //
6
+ // new Node().aspect(AudioZone, { box: [6, 2.5, 6], reverb: 'hall', blend: 2 })
7
+ // corridor.aspect(Shape, { box: [1, 1.5, 8] }).aspect(AudioZone, { reverb: 'bathroom' }) // the Shape's box
8
+ //
9
+ // Sizes follow Shape's convention: `box` = HALF-extents, `sphere` = radius, in the node's local
10
+ // units (the node's scale applies). Without host audio the aspect is inert.
11
+
12
+ import { Aspect } from "../core/Aspect"
13
+ import { reverbBlock, type ReverbParams, type ReverbPreset } from "../audio/Bus"
14
+ import { audio } from "../audio/audio"
15
+ import { audioSupported } from "../audio/support"
16
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
17
+ import type { Node } from "./Node"
18
+ import { Shape } from "./Shape"
19
+
20
+ export class AudioZone extends Aspect<"audioZone", Node> {
21
+ static readonly aspect = "audioZone"
22
+
23
+ private _box: Vec3Like | null = null
24
+ private _sphere: number | null = null
25
+ private _reverb: ReverbPreset | ReverbParams = "room"
26
+ private _blend = 1
27
+ private _bus = "sfx"
28
+ /** @internal engine zone id. */
29
+ _id = 0
30
+ /** The engine's zone id — what `audio.stats.listenerZone` reports while the listener is inside. */
31
+ get id(): number { return this._id }
32
+
33
+ /** Half-extents [hx, hy, hz]; falls back to the node's Shape box, then a 1 m cube. */
34
+ get box(): Vec3Like | null { return this._box }
35
+ set box(v: Vec3Like | null) { this._box = v; if (v) this._sphere = null; this._push() }
36
+ /** Radius; falls back to the node's Shape sphere. */
37
+ get sphere(): number | null { return this._sphere }
38
+ set sphere(v: number | null) { this._sphere = v; if (v !== null) this._box = null; this._push() }
39
+ /** A preset name or explicit params (see Bus.reverb). */
40
+ get reverb(): ReverbPreset | ReverbParams { return this._reverb }
41
+ set reverb(v: ReverbPreset | ReverbParams) { this._reverb = v; this._push() }
42
+ /** Crossfade depth in metres inside the border (0 = a hard edge). Default 1. */
43
+ get blend(): number { return this._blend }
44
+ set blend(v: number) { this._blend = v; this._push() }
45
+ /** The bus the zone's reverb rides on. Default `sfx`. */
46
+ get bus(): string { return this._bus }
47
+ set bus(v: string) { this._bus = v; this._push() }
48
+
49
+ onAttach(): void {
50
+ if (!audioSupported) return
51
+ this._id = _creatorAudio.createZone()
52
+ if (!this._id) return
53
+ _creatorAudio.attachZone(this._id, this.node.id)
54
+ this._push()
55
+ }
56
+
57
+ onDetach(): void {
58
+ if (this._id && audioSupported) _creatorAudio.destroyZone(this._id)
59
+ this._id = 0
60
+ }
61
+
62
+ private _push(): void {
63
+ if (!this._id || !audioSupported) return
64
+ let box = this._box, sphere = this._sphere
65
+ if (!box && sphere === null) {
66
+ const shape = this.node.get(Shape)
67
+ if (shape?.sphere !== undefined) sphere = shape.sphere
68
+ else if (shape?.box) box = shape.box
69
+ }
70
+ const dims = sphere !== null
71
+ ? new Float32Array([ sphere * 2, sphere * 2, sphere * 2 ])
72
+ : new Float32Array(box ? [ cx(box) * 2, cy(box) * 2, cz(box) * 2 ] : [ 2, 2, 2 ])
73
+ _creatorAudio.setZone(this._id, sphere !== null ? 1 : 0, dims, this._blend, audio._busId(this._bus), reverbBlock(this._reverb))
74
+ }
75
+ }
@@ -0,0 +1,233 @@
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
+ }
39
+
40
+ export type DecalSetOptions = DecalOptions & {
41
+ /** The material — `Material.decal({ map })` by default (`map` below is its shortcut). A custom
42
+ * material must keep decal.mat's vertex contract. */
43
+ material?: Material
44
+ /** Atlas texture for the default material. */
45
+ map?: Texture
46
+ /** Tangent-space normal atlas (same cell grid) → a RELIEF decal that bends the surface's
47
+ * lighting instead of painting a colour (`Material.decal` `normalMap`). Footprints and dents
48
+ * need only this; a bullet hole gives `map` too and its colour multiplies in. */
49
+ normalMap?: Texture
50
+ /** Relief strength for `normalMap`. Default 1. Default material only. */
51
+ bump?: number
52
+ /** Atlas grid of the map: columns, or [columns, rows]. Default 1 (the whole texture). */
53
+ sheet?: number | [number, number]
54
+ /** Slot budget — the most decals alive at once. Default 256. */
55
+ max?: number
56
+ /** Soft fraction (0..1) of the box's half depth at both ends, so an oblique surface leaves the
57
+ * box gently. Default 0.3. Default material only. */
58
+ edge?: number
59
+ /** Surfaces turned more than this away from the projection axis fade out — the cosine of the
60
+ * angle (0.3 ≈ 72°) keeps a floor hit off the wall it meets; 0 = project onto anything.
61
+ * Default 0.3. Default material only. */
62
+ angleFade?: number
63
+ /** HDR boost of the image (0 = none). Default material only. */
64
+ emissive?: number
65
+ name?: string
66
+ /** Coarse draw order, 0 (first) … 7 (last); default 4 — see `Mesh.renderPriority`. Decals are
67
+ * blended and sort with the other blended draws of their priority. */
68
+ renderPriority?: number
69
+ }
70
+
71
+ /** `spawn` placement: where the image sits on the surface. */
72
+ export type DecalSpawnOptions = DecalOptions & {
73
+ /** World direction the image's top points along the surface (projected onto it) — a footprint's
74
+ * travel direction. Default: a random spin. */
75
+ up?: Vec3Like
76
+ /** Extra spin around the normal, radians. Default: random when `up` is not given, else 0. */
77
+ rotation?: number
78
+ }
79
+
80
+ /** `place` / `update` placement: a full frame, like a node looking INTO the surface (its −Z is the
81
+ * projection direction, +Y the image's top). */
82
+ export type DecalPlacement = DecalOptions & {
83
+ position: Vec3Like
84
+ /** A quaternion, or Euler degrees (YXZ) like `Node.eulerAngles`. Default identity = projecting
85
+ * down −Z. */
86
+ rotation?: QuatLike | Vec3Like
87
+ }
88
+
89
+ const RECORD = 23
90
+ const NO_SLOT = 0xFFFFFFFF
91
+
92
+ export class DecalSet extends Node {
93
+ private _material: Material
94
+ private readonly _max: number
95
+ private readonly _cols: number
96
+ private readonly _rows: number
97
+ private readonly _defaults: DecalOptions
98
+ private readonly _rec = new Float32Array(RECORD)
99
+ private readonly _slots = new Map<number, DecalPlacement>()
100
+ private static _warned = false
101
+
102
+ constructor(options: DecalSetOptions = {}) {
103
+ super()
104
+ if (options.name) this.name = options.name
105
+ this._material = options.material ?? Material.decal({
106
+ map: options.map, normalMap: options.normalMap, bump: options.bump,
107
+ edge: options.edge, angleFade: options.angleFade, emissive: options.emissive,
108
+ })
109
+ this._max = Math.max(1, Math.round(options.max ?? 256))
110
+ const sheet = options.sheet ?? 1
111
+ this._cols = Math.max(1, Array.isArray(sheet) ? sheet[0] : sheet)
112
+ this._rows = Math.max(1, Array.isArray(sheet) ? sheet[1] : 1)
113
+ this._defaults = {
114
+ size: options.size ?? 0.2, depth: options.depth, frame: options.frame ?? 0, tint: options.tint,
115
+ opacity: options.opacity ?? 1, life: options.life ?? 0, fadeIn: options.fadeIn ?? 0, fadeOut: options.fadeOut ?? 0,
116
+ }
117
+ if (_creator.createDecalSet) {
118
+ _creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
119
+ if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
120
+ } else if (!DecalSet._warned) {
121
+ DecalSet._warned = true
122
+ console.warn("[decals] this host has no createDecalSet — decals are not drawn")
123
+ }
124
+ }
125
+
126
+ get material(): Material { return this._material }
127
+ /** Decals alive right now. */
128
+ get count(): number { return _creator.decalCount ? _creator.decalCount(this.id) : 0 }
129
+ /** The slot budget the set was created with. */
130
+ get max(): number { return this._max }
131
+
132
+ /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
133
+ set renderPriority(v: number) {
134
+ if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
135
+ }
136
+
137
+ /** Stamp a decal on a surface: `point` on it, `normal` out of it (a raycast hit, a foot plant
138
+ * with `Vec3.up`). Returns the slot for `update` / `remove` (−1 when the host draws none). */
139
+ spawn(point: Vec3Like, normal: Vec3Like, options: DecalSpawnOptions = {}): number {
140
+ const n = new Vec3(normal).normalize()
141
+ // Image up on the surface: the hint projected onto the plane, or any perpendicular.
142
+ let up = options.up ? new Vec3(options.up) : null
143
+ if (up) { up = up.sub(n.scale(up.dot(n))); if (up.length() < 1e-4) up = null }
144
+ if (!up) {
145
+ const helper = Math.abs(n.y) < 0.99 ? Vec3.up : Vec3.right
146
+ up = helper.sub(n.scale(helper.dot(n)))
147
+ }
148
+ up = up.normalize()
149
+ // right = up × normal keeps the frame right-handed (right × up = normal), so the image reads
150
+ // unmirrored from the normal's side; then the spin around the normal.
151
+ let right = up.cross(n)
152
+ const spin = options.rotation ?? (options.up ? 0 : Math.random() * Math.PI * 2)
153
+ if (spin !== 0) {
154
+ const c = Math.cos(spin), s = Math.sin(spin)
155
+ const r2 = right.scale(c).add(up.scale(s))
156
+ up = up.scale(c).sub(right.scale(s))
157
+ right = r2
158
+ }
159
+ return this._write(NO_SLOT, right, up, n, new Vec3(point), options)
160
+ }
161
+
162
+ /** Place a decal by a full frame (an editor-placed stain, a moving marker). Returns the slot. */
163
+ place(placement: DecalPlacement): number {
164
+ const slot = this._writePlacement(NO_SLOT, placement)
165
+ if (slot >= 0) this._slots.set(slot, placement)
166
+ return slot
167
+ }
168
+
169
+ /** Move / restyle a placed decal; fields left out keep the values it was placed with. Its birth
170
+ * time (the life clock) is kept. */
171
+ update(slot: number, placement: Partial<DecalPlacement>): this {
172
+ const prev = this._slots.get(slot)
173
+ if (!prev) return this
174
+ const next = { ...prev, ...placement }
175
+ this._slots.set(slot, next)
176
+ this._writePlacement(slot, next)
177
+ return this
178
+ }
179
+
180
+ remove(slot: number): this {
181
+ this._slots.delete(slot)
182
+ if (_creator.removeDecal && slot >= 0) _creator.removeDecal(this.id, slot)
183
+ return this
184
+ }
185
+
186
+ clear(): this {
187
+ this._slots.clear()
188
+ if (_creator.clearDecals) _creator.clearDecals(this.id)
189
+ return this
190
+ }
191
+
192
+ private _writePlacement(slot: number, p: DecalPlacement): number {
193
+ let q: Quat
194
+ if (p.rotation === undefined) q = Quat.identity
195
+ 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)
196
+ else { const e = new Vec3(p.rotation as Vec3Like); q = Quat.fromEuler(e.x, e.y, e.z) }
197
+ const right = q.rotateVec3(Vec3.right)
198
+ const up = q.rotateVec3(Vec3.up)
199
+ const normal = right.cross(up) // +Z of the frame: the node's −Z looks into the surface
200
+ return this._write(slot, right, up, normal, new Vec3(p.position), p)
201
+ }
202
+
203
+ private _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
204
+ const d = this._defaults
205
+ const size = o.size ?? d.size!
206
+ const w = typeof size === "number" ? size : size[0]
207
+ const h = typeof size === "number" ? size : size[1]
208
+ const depth = o.depth ?? d.depth ?? Math.min(w, h)
209
+ const r = this._rec
210
+ r[0] = right.x * w; r[1] = right.y * w; r[2] = right.z * w
211
+ r[3] = up.x * h; r[4] = up.y * h; r[5] = up.z * h
212
+ r[6] = normal.x * depth; r[7] = normal.y * depth; r[8] = normal.z * depth
213
+ r[9] = centre.x; r[10] = centre.y; r[11] = centre.z
214
+ // atlas cell → rect (v grows downward: v0 is the cell's top)
215
+ const cells = this._cols * this._rows
216
+ const frameOpt = o.frame ?? d.frame!
217
+ const frame = frameOpt === "random" ? Math.floor(Math.random() * cells) : ((Math.floor(frameOpt) % cells) + cells) % cells
218
+ const col = frame % this._cols, row = Math.floor(frame / this._cols)
219
+ r[12] = col / this._cols; r[13] = row / this._rows; r[14] = (col + 1) / this._cols; r[15] = (row + 1) / this._rows
220
+ const tint = o.tint ?? d.tint
221
+ const [tr, tg, tb, ta] = tint === undefined ? [1, 1, 1, 1] : Color.toRgba01(tint)
222
+ const opacity = o.opacity ?? d.opacity!
223
+ r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
224
+ r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
225
+ if (slot === NO_SLOT) {
226
+ if (!_creator.addDecal) return -1
227
+ const s = _creator.addDecal(this.id, r)
228
+ return s === NO_SLOT ? -1 : s
229
+ }
230
+ if (_creator.updateDecal) _creator.updateDecal(this.id, slot, r)
231
+ return slot
232
+ }
233
+ }
@@ -25,6 +25,11 @@ export class Geometry {
25
25
  * tiles per face, which would fold every face onto the same texels); absent = the host reuses
26
26
  * `uv`, which is right for a plane. */
27
27
  uv1?: Float32Array
28
+ /** Per-vertex COLOURS: 4 bytes (r, g, b, a) per vertex, 0..255. A material that declares
29
+ * `requires: [color]` reads them through `getColor()` — one mesh, many colours, no material per
30
+ * shade (a debug drawer, a gradient along a curve). Absent = every vertex white, which is what
31
+ * the built-in materials expect. */
32
+ colors?: Uint8Array
28
33
  /** @internal native mesh-type enum (0 triangles / 1 edges / 2 vertices). */
29
34
  _meshKind = 0
30
35