lecodes-sdk 0.20.0 → 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 (64) hide show
  1. package/dist/global.d.ts +31 -0
  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/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/Material.d.ts +86 -2
  14. package/dist/types/gl/Mesh.d.ts +11 -0
  15. package/dist/types/gl/Scene.d.ts +23 -0
  16. package/dist/types/gl/SceneAudio.d.ts +11 -0
  17. package/dist/types/gl/Texture.d.ts +29 -1
  18. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  19. package/dist/types/gl/animation/core.d.ts +15 -10
  20. package/dist/types/gl/state.d.ts +0 -1
  21. package/dist/types/inject.d.ts +10 -0
  22. package/dist/types/runtime/input.d.ts +11 -0
  23. package/dist/types/ui/UIImage.d.ts +15 -5
  24. package/dist/types.json +1 -1
  25. package/package.json +1 -1
  26. package/prompts/dist/2d-game.md +408 -197
  27. package/prompts/dist/3d-app.md +491 -166
  28. package/prompts/dist/ar-app.md +373 -163
  29. package/prompts/dist/design.md +83 -87
  30. package/prompts/dist/ui-app.md +325 -136
  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 -1352
  37. package/src/compile/compileProject.ts +30 -15
  38. package/src/compile/index.ts +3 -0
  39. package/src/core/Aspect.ts +33 -8
  40. package/src/g2/Scene2D.ts +7 -0
  41. package/src/gl/AudioSource.ts +113 -0
  42. package/src/gl/AudioZone.ts +75 -0
  43. package/src/gl/CameraPlace.ts +52 -52
  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/Material.ts +152 -4
  49. package/src/gl/Mesh.ts +20 -1
  50. package/src/gl/Ragdoll.ts +270 -270
  51. package/src/gl/Scene.ts +41 -7
  52. package/src/gl/SceneAudio.ts +26 -0
  53. package/src/gl/Texture.ts +43 -3
  54. package/src/gl/Trigger.ts +45 -45
  55. package/src/gl/Vehicle.ts +5 -5
  56. package/src/gl/animation/AnimationClip.ts +43 -20
  57. package/src/gl/animation/Animator.ts +4 -3
  58. package/src/gl/animation/core.ts +20 -15
  59. package/src/gl/scenarios.ts +291 -291
  60. package/src/gl/state.ts +1 -1
  61. package/src/inject.ts +12 -0
  62. package/src/runtime/input.ts +6 -1
  63. package/src/scene/gizmos.ts +148 -148
  64. package/src/ui/UIImage.ts +21 -7
@@ -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) {
@@ -117,16 +118,30 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
117
118
  files[e.path] = e.text ?? ""
118
119
  }
119
120
  }
121
+ return files
122
+ }
120
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)
121
133
  const { code, map } = await bundleProjectWithMap(
122
134
  entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
123
135
  )
124
- if (opts.header === false) {
125
- return map ? `${code}\n${inlineSourceMapComment(map)}` : code
126
- }
136
+ if (opts.header === false) return { code, map, entry }
127
137
  const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
128
- if (!map) return header + code
129
- // 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).
130
139
  const headerLines = (header.match(/\n/g) ?? []).length
131
- 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
132
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) } }
@@ -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 }
@@ -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
+ }
@@ -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
+ }