@jgengine/shell 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -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,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
+ }
@@ -1,12 +1,22 @@
1
+ import { type GamepadFeelConfig } from "@jgengine/core/input/gamepadModel";
2
+ import type { LocalPlayerSlot, LocalPlayers } from "@jgengine/core/runtime/localPlayers";
1
3
  import type { ActionCodesMap, ActionStateTracker } from "@jgengine/core/input/actionBindings";
2
4
  import type { InputSnapshot } from "@jgengine/core/runtime/inputSnapshot";
3
5
  export { mergeGamepadFrame, mergeGamepadInput } from "./gamepadMerge.js";
4
6
  /** Poll browser gamepads and feed semantic actions into the shell tracker. */
5
- export declare function GamepadSource({ tracker, bindings, analogRef, input, }: {
7
+ export declare function GamepadSource({ tracker, bindings, analogRef, input, feel, seats, onSeatJoin, seatsActive, }: {
6
8
  tracker: ActionStateTracker<string>;
7
9
  bindings: ActionCodesMap;
8
10
  analogRef: {
9
11
  current: Readonly<Record<string, number>> | null;
10
12
  };
11
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;
12
22
  }): null;
@@ -1,64 +1,69 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
2
2
  import { useFrame } from "@react-three/fiber";
3
- import { resolveGamepadFrame, } from "@jgengine/core/input/gamepadModel";
4
- import { mergeGamepadInput } from "./gamepadMerge.js";
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";
5
6
  export { mergeGamepadFrame, mergeGamepadInput } from "./gamepadMerge.js";
6
- function snapshotOf(gamepad) {
7
- return {
8
- id: gamepad.id,
9
- axes: Array.from(gamepad.axes),
10
- buttons: Array.from(gamepad.buttons, (button) => ({ pressed: button.pressed, value: button.value })),
11
- connected: gamepad.connected,
12
- };
7
+ const NO_PADS = [];
8
+ function browserPads() {
9
+ return typeof navigator === "undefined" || navigator.getGamepads === undefined ? NO_PADS : navigator.getGamepads();
13
10
  }
14
- function gamepadCodes(bindings) {
15
- const result = new Map();
16
- for (const [action, config] of Object.entries(bindings)) {
17
- const codes = Array.isArray(config)
18
- ? config
19
- : [...(config.hold ?? []), ...(config.toggle ?? [])];
20
- const padCodes = codes.filter((code) => code.startsWith("pad:") || code.startsWith("padaxis:"));
21
- if (padCodes.length > 0)
22
- result.set(action, padCodes);
23
- }
24
- return result;
11
+ function actuatorOf(pad) {
12
+ return pad === null || pad === undefined || !("vibrationActuator" in pad) ? null : (pad.vibrationActuator ?? null);
25
13
  }
26
- function syncTracker(tracker, bindings, previous, next) {
27
- for (const [action, codes] of bindings) {
28
- for (const code of codes) {
29
- const wasDown = previous.has(action);
30
- const isDown = next.has(action);
31
- if (isDown && !wasDown)
32
- tracker.handleDown(code);
33
- if (!isDown && wasDown)
34
- tracker.handleUp(code);
35
- }
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;
36
29
  }
30
+ return -1;
37
31
  }
38
- function syntheticSnapshot() {
39
- if (typeof window === "undefined" || !new URLSearchParams(window.location.search).has("gamepad"))
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)
40
45
  return null;
41
- return { id: "Synthetic Gamepad", axes: [0, 0], buttons: [{ pressed: true, value: 1 }], connected: true };
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 }));
42
49
  }
43
50
  /** Poll browser gamepads and feed semantic actions into the shell tracker. */
44
- export function GamepadSource({ tracker, bindings, analogRef, input, }) {
45
- const previousHeld = useRef(new Set());
46
- const previousAnalog = useRef(new Set());
51
+ export function GamepadSource({ tracker, bindings, analogRef, input, feel, seats, onSeatJoin, seatsActive, }) {
52
+ const poll = useRef(emptyGamepadPoll());
47
53
  const padBindings = useRef(gamepadCodes(bindings));
54
+ const options = useMemo(() => gamepadFeelOptions(feel), [feel]);
55
+ const synthetic = useMemo(syntheticPads, []);
48
56
  useEffect(() => {
49
- padBindings.current = gamepadCodes(bindings);
50
- previousHeld.current.clear();
51
- previousAnalog.current.clear();
52
- tracker.reset();
57
+ const next = gamepadCodes(bindings);
58
+ rebindGamepadPoll(poll.current, padBindings.current, next, tracker);
59
+ padBindings.current = next;
53
60
  }, [bindings, tracker]);
54
61
  useEffect(() => {
55
62
  const inputWithRumble = input;
56
63
  inputWithRumble.rumble = async (userId, options) => {
57
- const pads = typeof navigator === "undefined" || navigator.getGamepads === undefined ? [] : navigator.getGamepads();
58
- const requested = /^gamepad:(\d+)$/.exec(userId)?.[1];
59
- const pad = (requested === undefined ? Array.from(pads).find((candidate) => candidate?.connected) : pads[Number(requested)]) ?? null;
60
- const actuator = pad?.vibrationActuator;
61
- if (actuator === undefined)
64
+ const pads = browserPads();
65
+ const actuator = actuatorOf(pads[padIndexForUser(userId, seats, pads)]);
66
+ if (actuator === null)
62
67
  return false;
63
68
  try {
64
69
  await actuator.playEffect("dual-rumble", {
@@ -75,33 +80,74 @@ export function GamepadSource({ tracker, bindings, analogRef, input, }) {
75
80
  return () => {
76
81
  delete inputWithRumble.rumble;
77
82
  };
78
- }, [input]);
79
- useFrame(() => {
80
- const synthetic = syntheticSnapshot();
81
- const pads = synthetic === null
82
- ? (typeof navigator === "undefined" || navigator.getGamepads === undefined ? [] : navigator.getGamepads())
83
- : [synthetic];
84
- const frames = [];
85
- for (let index = 0; index < pads.length; index += 1) {
86
- const pad = pads[index];
87
- if (pad !== null && pad !== undefined) {
88
- frames.push(resolveGamepadFrame(snapshotOf(pad), padBindingsToGamepad(padBindings.current), {
89
- deadzone: { kind: "axial", inner: 0.12, outer: 0.95 },
90
- }));
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);
91
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);
92
150
  }
93
- const baseAnalog = { ...(analogRef.current ?? {}) };
94
- for (const action of previousAnalog.current)
95
- delete baseAnalog[action];
96
- const merged = mergeGamepadInput(frames, { held: [], analog: baseAnalog });
97
- const nextHeld = new Set(merged.held);
98
- syncTracker(tracker, padBindings.current, previousHeld.current, nextHeld);
99
- previousHeld.current = nextHeld;
100
- analogRef.current = Object.keys(merged.analog).length === 0 ? null : merged.analog;
101
- previousAnalog.current = new Set(Object.keys(mergeGamepadInput(frames).analog));
102
151
  });
103
152
  return null;
104
153
  }
105
- function padBindingsToGamepad(bindings) {
106
- return Object.fromEntries(bindings.entries());
107
- }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ interface LockableElement {
2
+ requestPointerLock?: (options?: {
3
+ unadjustedMovement?: boolean;
4
+ }) => Promise<void> | void;
5
+ }
6
+ /**
7
+ * Request pointer lock with raw (unaccelerated) mouse deltas, falling back to a plain lock when
8
+ * the browser or OS rejects `unadjustedMovement`.
9
+ */
10
+ export declare function requestRawPointerLock(element: LockableElement): void;
11
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Request pointer lock with raw (unaccelerated) mouse deltas, falling back to a plain lock when
3
+ * the browser or OS rejects `unadjustedMovement`.
4
+ */
5
+ export function requestRawPointerLock(element) {
6
+ const request = element.requestPointerLock;
7
+ if (request === undefined)
8
+ return;
9
+ let pending;
10
+ try {
11
+ pending = request.call(element, { unadjustedMovement: true });
12
+ }
13
+ catch {
14
+ pending = request.call(element);
15
+ }
16
+ if (pending !== undefined && typeof pending.catch === "function") {
17
+ pending.catch(() => request.call(element)?.catch?.(() => undefined));
18
+ }
19
+ }
@@ -3,7 +3,7 @@ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
3
3
  /**
4
4
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
5
5
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
6
- * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
6
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Stylize → Grade. Rendered only
7
7
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
8
8
  *
9
9
  * `stages` (resolved from the player's graphics profile) controls expensive passes
@@ -7,6 +7,7 @@ import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
8
  import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
9
9
  import { createGradePass } from "./gradeShader.js";
10
+ import { StylizePass } from "./stylizePass.js";
10
11
  import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
11
12
  /**
12
13
  * GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
@@ -60,7 +61,7 @@ function syncSize(built, width, height, pixelRatio) {
60
61
  /**
61
62
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
62
63
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
63
- * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
64
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Stylize → Grade. Rendered only
64
65
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
65
66
  *
66
67
  * `stages` (resolved from the player's graphics profile) controls expensive passes
@@ -116,6 +117,9 @@ export function PostProcessing({ config, quality = "high", stages }) {
116
117
  composer.addPass(new SMAAPass());
117
118
  }
118
119
  composer.addPass(new OutputPass());
120
+ if (config.stylize !== undefined && config.stylize !== false) {
121
+ composer.addPass(new StylizePass(scene, camera, config.stylize));
122
+ }
119
123
  let grade = null;
120
124
  if (config.grade !== false) {
121
125
  grade = createGradePass(config.grade ?? {});