@jgengine/shell 0.18.1 → 0.19.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 +158 -7
- package/dist/GamePlayerShell.js +20 -3
- package/dist/Shell3dPresentation.d.ts +3 -9
- package/dist/Shell3dPresentation.js +26 -6
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/audio/AudioComponents.js +6 -2
- package/dist/audio/audioEngine.d.ts +8 -0
- package/dist/audio/audioEngine.js +65 -6
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +70 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +20 -3
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.js +13 -2
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +1 -1
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +11 -1
- package/dist/input/gamepadSource.js +117 -71
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/postfx/PostProcessing.d.ts +1 -1
- package/dist/postfx/PostProcessing.js +5 -1
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/SceneModels.js +1 -1
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +29 -0
- package/dist/render/modelLoad.js +61 -1
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +53 -4
- package/dist/render/useFootIk.js +197 -70
- package/dist/render/useModelAnimation.d.ts +24 -0
- package/dist/render/useModelAnimation.js +61 -40
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +6 -6
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +9 -3
- package/dist/settings/appliedSettings.js +6 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/useShellMultiplayerSync.d.ts +1 -1
- package/dist/useShellMultiplayerSync.js +5 -3
- package/dist/world/WorldParticles.js +1 -1
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +13 -16
- package/skills/jgengine-ui/api.md +418 -317
- package/skills/jgengine-ui/capabilities.md +16 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -81
- package/skills/jgengine-world/SKILL.md +15 -3
- package/skills/jgengine-world/api.md +873 -626
- package/skills/jgengine-world/capabilities.md +130 -6
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
- package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
|
|
2
|
+
import { dopplerRate } from "@jgengine/core/audio/doppler";
|
|
2
3
|
import { patchDuration } from "@jgengine/core/audio/synth";
|
|
3
4
|
import { createDisposer } from "@jgengine/core/game/defineGame";
|
|
4
|
-
import { clampLoopGain, clampLoopRate } from "./loopParams.js";
|
|
5
|
+
import { clampLoopCutoff, clampLoopGain, clampLoopRate, MIN_LOOP_CUTOFF, MAX_LOOP_CUTOFF } from "./loopParams.js";
|
|
5
6
|
import { MusicDirector } from "./musicDirector.js";
|
|
6
7
|
import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
|
|
7
8
|
/** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
|
|
8
9
|
const LOOP_PARAM_SMOOTH_TC = 0.02;
|
|
10
|
+
const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
|
|
9
11
|
function createNoopEngine() {
|
|
10
12
|
return {
|
|
11
13
|
setListenerPose: () => undefined,
|
|
@@ -140,12 +142,17 @@ export function createAudioEngine(config = {}) {
|
|
|
140
142
|
return pending;
|
|
141
143
|
}
|
|
142
144
|
let listenerPosition = { x: 0, y: 0, z: 0 };
|
|
145
|
+
let listenerVelocity = { x: 0, y: 0, z: 0 };
|
|
146
|
+
const nyquist = context.sampleRate / 2;
|
|
143
147
|
const activeSpatialUpdaters = new Set();
|
|
144
148
|
const activeLoops = new Set();
|
|
145
149
|
const NOOP_HANDLE = {
|
|
146
150
|
setPosition: () => undefined,
|
|
147
151
|
setRate: () => undefined,
|
|
148
152
|
setGain: () => undefined,
|
|
153
|
+
setLowpass: () => undefined,
|
|
154
|
+
setHighpass: () => undefined,
|
|
155
|
+
setVelocity: () => undefined,
|
|
149
156
|
stop: () => undefined,
|
|
150
157
|
};
|
|
151
158
|
function playInternal(soundId, position, loop) {
|
|
@@ -187,16 +194,34 @@ export function createAudioEngine(config = {}) {
|
|
|
187
194
|
// Live control state, applied on source creation so updates that race the async buffer load stick.
|
|
188
195
|
let currentRate = 1;
|
|
189
196
|
let currentUserGain = 1;
|
|
197
|
+
let currentLowpass = MAX_LOOP_CUTOFF;
|
|
198
|
+
let currentHighpass = MIN_LOOP_CUTOFF;
|
|
199
|
+
let currentVelocity = { x: 0, y: 0, z: 0 };
|
|
200
|
+
const dopplerFactor = loop ? sound.doppler ?? 0 : 0;
|
|
190
201
|
let sourceNode = null;
|
|
202
|
+
let lowpassNode = null;
|
|
203
|
+
let highpassNode = null;
|
|
191
204
|
let userGainNode = null;
|
|
192
205
|
let falloffGain = null;
|
|
193
206
|
let pannerNode = null;
|
|
207
|
+
function effectiveRate() {
|
|
208
|
+
if (dopplerFactor === 0)
|
|
209
|
+
return currentRate;
|
|
210
|
+
const shift = dopplerRate([listenerPosition.x, listenerPosition.y, listenerPosition.z], [listenerVelocity.x, listenerVelocity.y, listenerVelocity.z], [currentPosition.x, currentPosition.y, currentPosition.z], [currentVelocity.x, currentVelocity.y, currentVelocity.z], { factor: dopplerFactor });
|
|
211
|
+
return clampLoopRate(currentRate * shift);
|
|
212
|
+
}
|
|
213
|
+
function applyRate() {
|
|
214
|
+
if (sourceNode !== null)
|
|
215
|
+
sourceNode.playbackRate.setTargetAtTime(effectiveRate(), context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
216
|
+
}
|
|
194
217
|
function updateFalloff() {
|
|
195
218
|
if (falloffGain !== null && sound.spatial === undefined) {
|
|
196
219
|
falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
197
220
|
}
|
|
198
221
|
if (pannerNode !== null)
|
|
199
222
|
setPannerPosition(pannerNode, currentPosition);
|
|
223
|
+
if (dopplerFactor !== 0)
|
|
224
|
+
applyRate();
|
|
200
225
|
}
|
|
201
226
|
void bufferPromise.then((buffer) => {
|
|
202
227
|
if (buffer === null)
|
|
@@ -204,13 +229,28 @@ export function createAudioEngine(config = {}) {
|
|
|
204
229
|
const src = context.createBufferSource();
|
|
205
230
|
src.buffer = buffer;
|
|
206
231
|
src.loop = loop || (sound.loop ?? false);
|
|
207
|
-
src.playbackRate.value =
|
|
208
|
-
// source → per-loop user gain (setGain) → distance falloff → bus
|
|
232
|
+
src.playbackRate.value = effectiveRate();
|
|
233
|
+
// source → [lowpass → highpass, loops only] → per-loop user gain (setGain) → distance falloff → bus
|
|
209
234
|
const uGain = context.createGain();
|
|
210
235
|
uGain.gain.value = currentUserGain;
|
|
211
236
|
const fGain = context.createGain();
|
|
212
237
|
fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
213
|
-
|
|
238
|
+
if (loop) {
|
|
239
|
+
const lp = context.createBiquadFilter();
|
|
240
|
+
lp.type = "lowpass";
|
|
241
|
+
lp.frequency.value = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
|
|
242
|
+
const hp = context.createBiquadFilter();
|
|
243
|
+
hp.type = "highpass";
|
|
244
|
+
hp.frequency.value = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
|
|
245
|
+
src.connect(lp);
|
|
246
|
+
lp.connect(hp);
|
|
247
|
+
hp.connect(uGain);
|
|
248
|
+
lowpassNode = lp;
|
|
249
|
+
highpassNode = hp;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
src.connect(uGain);
|
|
253
|
+
}
|
|
214
254
|
uGain.connect(fGain);
|
|
215
255
|
if (sound.spatial !== undefined) {
|
|
216
256
|
const panner = createPanner(context, sound.spatial, currentPosition);
|
|
@@ -233,10 +273,14 @@ export function createAudioEngine(config = {}) {
|
|
|
233
273
|
catch {
|
|
234
274
|
}
|
|
235
275
|
src.disconnect();
|
|
276
|
+
lowpassNode?.disconnect();
|
|
277
|
+
highpassNode?.disconnect();
|
|
236
278
|
uGain.disconnect();
|
|
237
279
|
fGain.disconnect();
|
|
238
280
|
pannerNode?.disconnect();
|
|
239
281
|
sourceNode = null;
|
|
282
|
+
lowpassNode = null;
|
|
283
|
+
highpassNode = null;
|
|
240
284
|
userGainNode = null;
|
|
241
285
|
falloffGain = null;
|
|
242
286
|
});
|
|
@@ -250,8 +294,22 @@ export function createAudioEngine(config = {}) {
|
|
|
250
294
|
},
|
|
251
295
|
setRate(rate) {
|
|
252
296
|
currentRate = clampLoopRate(rate);
|
|
253
|
-
|
|
254
|
-
|
|
297
|
+
applyRate();
|
|
298
|
+
},
|
|
299
|
+
setLowpass(hz) {
|
|
300
|
+
currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
|
|
301
|
+
if (lowpassNode !== null)
|
|
302
|
+
lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
303
|
+
},
|
|
304
|
+
setHighpass(hz) {
|
|
305
|
+
currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
|
|
306
|
+
if (highpassNode !== null)
|
|
307
|
+
highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
308
|
+
},
|
|
309
|
+
setVelocity(velocity) {
|
|
310
|
+
currentVelocity = velocity;
|
|
311
|
+
if (dopplerFactor !== 0)
|
|
312
|
+
applyRate();
|
|
255
313
|
},
|
|
256
314
|
setGain(gain) {
|
|
257
315
|
currentUserGain = clampLoopGain(gain);
|
|
@@ -272,6 +330,7 @@ export function createAudioEngine(config = {}) {
|
|
|
272
330
|
setListenerPose(position) {
|
|
273
331
|
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
274
332
|
listenerPosition = pose.position;
|
|
333
|
+
listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
|
|
275
334
|
const listener = context.listener;
|
|
276
335
|
setAudioParam(listener.positionX, pose.position.x);
|
|
277
336
|
setAudioParam(listener.positionY, pose.position.y);
|
package/dist/audio/audioWire.js
CHANGED
|
@@ -29,7 +29,7 @@ export function attachAudioEventWire(events, audioEngine) {
|
|
|
29
29
|
if (handle !== null)
|
|
30
30
|
loops.set(id, { sound, handle });
|
|
31
31
|
});
|
|
32
|
-
const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at }) => {
|
|
32
|
+
const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at, velocity, lowpass, highpass }) => {
|
|
33
33
|
const entry = loops.get(id);
|
|
34
34
|
// Unknown id: a live update may race a stop — silently drop it.
|
|
35
35
|
if (entry === undefined)
|
|
@@ -40,6 +40,12 @@ export function attachAudioEventWire(events, audioEngine) {
|
|
|
40
40
|
entry.handle.setGain(gain);
|
|
41
41
|
if (at !== undefined)
|
|
42
42
|
entry.handle.setPosition({ x: at[0], y: at[1], z: at[2] });
|
|
43
|
+
if (velocity !== undefined)
|
|
44
|
+
entry.handle.setVelocity({ x: velocity[0], y: velocity[1], z: velocity[2] });
|
|
45
|
+
if (lowpass !== undefined)
|
|
46
|
+
entry.handle.setLowpass(lowpass);
|
|
47
|
+
if (highpass !== undefined)
|
|
48
|
+
entry.handle.setHighpass(highpass);
|
|
43
49
|
});
|
|
44
50
|
const offLoopStop = events.on("audio.loopStop", ({ id }) => {
|
|
45
51
|
const entry = loops.get(id);
|
|
@@ -18,3 +18,42 @@ export declare function clampLoopRate(rate: number): number;
|
|
|
18
18
|
* @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
|
|
19
19
|
*/
|
|
20
20
|
export declare function clampLoopGain(gain: number): number;
|
|
21
|
+
/** Lowest live filter cutoff, Hz. */
|
|
22
|
+
export declare const MIN_LOOP_CUTOFF = 10;
|
|
23
|
+
/** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
|
|
24
|
+
export declare const MAX_LOOP_CUTOFF = 22050;
|
|
25
|
+
/**
|
|
26
|
+
* Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
|
|
27
|
+
* falls back to `fallback` (the open, transparent end of that filter).
|
|
28
|
+
* @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function clampLoopCutoff(hz: number, fallback: number, nyquist?: number): number;
|
|
31
|
+
/** Smoothed listener velocity estimate from per-frame camera positions. @internal */
|
|
32
|
+
export interface ListenerVelocityTrack {
|
|
33
|
+
last: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
z: number;
|
|
37
|
+
} | null;
|
|
38
|
+
velocity: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
z: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** @internal */
|
|
45
|
+
export declare function createListenerVelocityTrack(): ListenerVelocityTrack;
|
|
46
|
+
/**
|
|
47
|
+
* Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
|
|
48
|
+
* ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export declare function stepListenerVelocity(track: ListenerVelocityTrack, position: {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
z: number;
|
|
55
|
+
}, dt: number): {
|
|
56
|
+
x: number;
|
|
57
|
+
y: number;
|
|
58
|
+
z: number;
|
|
59
|
+
};
|
package/dist/audio/loopParams.js
CHANGED
|
@@ -26,3 +26,46 @@ export function clampLoopGain(gain) {
|
|
|
26
26
|
return 0;
|
|
27
27
|
return Math.min(1, Math.max(0, gain));
|
|
28
28
|
}
|
|
29
|
+
/** Lowest live filter cutoff, Hz. */
|
|
30
|
+
export const MIN_LOOP_CUTOFF = 10;
|
|
31
|
+
/** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
|
|
32
|
+
export const MAX_LOOP_CUTOFF = 22050;
|
|
33
|
+
/**
|
|
34
|
+
* Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
|
|
35
|
+
* falls back to `fallback` (the open, transparent end of that filter).
|
|
36
|
+
* @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
|
|
37
|
+
*/
|
|
38
|
+
export function clampLoopCutoff(hz, fallback, nyquist = MAX_LOOP_CUTOFF) {
|
|
39
|
+
const max = Math.min(MAX_LOOP_CUTOFF, nyquist);
|
|
40
|
+
if (!Number.isFinite(hz))
|
|
41
|
+
return Math.min(max, Math.max(MIN_LOOP_CUTOFF, fallback));
|
|
42
|
+
return Math.min(max, Math.max(MIN_LOOP_CUTOFF, hz));
|
|
43
|
+
}
|
|
44
|
+
/** @internal */
|
|
45
|
+
export function createListenerVelocityTrack() {
|
|
46
|
+
return { last: null, velocity: { x: 0, y: 0, z: 0 } };
|
|
47
|
+
}
|
|
48
|
+
/** Camera jumps faster than this (world units/s) are cuts or teleports, not motion, and reset the estimate. */
|
|
49
|
+
const MAX_LISTENER_SPEED = 400;
|
|
50
|
+
/**
|
|
51
|
+
* Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
|
|
52
|
+
* ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export function stepListenerVelocity(track, position, dt) {
|
|
56
|
+
const last = track.last;
|
|
57
|
+
track.last = { x: position.x, y: position.y, z: position.z };
|
|
58
|
+
if (last === null || !(dt > 0))
|
|
59
|
+
return track.velocity;
|
|
60
|
+
const vx = (position.x - last.x) / dt;
|
|
61
|
+
const vy = (position.y - last.y) / dt;
|
|
62
|
+
const vz = (position.z - last.z) / dt;
|
|
63
|
+
if (Math.hypot(vx, vy, vz) > MAX_LISTENER_SPEED) {
|
|
64
|
+
track.velocity = { x: 0, y: 0, z: 0 };
|
|
65
|
+
return track.velocity;
|
|
66
|
+
}
|
|
67
|
+
const blend = 1 - Math.exp(-dt / 0.05);
|
|
68
|
+
const v = track.velocity;
|
|
69
|
+
track.velocity = { x: v.x + (vx - v.x) * blend, y: v.y + (vy - v.y) * blend, z: v.z + (vz - v.z) * blend };
|
|
70
|
+
return track.velocity;
|
|
71
|
+
}
|
|
@@ -37,7 +37,7 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
|
|
|
37
37
|
const kind = resolveRigKind(config);
|
|
38
38
|
switch (kind) {
|
|
39
39
|
case "first":
|
|
40
|
-
return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel }));
|
|
40
|
+
return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel, weapon: config?.weapon }));
|
|
41
41
|
case "topDown":
|
|
42
42
|
return _jsx(TopDownRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
|
|
43
43
|
case "rts":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ComponentType, type MutableRefObject } from "react";
|
|
2
2
|
import * as THREE from "three";
|
|
3
|
-
import type { FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import type { CameraWeaponView, FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
|
|
4
4
|
import type { EntityRenderCues } from "@jgengine/core/combat/renderCues";
|
|
5
5
|
/** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
|
|
6
6
|
export declare function readFirstPersonMuzzle(target: THREE.Vector3): boolean;
|
|
@@ -15,5 +15,7 @@ export interface GameFirstPersonCameraProps {
|
|
|
15
15
|
followEntityId?: string;
|
|
16
16
|
/** Custom viewmodel component replacing the built-in three-mesh gun, rendered inside the same camera-locked, muzzle-tracked anchor. Ignored when `config.viewmodel === false`. */
|
|
17
17
|
viewmodel?: ComponentType<ViewmodelProps>;
|
|
18
|
+
/** Held-weapon source (`GameCameraConfig.weapon`): poses the viewmodel and adds recoil to the look. */
|
|
19
|
+
weapon?: (entityId: string) => CameraWeaponView | null;
|
|
18
20
|
}
|
|
19
|
-
export declare function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, }: GameFirstPersonCameraProps): import("react").JSX.Element | null;
|
|
21
|
+
export declare function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, weapon, }: GameFirstPersonCameraProps): import("react").JSX.Element | null;
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
-
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { DEFAULT_EYE_HEIGHT } from "@jgengine/core/combat/shotOrigin";
|
|
6
|
+
import { createWeaponPresentation, viewmodelFovScale, } from "@jgengine/core/combat/weaponPresentation";
|
|
6
7
|
import { useGameContext } from "@jgengine/react/provider";
|
|
7
8
|
import { usePlayer } from "@jgengine/react/hooks";
|
|
8
9
|
import { useEntityRenderCues } from "../render/useEntityRenderCues.js";
|
|
9
10
|
import { usePlayerFov } from "./PlayerFov.js";
|
|
10
11
|
import { GAME_SIM_FRAME_PRIORITY, ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
12
|
+
import { requestRawPointerLock } from "../input/pointerLock.js";
|
|
11
13
|
const DEFAULT_SENSITIVITY = 0.0025;
|
|
12
14
|
const DEFAULT_MAX_PITCH = 1.45;
|
|
13
15
|
const VIEWMODEL_ORIGIN = new THREE.Vector3(0.34, -0.26, -0.72);
|
|
14
16
|
const MUZZLE_TIP_LOCAL = new THREE.Vector3(0, 0.03, -0.61);
|
|
15
|
-
const MUZZLE_OFFSET = VIEWMODEL_ORIGIN.clone().add(MUZZLE_TIP_LOCAL);
|
|
16
17
|
const muzzleWorld = new THREE.Vector3();
|
|
17
18
|
let muzzleTracked = false;
|
|
18
19
|
/** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
|
|
@@ -22,7 +23,7 @@ export function readFirstPersonMuzzle(target) {
|
|
|
22
23
|
target.copy(muzzleWorld);
|
|
23
24
|
return true;
|
|
24
25
|
}
|
|
25
|
-
export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, }) {
|
|
26
|
+
export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, weapon, }) {
|
|
26
27
|
const eyeHeight = config?.eyeHeight ?? DEFAULT_EYE_HEIGHT;
|
|
27
28
|
const sensitivity = config?.sensitivity ?? DEFAULT_SENSITIVITY;
|
|
28
29
|
const maxPitch = config?.maxPitch ?? DEFAULT_MAX_PITCH;
|
|
@@ -34,12 +35,16 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
|
|
|
34
35
|
const followId = followEntityId ?? userId;
|
|
35
36
|
const seededRef = useRef(false);
|
|
36
37
|
const cuesRef = useEntityRenderCues(followId);
|
|
38
|
+
const presentation = useMemo(() => createWeaponPresentation(), []);
|
|
39
|
+
const tuningRef = useRef(undefined);
|
|
40
|
+
const lastLookRef = useRef(null);
|
|
41
|
+
const poseRef = useRef(null);
|
|
37
42
|
useEffect(() => {
|
|
38
43
|
const requestLock = () => {
|
|
39
44
|
if (window.matchMedia?.("(pointer: coarse)").matches)
|
|
40
45
|
return;
|
|
41
46
|
if (document.pointerLockElement !== domElement)
|
|
42
|
-
|
|
47
|
+
requestRawPointerLock(domElement);
|
|
43
48
|
};
|
|
44
49
|
const onMove = (event) => {
|
|
45
50
|
if (document.pointerLockElement !== domElement)
|
|
@@ -54,7 +59,7 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
|
|
|
54
59
|
window.removeEventListener("mousemove", onMove);
|
|
55
60
|
};
|
|
56
61
|
}, [domElement, sensitivity, maxPitch, yawRef, pitchRef]);
|
|
57
|
-
useFrame(() => {
|
|
62
|
+
useFrame((_, dt) => {
|
|
58
63
|
const entity = ctx.scene.entity.get(followId);
|
|
59
64
|
if (entity === null)
|
|
60
65
|
return;
|
|
@@ -62,22 +67,45 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
|
|
|
62
67
|
seededRef.current = true;
|
|
63
68
|
yawRef.current = entity.rotationY;
|
|
64
69
|
}
|
|
65
|
-
const
|
|
70
|
+
const view = weapon?.(followId) ?? null;
|
|
71
|
+
const last = (lastLookRef.current ??= { yaw: yawRef.current, pitch: pitchRef.current });
|
|
72
|
+
const step = Math.max(1e-4, dt);
|
|
73
|
+
let pose = null;
|
|
74
|
+
if (view !== null) {
|
|
75
|
+
if (view.presentation !== tuningRef.current) {
|
|
76
|
+
tuningRef.current = view.presentation;
|
|
77
|
+
presentation.retune(view.presentation ?? {});
|
|
78
|
+
}
|
|
79
|
+
pose = presentation.update(dt, {
|
|
80
|
+
lookYawRate: (yawRef.current - last.yaw) / step,
|
|
81
|
+
lookPitchRate: (pitchRef.current - last.pitch) / step,
|
|
82
|
+
speed: cuesRef.current.speed,
|
|
83
|
+
bobPhase: cuesRef.current.bobPhase,
|
|
84
|
+
handling: view.handling,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
last.yaw = yawRef.current;
|
|
88
|
+
last.pitch = pitchRef.current;
|
|
89
|
+
poseRef.current = pose;
|
|
90
|
+
const yaw = yawRef.current - (pose?.lookYaw ?? 0);
|
|
91
|
+
const pitch = Math.max(-maxPitch, Math.min(maxPitch, pitchRef.current + (pose?.lookPitch ?? 0)));
|
|
92
|
+
const cosPitch = Math.cos(pitch);
|
|
66
93
|
camera.position.set(entity.position[0], entity.position[1] + eyeHeight, entity.position[2]);
|
|
67
|
-
camera.lookAt(camera.position.x + Math.sin(
|
|
94
|
+
camera.lookAt(camera.position.x + Math.sin(yaw) * cosPitch, camera.position.y + Math.sin(pitch), camera.position.z + Math.cos(yaw) * cosPitch);
|
|
68
95
|
if (camera.isPerspectiveCamera === true) {
|
|
69
96
|
const perspective = camera;
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
const fov = playerFov.fov * (pose?.fovScale ?? 1);
|
|
98
|
+
if (Math.abs(perspective.fov - fov) > 0.001) {
|
|
99
|
+
perspective.fov = fov;
|
|
72
100
|
perspective.updateProjectionMatrix();
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
103
|
}, ORBIT_CAMERA_FRAME_PRIORITY);
|
|
76
104
|
if (config?.viewmodel === false)
|
|
77
105
|
return null;
|
|
78
|
-
return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef });
|
|
106
|
+
return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef, poseRef: poseRef });
|
|
79
107
|
}
|
|
80
|
-
function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, }) {
|
|
108
|
+
function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, poseRef, }) {
|
|
81
109
|
const groupRef = useRef(null);
|
|
82
110
|
useEffect(() => () => {
|
|
83
111
|
muzzleTracked = false;
|
|
@@ -86,12 +114,21 @@ function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, }) {
|
|
|
86
114
|
const group = groupRef.current;
|
|
87
115
|
if (group === null)
|
|
88
116
|
return;
|
|
117
|
+
const pose = poseRef.current;
|
|
118
|
+
const perspective = camera;
|
|
119
|
+
// Scaling camera-space x/y by this projects the viewmodel at its own FOV without a second render pass.
|
|
120
|
+
const squash = pose?.viewmodelFov == null || perspective.isPerspectiveCamera !== true ? 1 : viewmodelFovScale(perspective.fov, pose.viewmodelFov);
|
|
89
121
|
group.position.copy(camera.position);
|
|
90
122
|
group.quaternion.copy(camera.quaternion);
|
|
91
|
-
group.translateX(VIEWMODEL_ORIGIN.x);
|
|
92
|
-
group.translateY(VIEWMODEL_ORIGIN.y);
|
|
93
|
-
group.translateZ(VIEWMODEL_ORIGIN.z);
|
|
94
|
-
|
|
123
|
+
group.translateX((pose?.offset[0] ?? VIEWMODEL_ORIGIN.x) * squash);
|
|
124
|
+
group.translateY((pose?.offset[1] ?? VIEWMODEL_ORIGIN.y) * squash);
|
|
125
|
+
group.translateZ(pose?.offset[2] ?? VIEWMODEL_ORIGIN.z);
|
|
126
|
+
group.rotateX(pose?.pitch ?? 0);
|
|
127
|
+
group.rotateY(pose?.yaw ?? 0);
|
|
128
|
+
group.rotateZ(pose?.roll ?? 0);
|
|
129
|
+
group.scale.set(squash, squash, 1);
|
|
130
|
+
group.updateMatrixWorld();
|
|
131
|
+
group.localToWorld(muzzleWorld.copy(MUZZLE_TIP_LOCAL));
|
|
95
132
|
muzzleTracked = true;
|
|
96
133
|
}, GAME_SIM_FRAME_PRIORITY);
|
|
97
134
|
if (Viewmodel !== undefined) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { GameCameraConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import { type ViewportDef, type ViewportsConfig } from "@jgengine/core/game/viewports";
|
|
4
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
5
|
+
import { type LocalPlayerSlot } from "@jgengine/core/runtime/localPlayers";
|
|
6
|
+
/** Camera yaw the split-screen renderer last used for a seat's user, so the walk controller moves that seat camera-relative. @internal */
|
|
7
|
+
export declare function seatCameraYaw(ctx: GameContext, userId: string): number | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* The viewports for the seats currently joined, re-resolved when a seat joins or leaves.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function useSeatViewports(ctx: GameContext, config: ViewportsConfig | undefined): readonly ViewportDef[];
|
|
13
|
+
/**
|
|
14
|
+
* Draws one scissored render per viewport. The primary seat keeps the main camera and its rig; every
|
|
15
|
+
* other seat gets a follow camera (chase or top-down) on its possessed entity. Mounted only while more
|
|
16
|
+
* than one viewport is drawn, since a render-priority frame callback takes over R3F's own render.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function SplitScreenRenderer({ ctx, viewports, config, mainCamera, }: {
|
|
20
|
+
ctx: GameContext;
|
|
21
|
+
viewports: readonly ViewportDef[];
|
|
22
|
+
config: ViewportsConfig | undefined;
|
|
23
|
+
mainCamera: GameCameraConfig | undefined;
|
|
24
|
+
}): null;
|
|
25
|
+
/**
|
|
26
|
+
* One absolutely-positioned HUD root per viewport, laid over the canvas: render each seat's own
|
|
27
|
+
* health, prompts or score with `children(slot)`. Pass the same `viewports` config the game gave `defineGame`.
|
|
28
|
+
* @capability split-screen-hud Per-seat HUD roots positioned over each split-screen viewport.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ViewportHuds({ ctx, config, children, }: {
|
|
31
|
+
ctx: GameContext;
|
|
32
|
+
config?: ViewportsConfig;
|
|
33
|
+
children: (slot: LocalPlayerSlot, viewport: ViewportDef) => ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import { useMemo, useRef, useSyncExternalStore } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { resolveViewports, viewportPixels, } from "@jgengine/core/game/viewports";
|
|
6
|
+
import { localPlayers } from "@jgengine/core/runtime/localPlayers";
|
|
7
|
+
import { createChaseRigState, resolveChase, resolveTopDown, stepChase, topDownPose } from "./rigMath.js";
|
|
8
|
+
const SEAT_YAW = new WeakMap();
|
|
9
|
+
/** Camera yaw the split-screen renderer last used for a seat's user, so the walk controller moves that seat camera-relative. @internal */
|
|
10
|
+
export function seatCameraYaw(ctx, userId) {
|
|
11
|
+
return SEAT_YAW.get(ctx)?.get(userId);
|
|
12
|
+
}
|
|
13
|
+
function seatSlotIds(ctx) {
|
|
14
|
+
return localPlayers(ctx).slots().map((slot) => slot.slotId);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The viewports for the seats currently joined, re-resolved when a seat joins or leaves.
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export function useSeatViewports(ctx, config) {
|
|
21
|
+
const seats = localPlayers(ctx);
|
|
22
|
+
const key = useSyncExternalStore(seats.subscribe, () => seatSlotIds(ctx).join("|"), () => seatSlotIds(ctx).join("|"));
|
|
23
|
+
return useMemo(() => resolveViewports(config, key === "" ? [] : key.split("|")), [config, key]);
|
|
24
|
+
}
|
|
25
|
+
function seatRigKind(camera, main) {
|
|
26
|
+
return camera?.rig ?? (main?.rig === "topDown" ? "topDown" : "chase");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Draws one scissored render per viewport. The primary seat keeps the main camera and its rig; every
|
|
30
|
+
* other seat gets a follow camera (chase or top-down) on its possessed entity. Mounted only while more
|
|
31
|
+
* than one viewport is drawn, since a render-priority frame callback takes over R3F's own render.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function SplitScreenRenderer({ ctx, viewports, config, mainCamera, }) {
|
|
35
|
+
const gl = useThree((state) => state.gl);
|
|
36
|
+
const scene = useThree((state) => state.scene);
|
|
37
|
+
const camera = useThree((state) => state.camera);
|
|
38
|
+
const size = useThree((state) => state.size);
|
|
39
|
+
const rigs = useRef(new Map());
|
|
40
|
+
useFrame((_state, dt) => {
|
|
41
|
+
const seats = localPlayers(ctx);
|
|
42
|
+
let yaws = SEAT_YAW.get(ctx);
|
|
43
|
+
if (yaws === undefined) {
|
|
44
|
+
yaws = new Map();
|
|
45
|
+
SEAT_YAW.set(ctx, yaws);
|
|
46
|
+
}
|
|
47
|
+
gl.setScissorTest(true);
|
|
48
|
+
for (const def of viewports) {
|
|
49
|
+
const slot = seats.slot(def.slot);
|
|
50
|
+
if (slot === null)
|
|
51
|
+
continue;
|
|
52
|
+
const pixels = viewportPixels(def.rect, size.width, size.height);
|
|
53
|
+
const view = slot.index === 0 ? camera : seatCamera(slot, def.camera ?? config?.camera, dt, yaws);
|
|
54
|
+
if (view instanceof THREE.PerspectiveCamera) {
|
|
55
|
+
view.aspect = pixels.width / pixels.height;
|
|
56
|
+
view.updateProjectionMatrix();
|
|
57
|
+
}
|
|
58
|
+
gl.setViewport(pixels.x, pixels.y, pixels.width, pixels.height);
|
|
59
|
+
gl.setScissor(pixels.x, pixels.y, pixels.width, pixels.height);
|
|
60
|
+
gl.render(scene, view);
|
|
61
|
+
}
|
|
62
|
+
gl.setScissorTest(false);
|
|
63
|
+
gl.setViewport(0, 0, size.width, size.height);
|
|
64
|
+
if (camera instanceof THREE.PerspectiveCamera) {
|
|
65
|
+
camera.aspect = size.width / size.height;
|
|
66
|
+
camera.updateProjectionMatrix();
|
|
67
|
+
}
|
|
68
|
+
}, 1);
|
|
69
|
+
function seatCamera(slot, cameraConfig, dt, yaws) {
|
|
70
|
+
let rig = rigs.current.get(slot.slotId);
|
|
71
|
+
if (rig === undefined) {
|
|
72
|
+
const created = new THREE.PerspectiveCamera();
|
|
73
|
+
if (camera instanceof THREE.PerspectiveCamera) {
|
|
74
|
+
created.near = camera.near;
|
|
75
|
+
created.far = camera.far;
|
|
76
|
+
}
|
|
77
|
+
rig = { camera: created, chase: createChaseRigState() };
|
|
78
|
+
rigs.current.set(slot.slotId, rig);
|
|
79
|
+
}
|
|
80
|
+
const entity = ctx.scene.entity.get(ctx.player.possession.active(slot.userId));
|
|
81
|
+
const follow = entity === null ? { x: 0, y: 0, z: 0 } : { x: entity.position[0], y: entity.position[1], z: entity.position[2] };
|
|
82
|
+
let pose;
|
|
83
|
+
if (seatRigKind(cameraConfig, mainCamera) === "topDown") {
|
|
84
|
+
const resolved = resolveTopDown(cameraConfig?.topDown ?? mainCamera?.topDown);
|
|
85
|
+
pose = topDownPose(follow, resolved, rig.camera.fov);
|
|
86
|
+
yaws.set(slot.userId, resolved.yaw);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const velocity = entity?.velocity;
|
|
90
|
+
const step = stepChase(rig.chase, {
|
|
91
|
+
follow,
|
|
92
|
+
yaw: entity?.rotationY ?? 0,
|
|
93
|
+
bodyPitch: entity?.rotationX ?? 0,
|
|
94
|
+
velocity: velocity === undefined ? null : { x: velocity[0], y: velocity[1], z: velocity[2] },
|
|
95
|
+
lookBack: false,
|
|
96
|
+
fovKick: 0,
|
|
97
|
+
}, resolveChase(cameraConfig?.chase ?? mainCamera?.chase), dt);
|
|
98
|
+
pose = step.pose;
|
|
99
|
+
yaws.set(slot.userId, step.anchorYaw);
|
|
100
|
+
}
|
|
101
|
+
rig.camera.position.set(pose.position.x, pose.position.y, pose.position.z);
|
|
102
|
+
rig.camera.fov = pose.fov;
|
|
103
|
+
rig.camera.lookAt(pose.lookAt.x, pose.lookAt.y, pose.lookAt.z);
|
|
104
|
+
return rig.camera;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One absolutely-positioned HUD root per viewport, laid over the canvas: render each seat's own
|
|
110
|
+
* health, prompts or score with `children(slot)`. Pass the same `viewports` config the game gave `defineGame`.
|
|
111
|
+
* @capability split-screen-hud Per-seat HUD roots positioned over each split-screen viewport.
|
|
112
|
+
*/
|
|
113
|
+
export function ViewportHuds({ ctx, config, children, }) {
|
|
114
|
+
const viewports = useSeatViewports(ctx, config);
|
|
115
|
+
const seats = localPlayers(ctx);
|
|
116
|
+
return (_jsx(_Fragment, { children: viewports.map((viewport) => {
|
|
117
|
+
const slot = seats.slot(viewport.slot);
|
|
118
|
+
if (slot === null)
|
|
119
|
+
return null;
|
|
120
|
+
const [x, y, width, height] = viewport.rect;
|
|
121
|
+
return (_jsx("div", { "data-jg-viewport": viewport.slot, className: "pointer-events-none absolute overflow-hidden", style: { left: `${x * 100}%`, top: `${y * 100}%`, width: `${width * 100}%`, height: `${height * 100}%` }, children: children(slot, viewport) }, viewport.slot));
|
|
122
|
+
}) }));
|
|
123
|
+
}
|