@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,8 +1,11 @@
1
+ import type { SkySunConfig } from "@jgengine/core/world/features";
1
2
  export interface DaylightCycleConfig {
2
3
  horizonColor?: string;
3
4
  zenithColor?: string;
4
5
  sunIntensity?: number;
5
6
  ambientIntensity?: number;
7
+ /** Fixed sun placement, or the noon placement the day arc swings around. Unset keeps the engine arc. */
8
+ sun?: SkySunConfig;
6
9
  }
7
10
  export interface DaylightState {
8
11
  sunPosition: [number, number, number];
@@ -16,6 +19,23 @@ export declare const DEFAULT_DAY_SUN_INTENSITY = 1;
16
19
  export declare const DEFAULT_DAY_AMBIENT_INTENSITY = 0.6;
17
20
  export declare const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
18
21
  export declare const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
22
+ /** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */
23
+ export declare const DEFAULT_SUN_ELEVATION_DEG: number;
24
+ /**
25
+ * Unit direction toward a sun at compass `azimuth` (0 = -Z, 90 = +X) and `elevation` above the
26
+ * horizon, both in degrees. Shared by the dome glow, the sky-owned sun light, and the editor.
27
+ * @internal
28
+ */
29
+ export declare function sunDirectionFromBearing(azimuth: number, elevation: number): [number, number, number];
30
+ /**
31
+ * Inverse of {@link sunDirectionFromBearing}: the bearing/elevation (degrees) of a world-space
32
+ * direction such as an authored directional light's position. A zero vector reads as overhead.
33
+ * @internal
34
+ */
35
+ export declare function bearingFromDirection(direction: readonly [number, number, number]): {
36
+ azimuth: number;
37
+ elevation: number;
38
+ };
19
39
  export declare const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>;
20
40
  /** @internal */
21
41
  export declare function lerpHexColor(a: string, b: string, t: number): string;
@@ -16,6 +16,34 @@ export const DEFAULT_DAY_SKY_TOP = "#3fa4f2";
16
16
  export const DEFAULT_DAY_SKY_BOTTOM = "#e3f4ff";
17
17
  const SUN_DISTANCE = 200;
18
18
  const SUN_DEPTH_RATIO = 0.4;
19
+ const DEG = Math.PI / 180;
20
+ /** The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees. */
21
+ export const DEFAULT_SUN_ELEVATION_DEG = Math.atan2(1, SUN_DEPTH_RATIO) / DEG;
22
+ /**
23
+ * Unit direction toward a sun at compass `azimuth` (0 = -Z, 90 = +X) and `elevation` above the
24
+ * horizon, both in degrees. Shared by the dome glow, the sky-owned sun light, and the editor.
25
+ * @internal
26
+ */
27
+ export function sunDirectionFromBearing(azimuth, elevation) {
28
+ const az = azimuth * DEG;
29
+ const el = elevation * DEG;
30
+ const flat = Math.cos(el);
31
+ return [Math.sin(az) * flat, Math.sin(el), -Math.cos(az) * flat];
32
+ }
33
+ /**
34
+ * Inverse of {@link sunDirectionFromBearing}: the bearing/elevation (degrees) of a world-space
35
+ * direction such as an authored directional light's position. A zero vector reads as overhead.
36
+ * @internal
37
+ */
38
+ export function bearingFromDirection(direction) {
39
+ const [x, y, z] = direction;
40
+ const length = Math.hypot(x, y, z);
41
+ if (length === 0)
42
+ return { azimuth: 0, elevation: 90 };
43
+ const azimuth = ((Math.atan2(x, -z) / DEG) + 360) % 360;
44
+ const elevation = Math.asin(Math.max(-1, Math.min(1, y / length))) / DEG;
45
+ return { azimuth, elevation };
46
+ }
19
47
  export const SKY_PRESET_DAY_FRACTION = {
20
48
  night: 0,
21
49
  day: 0.5,
@@ -106,13 +134,37 @@ export function daylightStateAt(dayFraction, config = {}) {
106
134
  const ambientIntensity = lerp(from.ambientIntensity, to.ambientIntensity, localT);
107
135
  const skyTop = lerpHexColor(from.skyTop, to.skyTop, localT);
108
136
  const skyBottom = lerpHexColor(from.skyBottom, to.skyBottom, localT);
137
+ const sunPosition = sunPositionAt(wrapped, config.sun);
138
+ return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom };
139
+ }
140
+ /**
141
+ * Sun position for a wrapped day fraction. Without `sun`, the legacy arc: east at dawn, ~68 degrees
142
+ * up at noon, west at dusk, tilted 0.4 toward +Z. With `sun`, the arc is rotated so noon lands
143
+ * exactly on the authored bearing and elevation and the sun still rises 90 degrees before it and
144
+ * sets 90 degrees after it, so a fixed preset (noon, dusk, night fractions) samples the same arc.
145
+ */
146
+ function sunPositionAt(wrapped, sun) {
109
147
  const angle = (wrapped - 0.25) * 2 * Math.PI;
110
- const elevation = Math.sin(angle);
111
- const azimuth = Math.cos(angle);
112
- const sunPosition = [
113
- azimuth * SUN_DISTANCE,
114
- elevation * SUN_DISTANCE,
115
- SUN_DISTANCE * SUN_DEPTH_RATIO,
148
+ const along = Math.cos(angle);
149
+ const up = Math.sin(angle);
150
+ if (sun === undefined) {
151
+ return [along * SUN_DISTANCE, up * SUN_DISTANCE, SUN_DISTANCE * SUN_DEPTH_RATIO];
152
+ }
153
+ const noon = sunDirectionFromBearing(sun.azimuth ?? 0, sun.elevation ?? DEFAULT_SUN_ELEVATION_DEG);
154
+ const horizon = normalize([noon[0], 0, noon[2]], [0, 0, -1]);
155
+ const east = [-horizon[2], 0, horizon[0]];
156
+ const zenith = normalize(cross(east, noon), [0, 1, 0]);
157
+ const rise = normalize(cross(noon, zenith), east);
158
+ return [
159
+ (rise[0] * along + noon[0] * up) * SUN_DISTANCE,
160
+ (rise[1] * along + noon[1] * up) * SUN_DISTANCE,
161
+ (rise[2] * along + noon[2] * up) * SUN_DISTANCE,
116
162
  ];
117
- return { sunPosition, sunIntensity, ambientIntensity, skyTop, skyBottom, background: skyBottom };
163
+ }
164
+ function cross(a, b) {
165
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
166
+ }
167
+ function normalize(v, fallback) {
168
+ const length = Math.hypot(v[0], v[1], v[2]);
169
+ return length < 1e-6 ? [fallback[0], fallback[1], fallback[2]] : [v[0] / length, v[1] / length, v[2] / length];
118
170
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One shadow map for an outdoor sun: 2048 texels over a 70 m box that leads the camera, so the
3
+ * ground the player looks at gets ~3.4 cm texels and a PCF radius wide enough to hide the
4
+ * stair-step. A 180 m box centered on the camera wasted half its texels behind the view.
5
+ * @internal
6
+ */
7
+ export declare const SUN_SHADOW: {
8
+ readonly mapSize: 2048;
9
+ readonly halfExtent: 35;
10
+ readonly lead: 22;
11
+ readonly radius: 6;
12
+ readonly distance: 220;
13
+ readonly near: 10;
14
+ readonly far: 520;
15
+ readonly bias: -0.0004;
16
+ readonly normalBias: 0.02;
17
+ };
18
+ /**
19
+ * Where the sun's shadow box centers this frame: `lead` meters ahead of the camera along its
20
+ * horizontal view, snapped to whole shadow texels so edges hold still while the camera moves.
21
+ * Looking straight down (no horizontal view) centers on the camera.
22
+ * @internal
23
+ */
24
+ export declare function sunShadowFocus(camera: {
25
+ x: number;
26
+ z: number;
27
+ }, forward: {
28
+ x: number;
29
+ z: number;
30
+ }, out?: {
31
+ x: number;
32
+ z: number;
33
+ }): {
34
+ x: number;
35
+ z: number;
36
+ };
37
+ /**
38
+ * The sun light's world position for a focus point: `distance` meters back along the sun
39
+ * direction, so near/far cover the box whatever magnitude the sky's sun vector carries.
40
+ * @internal
41
+ */
42
+ export declare function sunLightPosition(focus: {
43
+ x: number;
44
+ z: number;
45
+ }, sun: readonly [number, number, number], out?: [number, number, number]): [number, number, number];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One shadow map for an outdoor sun: 2048 texels over a 70 m box that leads the camera, so the
3
+ * ground the player looks at gets ~3.4 cm texels and a PCF radius wide enough to hide the
4
+ * stair-step. A 180 m box centered on the camera wasted half its texels behind the view.
5
+ * @internal
6
+ */
7
+ export const SUN_SHADOW = {
8
+ mapSize: 2048,
9
+ halfExtent: 35,
10
+ lead: 22,
11
+ radius: 6,
12
+ distance: 220,
13
+ near: 10,
14
+ far: 520,
15
+ bias: -0.0004,
16
+ normalBias: 0.02,
17
+ };
18
+ /**
19
+ * Where the sun's shadow box centers this frame: `lead` meters ahead of the camera along its
20
+ * horizontal view, snapped to whole shadow texels so edges hold still while the camera moves.
21
+ * Looking straight down (no horizontal view) centers on the camera.
22
+ * @internal
23
+ */
24
+ export function sunShadowFocus(camera, forward, out = { x: 0, z: 0 }) {
25
+ const length = Math.hypot(forward.x, forward.z);
26
+ const lead = length < 1e-6 ? 0 : SUN_SHADOW.lead / length;
27
+ const texel = (SUN_SHADOW.halfExtent * 2) / SUN_SHADOW.mapSize;
28
+ out.x = Math.round((camera.x + forward.x * lead) / texel) * texel;
29
+ out.z = Math.round((camera.z + forward.z * lead) / texel) * texel;
30
+ return out;
31
+ }
32
+ /**
33
+ * The sun light's world position for a focus point: `distance` meters back along the sun
34
+ * direction, so near/far cover the box whatever magnitude the sky's sun vector carries.
35
+ * @internal
36
+ */
37
+ export function sunLightPosition(focus, sun, out = [0, 0, 0]) {
38
+ const length = Math.hypot(sun[0], sun[1], sun[2]);
39
+ if (length < 1e-6) {
40
+ out[0] = focus.x;
41
+ out[1] = SUN_SHADOW.distance;
42
+ out[2] = focus.z;
43
+ return out;
44
+ }
45
+ const scale = SUN_SHADOW.distance / length;
46
+ out[0] = focus.x + sun[0] * scale;
47
+ out[1] = sun[1] * scale;
48
+ out[2] = focus.z + sun[2] * scale;
49
+ return out;
50
+ }
@@ -0,0 +1,10 @@
1
+ import type { GamepadFrame } from "@jgengine/core/input/gamepadModel";
2
+ /** Per-frame gamepad actions and analog values ready to merge with another input source. */
3
+ export interface GamepadInputFrame {
4
+ held: readonly string[];
5
+ analog: Readonly<Record<string, number>>;
6
+ }
7
+ /** Merge one resolved gamepad frame with another input source's semantic state. */
8
+ export declare function mergeGamepadFrame(base: GamepadInputFrame, gamepad: GamepadFrame): GamepadInputFrame;
9
+ /** Pure reducer used by the shell and synthetic gamepad tests. */
10
+ export declare function mergeGamepadInput(frames: readonly GamepadFrame[], base?: GamepadInputFrame): GamepadInputFrame;
@@ -0,0 +1,12 @@
1
+ /** Merge one resolved gamepad frame with another input source's semantic state. */
2
+ export function mergeGamepadFrame(base, gamepad) {
3
+ const held = [...new Set([...base.held, ...gamepad.held])];
4
+ const analog = { ...base.analog };
5
+ for (const [action, value] of Object.entries(gamepad.analog))
6
+ analog[action] = Math.max(analog[action] ?? 0, value);
7
+ return { held, analog };
8
+ }
9
+ /** Pure reducer used by the shell and synthetic gamepad tests. */
10
+ export function mergeGamepadInput(frames, base = { held: [], analog: {} }) {
11
+ return frames.reduce(mergeGamepadFrame, base);
12
+ }
@@ -0,0 +1,47 @@
1
+ import { type GamepadBindings, type GamepadFrame, type GamepadSample, type ResolveGamepadFrameOptions } from "@jgengine/core/input/gamepadModel";
2
+ import type { ActionCodesMap, ActionStateTracker } from "@jgengine/core/input/actionBindings";
3
+ import type { LocalPlayerSlot, LocalPlayers } from "@jgengine/core/runtime/localPlayers";
4
+ /** Buffers one shell gamepad poll reuses across frames. */
5
+ export interface GamepadPoll {
6
+ held: Set<string>;
7
+ nextHeld: Set<string>;
8
+ frame: GamepadFrame;
9
+ merged: Record<string, number>;
10
+ base: Readonly<Record<string, number>> | null;
11
+ }
12
+ /** Fresh buffers for {@link stepGamepadPoll}. */
13
+ export declare function emptyGamepadPoll(): GamepadPoll;
14
+ /** The pad-only slice of an action binding map, in the shape {@link resolveGamepadFrame} reads. */
15
+ export declare function gamepadCodes(bindings: ActionCodesMap): GamepadBindings;
16
+ /**
17
+ * Swap the pad bindings mid-game (a context push) without touching keyboard state: release the codes
18
+ * of pad-held actions whose pad codes changed (the next poll presses them under the new map), and
19
+ * keep the rest held.
20
+ */
21
+ export declare function rebindGamepadPoll(poll: GamepadPoll, previous: GamepadBindings, next: GamepadBindings, tracker: ActionStateTracker<string>): void;
22
+ /**
23
+ * One poll: resolve every connected pad, press/release tracker codes for actions whose pad state
24
+ * changed, and return the analog map to publish (pad values max-merged over the other source's).
25
+ * Allocation-free after the first frame.
26
+ */
27
+ export declare function stepGamepadPoll(poll: GamepadPoll, pads: ArrayLike<GamepadSample | null | undefined>, bindings: GamepadBindings, options: ResolveGamepadFrameOptions, tracker: ActionStateTracker<string>, analogIn: Readonly<Record<string, number>> | null): Readonly<Record<string, number>> | null;
28
+ /** Where each connected pad's input goes this frame; buffers are reused across frames. */
29
+ export interface GamepadRoute {
30
+ /** Pads driving the primary player, by pad index (`null` for pads routed elsewhere). */
31
+ primary: (GamepadSample | null)[];
32
+ /** Pads driving another local seat; entries past `seatCount` are stale pooled slots. */
33
+ seats: {
34
+ slotId: string;
35
+ pad: GamepadSample;
36
+ }[];
37
+ seatCount: number;
38
+ /** Seats opened this frame. */
39
+ joined: LocalPlayerSlot[];
40
+ }
41
+ /** Fresh buffers for {@link routeGamepads}. */
42
+ export declare function emptyGamepadRoute(): GamepadRoute;
43
+ /**
44
+ * Route pads to local seats: a claimed pad goes to its seat, an unclaimed pad hot-joins on a button
45
+ * press (stick drift never opens a seat), and with one seat every pad drives the primary player.
46
+ */
47
+ export declare function routeGamepads(pads: ArrayLike<GamepadSample | null | undefined>, seats: Pick<LocalPlayers, "assign" | "slotForDevice" | "config">, route: GamepadRoute): GamepadRoute;
@@ -0,0 +1,142 @@
1
+ import { resolveGamepadFrame, } from "@jgengine/core/input/gamepadModel";
2
+ /** Fresh buffers for {@link stepGamepadPoll}. */
3
+ export function emptyGamepadPoll() {
4
+ return { held: new Set(), nextHeld: new Set(), frame: { held: [], analog: {} }, merged: {}, base: null };
5
+ }
6
+ /** The pad-only slice of an action binding map, in the shape {@link resolveGamepadFrame} reads. */
7
+ export function gamepadCodes(bindings) {
8
+ const result = {};
9
+ for (const [action, config] of Object.entries(bindings)) {
10
+ const codes = Array.isArray(config)
11
+ ? config
12
+ : [...(config.hold ?? []), ...(config.toggle ?? [])];
13
+ const padCodes = codes.filter((code) => code.startsWith("pad:") || code.startsWith("padaxis:"));
14
+ if (padCodes.length > 0)
15
+ result[action] = padCodes;
16
+ }
17
+ return result;
18
+ }
19
+ function syncTracker(tracker, bindings, previous, next) {
20
+ for (const action in bindings) {
21
+ const wasDown = previous.has(action);
22
+ const isDown = next.has(action);
23
+ if (isDown === wasDown)
24
+ continue;
25
+ for (const code of bindings[action]) {
26
+ if (isDown)
27
+ tracker.handleDown(code);
28
+ else
29
+ tracker.handleUp(code);
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * Swap the pad bindings mid-game (a context push) without touching keyboard state: release the codes
35
+ * of pad-held actions whose pad codes changed (the next poll presses them under the new map), and
36
+ * keep the rest held.
37
+ */
38
+ export function rebindGamepadPoll(poll, previous, next, tracker) {
39
+ for (const action of poll.held) {
40
+ const before = (previous[action] ?? []);
41
+ const after = next[action];
42
+ if (after !== undefined && after.length === before.length && after.every((code, index) => code === before[index]))
43
+ continue;
44
+ for (const code of before)
45
+ tracker.handleUp(code);
46
+ poll.held.delete(action);
47
+ }
48
+ }
49
+ /**
50
+ * One poll: resolve every connected pad, press/release tracker codes for actions whose pad state
51
+ * changed, and return the analog map to publish (pad values max-merged over the other source's).
52
+ * Allocation-free after the first frame.
53
+ */
54
+ export function stepGamepadPoll(poll, pads, bindings, options, tracker, analogIn) {
55
+ // The analog slot is shared with the touch dock: anything other than our own merged record is a
56
+ // fresh touch publish (or null) and becomes the base the pad values merge over.
57
+ if (analogIn !== poll.merged)
58
+ poll.base = analogIn;
59
+ const merged = poll.merged;
60
+ for (const key in merged)
61
+ delete merged[key];
62
+ let any = false;
63
+ if (poll.base !== null) {
64
+ for (const action in poll.base) {
65
+ merged[action] = poll.base[action];
66
+ any = true;
67
+ }
68
+ }
69
+ const nextHeld = poll.nextHeld;
70
+ nextHeld.clear();
71
+ for (let index = 0; index < pads.length; index += 1) {
72
+ const pad = pads[index];
73
+ if (pad === null || pad === undefined)
74
+ continue;
75
+ const frame = resolveGamepadFrame(pad, bindings, options, poll.frame);
76
+ for (const action of frame.held)
77
+ nextHeld.add(action);
78
+ for (const action in frame.analog) {
79
+ const value = frame.analog[action];
80
+ if (value > (merged[action] ?? 0)) {
81
+ merged[action] = value;
82
+ any = true;
83
+ }
84
+ }
85
+ }
86
+ syncTracker(tracker, bindings, poll.held, nextHeld);
87
+ poll.nextHeld = poll.held;
88
+ poll.held = nextHeld;
89
+ return any ? merged : null;
90
+ }
91
+ /** Fresh buffers for {@link routeGamepads}. */
92
+ export function emptyGamepadRoute() {
93
+ return { primary: [], seats: [], seatCount: 0, joined: [] };
94
+ }
95
+ function anyButtonPressed(pad) {
96
+ for (let index = 0; index < pad.buttons.length; index += 1) {
97
+ if (pad.buttons[index]?.pressed === true)
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+ /**
103
+ * Route pads to local seats: a claimed pad goes to its seat, an unclaimed pad hot-joins on a button
104
+ * press (stick drift never opens a seat), and with one seat every pad drives the primary player.
105
+ */
106
+ export function routeGamepads(pads, seats, route) {
107
+ route.primary.length = pads.length;
108
+ route.seatCount = 0;
109
+ route.joined.length = 0;
110
+ const single = seats.config().maxSlots === 1;
111
+ for (let index = 0; index < pads.length; index += 1) {
112
+ route.primary[index] = null;
113
+ const pad = pads[index];
114
+ if (pad === null || pad === undefined || !pad.connected)
115
+ continue;
116
+ const deviceId = `gamepad:${index}`;
117
+ let slot = seats.slotForDevice(deviceId);
118
+ if (slot === null && (single || anyButtonPressed(pad))) {
119
+ const assigned = seats.assign(deviceId);
120
+ if (assigned !== null) {
121
+ slot = assigned.slot;
122
+ if (assigned.joined)
123
+ route.joined.push(slot);
124
+ }
125
+ }
126
+ if (slot === null)
127
+ continue;
128
+ if (slot.index === 0)
129
+ route.primary[index] = pad;
130
+ else {
131
+ const entry = route.seats[route.seatCount];
132
+ if (entry === undefined)
133
+ route.seats.push({ slotId: slot.slotId, pad });
134
+ else {
135
+ entry.slotId = slot.slotId;
136
+ entry.pad = pad;
137
+ }
138
+ route.seatCount += 1;
139
+ }
140
+ }
141
+ return route;
142
+ }
@@ -0,0 +1,22 @@
1
+ import { type GamepadFeelConfig } from "@jgengine/core/input/gamepadModel";
2
+ import type { LocalPlayerSlot, LocalPlayers } from "@jgengine/core/runtime/localPlayers";
3
+ import type { ActionCodesMap, ActionStateTracker } from "@jgengine/core/input/actionBindings";
4
+ import type { InputSnapshot } from "@jgengine/core/runtime/inputSnapshot";
5
+ export { mergeGamepadFrame, mergeGamepadInput } from "./gamepadMerge.js";
6
+ /** Poll browser gamepads and feed semantic actions into the shell tracker. */
7
+ export declare function GamepadSource({ tracker, bindings, analogRef, input, feel, seats, onSeatJoin, seatsActive, }: {
8
+ tracker: ActionStateTracker<string>;
9
+ bindings: ActionCodesMap;
10
+ analogRef: {
11
+ current: Readonly<Record<string, number>> | null;
12
+ };
13
+ input: InputSnapshot;
14
+ /** Game-level pad feel (`defineGame({ gamepad })`); unset keeps the shell defaults. */
15
+ feel?: GamepadFeelConfig;
16
+ /** Local seats; pads claimed by a seat other than the primary publish to that seat's input. */
17
+ seats?: LocalPlayers;
18
+ /** A pad just opened a new seat; the shell spawns its player here. */
19
+ onSeatJoin?: (slot: LocalPlayerSlot) => void;
20
+ /** False while play controls are gated (menus, orientation lock): seats publish nothing held. */
21
+ seatsActive?: () => boolean;
22
+ }): null;
@@ -0,0 +1,153 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { gamepadFeelOptions, resolveGamepadFrame, } from "@jgengine/core/input/gamepadModel";
4
+ import { emptyGamepadPoll, emptyGamepadRoute, gamepadCodes, rebindGamepadPoll, routeGamepads, stepGamepadPoll, } from "./gamepadPoll.js";
5
+ import { PAD_HAPTIC_EFFECT_MS, emptyPadHapticState, stepPadHaptics } from "./padHaptics.js";
6
+ export { mergeGamepadFrame, mergeGamepadInput } from "./gamepadMerge.js";
7
+ const NO_PADS = [];
8
+ function browserPads() {
9
+ return typeof navigator === "undefined" || navigator.getGamepads === undefined ? NO_PADS : navigator.getGamepads();
10
+ }
11
+ function actuatorOf(pad) {
12
+ return pad === null || pad === undefined || !("vibrationActuator" in pad) ? null : (pad.vibrationActuator ?? null);
13
+ }
14
+ function ignore() { }
15
+ /** Pad index a user's rumble goes to: their seat's pad, else the first connected pad for the primary seat. */
16
+ function padIndexForUser(userId, seats, pads) {
17
+ const legacy = /^gamepad:(\d+)$/.exec(userId)?.[1];
18
+ if (legacy !== undefined)
19
+ return Number(legacy);
20
+ const slot = seats?.slots().find((candidate) => candidate.userId === userId);
21
+ const claimed = slot?.deviceId === null || slot?.deviceId === undefined ? undefined : /^gamepad:(\d+)$/.exec(slot.deviceId)?.[1];
22
+ if (claimed !== undefined)
23
+ return Number(claimed);
24
+ if (slot !== undefined && slot.index !== 0)
25
+ return -1;
26
+ for (let index = 0; index < pads.length; index += 1) {
27
+ if (pads[index]?.connected === true)
28
+ return index;
29
+ }
30
+ return -1;
31
+ }
32
+ const NO_HELD = [];
33
+ function hasAnalog(analog) {
34
+ for (const _ in analog)
35
+ return true;
36
+ return false;
37
+ }
38
+ // `?gamepad` (or `?gamepad=N` for N pads) injects synthetic pads holding Start (button 9), since
39
+ // headless browsers cannot attach a real one; N > 1 hot-joins local seats.
40
+ function syntheticPads() {
41
+ if (typeof window === "undefined")
42
+ return null;
43
+ const param = new URLSearchParams(window.location.search).get("gamepad");
44
+ if (param === null)
45
+ return null;
46
+ const count = Math.max(1, Math.min(4, Number(param) || 1));
47
+ const buttons = Array.from({ length: 10 }, (_, index) => ({ pressed: index === 9, value: index === 9 ? 1 : 0 }));
48
+ return Array.from({ length: count }, () => ({ axes: [0, 0, 0, 0], buttons, connected: true }));
49
+ }
50
+ /** Poll browser gamepads and feed semantic actions into the shell tracker. */
51
+ export function GamepadSource({ tracker, bindings, analogRef, input, feel, seats, onSeatJoin, seatsActive, }) {
52
+ const poll = useRef(emptyGamepadPoll());
53
+ const padBindings = useRef(gamepadCodes(bindings));
54
+ const options = useMemo(() => gamepadFeelOptions(feel), [feel]);
55
+ const synthetic = useMemo(syntheticPads, []);
56
+ useEffect(() => {
57
+ const next = gamepadCodes(bindings);
58
+ rebindGamepadPoll(poll.current, padBindings.current, next, tracker);
59
+ padBindings.current = next;
60
+ }, [bindings, tracker]);
61
+ useEffect(() => {
62
+ const inputWithRumble = input;
63
+ inputWithRumble.rumble = async (userId, options) => {
64
+ const pads = browserPads();
65
+ const actuator = actuatorOf(pads[padIndexForUser(userId, seats, pads)]);
66
+ if (actuator === null)
67
+ return false;
68
+ try {
69
+ await actuator.playEffect("dual-rumble", {
70
+ duration: options.ms,
71
+ strongMagnitude: options.strong,
72
+ weakMagnitude: options.weak,
73
+ });
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ };
80
+ return () => {
81
+ delete inputWithRumble.rumble;
82
+ };
83
+ }, [input, seats]);
84
+ const hapticStates = useRef(new Map());
85
+ const driveHaptics = (dt, pads) => {
86
+ if (seats === undefined)
87
+ return;
88
+ const nowMs = performance.now();
89
+ const slots = seats.slots();
90
+ for (let index = 0; index < slots.length; index += 1) {
91
+ const level = input.haptics(slots[index].userId).mix(dt);
92
+ const padIndex = padIndexForUser(slots[index].userId, seats, pads);
93
+ const actuator = actuatorOf(pads[padIndex]);
94
+ if (actuator === null)
95
+ continue;
96
+ let state = hapticStates.current.get(padIndex);
97
+ if (state === undefined) {
98
+ state = emptyPadHapticState();
99
+ hapticStates.current.set(padIndex, state);
100
+ }
101
+ const command = stepPadHaptics(state, level, nowMs);
102
+ if (command === "play") {
103
+ actuator
104
+ .playEffect("dual-rumble", { duration: PAD_HAPTIC_EFFECT_MS, strongMagnitude: level.strong, weakMagnitude: level.weak })
105
+ .catch(ignore);
106
+ }
107
+ else if (command === "reset") {
108
+ actuator.reset?.().catch(ignore);
109
+ }
110
+ }
111
+ };
112
+ const route = useRef(emptyGamepadRoute());
113
+ const seatFrame = useRef({ held: [], analog: {} });
114
+ const seatsPublished = useRef(new Set());
115
+ useFrame((_state, dt) => {
116
+ const pads = synthetic ?? browserPads();
117
+ driveHaptics(dt, pads);
118
+ if (seats === undefined) {
119
+ analogRef.current = stepGamepadPoll(poll.current, pads, padBindings.current, options, tracker, analogRef.current);
120
+ return;
121
+ }
122
+ const routed = routeGamepads(pads, seats, route.current);
123
+ for (const slot of routed.joined)
124
+ onSeatJoin?.(slot);
125
+ analogRef.current = stepGamepadPoll(poll.current, routed.primary, padBindings.current, options, tracker, analogRef.current);
126
+ const active = seatsActive?.() ?? true;
127
+ const published = seatsPublished.current;
128
+ for (const slotId of published) {
129
+ let routedNow = false;
130
+ for (let index = 0; index < routed.seatCount; index += 1)
131
+ routedNow ||= routed.seats[index].slotId === slotId;
132
+ if (routedNow && active)
133
+ continue;
134
+ const seat = seats.local(slotId);
135
+ seat?.input.publish(NO_HELD);
136
+ seat?.input.publishAnalog(null);
137
+ published.delete(slotId);
138
+ }
139
+ if (!active)
140
+ return;
141
+ for (let index = 0; index < routed.seatCount; index += 1) {
142
+ const { slotId, pad } = routed.seats[index];
143
+ const seat = seats.local(slotId);
144
+ if (seat === null)
145
+ continue;
146
+ const frame = resolveGamepadFrame(pad, padBindings.current, options, seatFrame.current);
147
+ seat.input.publish(frame.held);
148
+ seat.input.publishAnalog(hasAnalog(frame.analog) ? frame.analog : null);
149
+ published.add(slotId);
150
+ }
151
+ });
152
+ return null;
153
+ }
@@ -1,3 +1,4 @@
1
+ import { requestRawPointerLock } from "./pointerLock.js";
1
2
  export function createMouseLookTracker(element, options = {}) {
2
3
  const sensitivity = options.sensitivity ?? 0.0024;
3
4
  const maxPitch = options.maxPitch ?? 1.15;
@@ -10,7 +11,7 @@ export function createMouseLookTracker(element, options = {}) {
10
11
  if (window.matchMedia?.("(pointer: coarse)").matches)
11
12
  return;
12
13
  if (document.pointerLockElement !== element)
13
- void element.requestPointerLock?.();
14
+ requestRawPointerLock(element);
14
15
  };
15
16
  const onMove = (event) => {
16
17
  if (pointerLock && document.pointerLockElement !== element)
@@ -0,0 +1,17 @@
1
+ import type { HapticLevel } from "@jgengine/core/input/haptics";
2
+ /** How long each rumble command lasts; refreshed before it runs out so a held level feels continuous. */
3
+ export declare const PAD_HAPTIC_EFFECT_MS = 120;
4
+ /** Last command sent to one pad. */
5
+ export interface PadHapticState {
6
+ strong: number;
7
+ weak: number;
8
+ sentAtMs: number;
9
+ active: boolean;
10
+ }
11
+ /** Fresh state for {@link stepPadHaptics}. */
12
+ export declare function emptyPadHapticState(): PadHapticState;
13
+ /**
14
+ * Decide what to send a pad this frame for a mixed level: `"play"` when the level changed or the
15
+ * last effect is about to run out, `"reset"` when it fell silent, `null` otherwise.
16
+ */
17
+ export declare function stepPadHaptics(state: PadHapticState, level: HapticLevel, nowMs: number): "play" | "reset" | null;
@@ -0,0 +1,32 @@
1
+ /** How long each rumble command lasts; refreshed before it runs out so a held level feels continuous. */
2
+ export const PAD_HAPTIC_EFFECT_MS = 120;
3
+ const REFRESH_MS = 80;
4
+ const CHANGE = 0.03;
5
+ const SILENT = 0.01;
6
+ /** Fresh state for {@link stepPadHaptics}. */
7
+ export function emptyPadHapticState() {
8
+ return { strong: 0, weak: 0, sentAtMs: -Infinity, active: false };
9
+ }
10
+ /**
11
+ * Decide what to send a pad this frame for a mixed level: `"play"` when the level changed or the
12
+ * last effect is about to run out, `"reset"` when it fell silent, `null` otherwise.
13
+ */
14
+ export function stepPadHaptics(state, level, nowMs) {
15
+ const silent = level.strong < SILENT && level.weak < SILENT;
16
+ if (silent) {
17
+ if (!state.active)
18
+ return null;
19
+ state.active = false;
20
+ state.strong = 0;
21
+ state.weak = 0;
22
+ return "reset";
23
+ }
24
+ const changed = Math.abs(level.strong - state.strong) > CHANGE || Math.abs(level.weak - state.weak) > CHANGE;
25
+ if (state.active && !changed && nowMs - state.sentAtMs < REFRESH_MS)
26
+ return null;
27
+ state.active = true;
28
+ state.strong = level.strong;
29
+ state.weak = level.weak;
30
+ state.sentAtMs = nowMs;
31
+ return "play";
32
+ }