@jgengine/shell 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/dist/CombatPresentation.d.ts +5 -0
- package/dist/CombatPresentation.js +8 -0
- package/dist/GameHost.d.ts +27 -1
- package/dist/GameHost.js +83 -4
- package/dist/GamePlayer.d.ts +5 -0
- package/dist/GamePlayer.js +5 -0
- package/dist/GamePlayerShell.d.ts +5 -0
- package/dist/GamePlayerShell.js +63 -359
- package/dist/GameUiPreview.js +3 -0
- package/dist/Shell3dPresentation.d.ts +63 -0
- package/dist/Shell3dPresentation.js +302 -0
- package/dist/ShellChrome.d.ts +51 -0
- package/dist/ShellChrome.js +51 -0
- package/dist/ShellHudPresentation.d.ts +41 -0
- package/dist/ShellHudPresentation.js +22 -0
- package/dist/audio/audioEngine.d.ts +4 -0
- package/dist/audio/audioEngine.js +111 -26
- package/dist/audio/audioWire.d.ts +22 -4
- package/dist/audio/audioWire.js +46 -3
- package/dist/audio/loopParams.d.ts +20 -0
- package/dist/audio/loopParams.js +28 -0
- package/dist/camera/GameCameraRig.js +6 -1
- package/dist/camera/GameOrbitCamera.js +7 -1
- package/dist/camera/cameraCollision.d.ts +28 -0
- package/dist/camera/cameraCollision.js +34 -0
- package/dist/camera/cameraRigs.d.ts +3 -1
- package/dist/camera/cameraRigs.js +24 -10
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +27 -0
- package/dist/camera/rigMath.js +45 -0
- package/dist/defineGame.d.ts +15 -1
- package/dist/defineGame.js +26 -4
- package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
- package/dist/devtools/DevtoolsOverlay.js +28 -12
- package/dist/devtools/agentBridge.js +4 -0
- package/dist/devtools/perfDiagnose.js +1 -1
- package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
- package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
- package/dist/environment/EnvironmentScene.js +17 -3
- package/dist/environment/RoadJunctions.d.ts +12 -0
- package/dist/environment/RoadJunctions.js +69 -0
- package/dist/environment/RoadRibbons.d.ts +8 -2
- package/dist/environment/RoadRibbons.js +26 -8
- package/dist/environment/terrainGroundColor.d.ts +9 -0
- package/dist/environment/terrainGroundColor.js +22 -0
- package/dist/gameKit.d.ts +27 -0
- package/dist/gameKit.js +29 -0
- package/dist/map/MapMarkerBeacons.d.ts +3 -2
- package/dist/postfx/PostProcessing.d.ts +9 -1
- package/dist/postfx/PostProcessing.js +11 -4
- package/dist/presentationResolve.d.ts +39 -0
- package/dist/presentationResolve.js +53 -0
- package/dist/registry.d.ts +1 -0
- package/dist/render/PartMotion.d.ts +18 -0
- package/dist/render/PartMotion.js +91 -0
- package/dist/render/SceneModels.d.ts +9 -2
- package/dist/render/SceneModels.js +31 -180
- package/dist/render/assetBase.d.ts +17 -0
- package/dist/render/assetBase.js +35 -0
- package/dist/render/measureBounds.d.ts +34 -0
- package/dist/render/measureBounds.js +127 -0
- package/dist/render/modelLoad.js +4 -1
- package/dist/render/resolveModel.d.ts +15 -0
- package/dist/render/resolveModel.js +43 -2
- package/dist/render/useModelAnimation.d.ts +10 -0
- package/dist/render/useModelAnimation.js +198 -0
- package/dist/scatter/InstancedScatter.js +5 -0
- package/dist/scatter/scatterProxies.js +24 -0
- package/dist/scene/AuthoredScene.d.ts +3 -1
- package/dist/scene/AuthoredScene.js +7 -2
- package/dist/scene/CityRenderer.d.ts +2 -0
- package/dist/scene/CityRenderer.js +1031 -0
- package/dist/scene/GrassFieldRenderer.js +53 -5
- package/dist/scene/PoleLineRenderer.d.ts +2 -0
- package/dist/scene/PoleLineRenderer.js +287 -0
- package/dist/scene/SoilPatchRenderer.js +70 -7
- package/dist/scene/WaterRenderer.js +34 -10
- package/dist/scene/builtinSceneKindRenderers.js +4 -0
- package/dist/scene/cityWindowMaterial.d.ts +16 -0
- package/dist/scene/cityWindowMaterial.js +81 -0
- package/dist/scene/sceneKindRenderers.d.ts +2 -0
- package/dist/settings/appliedSettings.d.ts +2 -1
- package/dist/settings/appliedSettings.js +4 -2
- package/dist/structures/GeneratedBuilding.d.ts +4 -0
- package/dist/structures/GeneratedBuilding.js +17 -7
- package/dist/structures/TransformGizmo.d.ts +11 -1
- package/dist/structures/TransformGizmo.js +9 -5
- package/dist/terrain/GrassField.d.ts +11 -3
- package/dist/terrain/GrassField.js +16 -6
- package/dist/terrain/grassGeometry.d.ts +33 -1
- package/dist/terrain/grassGeometry.js +83 -21
- package/dist/terrain/grassMaterial.d.ts +18 -0
- package/dist/terrain/grassMaterial.js +66 -13
- package/dist/terrain/heightfieldGeometry.d.ts +34 -0
- package/dist/terrain/heightfieldGeometry.js +109 -0
- package/dist/terrain/index.d.ts +1 -0
- package/dist/terrain/index.js +1 -0
- package/dist/terrain/soilPatchMaterial.d.ts +15 -2
- package/dist/terrain/soilPatchMaterial.js +46 -8
- package/dist/terrain/terrainDetailMaterial.js +15 -2
- package/dist/water/Ocean.d.ts +7 -1
- package/dist/water/Ocean.js +16 -3
- package/dist/water/OceanConfig.d.ts +9 -0
- package/dist/water/OceanConfig.js +6 -0
- package/dist/water/OceanMaterial.d.ts +12 -0
- package/dist/water/OceanMaterial.js +9 -0
- package/dist/water/OceanShader.d.ts +2 -2
- package/dist/water/OceanShader.js +78 -10
- package/dist/world/PlaceScene.d.ts +11 -0
- package/dist/world/PlaceScene.js +27 -0
- package/dist/world/WorldHud.d.ts +3 -1
- package/dist/world/WorldHud.js +2 -2
- package/dist/world/WorldScene.js +32 -4
- package/dist/worldSky.d.ts +7 -1
- package/dist/worldSky.js +11 -1
- package/package.json +4 -4
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +17 -10
- package/skills/jgengine-ui/api.md +316 -51
- package/skills/jgengine-ui/capabilities.md +135 -2
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +59 -9
- package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +453 -21
- package/skills/jgengine-world/capabilities.md +112 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/cartridge/runtime.d.ts +0 -15
- package/dist/cartridge/runtime.js +0 -556
- package/dist/cartridge/spec.d.ts +0 -188
- package/dist/cartridge/spec.js +0 -19
- package/dist/cartridge/validate.d.ts +0 -2
- package/dist/cartridge/validate.js +0 -78
- package/dist/cartridge.d.ts +0 -145
- package/dist/cartridge.js +0 -246
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
|
|
2
|
+
import { patchDuration } from "@jgengine/core/audio/synth";
|
|
2
3
|
import { createDisposer } from "@jgengine/core/game/defineGame";
|
|
4
|
+
import { clampLoopGain, clampLoopRate } from "./loopParams.js";
|
|
3
5
|
import { MusicDirector } from "./musicDirector.js";
|
|
4
6
|
import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
|
|
7
|
+
/** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
|
|
8
|
+
const LOOP_PARAM_SMOOTH_TC = 0.02;
|
|
5
9
|
function createNoopEngine() {
|
|
6
10
|
return {
|
|
7
11
|
setListenerPose: () => undefined,
|
|
@@ -20,6 +24,12 @@ function resolveAudioContextCtor() {
|
|
|
20
24
|
return (window.AudioContext ??
|
|
21
25
|
window.webkitAudioContext);
|
|
22
26
|
}
|
|
27
|
+
function resolveOfflineAudioContextCtor() {
|
|
28
|
+
if (typeof window === "undefined")
|
|
29
|
+
return undefined;
|
|
30
|
+
return (window.OfflineAudioContext ??
|
|
31
|
+
window.webkitOfflineAudioContext);
|
|
32
|
+
}
|
|
23
33
|
export function createAudioEngine(config = {}) {
|
|
24
34
|
const sounds = config.sounds ?? {};
|
|
25
35
|
const busDefs = config.buses ?? {};
|
|
@@ -74,65 +84,138 @@ export function createAudioEngine(config = {}) {
|
|
|
74
84
|
}
|
|
75
85
|
return pending;
|
|
76
86
|
}
|
|
87
|
+
// Retained synth loops render the procedural patch to one cached buffer, then loop it as a plain
|
|
88
|
+
// BufferSource — so live pitch is `playbackRate` and live gain is a gain node, exactly like sample loops (#1051).
|
|
89
|
+
const synthBufferCache = new Map();
|
|
90
|
+
function renderSynthBuffer(soundId, patch) {
|
|
91
|
+
let pending = synthBufferCache.get(soundId);
|
|
92
|
+
if (pending === undefined) {
|
|
93
|
+
const OfflineCtor = resolveOfflineAudioContextCtor();
|
|
94
|
+
if (OfflineCtor === undefined) {
|
|
95
|
+
pending = Promise.resolve(null);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
try {
|
|
99
|
+
const seconds = Math.max(patchDuration(patch), 0.05);
|
|
100
|
+
const frames = Math.max(1, Math.ceil(seconds * context.sampleRate));
|
|
101
|
+
const offline = new OfflineCtor(1, frames, context.sampleRate);
|
|
102
|
+
realizeSynthPatch(offline, offline.destination, createNoiseBuffer(offline), patch);
|
|
103
|
+
pending = offline.startRendering().catch(() => null);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
pending = Promise.resolve(null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
synthBufferCache.set(soundId, pending);
|
|
110
|
+
}
|
|
111
|
+
return pending;
|
|
112
|
+
}
|
|
77
113
|
let listenerPosition = { x: 0, y: 0, z: 0 };
|
|
78
114
|
const activeSpatialUpdaters = new Set();
|
|
115
|
+
const activeLoops = new Set();
|
|
116
|
+
const NOOP_HANDLE = {
|
|
117
|
+
setPosition: () => undefined,
|
|
118
|
+
setRate: () => undefined,
|
|
119
|
+
setGain: () => undefined,
|
|
120
|
+
stop: () => undefined,
|
|
121
|
+
};
|
|
79
122
|
function playInternal(soundId, position, loop) {
|
|
80
123
|
const sound = sounds[soundId];
|
|
81
124
|
if (sound === undefined)
|
|
82
125
|
return null;
|
|
83
126
|
const bus = busGainNode(sound.bus);
|
|
84
|
-
|
|
85
|
-
|
|
127
|
+
const currentPosition = position ?? listenerPosition;
|
|
128
|
+
// A one-shot synth cue realises its decaying voices live and is fire-and-forget.
|
|
129
|
+
if (sound.synth !== undefined && !loop) {
|
|
86
130
|
const cueGain = context.createGain();
|
|
87
131
|
cueGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
88
132
|
cueGain.connect(bus);
|
|
89
133
|
realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
|
|
90
|
-
return
|
|
134
|
+
return NOOP_HANDLE;
|
|
91
135
|
}
|
|
92
|
-
|
|
136
|
+
// Everything else resolves to one AudioBuffer we loop/play: sample URL, or a synth patch rendered once.
|
|
137
|
+
let bufferPromise;
|
|
138
|
+
if (sound.synth !== undefined)
|
|
139
|
+
bufferPromise = renderSynthBuffer(soundId, sound.synth);
|
|
140
|
+
else if (sound.url !== undefined)
|
|
141
|
+
bufferPromise = loadBuffer(sound.url);
|
|
142
|
+
else
|
|
93
143
|
return null;
|
|
144
|
+
return playBufferSource(sound, bus, bufferPromise, currentPosition, loop);
|
|
145
|
+
}
|
|
146
|
+
function playBufferSource(sound, bus, bufferPromise, initialPosition, loop) {
|
|
94
147
|
const disposer = createDisposer();
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
148
|
+
let currentPosition = initialPosition;
|
|
149
|
+
// Live control state, applied on source creation so updates that race the async buffer load stick.
|
|
150
|
+
let currentRate = 1;
|
|
151
|
+
let currentUserGain = 1;
|
|
152
|
+
let sourceNode = null;
|
|
153
|
+
let userGainNode = null;
|
|
154
|
+
let falloffGain = null;
|
|
155
|
+
function updateFalloff() {
|
|
156
|
+
if (falloffGain !== null) {
|
|
157
|
+
falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
99
158
|
}
|
|
100
159
|
}
|
|
101
|
-
void
|
|
160
|
+
void bufferPromise.then((buffer) => {
|
|
102
161
|
if (buffer === null)
|
|
103
162
|
return;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
163
|
+
const src = context.createBufferSource();
|
|
164
|
+
src.buffer = buffer;
|
|
165
|
+
src.loop = loop || (sound.loop ?? false);
|
|
166
|
+
src.playbackRate.value = currentRate;
|
|
167
|
+
// source → per-loop user gain (setGain) → distance falloff → bus
|
|
168
|
+
const uGain = context.createGain();
|
|
169
|
+
uGain.gain.value = currentUserGain;
|
|
170
|
+
const fGain = context.createGain();
|
|
171
|
+
fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
172
|
+
src.connect(uGain);
|
|
173
|
+
uGain.connect(fGain);
|
|
174
|
+
fGain.connect(bus);
|
|
175
|
+
src.start();
|
|
176
|
+
sourceNode = src;
|
|
177
|
+
userGainNode = uGain;
|
|
178
|
+
falloffGain = fGain;
|
|
113
179
|
disposer.onDispose(() => {
|
|
114
180
|
try {
|
|
115
|
-
|
|
181
|
+
src.stop();
|
|
116
182
|
}
|
|
117
183
|
catch {
|
|
118
184
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
185
|
+
src.disconnect();
|
|
186
|
+
uGain.disconnect();
|
|
187
|
+
fGain.disconnect();
|
|
188
|
+
sourceNode = null;
|
|
189
|
+
userGainNode = null;
|
|
190
|
+
falloffGain = null;
|
|
122
191
|
});
|
|
123
192
|
});
|
|
124
193
|
if (loop)
|
|
125
|
-
activeSpatialUpdaters.add(
|
|
126
|
-
|
|
194
|
+
activeSpatialUpdaters.add(updateFalloff);
|
|
195
|
+
const handle = {
|
|
127
196
|
setPosition(next) {
|
|
128
197
|
currentPosition = next;
|
|
129
|
-
|
|
198
|
+
updateFalloff();
|
|
199
|
+
},
|
|
200
|
+
setRate(rate) {
|
|
201
|
+
currentRate = clampLoopRate(rate);
|
|
202
|
+
if (sourceNode !== null)
|
|
203
|
+
sourceNode.playbackRate.setTargetAtTime(currentRate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
204
|
+
},
|
|
205
|
+
setGain(gain) {
|
|
206
|
+
currentUserGain = clampLoopGain(gain);
|
|
207
|
+
if (userGainNode !== null)
|
|
208
|
+
userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
130
209
|
},
|
|
131
210
|
stop() {
|
|
132
|
-
activeSpatialUpdaters.delete(
|
|
211
|
+
activeSpatialUpdaters.delete(updateFalloff);
|
|
212
|
+
activeLoops.delete(handle);
|
|
133
213
|
disposer.dispose();
|
|
134
214
|
},
|
|
135
215
|
};
|
|
216
|
+
if (loop)
|
|
217
|
+
activeLoops.add(handle);
|
|
218
|
+
return handle;
|
|
136
219
|
}
|
|
137
220
|
return {
|
|
138
221
|
setListenerPose(position) {
|
|
@@ -161,6 +244,8 @@ export function createAudioEngine(config = {}) {
|
|
|
161
244
|
void context.resume().catch(() => undefined);
|
|
162
245
|
},
|
|
163
246
|
dispose() {
|
|
247
|
+
for (const handle of [...activeLoops])
|
|
248
|
+
handle.stop();
|
|
164
249
|
activeSpatialUpdaters.clear();
|
|
165
250
|
director?.dispose();
|
|
166
251
|
void context.close().catch(() => undefined);
|
|
@@ -7,16 +7,34 @@ type AudioMusicPayload = {
|
|
|
7
7
|
theme: string | null;
|
|
8
8
|
transpose?: number;
|
|
9
9
|
};
|
|
10
|
-
|
|
10
|
+
type AudioLoopStartPayload = {
|
|
11
|
+
id: string;
|
|
12
|
+
sound: string;
|
|
13
|
+
at?: readonly [number, number, number];
|
|
14
|
+
};
|
|
15
|
+
type AudioLoopSetPayload = {
|
|
16
|
+
id: string;
|
|
17
|
+
rate?: number;
|
|
18
|
+
gain?: number;
|
|
19
|
+
at?: readonly [number, number, number];
|
|
20
|
+
};
|
|
21
|
+
type AudioLoopStopPayload = {
|
|
22
|
+
id: string;
|
|
23
|
+
};
|
|
24
|
+
/** Minimal event bus the shell wires to the audio engine, including retained id-keyed loops (#1051). @internal */
|
|
11
25
|
export type AudioEventBus = {
|
|
12
26
|
on(event: "audio.play", handler: (payload: AudioPlayPayload) => void): () => void;
|
|
13
27
|
on(event: "audio.music", handler: (payload: AudioMusicPayload) => void): () => void;
|
|
14
28
|
on(event: "audio.resume", handler: () => void): () => void;
|
|
29
|
+
on(event: "audio.loopStart", handler: (payload: AudioLoopStartPayload) => void): () => void;
|
|
30
|
+
on(event: "audio.loopSet", handler: (payload: AudioLoopSetPayload) => void): () => void;
|
|
31
|
+
on(event: "audio.loopStop", handler: (payload: AudioLoopStopPayload) => void): () => void;
|
|
15
32
|
};
|
|
16
33
|
/**
|
|
17
|
-
* Subscribe game audio events to the shell audio engine.
|
|
18
|
-
*
|
|
34
|
+
* Subscribe game audio events to the shell audio engine. Manages the retained-loop registry
|
|
35
|
+
* (`audio.loopStart`/`audio.loopSet`/`audio.loopStop`), keyed by stable id. Returns a single
|
|
36
|
+
* unsubscribe that tears every listener down and stops any loops still playing.
|
|
19
37
|
* @internal
|
|
20
38
|
*/
|
|
21
|
-
export declare function attachAudioEventWire(events: AudioEventBus, audioEngine: Pick<AudioEngine, "playOneShot" | "playMusic" | "resume">): () => void;
|
|
39
|
+
export declare function attachAudioEventWire(events: AudioEventBus, audioEngine: Pick<AudioEngine, "playOneShot" | "playLoop" | "playMusic" | "resume">): () => void;
|
|
22
40
|
export {};
|
package/dist/audio/audioWire.js
CHANGED
|
@@ -1,19 +1,62 @@
|
|
|
1
|
+
function toVec(at) {
|
|
2
|
+
return at === undefined ? undefined : { x: at[0], y: at[1], z: at[2] };
|
|
3
|
+
}
|
|
1
4
|
/**
|
|
2
|
-
* Subscribe game audio events to the shell audio engine.
|
|
3
|
-
*
|
|
5
|
+
* Subscribe game audio events to the shell audio engine. Manages the retained-loop registry
|
|
6
|
+
* (`audio.loopStart`/`audio.loopSet`/`audio.loopStop`), keyed by stable id. Returns a single
|
|
7
|
+
* unsubscribe that tears every listener down and stops any loops still playing.
|
|
4
8
|
* @internal
|
|
5
9
|
*/
|
|
6
10
|
export function attachAudioEventWire(events, audioEngine) {
|
|
11
|
+
const loops = new Map();
|
|
7
12
|
const offPlay = events.on("audio.play", ({ sound, at }) => {
|
|
8
|
-
audioEngine.playOneShot(sound, at
|
|
13
|
+
audioEngine.playOneShot(sound, toVec(at));
|
|
9
14
|
});
|
|
10
15
|
const offMusic = events.on("audio.music", ({ theme, transpose }) => {
|
|
11
16
|
audioEngine.playMusic(theme, transpose === undefined ? undefined : { transpose });
|
|
12
17
|
});
|
|
13
18
|
const offResume = events.on("audio.resume", () => audioEngine.resume());
|
|
19
|
+
const offLoopStart = events.on("audio.loopStart", ({ id, sound, at }) => {
|
|
20
|
+
const existing = loops.get(id);
|
|
21
|
+
if (existing !== undefined) {
|
|
22
|
+
// Same sound: idempotent — keep the running source so a per-tick re-issue never clicks.
|
|
23
|
+
if (existing.sound === sound)
|
|
24
|
+
return;
|
|
25
|
+
existing.handle.stop();
|
|
26
|
+
loops.delete(id);
|
|
27
|
+
}
|
|
28
|
+
const handle = audioEngine.playLoop(sound, toVec(at));
|
|
29
|
+
if (handle !== null)
|
|
30
|
+
loops.set(id, { sound, handle });
|
|
31
|
+
});
|
|
32
|
+
const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at }) => {
|
|
33
|
+
const entry = loops.get(id);
|
|
34
|
+
// Unknown id: a live update may race a stop — silently drop it.
|
|
35
|
+
if (entry === undefined)
|
|
36
|
+
return;
|
|
37
|
+
if (rate !== undefined)
|
|
38
|
+
entry.handle.setRate(rate);
|
|
39
|
+
if (gain !== undefined)
|
|
40
|
+
entry.handle.setGain(gain);
|
|
41
|
+
if (at !== undefined)
|
|
42
|
+
entry.handle.setPosition({ x: at[0], y: at[1], z: at[2] });
|
|
43
|
+
});
|
|
44
|
+
const offLoopStop = events.on("audio.loopStop", ({ id }) => {
|
|
45
|
+
const entry = loops.get(id);
|
|
46
|
+
if (entry === undefined)
|
|
47
|
+
return;
|
|
48
|
+
entry.handle.stop();
|
|
49
|
+
loops.delete(id);
|
|
50
|
+
});
|
|
14
51
|
return () => {
|
|
15
52
|
offPlay();
|
|
16
53
|
offMusic();
|
|
17
54
|
offResume();
|
|
55
|
+
offLoopStart();
|
|
56
|
+
offLoopSet();
|
|
57
|
+
offLoopStop();
|
|
58
|
+
for (const { handle } of loops.values())
|
|
59
|
+
handle.stop();
|
|
60
|
+
loops.clear();
|
|
18
61
|
};
|
|
19
62
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure clamps for retained-loop live control (#1051). Kept dependency-free so the
|
|
3
|
+
* playback-rate/gain windows are one testable source of truth the audio engine reads.
|
|
4
|
+
*/
|
|
5
|
+
/** Lowest live playback-rate multiplier (two octaves below authored pitch). */
|
|
6
|
+
export declare const MIN_LOOP_RATE = 0.25;
|
|
7
|
+
/** Highest live playback-rate multiplier (two octaves above authored pitch). */
|
|
8
|
+
export declare const MAX_LOOP_RATE = 4;
|
|
9
|
+
/**
|
|
10
|
+
* Clamp a loop playback-rate multiplier into the safe pitch window `MIN_LOOP_RATE`–`MAX_LOOP_RATE`
|
|
11
|
+
* (1 = authored pitch). Non-finite input (NaN/±Infinity) falls back to the authored `1`.
|
|
12
|
+
* @internal — the audio engine applies this; game code sets rate through `ctx.game.audio.setLoop`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function clampLoopRate(rate: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Clamp a loop gain multiplier into `0`–`1`. Non-finite input falls back to `0` (silence) so a
|
|
17
|
+
* stray NaN never blasts a loop to full volume.
|
|
18
|
+
* @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clampLoopGain(gain: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure clamps for retained-loop live control (#1051). Kept dependency-free so the
|
|
3
|
+
* playback-rate/gain windows are one testable source of truth the audio engine reads.
|
|
4
|
+
*/
|
|
5
|
+
/** Lowest live playback-rate multiplier (two octaves below authored pitch). */
|
|
6
|
+
export const MIN_LOOP_RATE = 0.25;
|
|
7
|
+
/** Highest live playback-rate multiplier (two octaves above authored pitch). */
|
|
8
|
+
export const MAX_LOOP_RATE = 4;
|
|
9
|
+
/**
|
|
10
|
+
* Clamp a loop playback-rate multiplier into the safe pitch window `MIN_LOOP_RATE`–`MAX_LOOP_RATE`
|
|
11
|
+
* (1 = authored pitch). Non-finite input (NaN/±Infinity) falls back to the authored `1`.
|
|
12
|
+
* @internal — the audio engine applies this; game code sets rate through `ctx.game.audio.setLoop`.
|
|
13
|
+
*/
|
|
14
|
+
export function clampLoopRate(rate) {
|
|
15
|
+
if (!Number.isFinite(rate))
|
|
16
|
+
return 1;
|
|
17
|
+
return Math.min(MAX_LOOP_RATE, Math.max(MIN_LOOP_RATE, rate));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Clamp a loop gain multiplier into `0`–`1`. Non-finite input falls back to `0` (silence) so a
|
|
21
|
+
* stray NaN never blasts a loop to full volume.
|
|
22
|
+
* @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
|
|
23
|
+
*/
|
|
24
|
+
export function clampLoopGain(gain) {
|
|
25
|
+
if (!Number.isFinite(gain))
|
|
26
|
+
return 0;
|
|
27
|
+
return Math.min(1, Math.max(0, gain));
|
|
28
|
+
}
|
|
@@ -22,7 +22,12 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
|
|
|
22
22
|
const followEntityId = directed.followEntityId;
|
|
23
23
|
const rig = (() => {
|
|
24
24
|
if (directed.cinematic !== undefined) {
|
|
25
|
-
|
|
25
|
+
// A director-set cinematic auto-clears when its keyframe path completes, so a
|
|
26
|
+
// one-shot flyover can never strand the camera if a game-side clear timer drifts
|
|
27
|
+
// (the rig plays in real time; sim-clock timers do not). Static config cinematics
|
|
28
|
+
// hold their final frame as before.
|
|
29
|
+
const owner = director !== undefined && director.cinematic() !== null ? director : undefined;
|
|
30
|
+
return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId, absoluteFov: true, ...(owner === undefined ? {} : { onComplete: () => owner.setCinematic(null) }) }));
|
|
26
31
|
}
|
|
27
32
|
const kind = resolveRigKind(config);
|
|
28
33
|
switch (kind) {
|
|
@@ -3,6 +3,7 @@ import { OrbitControls } from "@react-three/drei";
|
|
|
3
3
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
import { MOUSE, PerspectiveCamera, Raycaster, Vector3 } from "three";
|
|
6
|
+
import { isCameraOccluderTransparent } from "./cameraCollision.js";
|
|
6
7
|
import { useGameContext } from "@jgengine/react/provider";
|
|
7
8
|
import { usePlayer } from "@jgengine/react/hooks";
|
|
8
9
|
import { cameraLookPitch, compensatedFov, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
|
|
@@ -81,6 +82,10 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
|
|
|
81
82
|
ray.set(collisionOriginRef.current.set(t.x, t.y, t.z), dir);
|
|
82
83
|
ray.near = config.collision.minTargetDistance;
|
|
83
84
|
ray.far = dist;
|
|
85
|
+
// Sprites (VFX, billboards) live in the scene; three's Sprite.raycast
|
|
86
|
+
// errors every frame unless the raycaster has a camera, even though we
|
|
87
|
+
// discard sprite hits below. Setting it silences that per-frame log.
|
|
88
|
+
ray.camera = camera;
|
|
84
89
|
let blocked = 0;
|
|
85
90
|
for (const hit of ray.intersectObjects(scene.children, true)) {
|
|
86
91
|
const obj = hit.object;
|
|
@@ -88,7 +93,8 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
|
|
|
88
93
|
continue;
|
|
89
94
|
if (obj.isSprite === true)
|
|
90
95
|
continue;
|
|
91
|
-
|
|
96
|
+
// Inheritable: decor flags a group jgCameraTransparent and all children pass through.
|
|
97
|
+
if (isCameraOccluderTransparent(obj))
|
|
92
98
|
continue;
|
|
93
99
|
blocked = hit.distance;
|
|
94
100
|
break;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera spring-arm occlusion filtering.
|
|
3
|
+
*
|
|
4
|
+
* The orbit rig raycasts target→camera each frame and pulls the boom in past
|
|
5
|
+
* occluders. Author decor mounted through the `WorldOverlay` presentation seam
|
|
6
|
+
* is dressing, not collision geometry, so it should never yank the camera. A
|
|
7
|
+
* decor group flags itself camera-transparent once (`jgCameraTransparent`) and
|
|
8
|
+
* every descendant inherits it — a child can opt back into blocking the camera
|
|
9
|
+
* with `jgCameraCollide`.
|
|
10
|
+
*/
|
|
11
|
+
/** Minimal structural view of a raycast hit object: its own tags plus a parent link to walk. Keeps this filter free of a three.js import so it stays unit-testable. */
|
|
12
|
+
export interface CameraOccluder {
|
|
13
|
+
readonly userData?: Record<string, unknown>;
|
|
14
|
+
readonly parent?: CameraOccluder | null;
|
|
15
|
+
}
|
|
16
|
+
/** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
|
|
17
|
+
export declare const CAMERA_TRANSPARENT_USERDATA: {
|
|
18
|
+
readonly jgCameraTransparent: true;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Should the camera spring-arm ignore this raycast hit? Walks the object up its
|
|
22
|
+
* `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
|
|
23
|
+
* blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
|
|
24
|
+
* geometry blocks as before, so engine-owned ground/entities are unaffected.
|
|
25
|
+
*
|
|
26
|
+
* @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
|
|
27
|
+
*/
|
|
28
|
+
export declare function isCameraOccluderTransparent(object: CameraOccluder | null | undefined): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera spring-arm occlusion filtering.
|
|
3
|
+
*
|
|
4
|
+
* The orbit rig raycasts target→camera each frame and pulls the boom in past
|
|
5
|
+
* occluders. Author decor mounted through the `WorldOverlay` presentation seam
|
|
6
|
+
* is dressing, not collision geometry, so it should never yank the camera. A
|
|
7
|
+
* decor group flags itself camera-transparent once (`jgCameraTransparent`) and
|
|
8
|
+
* every descendant inherits it — a child can opt back into blocking the camera
|
|
9
|
+
* with `jgCameraCollide`.
|
|
10
|
+
*/
|
|
11
|
+
/** userData applied to the group wrapping author `WorldOverlay` decor so the spring-arm ignores it by default. */
|
|
12
|
+
export const CAMERA_TRANSPARENT_USERDATA = {
|
|
13
|
+
jgCameraTransparent: true,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Should the camera spring-arm ignore this raycast hit? Walks the object up its
|
|
17
|
+
* `.parent` chain and honors the nearest camera tag: `jgCameraCollide === true`
|
|
18
|
+
* blocks (opt back in), `jgCameraTransparent === true` passes through. Untagged
|
|
19
|
+
* geometry blocks as before, so engine-owned ground/entities are unaffected.
|
|
20
|
+
*
|
|
21
|
+
* @capability camera-transparent-decor let author decor pass through the orbit spring-arm; a child opts back in with jgCameraCollide
|
|
22
|
+
*/
|
|
23
|
+
export function isCameraOccluderTransparent(object) {
|
|
24
|
+
for (let node = object ?? null; node != null; node = node.parent ?? null) {
|
|
25
|
+
const data = node.userData;
|
|
26
|
+
if (data === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
if (data.jgCameraCollide === true)
|
|
29
|
+
return false;
|
|
30
|
+
if (data.jgCameraTransparent === true)
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
@@ -26,4 +26,6 @@ export declare function ChaseRig(props: RigProps): null;
|
|
|
26
26
|
* kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
|
|
27
27
|
*/
|
|
28
28
|
export declare function ObserverRig(props: RigProps): null;
|
|
29
|
-
export declare function CinematicRig(props: RigProps
|
|
29
|
+
export declare function CinematicRig(props: RigProps & {
|
|
30
|
+
onComplete?: () => void;
|
|
31
|
+
}): null;
|
|
@@ -4,7 +4,7 @@ import { PerspectiveCamera, Quaternion, Vector3 } from "three";
|
|
|
4
4
|
import { usePlayer } from "@jgengine/react/hooks";
|
|
5
5
|
import { useGameContext } from "@jgengine/react/provider";
|
|
6
6
|
import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
7
|
-
import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, } from "./rigMath.js";
|
|
7
|
+
import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
|
|
8
8
|
import { usePlayerFov } from "./PlayerFov.js";
|
|
9
9
|
import { useCameraShake } from "./shakeChannel.js";
|
|
10
10
|
import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
|
|
@@ -262,13 +262,9 @@ export function RtsRig(props) {
|
|
|
262
262
|
}
|
|
263
263
|
const center = centerRef.current;
|
|
264
264
|
if (panX !== 0 || panZ !== 0) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const worldZ = panZ * cos - panX * sin;
|
|
269
|
-
const len = Math.hypot(worldX, worldZ) || 1;
|
|
270
|
-
center.x += (worldX / len) * panSpeed * dt;
|
|
271
|
-
center.z += (worldZ / len) * panSpeed * dt;
|
|
265
|
+
const dir = rtsPanWorldDir(panX, panZ, yawRef.current);
|
|
266
|
+
center.x += dir.x * panSpeed * dt;
|
|
267
|
+
center.z += dir.z * panSpeed * dt;
|
|
272
268
|
}
|
|
273
269
|
else {
|
|
274
270
|
const follow = readFollow(ctx, followId);
|
|
@@ -424,6 +420,7 @@ export function ChaseRig(props) {
|
|
|
424
420
|
const posRef = useRef(null);
|
|
425
421
|
const lastFollowRef = useRef(null);
|
|
426
422
|
const lastYawRef = useRef(null);
|
|
423
|
+
const anchorYawRef = useRef(null);
|
|
427
424
|
const rollRef = useRef(0);
|
|
428
425
|
const view = config?.view ?? "chase";
|
|
429
426
|
useEffect(beginTransition, [view]);
|
|
@@ -454,15 +451,24 @@ export function ChaseRig(props) {
|
|
|
454
451
|
commit(pose, dt);
|
|
455
452
|
return;
|
|
456
453
|
}
|
|
454
|
+
let anchorYaw = yaw;
|
|
455
|
+
if (resolved.velocityYawBlend > 0) {
|
|
456
|
+
const velocity = dt > 0
|
|
457
|
+
? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
|
|
458
|
+
: { x: 0, y: 0, z: 0 };
|
|
459
|
+
const targetYaw = velocityYawTarget(yaw, velocity, resolved);
|
|
460
|
+
anchorYaw = smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, resolved.velocityYawResponse, dt);
|
|
461
|
+
anchorYawRef.current = anchorYaw;
|
|
462
|
+
}
|
|
457
463
|
const led = leadFollowPoint(follow, last, dt, resolved);
|
|
458
|
-
const desired = chaseDesiredPosition(led,
|
|
464
|
+
const desired = chaseDesiredPosition(led, anchorYaw, resolved);
|
|
459
465
|
const prev = posRef.current ?? desired;
|
|
460
466
|
const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
|
|
461
467
|
posRef.current = smoothed;
|
|
462
468
|
if (resolved.shakePerSpeed > 0 && speed > 0) {
|
|
463
469
|
shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
|
|
464
470
|
}
|
|
465
|
-
const pose = { position: smoothed, lookAt: chaseLookAt(led,
|
|
471
|
+
const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
|
|
466
472
|
commit(pose, dt);
|
|
467
473
|
if (rollRef.current !== 0)
|
|
468
474
|
camera.rotateZ(rollRef.current);
|
|
@@ -510,8 +516,12 @@ export function CinematicRig(props) {
|
|
|
510
516
|
const cinematic = props.config?.cinematic;
|
|
511
517
|
const { camera, commit, beginTransition } = useCameraCommit(props, followId);
|
|
512
518
|
const elapsedRef = useRef(0);
|
|
519
|
+
const completedRef = useRef(false);
|
|
520
|
+
const onCompleteRef = useRef(props.onComplete);
|
|
521
|
+
onCompleteRef.current = props.onComplete;
|
|
513
522
|
useEffect(() => {
|
|
514
523
|
elapsedRef.current = 0;
|
|
524
|
+
completedRef.current = false;
|
|
515
525
|
beginTransition();
|
|
516
526
|
}, [cinematic]);
|
|
517
527
|
useFrame((_, dt) => {
|
|
@@ -520,6 +530,10 @@ export function CinematicRig(props) {
|
|
|
520
530
|
elapsedRef.current += dt;
|
|
521
531
|
const sample = cinematicSample(cinematic.keyframes, elapsedRef.current, cinematic.loop ?? false, currentFov(camera));
|
|
522
532
|
commit(sample.pose, dt);
|
|
533
|
+
if (sample.done && !completedRef.current) {
|
|
534
|
+
completedRef.current = true;
|
|
535
|
+
onCompleteRef.current?.();
|
|
536
|
+
}
|
|
523
537
|
}, CAMERA_RIG_FRAME_PRIORITY);
|
|
524
538
|
return null;
|
|
525
539
|
}
|
package/dist/camera/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, type PlayerFovState,
|
|
|
7
7
|
export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, type CameraShakeChannel, } from "./shakeChannel.js";
|
|
8
8
|
export { rtsPanKeysConflict } from "./rigMath.js";
|
|
9
9
|
export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
10
|
+
export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
|
package/dist/camera/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { PlayerFovProvider, PlayerFovSlider, usePlayerFov, } from "./PlayerFov.j
|
|
|
7
7
|
export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, } from "./shakeChannel.js";
|
|
8
8
|
export { rtsPanKeysConflict } from "./rigMath.js";
|
|
9
9
|
export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
10
|
+
export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
|
package/dist/camera/rigMath.d.ts
CHANGED
|
@@ -37,6 +37,13 @@ export declare function resolveTopDown(config: TopDownCameraConfig | undefined):
|
|
|
37
37
|
* @internal
|
|
38
38
|
*/
|
|
39
39
|
export declare function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose;
|
|
40
|
+
/**
|
|
41
|
+
* World-space direction for RTS pan input: `panX` slides along screen-right,
|
|
42
|
+
* `panZ` along the camera's ground-plane forward, for a camera posed by
|
|
43
|
+
* `topDownPose` at the same yaw. Normalized; zero input returns zero.
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export declare function rtsPanWorldDir(panX: number, panZ: number, yaw: number): Vec3;
|
|
40
47
|
/** Exponential spring-arm approach toward a desired point (frame-rate independent).
|
|
41
48
|
* @internal
|
|
42
49
|
*/
|
|
@@ -121,6 +128,12 @@ export interface ResolvedChase {
|
|
|
121
128
|
bankPerYawRate: number;
|
|
122
129
|
bankMax: number;
|
|
123
130
|
bankDamping: number;
|
|
131
|
+
/** Max fraction of the slip angle the anchor yaw follows toward velocity; 0 = drift-lag off (#1051). */
|
|
132
|
+
velocityYawBlend: number;
|
|
133
|
+
/** Below this planar speed the drift-lag feature is fully off. */
|
|
134
|
+
velocityYawMinSpeed: number;
|
|
135
|
+
/** Exponential smoothing rate toward the blended anchor yaw (1-exp(-response*dt)). */
|
|
136
|
+
velocityYawResponse: number;
|
|
124
137
|
}
|
|
125
138
|
/** @internal */
|
|
126
139
|
export declare function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase;
|
|
@@ -152,6 +165,20 @@ export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved
|
|
|
152
165
|
* @internal
|
|
153
166
|
*/
|
|
154
167
|
export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
|
|
168
|
+
/**
|
|
169
|
+
* Drift-lag (#1051): the yaw the chase anchor should aim before temporal
|
|
170
|
+
* smoothing. When the target slides, its planar `velocity` diverges from its
|
|
171
|
+
* `heading`; this rotates the anchor from heading partway toward the velocity
|
|
172
|
+
* direction so the camera reveals the car's side while travel stays framed. The
|
|
173
|
+
* traversed fraction is `velocityYawBlend` scaled by how far the slip angle has
|
|
174
|
+
* opened (0 at straight-line travel, full at a 90° slide), so ordinary driving
|
|
175
|
+
* is untouched. Returns the raw heading — no swing — when the feature is off,
|
|
176
|
+
* when planar speed is below `velocityYawMinSpeed`, or when the target is
|
|
177
|
+
* reversing (velocity opposed to heading forward). Blending is shortest-arc, so
|
|
178
|
+
* it stays correct across the ±PI wrap seam.
|
|
179
|
+
* @internal
|
|
180
|
+
*/
|
|
181
|
+
export declare function velocityYawTarget(heading: number, velocity: Vec3, resolved: ResolvedChase): number;
|
|
155
182
|
/** World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
|
|
156
183
|
* @internal
|
|
157
184
|
*/
|