@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.
Files changed (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. 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 effectiveSky = backdropSky ?? worldSky;
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, { frameloop: poster && posterFrozen ? "demand" : "always", orthographic: orthographic, camera: orthographic
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(DevtoolsRendererProbe, {}), resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality })) : null] }), !poster &&
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 &&
@@ -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
  };
@@ -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 { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
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
- useFrame(() => {
7
- engine.setListenerPose({ x: camera.position.x, y: camera.position.y, z: camera.position.z });
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(position: Vec3): void;
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 = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
132
- cueGain.connect(bus);
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 = currentRate;
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
- src.connect(uGain);
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
- fGain.connect(bus);
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
- if (sourceNode !== null)
203
- sourceNode.playbackRate.setTargetAtTime(currentRate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
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
- listenerPosition = position;
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
  },
@@ -17,6 +17,9 @@ type AudioLoopSetPayload = {
17
17
  rate?: number;
18
18
  gain?: number;
19
19
  at?: readonly [number, number, number];
20
+ velocity?: readonly [number, number, number];
21
+ lowpass?: number;
22
+ highpass?: number;
20
23
  };
21
24
  type AudioLoopStopPayload = {
22
25
  id: string;
@@ -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
+ };