@jgengine/shell 0.17.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +130 -2
  2. package/dist/GamePlayerShell.js +10 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +8 -1
  6. package/dist/Shell3dPresentation.js +34 -5
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/TerritoryOverlay.d.ts +21 -0
  10. package/dist/TerritoryOverlay.js +9 -0
  11. package/dist/audio/AudioComponents.js +8 -1
  12. package/dist/audio/audioEngine.d.ts +7 -1
  13. package/dist/audio/audioEngine.js +67 -5
  14. package/dist/camera/cameraRigs.js +7 -0
  15. package/dist/defineGame.js +21 -6
  16. package/dist/devtools/KeysPanel.js +3 -3
  17. package/dist/drivers/FrameDriver.d.ts +5 -1
  18. package/dist/drivers/FrameDriver.js +61 -24
  19. package/dist/drivers/HudOnlyDriver.js +9 -4
  20. package/dist/environment/EnvironmentScene.js +1 -1
  21. package/dist/input/gamepadMerge.d.ts +10 -0
  22. package/dist/input/gamepadMerge.js +12 -0
  23. package/dist/input/gamepadSource.d.ts +12 -0
  24. package/dist/input/gamepadSource.js +107 -0
  25. package/dist/materialOverride.d.ts +4 -1
  26. package/dist/materialOverride.js +10 -1
  27. package/dist/postfx/PostProcessing.d.ts +10 -7
  28. package/dist/postfx/PostProcessing.js +33 -15
  29. package/dist/postfx/gradeShader.js +13 -0
  30. package/dist/render/CascadedShadows.d.ts +11 -0
  31. package/dist/render/CascadedShadows.js +19 -8
  32. package/dist/render/EnvironmentLighting.d.ts +4 -1
  33. package/dist/render/EnvironmentLighting.js +45 -9
  34. package/dist/render/SceneLighting.d.ts +16 -2
  35. package/dist/render/SceneLighting.js +56 -6
  36. package/dist/render/SceneModels.js +45 -9
  37. package/dist/render/modelLoad.d.ts +10 -0
  38. package/dist/render/modelLoad.js +28 -0
  39. package/dist/render/modelRender.d.ts +3 -0
  40. package/dist/render/modelRender.js +6 -2
  41. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
  42. package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
  43. package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
  44. package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
  45. package/dist/render/useFootIk.d.ts +9 -0
  46. package/dist/render/useFootIk.js +89 -0
  47. package/dist/render/useModelAnimation.d.ts +2 -0
  48. package/dist/render/useModelAnimation.js +163 -107
  49. package/dist/scene/CityRenderer.js +661 -236
  50. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  51. package/dist/scene/cityWindowMaterial.js +80 -21
  52. package/dist/settings/appliedSettings.d.ts +3 -1
  53. package/dist/settings/appliedSettings.js +5 -2
  54. package/dist/terrain/terrainDetailMaterial.js +7 -3
  55. package/dist/useShellMultiplayerSync.d.ts +8 -1
  56. package/dist/useShellMultiplayerSync.js +25 -5
  57. package/dist/visibility/CullingProvider.d.ts +2 -1
  58. package/dist/visibility/CullingProvider.js +7 -2
  59. package/dist/world/GridWorldScene.js +4 -0
  60. package/dist/world/TileLayerRenderer.d.ts +9 -0
  61. package/dist/world/TileLayerRenderer.js +64 -0
  62. package/dist/world/WorldParticles.d.ts +31 -0
  63. package/dist/world/WorldParticles.js +128 -0
  64. package/dist/world/WorldScene.d.ts +2 -1
  65. package/dist/world/WorldScene.js +46 -4
  66. package/dist/world/entityPose.d.ts +4 -0
  67. package/dist/world/entityPose.js +9 -0
  68. package/dist/world/tileLayerInstances.d.ts +11 -0
  69. package/dist/world/tileLayerInstances.js +28 -0
  70. package/dist/worldSync.d.ts +5 -1
  71. package/dist/worldSync.js +25 -4
  72. package/package.json +4 -4
  73. package/skills/ce-handoff/SKILL.md +1 -1
  74. package/skills/game-design/SKILL.md +11 -1
  75. package/skills/jgengine/SKILL.md +2 -0
  76. package/skills/jgengine/api.md +98 -22
  77. package/skills/jgengine/capabilities.md +28 -0
  78. package/skills/jgengine-ui/SKILL.md +8 -0
  79. package/skills/jgengine-ui/api.md +127 -16
  80. package/skills/jgengine-ui/capabilities.md +21 -0
  81. package/skills/jgengine-ui/reference.md +3 -0
  82. package/skills/jgengine-world/SKILL.md +6 -0
  83. package/skills/jgengine-world/api.md +312 -12
  84. package/skills/jgengine-world/capabilities.md +102 -0
  85. package/skills/level-design/SKILL.md +1 -1
  86. 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 = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
132
- cueGain.connect(bus);
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
- fGain.connect(bus);
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
- listenerPosition = position;
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;
@@ -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
- (game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
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
- backdrop,
86
- postProcessing,
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 = { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() };
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
- const player = ctx.scene.entity.get(playerId);
102
- if (player !== null && drivesPose && !serverAuthoritative) {
103
- endPhase = devtools.profile.begin("pose");
104
- stepPlayerMovement(ctx, ctx.player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() }, rawDt, movementTuning, yawRef.current);
105
- endPhase();
106
- }
107
- if (autoPickupRadius !== null && !serverAuthoritative) {
108
- endPhase = devtools.profile.begin("pickup");
109
- const self = ctx.scene.entity.get(playerId);
110
- if (self !== null) {
111
- const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
112
- if (nearest !== null)
113
- ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
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
- endPhase();
116
- }
117
- if (!serverAuthoritative) {
118
- devtools.profile.measure("onTick", () => {
119
- playable.loop.onTick(ctx, gameDt);
120
- });
121
- advanceBehaviors(ctx, gameDt);
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
- devtools.profile.measure("onTick", () => {
34
- playable.loop.onTick(ctx, gameDt);
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 `MeshStandardMaterial` alongside the tint override. The caller (a React component, via `useTexture`) owns loading; this function never fetches a URL. */
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;
@@ -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 instanceof THREE.MeshStandardMaterial))
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
- * `quality` (the player's graphics-quality setting) gates the passes whose cost
10
- * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
11
- * the whole scene for depth/normals and runs a multi-sample full-screen pass,
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;