@jgengine/shell 0.18.0 → 0.18.1
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 +114 -2
- package/dist/GamePlayerShell.js +10 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +8 -1
- package/dist/Shell3dPresentation.js +34 -5
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +8 -1
- package/dist/audio/audioEngine.d.ts +7 -1
- package/dist/audio/audioEngine.js +67 -5
- package/dist/camera/cameraRigs.js +7 -0
- package/dist/defineGame.js +21 -6
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +61 -24
- package/dist/drivers/HudOnlyDriver.js +9 -4
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadSource.d.ts +12 -0
- package/dist/input/gamepadSource.js +107 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +10 -7
- package/dist/postfx/PostProcessing.js +33 -15
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- package/dist/render/modelLoad.d.ts +10 -0
- package/dist/render/modelLoad.js +28 -0
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
- package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
- package/dist/render/useFootIk.d.ts +9 -0
- package/dist/render/useFootIk.js +89 -0
- package/dist/render/useModelAnimation.d.ts +2 -0
- package/dist/render/useModelAnimation.js +163 -107
- package/dist/scene/CityRenderer.js +661 -236
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/appliedSettings.d.ts +3 -1
- package/dist/settings/appliedSettings.js +5 -2
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +8 -1
- package/dist/useShellMultiplayerSync.js +25 -5
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-ui/SKILL.md +8 -0
- package/skills/jgengine-ui/api.md +127 -16
- package/skills/jgengine-ui/capabilities.md +21 -0
- package/skills/jgengine-ui/reference.md +3 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
- package/skills/level-design/SKILL.md +1 -1
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -30,6 +30,35 @@ function resolveOfflineAudioContextCtor() {
|
|
|
30
30
|
return (window.OfflineAudioContext ??
|
|
31
31
|
window.webkitOfflineAudioContext);
|
|
32
32
|
}
|
|
33
|
+
function setAudioParam(param, value) {
|
|
34
|
+
if (param === undefined)
|
|
35
|
+
return;
|
|
36
|
+
param.value = value;
|
|
37
|
+
}
|
|
38
|
+
function setPannerPosition(panner, position) {
|
|
39
|
+
setAudioParam(panner.positionX, position.x);
|
|
40
|
+
setAudioParam(panner.positionY, position.y);
|
|
41
|
+
setAudioParam(panner.positionZ, position.z);
|
|
42
|
+
}
|
|
43
|
+
function createPanner(context, spatial, position) {
|
|
44
|
+
const panner = context.createPanner();
|
|
45
|
+
panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
|
|
46
|
+
panner.distanceModel = "inverse";
|
|
47
|
+
if (spatial.refDistance !== undefined)
|
|
48
|
+
panner.refDistance = spatial.refDistance;
|
|
49
|
+
if (spatial.maxDistance !== undefined)
|
|
50
|
+
panner.maxDistance = spatial.maxDistance;
|
|
51
|
+
if (spatial.rolloff !== undefined)
|
|
52
|
+
panner.rolloffFactor = spatial.rolloff;
|
|
53
|
+
if (spatial.coneInner !== undefined)
|
|
54
|
+
panner.coneInnerAngle = spatial.coneInner;
|
|
55
|
+
if (spatial.coneOuter !== undefined)
|
|
56
|
+
panner.coneOuterAngle = spatial.coneOuter;
|
|
57
|
+
if (spatial.coneOuterGain !== undefined)
|
|
58
|
+
panner.coneOuterGain = spatial.coneOuterGain;
|
|
59
|
+
setPannerPosition(panner, position);
|
|
60
|
+
return panner;
|
|
61
|
+
}
|
|
33
62
|
export function createAudioEngine(config = {}) {
|
|
34
63
|
const sounds = config.sounds ?? {};
|
|
35
64
|
const busDefs = config.buses ?? {};
|
|
@@ -128,8 +157,17 @@ export function createAudioEngine(config = {}) {
|
|
|
128
157
|
// A one-shot synth cue realises its decaying voices live and is fire-and-forget.
|
|
129
158
|
if (sound.synth !== undefined && !loop) {
|
|
130
159
|
const cueGain = context.createGain();
|
|
131
|
-
cueGain.gain.value =
|
|
132
|
-
|
|
160
|
+
cueGain.gain.value = sound.spatial === undefined
|
|
161
|
+
? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
|
|
162
|
+
: sound.gain ?? 1;
|
|
163
|
+
if (sound.spatial !== undefined) {
|
|
164
|
+
const panner = createPanner(context, sound.spatial, currentPosition);
|
|
165
|
+
cueGain.connect(panner);
|
|
166
|
+
panner.connect(bus);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
cueGain.connect(bus);
|
|
170
|
+
}
|
|
133
171
|
realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
|
|
134
172
|
return NOOP_HANDLE;
|
|
135
173
|
}
|
|
@@ -152,10 +190,13 @@ export function createAudioEngine(config = {}) {
|
|
|
152
190
|
let sourceNode = null;
|
|
153
191
|
let userGainNode = null;
|
|
154
192
|
let falloffGain = null;
|
|
193
|
+
let pannerNode = null;
|
|
155
194
|
function updateFalloff() {
|
|
156
|
-
if (falloffGain !== null) {
|
|
195
|
+
if (falloffGain !== null && sound.spatial === undefined) {
|
|
157
196
|
falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
158
197
|
}
|
|
198
|
+
if (pannerNode !== null)
|
|
199
|
+
setPannerPosition(pannerNode, currentPosition);
|
|
159
200
|
}
|
|
160
201
|
void bufferPromise.then((buffer) => {
|
|
161
202
|
if (buffer === null)
|
|
@@ -171,7 +212,16 @@ export function createAudioEngine(config = {}) {
|
|
|
171
212
|
fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
172
213
|
src.connect(uGain);
|
|
173
214
|
uGain.connect(fGain);
|
|
174
|
-
|
|
215
|
+
if (sound.spatial !== undefined) {
|
|
216
|
+
const panner = createPanner(context, sound.spatial, currentPosition);
|
|
217
|
+
fGain.gain.value = 1;
|
|
218
|
+
fGain.connect(panner);
|
|
219
|
+
panner.connect(bus);
|
|
220
|
+
pannerNode = panner;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
fGain.connect(bus);
|
|
224
|
+
}
|
|
175
225
|
src.start();
|
|
176
226
|
sourceNode = src;
|
|
177
227
|
userGainNode = uGain;
|
|
@@ -185,6 +235,7 @@ export function createAudioEngine(config = {}) {
|
|
|
185
235
|
src.disconnect();
|
|
186
236
|
uGain.disconnect();
|
|
187
237
|
fGain.disconnect();
|
|
238
|
+
pannerNode?.disconnect();
|
|
188
239
|
sourceNode = null;
|
|
189
240
|
userGainNode = null;
|
|
190
241
|
falloffGain = null;
|
|
@@ -219,7 +270,18 @@ export function createAudioEngine(config = {}) {
|
|
|
219
270
|
}
|
|
220
271
|
return {
|
|
221
272
|
setListenerPose(position) {
|
|
222
|
-
|
|
273
|
+
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
274
|
+
listenerPosition = pose.position;
|
|
275
|
+
const listener = context.listener;
|
|
276
|
+
setAudioParam(listener.positionX, pose.position.x);
|
|
277
|
+
setAudioParam(listener.positionY, pose.position.y);
|
|
278
|
+
setAudioParam(listener.positionZ, pose.position.z);
|
|
279
|
+
setAudioParam(listener.forwardX, pose.forward.x);
|
|
280
|
+
setAudioParam(listener.forwardY, pose.forward.y);
|
|
281
|
+
setAudioParam(listener.forwardZ, pose.forward.z);
|
|
282
|
+
setAudioParam(listener.upX, pose.up.x);
|
|
283
|
+
setAudioParam(listener.upY, pose.up.y);
|
|
284
|
+
setAudioParam(listener.upZ, pose.up.z);
|
|
223
285
|
for (const updateGain of activeSpatialUpdaters)
|
|
224
286
|
updateGain();
|
|
225
287
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
2
2
|
import { useEffect, useMemo, useRef } from "react";
|
|
3
3
|
import { PerspectiveCamera, Quaternion, Vector3 } from "three";
|
|
4
|
+
import { snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
|
|
4
5
|
import { usePlayer } from "@jgengine/react/hooks";
|
|
5
6
|
import { useGameContext } from "@jgengine/react/provider";
|
|
6
7
|
import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
|
|
@@ -162,6 +163,12 @@ export function SideScrollRig(props) {
|
|
|
162
163
|
};
|
|
163
164
|
followRef.current = follow;
|
|
164
165
|
const pose = resolveSideScrollPose(follow, resolved, config?.fov ?? currentFov(camera));
|
|
166
|
+
const pixelPerfect = props.config?.projection === "orthographic" ? props.config.pixelPerfect : undefined;
|
|
167
|
+
if (pixelPerfect !== undefined) {
|
|
168
|
+
const snapped = snapPixelPerfectPosition(pose.position, pixelPerfect.pixelsPerUnit);
|
|
169
|
+
pose.position = snapped;
|
|
170
|
+
pose.lookAt = snapPixelPerfectPosition(pose.lookAt, pixelPerfect.pixelsPerUnit);
|
|
171
|
+
}
|
|
165
172
|
commit(pose, dt);
|
|
166
173
|
}, CAMERA_RIG_FRAME_PRIORITY);
|
|
167
174
|
return null;
|
package/dist/defineGame.js
CHANGED
|
@@ -3,6 +3,8 @@ import { useMemo } from "react";
|
|
|
3
3
|
import { defineGameDefinition as defineEngineGame, } from "@jgengine/core/game/defineGame";
|
|
4
4
|
import { syncLifecyclePhase } from "@jgengine/core/game/gamePhase";
|
|
5
5
|
import { offline } from "@jgengine/core/runtime/adapter";
|
|
6
|
+
import { lightingFromDocument } from "@jgengine/core/editor/environment";
|
|
7
|
+
import { resolveGameLook } from "@jgengine/core/render/lookPreset";
|
|
6
8
|
import { EnvironmentScene } from "./environment.js";
|
|
7
9
|
import { terrainGroundColorSampler } from "./environment/terrainGroundColor.js";
|
|
8
10
|
import { AuthoredScene } from "./scene/AuthoredScene.js";
|
|
@@ -12,6 +14,9 @@ function worldBackdrop(feature) {
|
|
|
12
14
|
return _jsx(EnvironmentScene, { feature: feature });
|
|
13
15
|
};
|
|
14
16
|
}
|
|
17
|
+
function isEnvironmentSource(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && "kind" in value && ["gradient", "hdri", "cube"].includes(value.kind);
|
|
19
|
+
}
|
|
15
20
|
function authoredSceneOverlay(document, placement, scatterModels, assets, world, Vfx) {
|
|
16
21
|
const terrain = world !== undefined && world.kind === "environment" ? world.terrain : undefined;
|
|
17
22
|
return function AuthoredSceneOverlay(props) {
|
|
@@ -28,12 +33,19 @@ const emptyUi = () => null;
|
|
|
28
33
|
* @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
|
|
29
34
|
*/
|
|
30
35
|
export function defineGame(config) {
|
|
31
|
-
const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting, backdrop, postProcessing, shadows, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
|
|
36
|
+
const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
|
|
32
37
|
const game = defineEngineGame({
|
|
33
38
|
...engineFields,
|
|
34
39
|
multiplayer: multiplayer ?? offline(),
|
|
35
40
|
loop,
|
|
36
41
|
});
|
|
42
|
+
const resolvedLook = resolveGameLook({
|
|
43
|
+
look,
|
|
44
|
+
lighting: authoredLighting,
|
|
45
|
+
backdrop,
|
|
46
|
+
postProcessing,
|
|
47
|
+
hasWorldSky: editorLayers?.environment?.preset !== undefined,
|
|
48
|
+
});
|
|
37
49
|
function withPhaseSync(inner) {
|
|
38
50
|
return (ctx, ...args) => {
|
|
39
51
|
inner?.(ctx, ...args);
|
|
@@ -53,8 +65,9 @@ export function defineGame(config) {
|
|
|
53
65
|
onDispose: composed?.onDispose?.bind(composed) ?? noop,
|
|
54
66
|
},
|
|
55
67
|
GameUI: GameUI ?? emptyUi,
|
|
56
|
-
environment: environment
|
|
57
|
-
|
|
68
|
+
environment: isEnvironmentSource(environment) ? undefined :
|
|
69
|
+
environment ??
|
|
70
|
+
(game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
|
|
58
71
|
camera: camera ?? { perspective: "third" },
|
|
59
72
|
editorLayers,
|
|
60
73
|
editorCatalogs,
|
|
@@ -81,10 +94,12 @@ export function defineGame(config) {
|
|
|
81
94
|
worldItem,
|
|
82
95
|
collision,
|
|
83
96
|
movement,
|
|
84
|
-
lighting,
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
lighting: resolvedLook.lighting ?? (editorLayers === undefined ? undefined : lightingFromDocument(editorLayers) ?? (editorLayers.environment?.pointLights === undefined ? undefined : { point: editorLayers.environment.pointLights })),
|
|
98
|
+
environmentSource: environmentSource ?? (isEnvironmentSource(environment) ? environment : undefined),
|
|
99
|
+
backdrop: resolvedLook.backdrop,
|
|
100
|
+
postProcessing: resolvedLook.postProcessing,
|
|
87
101
|
shadows,
|
|
102
|
+
graphics,
|
|
88
103
|
presentation,
|
|
89
104
|
objectStyles,
|
|
90
105
|
devtools,
|
|
@@ -2,15 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { bindingLabel } from "@jgengine/core/input/actionBindings";
|
|
3
3
|
function flattenCodes(codes) {
|
|
4
4
|
if (Array.isArray(codes)) {
|
|
5
|
-
return [{ codes: codes.map(bindingLabel).join(" / "), mode: "press" }];
|
|
5
|
+
return [{ codes: codes.map((code) => bindingLabel(code)).join(" / "), mode: "press" }];
|
|
6
6
|
}
|
|
7
7
|
const modes = codes;
|
|
8
8
|
const result = [];
|
|
9
9
|
if (modes.hold !== undefined && modes.hold.length > 0) {
|
|
10
|
-
result.push({ codes: modes.hold.map(bindingLabel).join(" / "), mode: "hold" });
|
|
10
|
+
result.push({ codes: modes.hold.map((code) => bindingLabel(code)).join(" / "), mode: "hold" });
|
|
11
11
|
}
|
|
12
12
|
if (modes.toggle !== undefined && modes.toggle.length > 0) {
|
|
13
|
-
result.push({ codes: modes.toggle.map(bindingLabel).join(" / "), mode: "toggle" });
|
|
13
|
+
result.push({ codes: modes.toggle.map((code) => bindingLabel(code)).join(" / "), mode: "toggle" });
|
|
14
14
|
}
|
|
15
15
|
return result;
|
|
16
16
|
}
|
|
@@ -4,8 +4,9 @@ import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
|
|
|
4
4
|
import type { PointerService } from "../pointer/pointerService.js";
|
|
5
5
|
import type { ShellMultiplayer } from "../multiplayer.js";
|
|
6
6
|
import type { PlayableGame } from "../registry.js";
|
|
7
|
+
import type { AuthoritativeFrameHandler } from "../worldSync.js";
|
|
7
8
|
export declare const POSTER_SETTLE_SECONDS = 1.6;
|
|
8
|
-
export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }: {
|
|
9
|
+
export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, authoritativeFrameRef, }: {
|
|
9
10
|
ctx: GameContext;
|
|
10
11
|
playable: PlayableGame;
|
|
11
12
|
tracker: ActionStateTracker<string>;
|
|
@@ -37,4 +38,7 @@ export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef,
|
|
|
37
38
|
pingCommand: string | undefined;
|
|
38
39
|
poster: boolean;
|
|
39
40
|
onPosterSettled: () => void;
|
|
41
|
+
authoritativeFrameRef: {
|
|
42
|
+
current: AuthoritativeFrameHandler | null;
|
|
43
|
+
};
|
|
40
44
|
}): null;
|
|
@@ -17,9 +17,10 @@ import { devtools } from "@jgengine/core/devtools/devtools";
|
|
|
17
17
|
import { collisionDebug } from "../devtools/collisionDebug.js";
|
|
18
18
|
import { GAME_SIM_FRAME_PRIORITY } from "../camera/index.js";
|
|
19
19
|
import { NO_ACTIONS } from "../shellConstants.js";
|
|
20
|
+
import { createPredictionBuffer } from "@jgengine/core/runtime/prediction";
|
|
20
21
|
const TURN_SPEED = 2.4;
|
|
21
22
|
export const POSTER_SETTLE_SECONDS = 1.6;
|
|
22
|
-
export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }) {
|
|
23
|
+
export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, authoritativeFrameRef, }) {
|
|
23
24
|
const posterElapsedRef = useRef(0);
|
|
24
25
|
const posterDoneRef = useRef(false);
|
|
25
26
|
const hasReportedTickError = useRef(false);
|
|
@@ -57,6 +58,26 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
57
58
|
}), [multiplayer, serverAuthoritative, serverIdRef]);
|
|
58
59
|
const lastSentInputRef = useRef(null);
|
|
59
60
|
const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
|
|
61
|
+
const predictionRef = useRef(createPredictionBuffer({
|
|
62
|
+
initial: [0, 0, 0],
|
|
63
|
+
maxTicks: 120,
|
|
64
|
+
step: (state) => state,
|
|
65
|
+
snapThreshold: 0.1,
|
|
66
|
+
}));
|
|
67
|
+
const predictionHandler = useMemo(() => (_revision) => {
|
|
68
|
+
const playerId = ctx.player.possession.active(ctx.player.userId);
|
|
69
|
+
const player = ctx.scene.entity.get(playerId);
|
|
70
|
+
if (player === null)
|
|
71
|
+
return;
|
|
72
|
+
const prediction = predictionRef.current;
|
|
73
|
+
const current = player.position;
|
|
74
|
+
const snapshot = prediction.snapshot();
|
|
75
|
+
const tick = snapshot.records.at(-1)?.tick ?? snapshot.confirmedTick;
|
|
76
|
+
const result = prediction.reconcile(tick, current);
|
|
77
|
+
if (result.snapped)
|
|
78
|
+
ctx.scene.entity.setPose(playerId, { position: current });
|
|
79
|
+
}, [ctx]);
|
|
80
|
+
authoritativeFrameRef.current = serverAuthoritative ? predictionHandler : null;
|
|
60
81
|
useFrame((_state, rawDt) => {
|
|
61
82
|
if (poster) {
|
|
62
83
|
if (posterDoneRef.current)
|
|
@@ -71,7 +92,12 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
71
92
|
const sendInput = () => {
|
|
72
93
|
if (!serverAuthoritative)
|
|
73
94
|
return;
|
|
74
|
-
const frame = {
|
|
95
|
+
const frame = {
|
|
96
|
+
held: ctx.input.held(),
|
|
97
|
+
pointer: ctx.input.pointer(),
|
|
98
|
+
analog: ctx.input.analog(),
|
|
99
|
+
tick: ctx.sim.tick(),
|
|
100
|
+
};
|
|
75
101
|
const last = lastSentInputRef.current;
|
|
76
102
|
if (last !== null && inputFramesEqual(last, frame))
|
|
77
103
|
return;
|
|
@@ -88,7 +114,6 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
88
114
|
try {
|
|
89
115
|
let endPhase = devtools.profile.begin("time+input");
|
|
90
116
|
const dt = Math.min(rawDt, 0.05);
|
|
91
|
-
const gameDt = ctx.time.advance(dt);
|
|
92
117
|
ctx.input.publish(heldActionsFor(tracker, inputActions));
|
|
93
118
|
ctx.input.publishPointer(pointerAxisRef.current);
|
|
94
119
|
ctx.input.publishAnalog(analogRef.current);
|
|
@@ -98,28 +123,40 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
98
123
|
yawRef.current = steerYaw(yawRef.current, turnInput, TURN_SPEED, dt);
|
|
99
124
|
endPhase();
|
|
100
125
|
const playerId = ctx.player.possession.active(ctx.player.userId);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
ctx.sim.advance(dt, (stepDt) => {
|
|
127
|
+
const gameDt = ctx.time.advance(stepDt);
|
|
128
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
129
|
+
const player = ctx.scene.entity.get(playerId);
|
|
130
|
+
// Server-authoritative sessions still run the deterministic local step as a prediction; the
|
|
131
|
+
// replicated pose is the confirmation that the transport reconciles on the next world diff.
|
|
132
|
+
if (player !== null && drivesPose) {
|
|
133
|
+
const endPose = devtools.profile.begin("pose");
|
|
134
|
+
stepPlayerMovement(ctx, ctx.player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() }, stepDt, movementTuning, yawRef.current, pitchRef.current);
|
|
135
|
+
const predicted = ctx.scene.entity.get(playerId);
|
|
136
|
+
if (predicted !== null) {
|
|
137
|
+
predictionRef.current.record({ held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog(), tick: ctx.sim.tick() }, stepDt, predicted.position);
|
|
138
|
+
}
|
|
139
|
+
endPose();
|
|
114
140
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
141
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
142
|
+
if (autoPickupRadius !== null && !serverAuthoritative) {
|
|
143
|
+
const endPickup = devtools.profile.begin("pickup");
|
|
144
|
+
const self = ctx.scene.entity.get(playerId);
|
|
145
|
+
if (self !== null) {
|
|
146
|
+
const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
|
|
147
|
+
if (nearest !== null)
|
|
148
|
+
ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
|
|
149
|
+
}
|
|
150
|
+
endPickup();
|
|
151
|
+
}
|
|
152
|
+
if (!serverAuthoritative) {
|
|
153
|
+
devtools.profile.measure("onTick", () => {
|
|
154
|
+
playable.loop.onTick(ctx, gameDt);
|
|
155
|
+
});
|
|
156
|
+
advanceBehaviors(ctx, gameDt);
|
|
157
|
+
}
|
|
158
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
159
|
+
});
|
|
123
160
|
endPhase = devtools.profile.begin("actions");
|
|
124
161
|
if (tracker.wasPressed("tabTarget")) {
|
|
125
162
|
if (ctx.game.commands.has("target.cycle"))
|
|
@@ -26,14 +26,19 @@ export function HudOnlyDriver({ ctx, playable, tracker, pointerAxisRef, gateRef,
|
|
|
26
26
|
try {
|
|
27
27
|
let endPhase = devtools.profile.begin("time+input");
|
|
28
28
|
const dt = Math.min(rawDt, 0.05);
|
|
29
|
-
const gameDt = ctx.time.advance(dt);
|
|
30
29
|
ctx.input.publish(heldActionsFor(tracker, inputActions));
|
|
31
30
|
ctx.input.publishPointer(pointerAxisRef.current);
|
|
32
31
|
endPhase();
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
ctx.sim.advance(dt, (stepDt) => {
|
|
33
|
+
const gameDt = ctx.time.advance(stepDt);
|
|
34
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
35
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
36
|
+
devtools.profile.measure("onTick", () => {
|
|
37
|
+
playable.loop.onTick(ctx, gameDt);
|
|
38
|
+
});
|
|
39
|
+
advanceBehaviors(ctx, gameDt);
|
|
40
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
35
41
|
});
|
|
36
|
-
advanceBehaviors(ctx, gameDt);
|
|
37
42
|
endPhase = devtools.profile.begin("actions");
|
|
38
43
|
const nowMs = performance.now();
|
|
39
44
|
for (const action of inputActions) {
|
|
@@ -85,7 +85,7 @@ function Vegetation({ grass, field, groundColor, }) {
|
|
|
85
85
|
const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
|
|
86
86
|
const colorBase = grass.colors[0];
|
|
87
87
|
const colorTip = grass.colors[grass.colors.length - 1];
|
|
88
|
-
return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true }) }));
|
|
88
|
+
return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true }) }));
|
|
89
89
|
}
|
|
90
90
|
/** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
|
|
91
91
|
function vegetationGroundColor(terrain, field, grass) {
|
|
@@ -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,12 @@
|
|
|
1
|
+
import type { ActionCodesMap, ActionStateTracker } from "@jgengine/core/input/actionBindings";
|
|
2
|
+
import type { InputSnapshot } from "@jgengine/core/runtime/inputSnapshot";
|
|
3
|
+
export { mergeGamepadFrame, mergeGamepadInput } from "./gamepadMerge.js";
|
|
4
|
+
/** Poll browser gamepads and feed semantic actions into the shell tracker. */
|
|
5
|
+
export declare function GamepadSource({ tracker, bindings, analogRef, input, }: {
|
|
6
|
+
tracker: ActionStateTracker<string>;
|
|
7
|
+
bindings: ActionCodesMap;
|
|
8
|
+
analogRef: {
|
|
9
|
+
current: Readonly<Record<string, number>> | null;
|
|
10
|
+
};
|
|
11
|
+
input: InputSnapshot;
|
|
12
|
+
}): null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { resolveGamepadFrame, } from "@jgengine/core/input/gamepadModel";
|
|
4
|
+
import { mergeGamepadInput } from "./gamepadMerge.js";
|
|
5
|
+
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
|
+
};
|
|
13
|
+
}
|
|
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;
|
|
25
|
+
}
|
|
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
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function syntheticSnapshot() {
|
|
39
|
+
if (typeof window === "undefined" || !new URLSearchParams(window.location.search).has("gamepad"))
|
|
40
|
+
return null;
|
|
41
|
+
return { id: "Synthetic Gamepad", axes: [0, 0], buttons: [{ pressed: true, value: 1 }], connected: true };
|
|
42
|
+
}
|
|
43
|
+
/** 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());
|
|
47
|
+
const padBindings = useRef(gamepadCodes(bindings));
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
padBindings.current = gamepadCodes(bindings);
|
|
50
|
+
previousHeld.current.clear();
|
|
51
|
+
previousAnalog.current.clear();
|
|
52
|
+
tracker.reset();
|
|
53
|
+
}, [bindings, tracker]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const inputWithRumble = input;
|
|
56
|
+
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)
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
await actuator.playEffect("dual-rumble", {
|
|
65
|
+
duration: options.ms,
|
|
66
|
+
strongMagnitude: options.strong,
|
|
67
|
+
weakMagnitude: options.weak,
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return () => {
|
|
76
|
+
delete inputWithRumble.rumble;
|
|
77
|
+
};
|
|
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
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
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
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function padBindingsToGamepad(bindings) {
|
|
106
|
+
return Object.fromEntries(bindings.entries());
|
|
107
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as THREE from "three";
|
|
|
2
2
|
import type { ModelMaterialOverride } from "@jgengine/core/game/playableGame";
|
|
3
3
|
export interface MaterialOverrideOptions {
|
|
4
4
|
clone?: boolean;
|
|
5
|
-
/** Loaded PBR textures matching `ModelMaterialOverride.maps`' roles, applied onto each
|
|
5
|
+
/** Loaded PBR textures matching `ModelMaterialOverride.maps`' roles, applied onto each standard or physical material alongside the tint override. The caller (a React component, via `useTexture`) owns loading; this function never fetches a URL. */
|
|
6
6
|
textures?: MaterialOverrideTextures;
|
|
7
7
|
}
|
|
8
8
|
/** Loaded PBR textures for `applyMaterialOverride`'s `textures` option — matches `ModelMaterialMaps`' roles. */
|
|
@@ -11,6 +11,9 @@ export interface MaterialOverrideTextures {
|
|
|
11
11
|
normal?: THREE.Texture;
|
|
12
12
|
roughness?: THREE.Texture;
|
|
13
13
|
ao?: THREE.Texture;
|
|
14
|
+
metalness?: THREE.Texture;
|
|
15
|
+
emissive?: THREE.Texture;
|
|
16
|
+
height?: THREE.Texture;
|
|
14
17
|
}
|
|
15
18
|
/** @internal */
|
|
16
19
|
export declare function applyMaterialOverride(root: THREE.Object3D, override: ModelMaterialOverride, options?: MaterialOverrideOptions): void;
|
package/dist/materialOverride.js
CHANGED
|
@@ -13,7 +13,7 @@ export function applyMaterialOverride(root, override, options) {
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
function overrideOne(material, override, clone, textures) {
|
|
16
|
-
if (!(material
|
|
16
|
+
if (!isStandardOrPhysicalMaterial(material))
|
|
17
17
|
return material;
|
|
18
18
|
const target = clone ? material.clone() : material;
|
|
19
19
|
if (override.color !== undefined)
|
|
@@ -34,12 +34,21 @@ function overrideOne(material, override, clone, textures) {
|
|
|
34
34
|
target.roughnessMap = textures.roughness;
|
|
35
35
|
if (textures?.ao !== undefined)
|
|
36
36
|
target.aoMap = textures.ao;
|
|
37
|
+
if (textures?.metalness !== undefined)
|
|
38
|
+
target.metalnessMap = textures.metalness;
|
|
39
|
+
if (textures?.emissive !== undefined)
|
|
40
|
+
target.emissiveMap = textures.emissive;
|
|
41
|
+
if (textures?.height !== undefined)
|
|
42
|
+
target.displacementMap = textures.height;
|
|
37
43
|
if (textures !== undefined)
|
|
38
44
|
target.needsUpdate = true;
|
|
39
45
|
if (override.rim !== undefined)
|
|
40
46
|
applyRimLight(target, override.rim);
|
|
41
47
|
return target;
|
|
42
48
|
}
|
|
49
|
+
function isStandardOrPhysicalMaterial(material) {
|
|
50
|
+
return material.isMeshStandardMaterial === true;
|
|
51
|
+
}
|
|
43
52
|
/**
|
|
44
53
|
* Adds a fresnel rim term to a standard material's outgoing light. Patched through
|
|
45
54
|
* `onBeforeCompile` rather than as a second pass so the rim shadows, fogs, and tonemaps with the
|
|
@@ -6,14 +6,17 @@ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
|
|
|
6
6
|
* post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
|
|
7
7
|
* when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
|
|
8
8
|
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
|
|
13
|
-
* SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
|
|
14
|
-
* for alpha-tested foliage that MSAA samples alone leave crawling).
|
|
9
|
+
* `stages` (resolved from the player's graphics profile) controls expensive passes
|
|
10
|
+
* independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
|
|
11
|
+
* them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
|
|
15
12
|
*/
|
|
16
|
-
export declare function PostProcessing({ config, quality }: {
|
|
13
|
+
export declare function PostProcessing({ config, quality, stages }: {
|
|
17
14
|
config: PostProcessingConfig;
|
|
18
15
|
quality?: GraphicsQuality;
|
|
16
|
+
stages?: {
|
|
17
|
+
ao: boolean;
|
|
18
|
+
bloom: boolean;
|
|
19
|
+
dof: boolean;
|
|
20
|
+
smaa: boolean;
|
|
21
|
+
};
|
|
19
22
|
}): null;
|