@jgengine/shell 0.18.0 → 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 +265 -2
- package/dist/GamePlayerShell.js +27 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +9 -8
- package/dist/Shell3dPresentation.js +57 -8
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +13 -2
- package/dist/audio/audioEngine.d.ts +15 -1
- package/dist/audio/audioEngine.js +132 -11
- 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 +77 -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 +38 -6
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/KeysPanel.js +3 -3
- 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.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +74 -26
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +10 -5
- 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/EnvironmentScene.js +1 -1
- 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/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +22 -0
- package/dist/input/gamepadSource.js +153 -0
- 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/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +11 -8
- package/dist/postfx/PostProcessing.js +38 -16
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- 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 +39 -0
- package/dist/render/modelLoad.js +89 -1
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +58 -0
- package/dist/render/useFootIk.js +216 -0
- package/dist/render/useModelAnimation.d.ts +26 -0
- package/dist/render/useModelAnimation.js +184 -107
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +666 -241
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +10 -2
- package/dist/settings/appliedSettings.js +9 -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/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +9 -2
- package/dist/useShellMultiplayerSync.js +30 -8
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +20 -15
- package/skills/jgengine-ui/api.md +538 -326
- package/skills/jgengine-ui/capabilities.md +37 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -78
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- 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/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Canvas } from "@react-three/fiber";
|
|
3
3
|
import { useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
5
|
import { contextVerbInput, } from "@jgengine/core/interaction/contextMenu";
|
|
5
6
|
import { createSelectionSet, isMarquee, screenRect, selectWithinRect, } from "@jgengine/core/scene/selection";
|
|
6
7
|
import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
|
|
7
8
|
import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
|
|
9
|
+
import { pixelPerfectFrustum, snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
|
|
8
10
|
import { BUILT_IN_SETTING_CATEGORIES, } from "@jgengine/core/settings/settingsModel";
|
|
9
11
|
import { playControlsActive } from "@jgengine/core/game/controlGate";
|
|
10
12
|
import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
|
|
@@ -22,6 +24,7 @@ import { DevtoolsRendererProbe } from "./devtools/DevtoolsOverlay.js";
|
|
|
22
24
|
import { CAMERA_TRANSPARENT_USERDATA, GameCameraRig, PlayerFovProvider, PlayerFovSlider, resolveRigKind, rtsPanKeysConflict, } from "./camera/index.js";
|
|
23
25
|
import { CullingProvider } from "./visibility/CullingProvider.js";
|
|
24
26
|
import { SkyDaylight, TimeOfDayDaylight } from "./environment.js";
|
|
27
|
+
import { bearingFromDirection } from "./environment/daylightCycle.js";
|
|
25
28
|
import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy.js";
|
|
26
29
|
import { EnvironmentScene } from "./environment/EnvironmentScene.js";
|
|
27
30
|
import { PointerProbe } from "./pointer/PointerProbe.js";
|
|
@@ -35,17 +38,45 @@ import { TouchControlsDock, TouchPlaySurface, touchDockClearance } from "./touch
|
|
|
35
38
|
import { SettingsRuntime } from "./settings/SettingsRuntime.js";
|
|
36
39
|
import { SettingsChrome } from "./settings/SettingsChrome.js";
|
|
37
40
|
import { AudioSettingsBridge } from "./settings/appliedSettings.js";
|
|
41
|
+
import { TextureFiltering } from "./render/TextureFiltering.js";
|
|
42
|
+
import { FrameRateLimiter } from "./drivers/FrameRateLimiter.js";
|
|
43
|
+
import { FpsCounter, FpsProbe } from "./diagnostics/FpsCounter.js";
|
|
38
44
|
import { ConfiguredLighting, BackdropFog } from "./render/SceneLighting.js";
|
|
39
45
|
import { WorldView, RemotePlayers } from "./world/WorldScene.js";
|
|
40
46
|
import { FrameDriver } from "./drivers/FrameDriver.js";
|
|
47
|
+
import { GamepadSource } from "./input/gamepadSource.js";
|
|
48
|
+
import { SplitScreenRenderer, useSeatViewports } from "./camera/Viewports.js";
|
|
49
|
+
import { localPlayers } from "@jgengine/core/runtime/localPlayers";
|
|
41
50
|
import { createShellKeyHandlers, ShellDebugOverlays, ShellGameUiChrome } from "./ShellChrome.js";
|
|
42
51
|
import { CombatPresentation } from "./CombatPresentation.js";
|
|
52
|
+
import { WorldParticles } from "./world/WorldParticles.js";
|
|
53
|
+
import { detectKtx2Support } from "./render/modelLoad.js";
|
|
43
54
|
import { resolvePresentationEffects, resolveWorldOverlayBars, } from "./presentationResolve.js";
|
|
44
55
|
const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
|
|
45
56
|
const DEFAULT_BACKGROUND_COLOR = "#14161b";
|
|
57
|
+
function PixelPerfectCamera({ pixelsPerUnit, integerScale }) {
|
|
58
|
+
const camera = useThree((state) => state.camera);
|
|
59
|
+
const size = useThree((state) => state.size);
|
|
60
|
+
useFrame(() => {
|
|
61
|
+
if (camera.isOrthographicCamera !== true)
|
|
62
|
+
return;
|
|
63
|
+
const frustum = pixelPerfectFrustum(size, pixelsPerUnit, integerScale === true);
|
|
64
|
+
camera.left = frustum.left;
|
|
65
|
+
camera.right = frustum.right;
|
|
66
|
+
camera.top = frustum.top;
|
|
67
|
+
camera.bottom = frustum.bottom;
|
|
68
|
+
camera.zoom = 1;
|
|
69
|
+
const snapped = snapPixelPerfectPosition(camera.position, pixelsPerUnit);
|
|
70
|
+
camera.position.set(snapped.x, snapped.y, snapped.z);
|
|
71
|
+
camera.updateProjectionMatrix();
|
|
72
|
+
}, 1);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
46
75
|
/** 3D play surface: canvas, world overlays, and shared chrome. @internal */
|
|
47
|
-
export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, primaryClickRef, cameraDraggingRef, serverIdRef, remotePlayers, touchScheme, touchSink, touchStyle, touchJoystickVariant, analogRef, orientationGate, orientationGateEl, coarsePointer, compact, graphics, settingsStore, bindingOverrides, rebindAction, resetActionBinding, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, audioEngine, poster, posterFrozen, onPosterSettled, }) {
|
|
76
|
+
export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, primaryClickRef, cameraDraggingRef, serverIdRef, remotePlayers, touchScheme, touchSink, touchStyle, touchJoystickVariant, analogRef, inputBindings, orientationGate, orientationGateEl, coarsePointer, compact, graphics, settingsStore, bindingOverrides, rebindAction, resetActionBinding, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, audioEngine, poster, posterFrozen, onPosterSettled, authoritativeFrameRef, }) {
|
|
48
77
|
const GameUI = playable.GameUI;
|
|
78
|
+
const ktx2SupportDetected = useRef(false);
|
|
79
|
+
const frameTally = useRef({ frames: 0 }).current;
|
|
49
80
|
const WorldOverlay = playable.WorldOverlay;
|
|
50
81
|
const pointerService = useMemo(() => createPointerService(), []);
|
|
51
82
|
const selection = useMemo(() => createSelectionSet(), [playable]);
|
|
@@ -67,7 +98,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
67
98
|
const worldSky = resolveWorldSky(playable.game.world);
|
|
68
99
|
const world = playable.game.world;
|
|
69
100
|
const biomeBands = world?.kind === "environment" ? world.terrain?.biomeBands : undefined;
|
|
70
|
-
const AutoEnvironment = playable.environment ??
|
|
101
|
+
const AutoEnvironment = (typeof playable.environment === "function" ? playable.environment : undefined) ??
|
|
71
102
|
(world?.kind === "environment"
|
|
72
103
|
? () => _jsx(EnvironmentScene, { feature: world })
|
|
73
104
|
: world?.kind === "place"
|
|
@@ -85,9 +116,13 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
85
116
|
const cinematicLook = (playable.look ?? "cinematic") !== "flat";
|
|
86
117
|
const backdrop = resolvedLook.backdrop;
|
|
87
118
|
const backdropSky = backdrop?.sky !== undefined ? resolveSkyDescriptor(backdrop.sky) : undefined;
|
|
88
|
-
const
|
|
89
|
-
const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
|
|
119
|
+
const authoredSky = backdropSky ?? worldSky;
|
|
90
120
|
const lighting = resolvedLook.lighting;
|
|
121
|
+
const sunFromLighting = lighting?.directional?.[0]?.position;
|
|
122
|
+
const effectiveSky = useMemo(() => authoredSky === undefined || authoredSky.sun !== undefined || authoredSky.timeOfDay || sunFromLighting === undefined
|
|
123
|
+
? authoredSky
|
|
124
|
+
: { ...authoredSky, sun: bearingFromDirection(sunFromLighting) }, [authoredSky, sunFromLighting]);
|
|
125
|
+
const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
|
|
91
126
|
const orthographic = playable.camera?.projection === "orthographic";
|
|
92
127
|
const pointerDownRef = useRef(null);
|
|
93
128
|
const marqueeStartRef = useRef(null);
|
|
@@ -234,6 +269,8 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
234
269
|
}
|
|
235
270
|
};
|
|
236
271
|
const controlsActive = playControlsActive(ctx);
|
|
272
|
+
const seatViewports = useSeatViewports(ctx, playable.viewports);
|
|
273
|
+
const splitScreen = playable.viewports !== undefined && seatViewports.length > 1;
|
|
237
274
|
const settingsDisabled = playable.settings === false;
|
|
238
275
|
const settingsConfig = playable.settings === false || playable.settings === undefined ? {} : playable.settings;
|
|
239
276
|
const settingsSurface = settingsDisabled ? false : settingsConfig.surface ?? false;
|
|
@@ -265,7 +302,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
265
302
|
controlsActive: () => playControlsActive(ctx),
|
|
266
303
|
exitPointerLockOnDevtools: true,
|
|
267
304
|
});
|
|
268
|
-
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.audio?.buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: playable.audio?.buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer &&
|
|
305
|
+
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.audio?.buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: playable.audio?.buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, graphics: playable.graphics, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer &&
|
|
269
306
|
touchScheme !== null &&
|
|
270
307
|
(touchScheme.joystick !== null || touchScheme.buttons.length > 0), overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, ...(poster && posterFrozen ? { "data-poster-ready": "" } : {}), className: "relative h-full w-full bg-neutral-950 outline-none", style: {
|
|
271
308
|
"--jg-hud-dock-clearance": `${dockMounted ? touchDockClearance(touchScheme, touchScale) : 0}px`,
|
|
@@ -279,7 +316,12 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
279
316
|
else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
|
|
280
317
|
ctx.game.commands.run("ui.hotbarScrollPrev", {});
|
|
281
318
|
}
|
|
282
|
-
}, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsxs(Canvas, {
|
|
319
|
+
}, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsxs(Canvas, { onCreated: ({ gl }) => {
|
|
320
|
+
if (ktx2SupportDetected.current)
|
|
321
|
+
return;
|
|
322
|
+
ktx2SupportDetected.current = true;
|
|
323
|
+
detectKtx2Support(gl);
|
|
324
|
+
}, frameloop: poster && posterFrozen ? "demand" : graphics.frameRateLimit > 0 && !poster ? "never" : "always", orthographic: orthographic, camera: orthographic
|
|
283
325
|
? {
|
|
284
326
|
zoom: playable.camera?.frustum?.zoom ?? CAMERA_FRUSTUM_DEFAULTS.zoom,
|
|
285
327
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
@@ -289,9 +331,16 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
289
331
|
fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
|
|
290
332
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
291
333
|
far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
|
|
292
|
-
}, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
334
|
+
}, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting, profile: graphics.profile })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
293
335
|
cameraDraggingRef.current = dragging;
|
|
294
|
-
} }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled }), _jsx(
|
|
336
|
+
} }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled, authoritativeFrameRef: authoritativeFrameRef }), _jsx(GamepadSource, { tracker: tracker, bindings: inputBindings ?? {}, analogRef: analogRef, input: ctx.input, feel: playable.gamepad, seats: localPlayers(ctx), onSeatJoin: (slot) => {
|
|
337
|
+
try {
|
|
338
|
+
playable.loop.onNewPlayer(ctx, { userId: slot.userId, isNew: true });
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
reportRuntimeError(error, "tick");
|
|
342
|
+
}
|
|
343
|
+
}, seatsActive: () => !gateRef.current && playControlsActive(ctx) }), _jsx(DevtoolsRendererProbe, {}), splitScreen ? (_jsx(SplitScreenRenderer, { ctx: ctx, viewports: seatViewports, config: playable.viewports, mainCamera: playable.camera })) : null, !splitScreen && resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality, stages: graphics.profile.postStages })) : null] }), graphics.showFps && !poster ? _jsx(FpsCounter, { tally: frameTally }) : null, !poster &&
|
|
295
344
|
!orientationGate &&
|
|
296
345
|
coarsePointer &&
|
|
297
346
|
controlsActive &&
|
package/dist/ShellChrome.d.ts
CHANGED
|
@@ -26,10 +26,12 @@ export declare function ShellDebugOverlays({ ctx, playable, multiplayer, diagnos
|
|
|
26
26
|
export type ShellKeyHandlers = {
|
|
27
27
|
onKeyDown: (event: {
|
|
28
28
|
code: string;
|
|
29
|
+
target?: EventTarget | null;
|
|
29
30
|
preventDefault: () => void;
|
|
30
31
|
}) => void;
|
|
31
32
|
onKeyUp: (event: {
|
|
32
33
|
code: string;
|
|
34
|
+
target?: EventTarget | null;
|
|
33
35
|
}) => void;
|
|
34
36
|
onBlur: () => void;
|
|
35
37
|
};
|
package/dist/ShellChrome.js
CHANGED
|
@@ -16,6 +16,12 @@ export function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, de
|
|
|
16
16
|
export function createShellKeyHandlers({ f2HeldRef, tracker, devtoolsEnabled, setDevtoolsOpen, controlsActive, exitPointerLockOnDevtools = false, }) {
|
|
17
17
|
return {
|
|
18
18
|
onKeyDown: (event) => {
|
|
19
|
+
const target = event.target;
|
|
20
|
+
if (target?.closest?.("input, textarea, select, [contenteditable]:not([contenteditable=false])")) {
|
|
21
|
+
f2HeldRef.current = false;
|
|
22
|
+
tracker.reset();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
19
25
|
if (event.code === "F2") {
|
|
20
26
|
event.preventDefault();
|
|
21
27
|
f2HeldRef.current = true;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Dispatch, type MutableRefObject, type RefObject, type SetStateAction } from "react";
|
|
2
2
|
import type { PointerAxisState } from "@jgengine/core/input/pointerAxis";
|
|
3
3
|
import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
|
|
4
4
|
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
@@ -6,6 +6,14 @@ import type { TouchScheme } from "@jgengine/core/input/touchScheme";
|
|
|
6
6
|
import type { RuntimeDiagnostic } from "./diagnostics/RuntimeDiagnostics.js";
|
|
7
7
|
import type { ShellMultiplayer } from "./multiplayer.js";
|
|
8
8
|
import type { PlayableGame } from "./registry.js";
|
|
9
|
+
/**
|
|
10
|
+
* Marks a HUD-only page ready for capture (`data-jg-capture="ready"`) unless a capture host already
|
|
11
|
+
* owns the flag. A HUD game has no `<canvas>`, so capture tools that wait for one would time out.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare function markHudCaptureReady(root: {
|
|
15
|
+
dataset: DOMStringMap;
|
|
16
|
+
}): void;
|
|
9
17
|
/** HUD-only play surface for non-3D shells. @internal */
|
|
10
18
|
export declare function ShellHudPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, }: {
|
|
11
19
|
playable: PlayableGame;
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from "react";
|
|
2
3
|
import { playControlsActive } from "@jgengine/core/game/controlGate";
|
|
3
4
|
import { GameViewportProvider } from "@jgengine/react/gameViewport";
|
|
4
5
|
import { HudOnlyDriver } from "./drivers/HudOnlyDriver.js";
|
|
5
6
|
import { createShellKeyHandlers, ShellDebugOverlays, ShellGameUiChrome } from "./ShellChrome.js";
|
|
6
7
|
import { TouchPlaySurface } from "./touch/TouchControlsOverlay.js";
|
|
8
|
+
/**
|
|
9
|
+
* Marks a HUD-only page ready for capture (`data-jg-capture="ready"`) unless a capture host already
|
|
10
|
+
* owns the flag. A HUD game has no `<canvas>`, so capture tools that wait for one would time out.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function markHudCaptureReady(root) {
|
|
14
|
+
if (root.dataset.jgCapture === undefined)
|
|
15
|
+
root.dataset.jgCapture = "ready";
|
|
16
|
+
}
|
|
7
17
|
/** HUD-only play surface for non-3D shells. @internal */
|
|
8
18
|
export function ShellHudPresentation({ playable, ctx, multiplayer, tracker, pointerAxisRef, gateRef, wrapperRef, f2HeldRef, yawRef, pitchRef, touchScheme, touchSink, orientationGate, orientationGateEl, coarsePointer, uiScale, diagnostics, devtoolsEnabled, devtoolsOpen, setDevtoolsOpen, reportRuntimeError, trackPointerAxis, deactivatePointerAxis, onPointerResumeAudio, }) {
|
|
9
19
|
const GameUI = playable.GameUI;
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
// Two frames so the first GameUI paint has landed before tools capture.
|
|
22
|
+
let second = 0;
|
|
23
|
+
const first = requestAnimationFrame(() => {
|
|
24
|
+
second = requestAnimationFrame(() => markHudCaptureReady(document.documentElement));
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
cancelAnimationFrame(first);
|
|
28
|
+
cancelAnimationFrame(second);
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
10
31
|
const keys = createShellKeyHandlers({
|
|
11
32
|
f2HeldRef,
|
|
12
33
|
tracker,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
/** A placement footprint cell and its authoritative preview status. */
|
|
3
|
+
export type TerritoryPreviewCell = {
|
|
4
|
+
key: string;
|
|
5
|
+
x: number;
|
|
6
|
+
z: number;
|
|
7
|
+
status: "owned" | "claimable" | "blocked";
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Placement footprint feedback with inline claim cost and affordability.
|
|
11
|
+
* @capability territory-overlay show footprint ownership, claim cost, and affordability
|
|
12
|
+
*/
|
|
13
|
+
export declare function TerritoryOverlay({ cells, cost, affordable, formatCost, renderCell, className, style }: {
|
|
14
|
+
cells: readonly TerritoryPreviewCell[];
|
|
15
|
+
cost: number;
|
|
16
|
+
affordable: boolean;
|
|
17
|
+
formatCost?: (cost: number) => ReactNode;
|
|
18
|
+
renderCell?: (cell: TerritoryPreviewCell) => ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Placement footprint feedback with inline claim cost and affordability.
|
|
4
|
+
* @capability territory-overlay show footprint ownership, claim cost, and affordability
|
|
5
|
+
*/
|
|
6
|
+
export function TerritoryOverlay({ cells, cost, affordable, formatCost = String, renderCell, className, style }) {
|
|
7
|
+
const blocked = cells.some((cell) => cell.status === "blocked");
|
|
8
|
+
return _jsxs("div", { className: className, style: { pointerEvents: "none", ...style }, "data-territory-overlay": true, children: [_jsx("div", { style: { display: "flex", gap: 3 }, children: cells.map((cell) => _jsx("span", { "data-territory-cell": cell.status, title: `${cell.x}, ${cell.z}: ${cell.status}`, children: renderCell ? renderCell(cell) : _jsx("span", { "aria-label": cell.status, style: { display: "inline-block", width: 8, height: 8, borderRadius: 2, background: cell.status === "owned" ? "var(--jg-accent, #5fc2a8)" : cell.status === "claimable" ? "#d6a548" : "#d96969" } }) }, cell.key)) }), _jsx("output", { "aria-live": "polite", "data-affordable": affordable, "data-blocked": blocked, children: blocked ? "Land unavailable" : _jsxs(_Fragment, { children: ["Land: ", formatCost(cost), affordable ? "" : " — Insufficient funds"] }) })] });
|
|
9
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
|
+
import { Quaternion, Vector3 } from "three";
|
|
3
4
|
import { useGameContext } from "@jgengine/react/provider";
|
|
5
|
+
import { createListenerVelocityTrack, stepListenerVelocity } from "./loopParams.js";
|
|
4
6
|
export function AudioListener({ engine }) {
|
|
5
7
|
const camera = useThree((state) => state.camera);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
const trackRef = useRef(createListenerVelocityTrack());
|
|
9
|
+
useFrame((_, dt) => {
|
|
10
|
+
const direction = camera.getWorldDirection(new Vector3());
|
|
11
|
+
const up = camera.up.clone().applyQuaternion(camera.getWorldQuaternion(new Quaternion()));
|
|
12
|
+
const position = { x: camera.position.x, y: camera.position.y, z: camera.position.z };
|
|
13
|
+
engine.setListenerPose({
|
|
14
|
+
position,
|
|
15
|
+
forward: { x: direction.x, y: direction.y, z: direction.z },
|
|
16
|
+
up: { x: up.x, y: up.y, z: up.z },
|
|
17
|
+
velocity: stepListenerVelocity(trackRef.current, position, dt),
|
|
18
|
+
});
|
|
8
19
|
});
|
|
9
20
|
return null;
|
|
10
21
|
}
|
|
@@ -6,6 +6,14 @@ export interface Vec3 {
|
|
|
6
6
|
y: number;
|
|
7
7
|
z: number;
|
|
8
8
|
}
|
|
9
|
+
/** Position and orientation for a spatial-audio listener. */
|
|
10
|
+
export interface ListenerPose {
|
|
11
|
+
position: Vec3;
|
|
12
|
+
forward: Vec3;
|
|
13
|
+
up: Vec3;
|
|
14
|
+
/** World velocity of the listener, for doppler on loops whose sound declares `doppler`. Default zero. */
|
|
15
|
+
velocity?: Vec3;
|
|
16
|
+
}
|
|
9
17
|
export interface AudioSceneConfig {
|
|
10
18
|
sounds?: Record<string, SoundDef>;
|
|
11
19
|
buses?: Record<string, AudioBusDef>;
|
|
@@ -20,10 +28,16 @@ export interface AudioEmitterHandle {
|
|
|
20
28
|
setRate(rate: number): void;
|
|
21
29
|
/** Live volume of a retained loop: `gain` scales the source (0–1), clamped and ramped ~20 ms (#1051). */
|
|
22
30
|
setGain(gain: number): void;
|
|
31
|
+
/** Live lowpass cutoff of a retained loop in Hz, clamped and ramped like rate. */
|
|
32
|
+
setLowpass(hz: number): void;
|
|
33
|
+
/** Live highpass cutoff of a retained loop in Hz, clamped and ramped like rate. */
|
|
34
|
+
setHighpass(hz: number): void;
|
|
35
|
+
/** Emitter world velocity for doppler; ignored unless the sound declares `doppler`. */
|
|
36
|
+
setVelocity(velocity: Vec3): void;
|
|
23
37
|
stop(): void;
|
|
24
38
|
}
|
|
25
39
|
export interface AudioEngine {
|
|
26
|
-
setListenerPose(
|
|
40
|
+
setListenerPose(pose: ListenerPose | Vec3): void;
|
|
27
41
|
playOneShot(soundId: string, position?: Vec3): void;
|
|
28
42
|
playLoop(soundId: string, position?: Vec3): AudioEmitterHandle | null;
|
|
29
43
|
/** Crossfade the procedural soundtrack to `themeId` (null fades out). No-op when no `music` catalog is configured. */
|
|
@@ -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,
|
|
@@ -30,6 +32,35 @@ function resolveOfflineAudioContextCtor() {
|
|
|
30
32
|
return (window.OfflineAudioContext ??
|
|
31
33
|
window.webkitOfflineAudioContext);
|
|
32
34
|
}
|
|
35
|
+
function setAudioParam(param, value) {
|
|
36
|
+
if (param === undefined)
|
|
37
|
+
return;
|
|
38
|
+
param.value = value;
|
|
39
|
+
}
|
|
40
|
+
function setPannerPosition(panner, position) {
|
|
41
|
+
setAudioParam(panner.positionX, position.x);
|
|
42
|
+
setAudioParam(panner.positionY, position.y);
|
|
43
|
+
setAudioParam(panner.positionZ, position.z);
|
|
44
|
+
}
|
|
45
|
+
function createPanner(context, spatial, position) {
|
|
46
|
+
const panner = context.createPanner();
|
|
47
|
+
panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
|
|
48
|
+
panner.distanceModel = "inverse";
|
|
49
|
+
if (spatial.refDistance !== undefined)
|
|
50
|
+
panner.refDistance = spatial.refDistance;
|
|
51
|
+
if (spatial.maxDistance !== undefined)
|
|
52
|
+
panner.maxDistance = spatial.maxDistance;
|
|
53
|
+
if (spatial.rolloff !== undefined)
|
|
54
|
+
panner.rolloffFactor = spatial.rolloff;
|
|
55
|
+
if (spatial.coneInner !== undefined)
|
|
56
|
+
panner.coneInnerAngle = spatial.coneInner;
|
|
57
|
+
if (spatial.coneOuter !== undefined)
|
|
58
|
+
panner.coneOuterAngle = spatial.coneOuter;
|
|
59
|
+
if (spatial.coneOuterGain !== undefined)
|
|
60
|
+
panner.coneOuterGain = spatial.coneOuterGain;
|
|
61
|
+
setPannerPosition(panner, position);
|
|
62
|
+
return panner;
|
|
63
|
+
}
|
|
33
64
|
export function createAudioEngine(config = {}) {
|
|
34
65
|
const sounds = config.sounds ?? {};
|
|
35
66
|
const busDefs = config.buses ?? {};
|
|
@@ -111,12 +142,17 @@ export function createAudioEngine(config = {}) {
|
|
|
111
142
|
return pending;
|
|
112
143
|
}
|
|
113
144
|
let listenerPosition = { x: 0, y: 0, z: 0 };
|
|
145
|
+
let listenerVelocity = { x: 0, y: 0, z: 0 };
|
|
146
|
+
const nyquist = context.sampleRate / 2;
|
|
114
147
|
const activeSpatialUpdaters = new Set();
|
|
115
148
|
const activeLoops = new Set();
|
|
116
149
|
const NOOP_HANDLE = {
|
|
117
150
|
setPosition: () => undefined,
|
|
118
151
|
setRate: () => undefined,
|
|
119
152
|
setGain: () => undefined,
|
|
153
|
+
setLowpass: () => undefined,
|
|
154
|
+
setHighpass: () => undefined,
|
|
155
|
+
setVelocity: () => undefined,
|
|
120
156
|
stop: () => undefined,
|
|
121
157
|
};
|
|
122
158
|
function playInternal(soundId, position, loop) {
|
|
@@ -128,8 +164,17 @@ export function createAudioEngine(config = {}) {
|
|
|
128
164
|
// A one-shot synth cue realises its decaying voices live and is fire-and-forget.
|
|
129
165
|
if (sound.synth !== undefined && !loop) {
|
|
130
166
|
const cueGain = context.createGain();
|
|
131
|
-
cueGain.gain.value =
|
|
132
|
-
|
|
167
|
+
cueGain.gain.value = sound.spatial === undefined
|
|
168
|
+
? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
|
|
169
|
+
: sound.gain ?? 1;
|
|
170
|
+
if (sound.spatial !== undefined) {
|
|
171
|
+
const panner = createPanner(context, sound.spatial, currentPosition);
|
|
172
|
+
cueGain.connect(panner);
|
|
173
|
+
panner.connect(bus);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
cueGain.connect(bus);
|
|
177
|
+
}
|
|
133
178
|
realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
|
|
134
179
|
return NOOP_HANDLE;
|
|
135
180
|
}
|
|
@@ -149,13 +194,34 @@ export function createAudioEngine(config = {}) {
|
|
|
149
194
|
// Live control state, applied on source creation so updates that race the async buffer load stick.
|
|
150
195
|
let currentRate = 1;
|
|
151
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;
|
|
152
201
|
let sourceNode = null;
|
|
202
|
+
let lowpassNode = null;
|
|
203
|
+
let highpassNode = null;
|
|
153
204
|
let userGainNode = null;
|
|
154
205
|
let falloffGain = null;
|
|
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
|
+
}
|
|
155
217
|
function updateFalloff() {
|
|
156
|
-
if (falloffGain !== null) {
|
|
218
|
+
if (falloffGain !== null && sound.spatial === undefined) {
|
|
157
219
|
falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
158
220
|
}
|
|
221
|
+
if (pannerNode !== null)
|
|
222
|
+
setPannerPosition(pannerNode, currentPosition);
|
|
223
|
+
if (dopplerFactor !== 0)
|
|
224
|
+
applyRate();
|
|
159
225
|
}
|
|
160
226
|
void bufferPromise.then((buffer) => {
|
|
161
227
|
if (buffer === null)
|
|
@@ -163,15 +229,39 @@ export function createAudioEngine(config = {}) {
|
|
|
163
229
|
const src = context.createBufferSource();
|
|
164
230
|
src.buffer = buffer;
|
|
165
231
|
src.loop = loop || (sound.loop ?? false);
|
|
166
|
-
src.playbackRate.value =
|
|
167
|
-
// 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
|
|
168
234
|
const uGain = context.createGain();
|
|
169
235
|
uGain.gain.value = currentUserGain;
|
|
170
236
|
const fGain = context.createGain();
|
|
171
237
|
fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
172
|
-
|
|
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
|
+
}
|
|
173
254
|
uGain.connect(fGain);
|
|
174
|
-
|
|
255
|
+
if (sound.spatial !== undefined) {
|
|
256
|
+
const panner = createPanner(context, sound.spatial, currentPosition);
|
|
257
|
+
fGain.gain.value = 1;
|
|
258
|
+
fGain.connect(panner);
|
|
259
|
+
panner.connect(bus);
|
|
260
|
+
pannerNode = panner;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
fGain.connect(bus);
|
|
264
|
+
}
|
|
175
265
|
src.start();
|
|
176
266
|
sourceNode = src;
|
|
177
267
|
userGainNode = uGain;
|
|
@@ -183,9 +273,14 @@ export function createAudioEngine(config = {}) {
|
|
|
183
273
|
catch {
|
|
184
274
|
}
|
|
185
275
|
src.disconnect();
|
|
276
|
+
lowpassNode?.disconnect();
|
|
277
|
+
highpassNode?.disconnect();
|
|
186
278
|
uGain.disconnect();
|
|
187
279
|
fGain.disconnect();
|
|
280
|
+
pannerNode?.disconnect();
|
|
188
281
|
sourceNode = null;
|
|
282
|
+
lowpassNode = null;
|
|
283
|
+
highpassNode = null;
|
|
189
284
|
userGainNode = null;
|
|
190
285
|
falloffGain = null;
|
|
191
286
|
});
|
|
@@ -199,8 +294,22 @@ export function createAudioEngine(config = {}) {
|
|
|
199
294
|
},
|
|
200
295
|
setRate(rate) {
|
|
201
296
|
currentRate = clampLoopRate(rate);
|
|
202
|
-
|
|
203
|
-
|
|
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();
|
|
204
313
|
},
|
|
205
314
|
setGain(gain) {
|
|
206
315
|
currentUserGain = clampLoopGain(gain);
|
|
@@ -219,7 +328,19 @@ export function createAudioEngine(config = {}) {
|
|
|
219
328
|
}
|
|
220
329
|
return {
|
|
221
330
|
setListenerPose(position) {
|
|
222
|
-
|
|
331
|
+
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
332
|
+
listenerPosition = pose.position;
|
|
333
|
+
listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
|
|
334
|
+
const listener = context.listener;
|
|
335
|
+
setAudioParam(listener.positionX, pose.position.x);
|
|
336
|
+
setAudioParam(listener.positionY, pose.position.y);
|
|
337
|
+
setAudioParam(listener.positionZ, pose.position.z);
|
|
338
|
+
setAudioParam(listener.forwardX, pose.forward.x);
|
|
339
|
+
setAudioParam(listener.forwardY, pose.forward.y);
|
|
340
|
+
setAudioParam(listener.forwardZ, pose.forward.z);
|
|
341
|
+
setAudioParam(listener.upX, pose.up.x);
|
|
342
|
+
setAudioParam(listener.upY, pose.up.y);
|
|
343
|
+
setAudioParam(listener.upZ, pose.up.z);
|
|
223
344
|
for (const updateGain of activeSpatialUpdaters)
|
|
224
345
|
updateGain();
|
|
225
346
|
},
|
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
|
+
};
|