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.
- package/dist/global.d.ts +31 -0
- package/dist/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +10 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1352
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +41 -7
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/inject.ts +12 -0
- package/src/runtime/input.ts +6 -1
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIImage.ts +21 -7
|
@@ -69,21 +69,22 @@ export type CompileOptions = {
|
|
|
69
69
|
serverUrl?: string
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(/[
|
|
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(/[
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/compile/index.ts
CHANGED
|
@@ -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,
|
package/src/core/Aspect.ts
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
|
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) => {
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
package/src/gl/CameraPlace.ts
CHANGED
|
@@ -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
|
+
}
|