@jgengine/shell 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +158 -7
- package/dist/GamePlayerShell.js +20 -3
- package/dist/Shell3dPresentation.d.ts +3 -9
- package/dist/Shell3dPresentation.js +26 -6
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/audio/AudioComponents.js +6 -2
- package/dist/audio/audioEngine.d.ts +8 -0
- package/dist/audio/audioEngine.js +65 -6
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +70 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +20 -3
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.js +13 -2
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +1 -1
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +11 -1
- package/dist/input/gamepadSource.js +117 -71
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/postfx/PostProcessing.d.ts +1 -1
- package/dist/postfx/PostProcessing.js +5 -1
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/SceneModels.js +1 -1
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +29 -0
- package/dist/render/modelLoad.js +61 -1
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +53 -4
- package/dist/render/useFootIk.js +197 -70
- package/dist/render/useModelAnimation.d.ts +24 -0
- package/dist/render/useModelAnimation.js +61 -40
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +6 -6
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +9 -3
- package/dist/settings/appliedSettings.js +6 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/useShellMultiplayerSync.d.ts +1 -1
- package/dist/useShellMultiplayerSync.js +5 -3
- package/dist/world/WorldParticles.js +1 -1
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +13 -16
- package/skills/jgengine-ui/api.md +418 -317
- package/skills/jgengine-ui/capabilities.md +16 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -81
- package/skills/jgengine-world/SKILL.md +15 -3
- package/skills/jgengine-world/api.md +873 -626
- package/skills/jgengine-world/capabilities.md +130 -6
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
- package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
|
@@ -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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
15
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
58
|
-
const
|
|
59
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
for (let index = 0; index <
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
}
|
package/dist/input/mouseLook.js
CHANGED
|
@@ -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
|
-
|
|
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 ?? {});
|