@jgengine/shell 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +10 -22
  4. package/dist/GamePlayerShell.js +709 -264
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +9 -0
  8. package/dist/audio/audioEngine.js +37 -0
  9. package/dist/audio/musicDirector.d.ts +31 -0
  10. package/dist/audio/musicDirector.js +125 -0
  11. package/dist/audio/musicVoices.d.ts +10 -0
  12. package/dist/audio/musicVoices.js +338 -0
  13. package/dist/audio/synthEngine.d.ts +9 -0
  14. package/dist/audio/synthEngine.js +57 -0
  15. package/dist/behaviour.d.ts +5 -24
  16. package/dist/behaviour.js +14 -50
  17. package/dist/behaviourAttach.d.ts +9 -0
  18. package/dist/behaviourAttach.js +32 -0
  19. package/dist/behaviourDriver.d.ts +7 -0
  20. package/dist/behaviourDriver.js +21 -0
  21. package/dist/camera/GameCameraRig.js +1 -1
  22. package/dist/camera/GameFirstPersonCamera.d.ts +3 -0
  23. package/dist/camera/GameFirstPersonCamera.js +35 -4
  24. package/dist/camera/GameInspectionCamera.js +12 -1
  25. package/dist/camera/GameOrbitCamera.js +44 -1
  26. package/dist/camera/PlayerFov.d.ts +18 -0
  27. package/dist/camera/PlayerFov.js +48 -0
  28. package/dist/camera/cameraBlendMath.d.ts +13 -0
  29. package/dist/camera/cameraBlendMath.js +36 -0
  30. package/dist/camera/cameraRigs.d.ts +3 -0
  31. package/dist/camera/cameraRigs.js +36 -33
  32. package/dist/camera/fovPreference.d.ts +25 -0
  33. package/dist/camera/fovPreference.js +73 -0
  34. package/dist/camera/index.d.ts +3 -0
  35. package/dist/camera/index.js +3 -0
  36. package/dist/camera/orbitCameraMath.d.ts +18 -0
  37. package/dist/camera/orbitCameraMath.js +6 -1
  38. package/dist/camera/rigMath.d.ts +10 -0
  39. package/dist/camera/rigMath.js +36 -1
  40. package/dist/camera/shakeChannel.d.ts +3 -17
  41. package/dist/camera/shakeChannel.js +2 -21
  42. package/dist/camera/shakeChannelMath.d.ts +8 -0
  43. package/dist/camera/shakeChannelMath.js +21 -0
  44. package/dist/cartridge.d.ts +145 -0
  45. package/dist/cartridge.js +246 -0
  46. package/dist/commandSink.d.ts +20 -0
  47. package/dist/commandSink.js +27 -0
  48. package/dist/defineGame.js +4 -1
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +181 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  52. package/dist/devtools/DevtoolsOverlay.js +383 -41
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +103 -0
  56. package/dist/devtools/collisionDebugMath.js +129 -0
  57. package/dist/environment/Daylight.d.ts +20 -8
  58. package/dist/environment/Daylight.js +63 -15
  59. package/dist/environment/EnvironmentScene.js +91 -35
  60. package/dist/environment/RoadRibbons.d.ts +7 -0
  61. package/dist/environment/RoadRibbons.js +57 -0
  62. package/dist/environment/groundPadMath.d.ts +2 -2
  63. package/dist/environment/groundPadMath.js +1 -1
  64. package/dist/environment/index.d.ts +2 -1
  65. package/dist/environment/index.js +1 -0
  66. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  67. package/dist/environment/skyLightingPolicy.js +6 -0
  68. package/dist/input/mouseLook.d.ts +27 -0
  69. package/dist/input/mouseLook.js +35 -0
  70. package/dist/inputSink.d.ts +18 -0
  71. package/dist/inputSink.js +35 -0
  72. package/dist/materialOverride.d.ts +4 -6
  73. package/dist/materialOverride.js +12 -16
  74. package/dist/multiplayer.js +7 -3
  75. package/dist/pointer/PointerProbe.js +6 -2
  76. package/dist/pointer/pointerService.d.ts +3 -0
  77. package/dist/pointer/pointerService.js +6 -0
  78. package/dist/postfx/PostProcessing.d.ts +10 -0
  79. package/dist/postfx/PostProcessing.js +82 -0
  80. package/dist/postfx/gradeShader.d.ts +4 -0
  81. package/dist/postfx/gradeShader.js +64 -0
  82. package/dist/render/modelRender.d.ts +10 -1
  83. package/dist/render/modelRender.js +32 -5
  84. package/dist/render/resolveModel.d.ts +14 -0
  85. package/dist/render/resolveModel.js +24 -0
  86. package/dist/settings/QuickControls.d.ts +4 -0
  87. package/dist/settings/QuickControls.js +42 -0
  88. package/dist/settings/SettingsChrome.d.ts +1 -0
  89. package/dist/settings/SettingsChrome.js +10 -0
  90. package/dist/settings/SettingsMenu.d.ts +6 -0
  91. package/dist/settings/SettingsMenu.js +148 -0
  92. package/dist/settings/SettingsRuntime.d.ts +11 -0
  93. package/dist/settings/SettingsRuntime.js +19 -0
  94. package/dist/settings/appliedSettings.d.ts +14 -0
  95. package/dist/settings/appliedSettings.js +27 -0
  96. package/dist/settings/settingsController.d.ts +20 -0
  97. package/dist/settings/settingsController.js +165 -0
  98. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  99. package/dist/structures/GeneratedBuilding.js +96 -8
  100. package/dist/structures/index.d.ts +1 -1
  101. package/dist/structures/index.js +1 -1
  102. package/dist/terrain/CarvedTerrain.d.ts +5 -1
  103. package/dist/terrain/CarvedTerrain.js +6 -5
  104. package/dist/terrain/GrassField.d.ts +3 -1
  105. package/dist/terrain/GrassField.js +4 -2
  106. package/dist/terrain/grassBudget.d.ts +3 -0
  107. package/dist/terrain/grassBudget.js +6 -0
  108. package/dist/terrain/grassGeometry.js +1 -1
  109. package/dist/terrain/terrainDetailMaterial.d.ts +14 -0
  110. package/dist/terrain/terrainDetailMaterial.js +90 -0
  111. package/dist/terrain/terrainMath.d.ts +8 -0
  112. package/dist/terrain/terrainMath.js +9 -0
  113. package/dist/touch/OrientationHint.d.ts +3 -0
  114. package/dist/touch/OrientationHint.js +13 -0
  115. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  116. package/dist/touch/TouchControlsOverlay.js +125 -9
  117. package/dist/visibility/CullingProvider.d.ts +21 -0
  118. package/dist/visibility/CullingProvider.js +134 -0
  119. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  120. package/dist/vision/FrustumSensorHud.js +25 -27
  121. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  122. package/dist/vision/frustumSampleEqual.js +10 -0
  123. package/dist/water/Ocean.js +1 -1
  124. package/dist/water/OceanConfig.d.ts +13 -0
  125. package/dist/water/OceanConfig.js +25 -17
  126. package/dist/water/index.d.ts +1 -1
  127. package/dist/water/index.js +1 -1
  128. package/dist/weather/FireSpreadLayer.js +7 -2
  129. package/dist/weather/RainField.d.ts +3 -1
  130. package/dist/weather/RainField.js +4 -4
  131. package/dist/weather/SnowField.d.ts +3 -1
  132. package/dist/weather/SnowField.js +4 -4
  133. package/dist/weather/fireSpreadPose.d.ts +2 -0
  134. package/dist/weather/fireSpreadPose.js +4 -0
  135. package/dist/weather/weatherMath.d.ts +5 -1
  136. package/dist/weather/weatherMath.js +7 -2
  137. package/dist/world/DataObjects.d.ts +1 -1
  138. package/dist/world/DataObjects.js +3 -1
  139. package/dist/world/SpriteBatch.d.ts +44 -0
  140. package/dist/world/SpriteBatch.js +112 -0
  141. package/dist/world/WorldHud.d.ts +6 -1
  142. package/dist/world/WorldHud.js +95 -48
  143. package/dist/world/entityPose.d.ts +14 -0
  144. package/dist/world/entityPose.js +10 -0
  145. package/dist/world/telegraphPulse.d.ts +1 -0
  146. package/dist/world/telegraphPulse.js +4 -0
  147. package/dist/world/worldBarSamples.d.ts +30 -0
  148. package/dist/world/worldBarSamples.js +57 -0
  149. package/dist/worldSync.d.ts +10 -0
  150. package/dist/worldSync.js +19 -0
  151. package/llms.txt +1522 -1143
  152. package/package.json +4 -4
@@ -1,42 +1,70 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Canvas, useFrame, useLoader } from "@react-three/fiber";
3
- import { Component, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
+ import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
4
4
  import * as THREE from "three";
5
5
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
6
+ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
6
7
  import { actionRepeatMs, createActionStateTracker, hotbarSlotActionIndex, resolveActionCommand, shouldDispatchAction, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
7
8
  import { deriveTouchScheme, withTouchCodes } from "@jgengine/core/input/touchScheme";
8
9
  import { buildContextMenu, contextVerbInput, } from "@jgengine/core/interaction/contextMenu";
9
10
  import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
10
11
  import { aimToPoint } from "@jgengine/core/input/pointer";
12
+ import { eyeHeightFromColliders } from "@jgengine/core/combat/shotOrigin";
13
+ import { normalizePointerToAxis } from "@jgengine/core/input/pointerAxis";
11
14
  import { createSelectionSet, isMarquee, screenRect, selectWithinRect, } from "@jgengine/core/scene/selection";
12
- import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "@jgengine/core/movement/movementModel";
13
- import { advanceVoxelPlayer, createVoxelPlayerBody, } from "@jgengine/core/movement/voxelController";
15
+ import { steerYaw } from "@jgengine/core/movement/steering";
16
+ import { stepPlayerMovement, resolvePlayerMovementTuning } from "@jgengine/core/movement/playerMovement";
14
17
  import { createGameContext } from "@jgengine/core/runtime/gameContext";
15
- import { groundFieldFor } from "@jgengine/core/world/terrain";
18
+ import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
19
+ import { attachWorldSync } from "./worldSync.js";
20
+ import { localCommandSink, resolveCommandSink } from "./commandSink.js";
21
+ import { inputFramesEqual, resolveInputSink } from "./inputSink.js";
16
22
  import { objectVisualScale } from "@jgengine/core/scene/objectStore";
17
23
  import { DEFAULT_PICKUP_RADIUS, WORLD_ITEM_ENTITY_NAME } from "@jgengine/core/game/worldItem";
18
24
  import { useGameContext } from "@jgengine/react/provider";
19
25
  import { useDisplayProfile } from "@jgengine/react/display";
26
+ import { HudViewportProvider } from "@jgengine/react/hudViewport";
27
+ import { GameViewportProvider } from "@jgengine/react/gameViewport";
28
+ import { RotateDeviceScreen } from "@jgengine/react/rotateDevice";
20
29
  import { useSceneEntities, useSceneObjects, usePlayer, useTarget } from "@jgengine/react/hooks";
21
30
  import { GameProvider } from "@jgengine/react/provider";
22
31
  import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
32
+ import { createSettingsStore } from "@jgengine/core/settings/settingsModel";
33
+ import { applyBindingOverrides, clearBindingOverride, loadBindingOverrides, saveBindingOverride, } from "@jgengine/core/input/bindingOverrides";
34
+ import { playControlsActive } from "@jgengine/core/game/controlGate";
35
+ import { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "@jgengine/core/ui/orientation";
36
+ import { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
23
37
  import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
24
38
  import { devtools } from "@jgengine/core/devtools/devtools";
39
+ import { VERSION } from "@jgengine/core/meta/changelog";
25
40
  import { AudioListener, EntityAudioEmitters, ObjectAudioEmitters } from "./audio/AudioComponents.js";
26
41
  import { createAudioEngine } from "./audio/audioEngine.js";
42
+ import { PostProcessing } from "./postfx/PostProcessing.js";
43
+ import { CollisionDebugWorld } from "./devtools/CollisionDebugWorld.js";
44
+ import { collisionDebug } from "./devtools/collisionDebug.js";
27
45
  import { DevtoolsOverlay, DevtoolsRendererProbe, withDevtoolsLatency } from "./devtools/DevtoolsOverlay.js";
28
- import { GAME_SIM_FRAME_PRIORITY, GameCameraRig, resolveRigKind, rtsPanKeysConflict } from "./camera/index.js";
46
+ import { GAME_SIM_FRAME_PRIORITY, GameCameraRig, PlayerFovProvider, PlayerFovSlider, resolveRigKind, rtsPanKeysConflict, } from "./camera/index.js";
47
+ import { resolveModel, tryResolveCatalogModel } from "./render/resolveModel.js";
48
+ import { CullingProvider, useRenderVisibility } from "./visibility/CullingProvider.js";
29
49
  import { SkyDaylight, TimeOfDayDaylight } from "./environment.js";
50
+ import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy.js";
30
51
  import { EnvironmentScene } from "./environment/EnvironmentScene.js";
31
52
  import { applyMaterialOverride } from "./materialOverride.js";
32
53
  import { PointerProbe } from "./pointer/PointerProbe.js";
33
- import { applyPaintTexture, cloneModelScene, createPaintCanvas, standardMaterialsOf, syncPaintCanvas, } from "./render/modelRender.js";
54
+ import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, syncPaintCanvas, } from "./render/modelRender.js";
55
+ import { writeEntityPose } from "./world/entityPose.js";
34
56
  import { MarqueeBox, ContextMenuView } from "./pointer/PointerOverlays.js";
35
57
  import { createPointerService, POINTER_ENTITY_KEY, POINTER_OBJECT_KEY, } from "./pointer/pointerService.js";
36
58
  import { CombatCameraShake, ProjectileTracers, Reticle, WorldEntityBars, WorldFloatText, WorldTelegraphs, } from "./world/WorldHud.js";
37
59
  import { GridWorldScene } from "./world/GridWorldScene.js";
38
60
  import { WorldItems } from "./world/WorldItems.js";
39
- import { TouchControlsDock, TouchPlaySurface } from "./touch/TouchControlsOverlay.js";
61
+ import { OrientationHint } from "./touch/OrientationHint.js";
62
+ import { TouchControlsDock, TouchPlaySurface, touchDockClearance } from "./touch/TouchControlsOverlay.js";
63
+ import { BUILT_IN_SETTING_CATEGORIES } from "@jgengine/core/settings/settingsModel";
64
+ import { SettingsProvider } from "@jgengine/react/settings";
65
+ import { SettingsRuntime } from "./settings/SettingsRuntime.js";
66
+ import { SettingsChrome } from "./settings/SettingsChrome.js";
67
+ import { AudioSettingsBridge, useGraphicsSettings } from "./settings/appliedSettings.js";
40
68
  const DEV_USER_ID = "dev-player";
41
69
  const TURN_SPEED = 2.4;
42
70
  const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
@@ -44,14 +72,15 @@ const GROUND_SIZE = 160;
44
72
  const GROUND_SEGMENTS = 80;
45
73
  const DEFAULT_BACKGROUND_COLOR = "#14161b";
46
74
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
47
- function errorToDiagnostic(error, phase) {
75
+ function errorToDiagnostic(error, phase, componentStack) {
76
+ const capturedAt = new Date().toISOString();
48
77
  if (error instanceof Error) {
49
- return { phase, message: error.message, stack: error.stack };
78
+ return { phase, message: error.message, stack: error.stack, componentStack, capturedAt };
50
79
  }
51
- return { phase, message: typeof error === "string" ? error : JSON.stringify(error) };
80
+ return { phase, message: typeof error === "string" ? error : JSON.stringify(error), componentStack, capturedAt };
52
81
  }
53
- function logRuntimeError(error, phase) {
54
- const diagnostic = errorToDiagnostic(error, phase);
82
+ function logRuntimeError(error, phase, componentStack) {
83
+ const diagnostic = errorToDiagnostic(error, phase, componentStack);
55
84
  console.error(`[jgengine:${phase}] ${diagnostic.message}`, error);
56
85
  return diagnostic;
57
86
  }
@@ -69,6 +98,10 @@ const RESERVED_INPUT_ACTIONS = new Set([
69
98
  "useAbility",
70
99
  "interact",
71
100
  ]);
101
+ /** No action names are reserved when no camera rig is active (hud/none presentation): games may bind `turnLeft`/`interact`/etc. as their own. */
102
+ const EMPTY_RESERVED = new Set();
103
+ /** Empty action list — published while the orientation gate is up to suppress all held input without touching the tracker. */
104
+ const NO_ACTIONS = [];
72
105
  const SHELL_MOVEMENT_ACTIONS = ["moveForward", "moveBack", "moveLeft", "moveRight", "jump"];
73
106
  function shellDrivesPlayerPose(input) {
74
107
  const bound = input ?? {};
@@ -101,9 +134,12 @@ function pointerAimFor(ctx, service) {
101
134
  const hit = service.worldHit();
102
135
  if (hit === null)
103
136
  return undefined;
104
- const player = ctx.scene.entity.get(ctx.player.userId);
105
- const origin = player === null ? hit.point : player.position;
106
- return aimToPoint([origin[0], origin[1] + 1, origin[2]], hit.point);
137
+ const shooter = ctx.scene.entity.get(ctx.player.possession.active(ctx.player.userId)) ??
138
+ ctx.scene.entity.get(ctx.player.userId);
139
+ if (shooter === null)
140
+ return undefined;
141
+ const eye = eyeHeightFromColliders(ctx.scene.entity.collidersOf(shooter.id));
142
+ return aimToPoint([shooter.position[0], shooter.position[1] + eye, shooter.position[2]], hit.point);
107
143
  }
108
144
  function pointerContextMenu(ctx, playable, hit) {
109
145
  if (hit.entity !== null) {
@@ -132,72 +168,35 @@ export function shouldFireBoundAction(tracker, action, input, repeatFiredAt, now
132
168
  });
133
169
  }
134
170
  /** Resolves and runs the command bound to `action` via the shell's action→command convention (shared by `FrameDriver` and `HudOnlyDriver`). */
135
- export function dispatchBoundAction(ctx, action, yaw, pitch, aim) {
136
- const command = resolveActionCommand(action, (name) => ctx.game.commands.has(name), RESERVED_INPUT_ACTIONS);
171
+ export function dispatchBoundAction(ctx, action, yaw, pitch, aim, reserved = RESERVED_INPUT_ACTIONS, sink = localCommandSink(ctx)) {
172
+ const command = resolveActionCommand(action, (name) => ctx.game.commands.has(name), reserved);
137
173
  if (command !== null)
138
- ctx.game.commands.run(command, { yaw, pitch, aim });
139
- }
140
- const OBSTACLE_GATHER_RADIUS = 3;
141
- /** Placed scene objects within `radius` of `center`, as `CollisionObstacle`s for `resolveObstacleStep` (#162.1). */
142
- export function nearbyObstacles(objects, center, radius = OBSTACLE_GATHER_RADIUS) {
143
- const radiusSq = radius * radius;
144
- const result = [];
145
- for (const object of objects) {
146
- const dx = object.position[0] - center[0];
147
- const dz = object.position[2] - center[2];
148
- if (dx * dx + dz * dz <= radiusSq)
149
- result.push({ position: object.position });
150
- }
151
- return result;
152
- }
153
- /** Applies a pending `MotionIntentBatch` to a vertical velocity: impulses add, then `verticalVelocity` replaces the result outright (#162.4). */
154
- export function applyMotionImpulses(currentVelocity, batch) {
155
- if (batch === null)
156
- return currentVelocity;
157
- let velocity = currentVelocity;
158
- for (const impulse of batch.impulses)
159
- velocity += impulse;
160
- return batch.verticalVelocity ?? velocity;
174
+ sink.run(command, { yaw, pitch, aim });
161
175
  }
176
+ export { applyMotionImpulses } from "@jgengine/core/runtime/motionIntents";
177
+ export { nearbyObstacles } from "@jgengine/core/movement/movementModel";
178
+ export { resolvePhysicsTuning } from "@jgengine/core/movement/playerMovement";
179
+ export { hasEnvironmentTerrain } from "@jgengine/core/world/terrain";
162
180
  /** The world's declared sky, when its world feature is an environment with one (#196.1). */
163
181
  export function resolveWorldSky(world) {
164
182
  return world?.kind === "environment" ? world.sky : undefined;
165
183
  }
166
- /**
167
- * Maps the game's declared `physics` onto the movement controllers' tuning
168
- * overrides. `PhysicsConfig.gravity` is a signed world acceleration (negative
169
- * points down, matching every game's config and the Y-up convention), but the
170
- * controllers integrate `velocityY -= gravityAcceleration * dt` and so expect a
171
- * positive downward magnitude. Negating here is what keeps a down-pointing
172
- * gravity pulling the player *down*; passing the signed value straight through
173
- * flipped the sign and launched airborne players upward instead.
174
- */
175
- export function resolvePhysicsTuning(physics) {
176
- if (physics?.gravity === undefined && physics?.jumpVelocity === undefined)
177
- return undefined;
178
- const tuning = {};
179
- if (physics.gravity !== undefined)
180
- tuning.gravityAcceleration = -physics.gravity;
181
- if (physics.jumpVelocity !== undefined)
182
- tuning.jumpVelocity = physics.jumpVelocity;
183
- return tuning;
184
- }
185
- /** True when the world is an environment feature with terrain, so the voxel controller should sample its height. */
186
- export function hasEnvironmentTerrain(world) {
187
- return world?.kind === "environment" && world.terrain !== undefined;
188
- }
189
184
  function colorFromId(id) {
190
185
  let hash = 0;
191
186
  for (let index = 0; index < id.length; index += 1)
192
187
  hash = (hash * 31 + id.charCodeAt(index)) >>> 0;
193
188
  return `hsl(${hash % 360}, 65%, 55%)`;
194
189
  }
190
+ function DirectionalShadowLight({ entry }) {
191
+ const size = entry.shadowCameraSize ?? 40;
192
+ return (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": entry.shadowMapSize ?? 1024, "shadow-mapSize-height": entry.shadowMapSize ?? 1024, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": Math.max(200, size * 6), "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
193
+ }
195
194
  function ConfiguredLighting({ lighting }) {
196
195
  return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { color: lighting.ambient.color, intensity: lighting.ambient.intensity ?? 0.55 })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { args: [
197
196
  lighting.hemisphere.skyColor ?? "#bfe3ff",
198
197
  lighting.hemisphere.groundColor ?? "#4c6b34",
199
198
  lighting.hemisphere.intensity ?? 0.55,
200
- ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false }, index)))] }));
199
+ ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry }, index)))] }));
201
200
  }
202
201
  function BackdropFog({ fog }) {
203
202
  if (fog === undefined)
@@ -213,32 +212,193 @@ function EntitySprite({ sprite }) {
213
212
  }, [texture]);
214
213
  return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: texture, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
215
214
  }
215
+ class ModelFallbackBoundary extends Component {
216
+ state = { failed: false };
217
+ static getDerivedStateFromError() {
218
+ return { failed: true };
219
+ }
220
+ render() {
221
+ return this.state.failed ? this.props.fallback : this.props.children;
222
+ }
223
+ }
224
+ function IsolatedEntityModel({ model, instanceId, fallback, }) {
225
+ return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId }) }) }));
226
+ }
227
+ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
228
+ const gltf = useLoader(GLTFLoader, model.url, (loader) => {
229
+ loader.setMeshoptDecoder(MeshoptDecoder);
230
+ });
231
+ const weaponScene = useMemo(() => cloneModelScene(gltf.scene), [gltf]);
232
+ const px = position?.[0] ?? 0;
233
+ const py = position?.[1] ?? 0;
234
+ const pz = position?.[2] ?? 0;
235
+ const rx = rotation?.[0] ?? 0;
236
+ const ry = rotation?.[1] ?? 0;
237
+ const rz = rotation?.[2] ?? 0;
238
+ const s = scale ?? 1;
239
+ useEffect(() => {
240
+ const bone = rig.getObjectByName(slot);
241
+ if (bone === undefined) {
242
+ if (typeof console !== "undefined") {
243
+ console.warn(`[jgengine] entityModels attachment: bone/slot "${slot}" not found on the rig`);
244
+ }
245
+ return;
246
+ }
247
+ weaponScene.position.set(px, py, pz);
248
+ weaponScene.rotation.set(rx, ry, rz);
249
+ weaponScene.scale.setScalar(s);
250
+ bone.add(weaponScene);
251
+ return () => {
252
+ bone.remove(weaponScene);
253
+ };
254
+ }, [rig, weaponScene, slot, px, py, pz, rx, ry, rz, s]);
255
+ useEffect(() => () => disposeClonedMaterials(weaponScene), [weaponScene]);
256
+ return null;
257
+ }
258
+ /** Resolves an entity model plus any bone attachments' models through the asset catalog, so `EntityModel` receives fully-resolved `ModelConfig`s. */
259
+ function resolveEntityModel(value, assets, key) {
260
+ const model = resolveModel(value, assets, { seam: "entityModels", key });
261
+ if (model?.attachments === undefined)
262
+ return model;
263
+ return {
264
+ ...model,
265
+ attachments: model.attachments.map((attachment) => ({
266
+ ...attachment,
267
+ model: resolveModel(attachment.model, assets) ?? attachment.model,
268
+ })),
269
+ };
270
+ }
216
271
  function EntityModel({ model, instanceId }) {
217
- const gltf = useLoader(GLTFLoader, model.url);
272
+ const gltf = useLoader(GLTFLoader, model.url, (loader) => {
273
+ loader.setMeshoptDecoder(MeshoptDecoder);
274
+ });
218
275
  const ctx = useGameContext();
219
276
  const material = model.material;
220
- const scale = model.scale ?? 1;
221
277
  const baseY = model.y ?? 0;
222
278
  const dims = model.dims;
223
- const centered = (model.anchor ?? "center") === "center" && dims !== undefined;
224
- const position = centered
225
- ? [-scale * dims.center.x, baseY - scale * dims.minY, -scale * dims.center.z]
226
- : [0, baseY, 0];
227
279
  const scene = useMemo(() => {
228
280
  const cloned = cloneModelScene(gltf.scene);
229
281
  if (material !== undefined)
230
- applyMaterialOverride(cloned, material);
282
+ applyMaterialOverride(cloned, material, { clone: false });
231
283
  return cloned;
232
284
  }, [gltf, material]);
285
+ const measured = useMemo(() => {
286
+ if (model.targetHeight === undefined)
287
+ return null;
288
+ const box = new THREE.Box3().setFromObject(scene);
289
+ const height = box.max.y - box.min.y;
290
+ if (!Number.isFinite(height) || height <= 0)
291
+ return null;
292
+ return {
293
+ normalize: model.targetHeight / height,
294
+ minY: box.min.y,
295
+ centerX: (box.min.x + box.max.x) / 2,
296
+ centerZ: (box.min.z + box.max.z) / 2,
297
+ };
298
+ }, [scene, model.targetHeight]);
299
+ const scale = (model.scale ?? 1) * (measured?.normalize ?? 1);
300
+ const centered = (model.anchor ?? "center") === "center" && dims !== undefined;
301
+ const position = measured !== null
302
+ ? [-scale * measured.centerX, baseY - scale * measured.minY, -scale * measured.centerZ]
303
+ : centered
304
+ ? [-scale * dims.center.x, baseY - scale * dims.minY, -scale * dims.center.z]
305
+ : [0, baseY, 0];
306
+ useEffect(() => () => {
307
+ disposeClonedMaterials(scene);
308
+ }, [scene]);
233
309
  const animation = model.animation;
234
310
  const mixerRef = useRef(null);
235
311
  const animationPausedRef = useRef(false);
312
+ const stateActionsRef = useRef(null);
313
+ const states = animation?.states;
314
+ const oneShots = animation?.oneShots;
315
+ const oneShotPlayRef = useRef(null);
316
+ const activeOneShotRef = useRef(null);
236
317
  useEffect(() => {
237
318
  if (animation === undefined || gltf.animations.length === 0) {
238
319
  mixerRef.current = null;
320
+ stateActionsRef.current = null;
239
321
  return;
240
322
  }
241
323
  const mixer = new THREE.AnimationMixer(scene);
324
+ if (states !== undefined) {
325
+ const clipFor = (name) => THREE.AnimationClip.findByName(gltf.animations, name) ?? gltf.animations[0];
326
+ const actions = {
327
+ idle: mixer.clipAction(clipFor(states.idle)),
328
+ walk: mixer.clipAction(clipFor(states.walk)),
329
+ ...(states.run === undefined ? {} : { run: mixer.clipAction(clipFor(states.run)) }),
330
+ };
331
+ for (const action of Object.values(actions)) {
332
+ action.setLoop(THREE.LoopRepeat, Infinity);
333
+ action.timeScale = animation.timeScale ?? 1;
334
+ action.enabled = true;
335
+ }
336
+ actions.idle.play();
337
+ mixer.update(0);
338
+ mixerRef.current = mixer;
339
+ stateActionsRef.current = { actions, active: "idle", lastPos: null, smoothedSpeed: 0 };
340
+ animationPausedRef.current = false;
341
+ let onOneShotFinished = null;
342
+ if (oneShots !== undefined) {
343
+ const clipNames = new Set();
344
+ for (const spec of Object.values(oneShots)) {
345
+ if (typeof spec === "string")
346
+ clipNames.add(spec);
347
+ else
348
+ for (const name of spec)
349
+ clipNames.add(name);
350
+ }
351
+ const oneShotActions = new Map();
352
+ for (const name of clipNames) {
353
+ const found = THREE.AnimationClip.findByName(gltf.animations, name);
354
+ if (found === null)
355
+ continue;
356
+ const oneShotAction = mixer.clipAction(found);
357
+ oneShotAction.setLoop(THREE.LoopOnce, 1);
358
+ oneShotAction.enabled = true;
359
+ oneShotActions.set(name, oneShotAction);
360
+ }
361
+ onOneShotFinished = ({ action }) => {
362
+ const active = activeOneShotRef.current;
363
+ if (active === null || action !== active.action || active.isDeath)
364
+ return;
365
+ const machine = stateActionsRef.current;
366
+ const back = machine?.actions[machine.active];
367
+ action.fadeOut(0.15);
368
+ if (back !== undefined)
369
+ back.reset().fadeIn(0.15).play();
370
+ activeOneShotRef.current = null;
371
+ };
372
+ mixer.addEventListener("finished", onOneShotFinished);
373
+ oneShotPlayRef.current = (event) => {
374
+ const active = activeOneShotRef.current;
375
+ if (active !== null && active.isDeath)
376
+ return;
377
+ const clipName = resolveOneShotClip(oneShots, event, Math.random());
378
+ if (clipName === null)
379
+ return;
380
+ const oneShotAction = oneShotActions.get(clipName);
381
+ if (oneShotAction === undefined)
382
+ return;
383
+ const machine = stateActionsRef.current;
384
+ machine?.actions[machine.active]?.fadeOut(0.1);
385
+ if (active !== null && active.action !== oneShotAction)
386
+ active.action.stop();
387
+ oneShotAction.clampWhenFinished = event === "death";
388
+ oneShotAction.reset().fadeIn(0.1).play();
389
+ activeOneShotRef.current = { action: oneShotAction, isDeath: event === "death" };
390
+ };
391
+ }
392
+ return () => {
393
+ if (onOneShotFinished !== null)
394
+ mixer.removeEventListener("finished", onOneShotFinished);
395
+ oneShotPlayRef.current = null;
396
+ activeOneShotRef.current = null;
397
+ mixer.stopAllAction();
398
+ mixerRef.current = null;
399
+ stateActionsRef.current = null;
400
+ };
401
+ }
242
402
  const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(gltf.animations, animation.clip) : undefined) ??
243
403
  gltf.animations[0];
244
404
  const action = mixer.clipAction(clip);
@@ -257,16 +417,82 @@ function EntityModel({ model, instanceId }) {
257
417
  mixer.stopAllAction();
258
418
  mixerRef.current = null;
259
419
  };
260
- }, [scene, gltf, animation?.clip, animation?.loop, animation?.timeScale, animation?.paused, animation?.time]);
420
+ }, [
421
+ scene,
422
+ gltf,
423
+ animation?.clip,
424
+ animation?.loop,
425
+ animation?.timeScale,
426
+ animation?.paused,
427
+ animation?.time,
428
+ states,
429
+ oneShots,
430
+ ]);
431
+ useEffect(() => {
432
+ if (instanceId === undefined || oneShots === undefined)
433
+ return;
434
+ const fire = (event) => oneShotPlayRef.current?.(event);
435
+ const offAnimation = ctx.game.events.on("entity.animation", (event) => {
436
+ if (event.instanceId === instanceId)
437
+ fire(event.event);
438
+ });
439
+ const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
440
+ if (event.instanceId === instanceId)
441
+ fire("hit");
442
+ });
443
+ const offDied = ctx.game.events.on("entity.died", (event) => {
444
+ if (event.instanceId === instanceId)
445
+ fire("death");
446
+ });
447
+ return () => {
448
+ offAnimation();
449
+ offHit();
450
+ offDied();
451
+ };
452
+ }, [ctx, instanceId, oneShots]);
261
453
  const paintCanvasRef = useRef(null);
262
454
  const paintDrawnCountRef = useRef(0);
263
455
  const paintVersionRef = useRef(-1);
456
+ const materialCacheRef = useRef(null);
264
457
  useEffect(() => {
265
458
  paintCanvasRef.current = null;
266
459
  paintDrawnCountRef.current = 0;
267
460
  paintVersionRef.current = -1;
461
+ materialCacheRef.current = null;
268
462
  }, [scene]);
269
463
  useFrame((_state, delta) => {
464
+ const stateMachine = stateActionsRef.current;
465
+ if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0) {
466
+ const entity = ctx.scene.entity.get(instanceId);
467
+ if (entity !== null) {
468
+ const [x, , z] = entity.position;
469
+ if (stateMachine.lastPos !== null) {
470
+ const instantSpeed = Math.hypot(x - stateMachine.lastPos[0], z - stateMachine.lastPos[2]) / delta;
471
+ stateMachine.smoothedSpeed +=
472
+ (instantSpeed - stateMachine.smoothedSpeed) * Math.min(1, delta * 12);
473
+ }
474
+ stateMachine.lastPos = [x, entity.position[1], z];
475
+ const walkSpeed = states.walkSpeed ?? 0.5;
476
+ const runSpeed = states.runSpeed ?? 6;
477
+ const next = stateMachine.smoothedSpeed < walkSpeed
478
+ ? "idle"
479
+ : stateMachine.actions.run !== undefined && stateMachine.smoothedSpeed >= runSpeed
480
+ ? "run"
481
+ : "walk";
482
+ if (next !== stateMachine.active) {
483
+ if (activeOneShotRef.current === null) {
484
+ const fade = states.fadeSec ?? 0.2;
485
+ const from = stateMachine.actions[stateMachine.active];
486
+ const to = stateMachine.actions[next];
487
+ if (from !== undefined && to !== undefined) {
488
+ to.reset().fadeIn(fade).play();
489
+ from.fadeOut(fade);
490
+ }
491
+ }
492
+ stateMachine.active = next;
493
+ }
494
+ }
495
+ }
270
496
  if (mixerRef.current !== null && !animationPausedRef.current)
271
497
  mixerRef.current.update(delta);
272
498
  if (instanceId === undefined)
@@ -277,45 +503,68 @@ function EntityModel({ model, instanceId }) {
277
503
  return;
278
504
  paintVersionRef.current = version;
279
505
  const strokes = paint.strokes(instanceId);
506
+ const cache = cacheStandardMaterials(scene, materialCacheRef.current);
507
+ materialCacheRef.current = cache;
280
508
  if (paintCanvasRef.current === null) {
281
509
  if (strokes.length === 0)
282
510
  return;
283
- const materials = standardMaterialsOf(scene);
284
- const seed = materials[0];
511
+ const seed = cache.materials[0];
285
512
  if (seed === undefined)
286
513
  return;
287
514
  const paintCanvas = createPaintCanvas(seed);
288
515
  paintCanvasRef.current = paintCanvas;
289
- applyPaintTexture(scene, paintCanvas);
516
+ applyPaintTextureToMaterials(cache.materials, paintCanvas);
290
517
  }
291
- const seedColor = standardMaterialsOf(scene)[0]?.color ?? new THREE.Color("#ffffff");
292
- paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, seedColor, strokes, paintDrawnCountRef.current);
518
+ paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
293
519
  });
294
- return _jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] });
295
- }
296
- function resolveModel(value, assets) {
297
- if (value === undefined)
298
- return undefined;
299
- if (typeof value !== "string")
300
- return value;
301
- const ref = assets.resolve(value);
302
- if (ref === null)
303
- return undefined;
304
- return ref.dims === undefined ? { url: ref.url } : { url: ref.url, dims: ref.dims };
520
+ return (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }, `${attachment.slot}-${index}`)))] }));
305
521
  }
306
522
  function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, onSelect, }) {
307
- const color = isLocal ? "#4ade80" : entity.role === "npc" ? colorFromId(entity.name) : "#9ca3af";
308
- return (_jsxs("group", { position: [entity.position[0], entity.position[1], entity.position[2]], "rotation-y": entity.rotationY, userData: { [POINTER_ENTITY_KEY]: entity.id }, onPointerDown: (event) => {
523
+ const groupRef = useRef(null);
524
+ const ctx = useGameContext();
525
+ const visibleRef = useRenderVisibility();
526
+ const entityId = entity.id;
527
+ const role = entity.role;
528
+ const name = entity.name;
529
+ const color = isLocal ? "#4ade80" : role === "npc" ? colorFromId(name) : "#9ca3af";
530
+ useFrame(() => {
531
+ const group = groupRef.current;
532
+ if (group === null)
533
+ return;
534
+ const live = ctx.scene.entity.get(entityId);
535
+ if (live === null)
536
+ return;
537
+ writeEntityPose(group, live);
538
+ group.visible = visibleRef.current(entityId);
539
+ });
540
+ return (_jsxs("group", { ref: groupRef, userData: { [POINTER_ENTITY_KEY]: entityId }, onPointerDown: (event) => {
309
541
  event.stopPropagation();
310
- if (!isLocal)
311
- onSelect(entity);
312
- }, children: [selected ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.02, children: [_jsx("ringGeometry", { args: [0.8, 0.95, 32] }), _jsx("meshBasicMaterial", { color: "#34d399", transparent: true, opacity: 0.9 })] })) : null, custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(EntityModel, { model: model, instanceId: entity.id })) : sprite !== undefined ? (_jsx(EntitySprite, { sprite: sprite })) : entity.role === "prop" ? (_jsxs("mesh", { "position-y": 0.5, children: [_jsx("sphereGeometry", { args: [0.45, 16, 16] }), _jsx("meshStandardMaterial", { color: color })] })) : (_jsxs(_Fragment, { children: [_jsxs("mesh", { "position-y": 0.95, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx("meshStandardMaterial", { color: color })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] })), targeted ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.6, 0.75, 28] }), _jsx("meshBasicMaterial", { color: "#f87171" })] })) : null] }));
542
+ if (isLocal)
543
+ return;
544
+ const live = ctx.scene.entity.get(entityId);
545
+ if (live !== null)
546
+ onSelect(live);
547
+ }, children: [selected ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.02, children: [_jsx("ringGeometry", { args: [0.8, 0.95, 32] }), _jsx("meshBasicMaterial", { color: "#34d399", transparent: true, opacity: 0.9 })] })) : null, custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: entityId, fallback: sprite !== undefined ? _jsx(EntitySprite, { sprite: sprite }) : undefined })) : sprite !== undefined ? (_jsx(EntitySprite, { sprite: sprite })) : role === "prop" ? (_jsxs("mesh", { "position-y": 0.5, children: [_jsx("sphereGeometry", { args: [0.45, 16, 16] }), _jsx("meshStandardMaterial", { color: color })] })) : (_jsxs(_Fragment, { children: [_jsxs("mesh", { "position-y": 0.95, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx("meshStandardMaterial", { color: color })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] })), targeted ? (_jsxs("mesh", { "rotation-x": -Math.PI / 2, "position-y": 0.03, children: [_jsx("ringGeometry", { args: [0.6, 0.75, 28] }), _jsx("meshBasicMaterial", { color: "#f87171" })] })) : null] }));
313
548
  }
314
549
  function ObjectMarker({ object, custom, model, style, }) {
550
+ const groupRef = useRef(null);
551
+ const ctx = useGameContext();
552
+ const visibleRef = useRenderVisibility();
553
+ const instanceId = object.instanceId;
315
554
  const [scaleX, scaleY, scaleZ] = objectVisualScale(object.visual);
316
555
  const color = object.visual?.color ?? style?.color ?? colorFromId(object.catalogId);
317
556
  const opacity = object.visual?.opacity ?? style?.opacity ?? 1;
318
- return (_jsx("group", { position: [object.position[0], object.position[1], object.position[2]], "rotation-y": object.rotationY, userData: { [POINTER_OBJECT_KEY]: object.instanceId }, children: custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(EntityModel, { model: model, instanceId: object.instanceId })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
557
+ useFrame(() => {
558
+ const group = groupRef.current;
559
+ if (group === null)
560
+ return;
561
+ const live = ctx.scene.object.get(instanceId);
562
+ if (live === null)
563
+ return;
564
+ writeEntityPose(group, live);
565
+ group.visible = visibleRef.current(instanceId);
566
+ });
567
+ return (_jsx("group", { ref: groupRef, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
319
568
  }
320
569
  function GroundPlane() {
321
570
  const geometry = useMemo(() => {
@@ -348,7 +597,12 @@ function RockField() {
348
597
  }), []);
349
598
  return (_jsx(_Fragment, { children: rocks.map((rock) => (_jsxs("mesh", { position: [rock.x, 0.25 * rock.scale, rock.z], rotation: [0.1, rock.rotation, -0.08], scale: [rock.scale * 1.4, rock.scale * 0.7, rock.scale], children: [_jsx("dodecahedronGeometry", { args: [0.8, 0] }), _jsx("meshStandardMaterial", { color: "#6b6f63", roughness: 1 })] }, rock.id))) }));
350
599
  }
351
- function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment: Environment, assets, renderEntity, renderObject, selectedIds, }) {
600
+ function WorldEnvironment({ environment: Environment }) {
601
+ if (Environment !== undefined)
602
+ return _jsx(Environment, {});
603
+ return (_jsxs(_Fragment, { children: [_jsx(GroundPlane, {}), _jsx("gridHelper", { args: [160, 80, "#3a3f4a", "#2b2f38"], "position-y": 0.01 }), _jsx(RockField, {})] }));
604
+ }
605
+ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles, assets, renderEntity, renderObject, selectedIds, hideLocalActor, }) {
352
606
  const ctx = useGameContext();
353
607
  const entities = useSceneEntities();
354
608
  const objects = useSceneObjects();
@@ -359,33 +613,36 @@ function WorldView({ entitySprites, entityModels, objectModels, objectStyles, en
359
613
  const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
360
614
  ctx.scene.entity.setTarget(controlledId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
361
615
  };
362
- return (_jsxs(_Fragment, { children: [Environment !== undefined ? (_jsx(Environment, {})) : (_jsxs(_Fragment, { children: [_jsx(GroundPlane, {}), _jsx("gridHelper", { args: [160, 80, "#3a3f4a", "#2b2f38"], "position-y": 0.01 }), _jsx(RockField, {})] })), entities
616
+ return (_jsxs(_Fragment, { children: [entities
363
617
  .filter((entity) => entity.name !== WORLD_ITEM_ENTITY_NAME)
364
- .map((entity) => (_jsx(EntityMarker, { entity: entity, custom: renderEntity?.(entity), model: resolveModel(entityModels?.[entity.name], assets), sprite: entitySprites?.[entity.name], isLocal: entity.id === controlledId, targeted: entity.id === targetId, selected: selectedIds.has(entity.id), onSelect: handleSelect }, entity.id))), objects.map((object) => {
365
- const model = resolveModel(objectModels?.[object.catalogId], assets) ??
366
- resolveModel(object.catalogId, assets);
618
+ .filter((entity) => !(hideLocalActor && entity.id === controlledId))
619
+ .map((entity) => (_jsx(EntityMarker, { entity: entity, custom: renderEntity?.(entity), model: resolveEntityModel(entityModels?.[entity.name], assets, entity.name), sprite: entitySprites?.[entity.name], isLocal: entity.id === controlledId, targeted: entity.id === targetId, selected: selectedIds.has(entity.id), onSelect: handleSelect }, entity.id))), objects.map((object) => {
620
+ const model = resolveModel(objectModels?.[object.catalogId], assets, {
621
+ seam: "objectModels",
622
+ key: object.catalogId,
623
+ }) ?? tryResolveCatalogModel(object.catalogId, assets);
367
624
  return (_jsx(ObjectMarker, { object: object, custom: renderObject?.(object), model: model, style: objectStyles?.[object.catalogId] }, object.instanceId));
368
625
  })] }));
369
626
  }
627
+ function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, }) {
628
+ return (_jsxs(_Fragment, { children: [_jsx(WorldEnvironment, { environment: environment }), _jsx(WorldActors, { entitySprites: entitySprites, entityModels: entityModels, objectModels: objectModels, objectStyles: objectStyles, assets: assets, renderEntity: renderEntity, renderObject: renderObject, selectedIds: selectedIds, hideLocalActor: hideLocalActor })] }));
629
+ }
370
630
  function RemotePlayers({ rows }) {
371
631
  return (_jsx(_Fragment, { children: rows.map((row) => (_jsxs("group", { position: [row.position.x, row.position.y, row.position.z], "rotation-y": row.rotationY, children: [_jsxs("mesh", { "position-y": 0.95, children: [_jsx("capsuleGeometry", { args: [0.35, 1.1, 6, 14] }), _jsx("meshStandardMaterial", { color: colorFromId(row.userId) })] }), _jsxs("mesh", { position: [0, 1.35, 0.32], children: [_jsx("boxGeometry", { args: [0.16, 0.16, 0.16] }), _jsx("meshStandardMaterial", { color: "#f8fafc" })] })] }, row.userId))) }));
372
632
  }
373
- function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, }) {
374
- const motionRef = useRef(createPlayerMotionState());
375
- const voxelBodyRef = useRef(null);
376
- const solidCacheRef = useRef({ count: -1, set: new Set() });
633
+ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }) {
634
+ const posterElapsedRef = useRef(0);
635
+ const posterDoneRef = useRef(false);
377
636
  const hasReportedTickError = useRef(false);
378
637
  const repeatFiredAtRef = useRef(new Map());
379
638
  const slotActions = useMemo(() => findHotbarSlotActions(playable.game.input), [playable]);
380
639
  const hotbarId = useMemo(() => hotbarIdFor(playable), [playable]);
381
- const collision = playable.collision;
382
- const movement = playable.movement;
383
- const voxelDims = useMemo(() => ({
384
- halfWidth: collision?.halfWidth ?? 0.3,
385
- height: collision?.height ?? 1.8,
386
- stepHeight: collision?.stepHeight ?? 0.6,
387
- }), [collision]);
388
- const movementTuning = useMemo(() => resolvePhysicsTuning(playable.game.physics), [playable]);
640
+ const movementTuning = useMemo(() => resolvePlayerMovementTuning({
641
+ collision: playable.collision,
642
+ movement: playable.movement,
643
+ physics: playable.game.physics,
644
+ world: playable.game.world,
645
+ }), [playable]);
389
646
  const autoPickupRadius = useMemo(() => {
390
647
  const cfg = playable.worldItem?.autoPickup;
391
648
  if (cfg === undefined || cfg === false)
@@ -393,121 +650,85 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
393
650
  const fallback = playable.worldItem?.pickupRadius ?? DEFAULT_PICKUP_RADIUS;
394
651
  return cfg === true ? fallback : cfg.radius ?? fallback;
395
652
  }, [playable]);
396
- const ground = useMemo(() => groundFieldFor(playable.game.world), [playable]);
397
653
  const drivesPose = useMemo(() => shellDrivesPlayerPose(playable.game.input), [playable]);
654
+ const serverAuthoritative = useMemo(() => isServerAuthoritative(playable.game.multiplayer) && multiplayer !== null, [playable, multiplayer]);
655
+ const commandSink = useMemo(() => ({
656
+ run: (name, input) => resolveCommandSink(ctx, {
657
+ serverAuthoritative,
658
+ backend: multiplayer?.backend ?? null,
659
+ serverId: serverIdRef.current,
660
+ }).run(name, input),
661
+ }), [ctx, multiplayer, serverAuthoritative, serverIdRef]);
662
+ const inputSink = useMemo(() => ({
663
+ send: (frame) => resolveInputSink({
664
+ serverAuthoritative,
665
+ backend: multiplayer?.backend ?? null,
666
+ serverId: serverIdRef.current,
667
+ }).send(frame),
668
+ }), [multiplayer, serverAuthoritative, serverIdRef]);
669
+ const lastSentInputRef = useRef(null);
398
670
  const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
399
- const hasTerrain = useMemo(() => hasEnvironmentTerrain(playable.game.world), [playable]);
400
671
  useFrame((_state, rawDt) => {
672
+ if (poster) {
673
+ if (posterDoneRef.current)
674
+ return;
675
+ posterElapsedRef.current += Math.min(rawDt, 0.05);
676
+ if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) {
677
+ posterDoneRef.current = true;
678
+ onPosterSettled();
679
+ return;
680
+ }
681
+ }
682
+ const sendInput = () => {
683
+ if (!serverAuthoritative)
684
+ return;
685
+ const frame = { held: ctx.input.held(), pointer: ctx.input.pointer() };
686
+ const last = lastSentInputRef.current;
687
+ if (last !== null && inputFramesEqual(last, frame))
688
+ return;
689
+ lastSentInputRef.current = frame;
690
+ inputSink.send(frame);
691
+ };
692
+ if (gateRef.current) {
693
+ ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS));
694
+ sendInput();
695
+ return;
696
+ }
401
697
  const simStart = performance.now();
402
698
  try {
699
+ let endPhase = devtools.profile.begin("time+input");
403
700
  const dt = Math.min(rawDt, 0.05);
404
701
  const gameDt = ctx.time.advance(dt);
405
702
  ctx.input.publish(heldActionsFor(tracker, inputActions));
406
- if (tracker.isDown("turnLeft"))
407
- yawRef.current += TURN_SPEED * dt;
408
- if (tracker.isDown("turnRight"))
409
- yawRef.current -= TURN_SPEED * dt;
703
+ ctx.input.publishPointer(pointerAxisRef.current);
704
+ sendInput();
705
+ const turnInput = (tracker.isDown("turnRight") ? 1 : 0) - (tracker.isDown("turnLeft") ? 1 : 0);
706
+ if (turnInput !== 0)
707
+ yawRef.current = steerYaw(yawRef.current, turnInput, TURN_SPEED, dt);
708
+ endPhase();
410
709
  const playerId = ctx.player.possession.active(ctx.player.userId);
411
710
  const player = ctx.scene.entity.get(playerId);
412
- const forwardX = Math.sin(yawRef.current);
413
- const forwardZ = Math.cos(yawRef.current);
414
- if (player !== null && drivesPose) {
415
- const keys = createEmptyMovementKeys();
416
- keys.w = tracker.isDown("moveForward");
417
- keys.s = tracker.isDown("moveBack");
418
- keys.a = tracker.isDown("moveLeft");
419
- keys.d = tracker.isDown("moveRight");
420
- keys.shift = tracker.isDown("sprint");
421
- keys.space = tracker.isDown("jump");
422
- const intent = resolveMovementIntent(keys, true);
423
- const motionBatch = ctx.player.motion.takePending();
424
- if (collision?.voxel) {
425
- let body = voxelBodyRef.current;
426
- if (body === null) {
427
- body = createVoxelPlayerBody(player.position[0], player.position[1], player.position[2]);
428
- voxelBodyRef.current = body;
429
- }
430
- const objects = ctx.scene.object.list();
431
- const cache = solidCacheRef.current;
432
- if (cache.count !== objects.length) {
433
- cache.set = new Set(objects.map((o) => `${o.position[0]},${o.position[1]},${o.position[2]}`));
434
- cache.count = objects.length;
435
- }
436
- const solids = cache.set;
437
- const isSolid = (x, y, z) => solids.has(`${x},${y},${z}`);
438
- body.velocityY = applyMotionImpulses(body.velocityY, motionBatch);
439
- advanceVoxelPlayer(body, intent, forwardX, forwardZ, player.movement.walkSpeed ?? 2, rawDt, isSolid, voxelDims, movementTuning, hasTerrain ? (x, z) => ground.sampleHeight(x, z) : undefined);
440
- if (motionBatch !== null && motionBatch.y !== null)
441
- body.y = motionBatch.y;
442
- ctx.scene.entity.setPose(playerId, {
443
- position: [body.x, body.y, body.z],
444
- rotationY: intent.moving
445
- ? Math.atan2(body.velocityX, body.velocityZ)
446
- : player.rotationY,
447
- dt: rawDt,
448
- });
449
- }
450
- else {
451
- const motion = motionRef.current;
452
- motion.verticalVelocity = applyMotionImpulses(motion.verticalVelocity, motionBatch);
453
- const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, player.movement.walkSpeed ?? 2, rawDt, movementTuning);
454
- let stepX = step.stepX;
455
- let stepZ = step.stepZ;
456
- if (movement?.mode === "axis") {
457
- const constrained = constrainStepToAxis(stepX, stepZ, movement.axis ?? "x");
458
- stepX = constrained.stepX;
459
- stepZ = constrained.stepZ;
460
- }
461
- if (movement?.collideObjects === true) {
462
- const obstacles = nearbyObstacles(ctx.scene.object.list(), player.position);
463
- const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles);
464
- stepX = resolved.stepX;
465
- stepZ = resolved.stepZ;
466
- }
467
- let nextX = player.position[0] + stepX;
468
- let nextZ = player.position[2] + stepZ;
469
- if (movement?.mode === "grid") {
470
- const snapped = snapPositionToGrid(nextX, nextZ, movement.cellSize ?? 1);
471
- nextX = snapped[0];
472
- nextZ = snapped[1];
473
- }
474
- let nextY = ground.sampleHeight(nextX, nextZ) + motion.jumpOffset;
475
- if (motionBatch !== null && motionBatch.y !== null) {
476
- nextY = motionBatch.y;
477
- motion.jumpOffset = motionBatch.y - ground.sampleHeight(nextX, nextZ);
478
- }
479
- if (movement?.beforeCommit !== undefined) {
480
- const frame = {
481
- entityId: playerId,
482
- current: player.position,
483
- next: [nextX, nextY, nextZ],
484
- dt: rawDt,
485
- };
486
- const replacement = movement.beforeCommit(frame);
487
- if (replacement !== undefined) {
488
- nextX = replacement[0];
489
- nextY = replacement[1];
490
- nextZ = replacement[2];
491
- }
492
- }
493
- ctx.scene.entity.setPose(playerId, {
494
- position: [nextX, nextY, nextZ],
495
- rotationY: intent.moving
496
- ? Math.atan2(motion.horizontalVelocityX, motion.horizontalVelocityZ)
497
- : player.rotationY,
498
- dt: rawDt,
499
- });
500
- }
711
+ if (player !== null && drivesPose && !serverAuthoritative) {
712
+ endPhase = devtools.profile.begin("pose");
713
+ stepPlayerMovement(ctx, ctx.player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, rawDt, movementTuning, yawRef.current);
714
+ endPhase();
501
715
  }
502
- if (autoPickupRadius !== null) {
716
+ if (autoPickupRadius !== null && !serverAuthoritative) {
717
+ endPhase = devtools.profile.begin("pickup");
503
718
  const self = ctx.scene.entity.get(playerId);
504
719
  if (self !== null) {
505
720
  const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
506
721
  if (nearest !== null)
507
722
  ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
508
723
  }
724
+ endPhase();
509
725
  }
510
- playable.loop.onTick(ctx, gameDt);
726
+ if (!serverAuthoritative) {
727
+ devtools.profile.measure("onTick", () => {
728
+ playable.loop.onTick(ctx, gameDt);
729
+ });
730
+ }
731
+ endPhase = devtools.profile.begin("actions");
511
732
  if (tracker.wasPressed("tabTarget")) {
512
733
  if (ctx.game.commands.has("target.cycle"))
513
734
  ctx.game.commands.run("target.cycle", {});
@@ -523,7 +744,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
523
744
  if (pingCommand !== undefined && tracker.wasPressed("ping")) {
524
745
  const hit = pointerService.worldHit();
525
746
  if (hit !== null && ctx.game.commands.has(pingCommand)) {
526
- ctx.game.commands.run(pingCommand, {
747
+ commandSink.run(pingCommand, {
527
748
  point: hit.point,
528
749
  entity: hit.entity,
529
750
  object: hit.object,
@@ -533,6 +754,18 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
533
754
  }
534
755
  const aimOverride = pointerAim ? pointerAimFor(ctx, pointerService) : undefined;
535
756
  const commandAim = aimOverride ?? { yaw: yawRef.current, pitch: pitchRef.current };
757
+ if (collisionDebug.getState().layers.aimLaser) {
758
+ const aimFrom = ctx.player.possession.active(playerId) ?? playerId;
759
+ collisionDebug.setAimProbe({
760
+ from: aimFrom,
761
+ aim: commandAim,
762
+ originPolicy: { kind: "eye" },
763
+ maxDistance: 100,
764
+ });
765
+ }
766
+ else if (collisionDebug.getAimProbe() !== null) {
767
+ collisionDebug.setAimProbe(null);
768
+ }
536
769
  const nowMs = performance.now();
537
770
  for (const action of Object.keys(playable.game.input ?? {})) {
538
771
  const pressed = tracker.wasPressed(action);
@@ -546,7 +779,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
546
779
  if (prompts !== undefined && focus !== null) {
547
780
  const active = resolveActivePrompt({ x: focus.position[0], z: focus.position[2] }, prompts);
548
781
  if (active !== null && active.prompt.invoke !== null) {
549
- ctx.game.commands.run(active.prompt.invoke.name, active.prompt.invoke.input);
782
+ commandSink.run(active.prompt.invoke.name, active.prompt.invoke.input);
550
783
  }
551
784
  }
552
785
  continue;
@@ -554,7 +787,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
554
787
  if (!shouldFireBoundAction(tracker, action, playable.game.input, repeatFiredAtRef.current, nowMs))
555
788
  continue;
556
789
  repeatFiredAtRef.current.set(action, nowMs);
557
- dispatchBoundAction(ctx, action, yawRef.current, pitchRef.current, commandAim);
790
+ dispatchBoundAction(ctx, action, yawRef.current, pitchRef.current, commandAim, RESERVED_INPUT_ACTIONS, commandSink);
558
791
  }
559
792
  if (hotbarId !== null) {
560
793
  for (const { action, slot } of slotActions) {
@@ -580,8 +813,10 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
580
813
  }
581
814
  }
582
815
  tracker.endFrame();
816
+ endPhase();
583
817
  const serverId = serverIdRef.current;
584
818
  if (multiplayer !== null && serverId !== null) {
819
+ endPhase = devtools.profile.begin("presence");
585
820
  const focus = ctx.scene.entity.get(playerId);
586
821
  if (focus !== null) {
587
822
  multiplayer.backend.presenceSync.syncPose(serverId, {
@@ -592,6 +827,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
592
827
  rotationPitch: pitchRef.current,
593
828
  });
594
829
  }
830
+ endPhase();
595
831
  }
596
832
  }
597
833
  catch (error) {
@@ -604,7 +840,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
604
840
  }, GAME_SIM_FRAME_PRIORITY);
605
841
  return null;
606
842
  }
607
- function HudOnlyDriver({ ctx, playable, tracker, onRuntimeError, }) {
843
+ function HudOnlyDriver({ ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, }) {
608
844
  const hasReportedTickError = useRef(false);
609
845
  const repeatFiredAtRef = useRef(new Map());
610
846
  const lastFrameRef = useRef(null);
@@ -617,21 +853,32 @@ function HudOnlyDriver({ ctx, playable, tracker, onRuntimeError, }) {
617
853
  lastFrameRef.current = now;
618
854
  if (last === null)
619
855
  return;
856
+ if (gateRef.current) {
857
+ ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS));
858
+ return;
859
+ }
620
860
  const rawDt = (now - last) / 1000;
621
861
  const simStart = performance.now();
622
862
  try {
863
+ let endPhase = devtools.profile.begin("time+input");
623
864
  const dt = Math.min(rawDt, 0.05);
624
865
  const gameDt = ctx.time.advance(dt);
625
866
  ctx.input.publish(heldActionsFor(tracker, inputActions));
626
- playable.loop.onTick(ctx, gameDt);
867
+ ctx.input.publishPointer(pointerAxisRef.current);
868
+ endPhase();
869
+ devtools.profile.measure("onTick", () => {
870
+ playable.loop.onTick(ctx, gameDt);
871
+ });
872
+ endPhase = devtools.profile.begin("actions");
627
873
  const nowMs = performance.now();
628
874
  for (const action of inputActions) {
629
875
  if (!shouldFireBoundAction(tracker, action, playable.game.input, repeatFiredAtRef.current, nowMs))
630
876
  continue;
631
877
  repeatFiredAtRef.current.set(action, nowMs);
632
- dispatchBoundAction(ctx, action, 0, 0, { yaw: 0, pitch: 0 });
878
+ dispatchBoundAction(ctx, action, 0, 0, { yaw: 0, pitch: 0 }, EMPTY_RESERVED);
633
879
  }
634
880
  tracker.endFrame();
881
+ endPhase();
635
882
  }
636
883
  catch (error) {
637
884
  if (!hasReportedTickError.current) {
@@ -643,7 +890,7 @@ function HudOnlyDriver({ ctx, playable, tracker, onRuntimeError, }) {
643
890
  };
644
891
  frameId = requestAnimationFrame(tick);
645
892
  return () => cancelAnimationFrame(frameId);
646
- }, [ctx, playable, tracker, onRuntimeError, inputActions]);
893
+ }, [ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, inputActions]);
647
894
  return null;
648
895
  }
649
896
  class GameUiErrorBoundary extends Component {
@@ -651,8 +898,8 @@ class GameUiErrorBoundary extends Component {
651
898
  static getDerivedStateFromError() {
652
899
  return { failed: true };
653
900
  }
654
- componentDidCatch(error) {
655
- this.props.onRuntimeError(error, "ui-render");
901
+ componentDidCatch(error, info) {
902
+ this.props.onRuntimeError(error, "ui-render", info.componentStack ?? undefined);
656
903
  }
657
904
  render() {
658
905
  if (this.state.failed)
@@ -660,16 +907,53 @@ class GameUiErrorBoundary extends Component {
660
907
  return this.props.children;
661
908
  }
662
909
  }
663
- function DiagnosticOverlay({ diagnostics }) {
910
+ function expandedReactError(message) {
911
+ return /Minified React error #185\b/.test(message)
912
+ ? "Maximum update depth exceeded. A component is repeatedly updating state during render or after every update."
913
+ : null;
914
+ }
915
+ function diagnosticReport(diagnostic, gameName) {
916
+ const explanation = expandedReactError(diagnostic.message);
917
+ return [
918
+ "JGengine runtime error",
919
+ `Game: ${gameName}`,
920
+ `Engine: ${VERSION}`,
921
+ `Phase: ${diagnostic.phase}`,
922
+ `Time: ${diagnostic.capturedAt}`,
923
+ `Page: ${window.location.origin}${window.location.pathname}`,
924
+ `Browser: ${navigator.userAgent}`,
925
+ explanation === null ? null : `Explanation: ${explanation}`,
926
+ "",
927
+ `Message: ${diagnostic.message}`,
928
+ diagnostic.stack === undefined ? null : `JavaScript stack:\n${diagnostic.stack}`,
929
+ diagnostic.componentStack === undefined ? null : `React component stack:\n${diagnostic.componentStack}`,
930
+ ]
931
+ .filter((line) => line !== null)
932
+ .join("\n");
933
+ }
934
+ function DiagnosticOverlay({ diagnostics, gameName }) {
935
+ const [copied, setCopied] = useState(false);
664
936
  if (diagnostics.length === 0)
665
937
  return null;
666
938
  const latest = diagnostics[diagnostics.length - 1];
667
- return (_jsxs("div", { className: "pointer-events-auto absolute right-4 top-4 z-50 max-w-lg rounded border border-red-400/60 bg-red-950/95 p-3 text-xs text-red-50 shadow-2xl", children: [_jsx("div", { className: "mb-1 font-semibold uppercase tracking-wide text-red-200", children: "JG engine error" }), _jsxs("div", { className: "font-mono text-[11px] text-red-100", children: ["[", latest.phase, "] ", latest.message] }), latest.stack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.stack })) : null] }));
939
+ const explanation = expandedReactError(latest.message);
940
+ const report = diagnosticReport(latest, gameName);
941
+ const issueBody = report.length > 8000 ? `${report.slice(0, 8000)}\n\n[Report truncated; use Copy error for the full report.]` : report;
942
+ const issueUrl = `https://github.com/Noisemaker111/jgengine/issues/new?title=${encodeURIComponent(`[BUG] ${latest.phase}: ${latest.message.slice(0, 100)}`)}&body=${encodeURIComponent(issueBody)}`;
943
+ return (_jsxs("div", { className: "pointer-events-auto absolute right-4 top-4 z-50 max-w-lg rounded border border-red-400/60 bg-red-950/95 p-3 text-xs text-red-50 shadow-2xl", children: [_jsx("div", { className: "mb-1 font-semibold uppercase tracking-wide text-red-200", children: "JG engine error" }), _jsxs("div", { className: "font-mono text-[11px] text-red-100", children: ["[", latest.phase, "] ", latest.message] }), explanation !== null ? _jsx("div", { className: "mt-2 text-red-100", children: explanation }) : null, latest.stack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.stack })) : null, latest.componentStack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.componentStack })) : null, _jsxs("div", { className: "mt-3 flex gap-2", children: [_jsx("button", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 font-semibold hover:bg-red-800", type: "button", onClick: () => {
944
+ void navigator.clipboard.writeText(report).then(() => {
945
+ setCopied(true);
946
+ window.setTimeout(() => setCopied(false), 2000);
947
+ });
948
+ }, children: copied ? "Copied" : "Copy error" }), _jsx("a", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 font-semibold hover:bg-red-800", href: issueUrl, rel: "noreferrer", target: "_blank", children: "File issue" })] })] }));
668
949
  }
669
- export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, }) {
950
+ const POSTER_SETTLE_SECONDS = 1.6;
951
+ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, poster = false, onContextReady, }) {
670
952
  const multiplayer = useMemo(() => (rawMultiplayer === null ? null : withDevtoolsLatency(rawMultiplayer)), [rawMultiplayer]);
671
- const devtoolsEnabled = playable.devtools !== false;
953
+ const devtoolsEnabled = playable.devtools !== false && !poster;
672
954
  const [devtoolsOpen, setDevtoolsOpen] = useState(false);
955
+ const [posterFrozen, setPosterFrozen] = useState(false);
956
+ const posterSettledRef = useRef(false);
673
957
  const [ctx, setCtx] = useState(null);
674
958
  const [diagnostics, setDiagnostics] = useState([]);
675
959
  const [remotePlayers, setRemotePlayers] = useState([]);
@@ -680,25 +964,101 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
680
964
  const cameraDraggingRef = useRef(false);
681
965
  const primaryClickRef = useRef(false);
682
966
  const pointerDownRef = useRef(null);
967
+ const pointerAxisRef = useRef(null);
683
968
  const marqueeStartRef = useRef(null);
969
+ const f2HeldRef = useRef(false);
970
+ const f2ChordedRef = useRef(false);
684
971
  const pointerService = useMemo(() => createPointerService(), []);
685
972
  const selection = useMemo(() => createSelectionSet(), [playable]);
686
973
  const [marquee, setMarquee] = useState(null);
687
974
  const [selectedIds, setSelectedIds] = useState(() => new Set());
688
975
  const [contextMenu, setContextMenu] = useState(null);
689
- const tracker = useMemo(() => createActionStateTracker(toActionStateBindingMap(withTouchCodes(playable.game.input))), [playable]);
976
+ const settingsStore = useMemo(() => createSettingsStore(), []);
977
+ const [bindingOverrides, setBindingOverrides] = useState(() => loadBindingOverrides(playable.game.name));
978
+ useEffect(() => {
979
+ setBindingOverrides(loadBindingOverrides(playable.game.name));
980
+ }, [playable]);
981
+ const effectiveInput = useMemo(() => applyBindingOverrides(playable.game.input ?? {}, bindingOverrides), [playable, bindingOverrides]);
982
+ const rebindAction = useCallback((action, code) => setBindingOverrides(saveBindingOverride(playable.game.name, action, [code])), [playable]);
983
+ const resetActionBinding = useCallback((action) => setBindingOverrides(clearBindingOverride(playable.game.name, action)), [playable]);
984
+ const tracker = useMemo(() => createActionStateTracker(toActionStateBindingMap(withTouchCodes(effectiveInput))), [effectiveInput]);
985
+ const graphics = useGraphicsSettings(settingsStore, playable.shadows ?? true);
986
+ const trackPointerAxis = (event) => {
987
+ const rect = wrapperRef.current?.getBoundingClientRect();
988
+ if (rect === undefined)
989
+ return;
990
+ pointerAxisRef.current = normalizePointerToAxis(event.clientX, event.clientY, rect);
991
+ };
992
+ const deactivatePointerAxis = () => {
993
+ const state = pointerAxisRef.current;
994
+ if (state !== null && state.active)
995
+ pointerAxisRef.current = { ...state, active: false };
996
+ };
690
997
  const touchScheme = useMemo(() => deriveTouchScheme(playable.game.input, {
691
- reserved: RESERVED_INPUT_ACTIONS,
998
+ reserved: resolveRigKind(playable.camera) === "none" || playable.presentation === "hud"
999
+ ? EMPTY_RESERVED
1000
+ : RESERVED_INPUT_ACTIONS,
692
1001
  firstPerson: resolveRigKind(playable.camera) === "first",
693
1002
  config: playable.touch,
694
1003
  }), [playable]);
695
- const { coarsePointer } = useDisplayProfile();
1004
+ const { coarsePointer, portrait, compact } = useDisplayProfile();
696
1005
  const touchSink = useMemo(() => ({ onCodeDown: (code) => tracker.handleDown(code), onCodeUp: (code) => tracker.handleUp(code) }), [tracker]);
697
- const audioEngine = useMemo(() => createAudioEngine({ sounds: playable.audio?.sounds, buses: playable.audio?.buses }), [playable]);
1006
+ const gateRef = useRef(false);
1007
+ const orientationPlatform = coarsePointer ? "mobile" : "desktop";
1008
+ const orientationRequirement = useMemo(() => resolveOrientationRequirement(playable.orientation, orientationPlatform), [playable, orientationPlatform]);
1009
+ const liveOrientation = portrait ? "portrait" : "landscape";
1010
+ const orientationGate = !poster && coarsePointer && orientationGateActive(orientationRequirement, liveOrientation);
1011
+ const orientationHint = !poster && coarsePointer && orientationHintActive(orientationRequirement, liveOrientation);
1012
+ gateRef.current = orientationGate;
1013
+ const orientationGateEl = orientationGate ? (_jsx(RotateDeviceScreen, { requiredOrientation: orientationRequirement.required ?? "landscape", title: orientationRequirement.required === "portrait" ? "Turn your phone upright" : "Turn your phone sideways", description: `${playable.game.name} is built for ${orientationRequirement.required ?? "landscape"} play.` })) : orientationHint && orientationRequirement.preferred !== null ? (_jsx(OrientationHint, { wanted: orientationRequirement.preferred })) : null;
1014
+ const audioEngine = useMemo(() => createAudioEngine({
1015
+ sounds: playable.audio?.sounds,
1016
+ buses: playable.audio?.buses,
1017
+ music: playable.audio?.music,
1018
+ musicBus: playable.audio?.musicBus,
1019
+ }), [playable]);
698
1020
  useEffect(() => () => audioEngine.dispose(), [audioEngine]);
1021
+ useEffect(() => {
1022
+ if (ctx === null)
1023
+ return;
1024
+ const offPlay = ctx.game.events.on("audio.play", ({ sound, at }) => {
1025
+ audioEngine.playOneShot(sound, at === undefined ? undefined : { x: at[0], y: at[1], z: at[2] });
1026
+ });
1027
+ const offMusic = ctx.game.events.on("audio.music", ({ theme, transpose }) => {
1028
+ audioEngine.playMusic(theme, transpose === undefined ? undefined : { transpose });
1029
+ });
1030
+ const offResume = ctx.game.events.on("audio.resume", () => audioEngine.resume());
1031
+ return () => {
1032
+ offPlay();
1033
+ offMusic();
1034
+ offResume();
1035
+ };
1036
+ }, [ctx, audioEngine]);
1037
+ useEffect(() => {
1038
+ if (typeof document === "undefined")
1039
+ return;
1040
+ document.documentElement.dataset.jgPresentation = playable.presentation ?? "3d";
1041
+ return () => {
1042
+ delete document.documentElement.dataset.jgPresentation;
1043
+ };
1044
+ }, [playable]);
1045
+ useEffect(() => {
1046
+ if (ctx === null || typeof document === "undefined")
1047
+ return;
1048
+ const hud = resolveRigKind(playable.camera) === "none" || playable.presentation === "hud";
1049
+ if (!hud)
1050
+ return;
1051
+ const frame = requestAnimationFrame(() => {
1052
+ document.documentElement.dataset.jgFrameReady = "1";
1053
+ });
1054
+ return () => {
1055
+ cancelAnimationFrame(frame);
1056
+ delete document.documentElement.dataset.jgFrameReady;
1057
+ };
1058
+ }, [ctx, playable]);
699
1059
  const userId = multiplayer?.userId ?? DEV_USER_ID;
700
- const reportRuntimeError = (error, phase) => {
701
- const diagnostic = logRuntimeError(error, phase);
1060
+ const reportRuntimeError = (error, phase, componentStack) => {
1061
+ const diagnostic = logRuntimeError(error, phase, componentStack);
702
1062
  setDiagnostics((current) => [...current.slice(-4), { ...diagnostic, id: Date.now() + current.length }]);
703
1063
  };
704
1064
  useEffect(() => {
@@ -712,6 +1072,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
712
1072
  });
713
1073
  playable.loop.onInit(context);
714
1074
  playable.loop.onNewPlayer(context);
1075
+ onContextReady?.(context);
715
1076
  setCtx(context);
716
1077
  }
717
1078
  catch (error) {
@@ -736,6 +1097,9 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
736
1097
  return;
737
1098
  }
738
1099
  serverIdRef.current = joined.serverId;
1100
+ if (isServerAuthoritative(playable.game.multiplayer) && multiplayer.backend.feeds !== undefined) {
1101
+ cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx));
1102
+ }
739
1103
  cleanups.push(multiplayer.backend.presenceSync.subscribe(joined.serverId, (rows) => {
740
1104
  setRemotePlayers(rows.filter((row) => row.userId !== multiplayer.userId));
741
1105
  }));
@@ -771,8 +1135,9 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
771
1135
  cleanups.push(remoteUnsub);
772
1136
  }
773
1137
  const chatSync = multiplayer.backend.chatSyncFor?.(joined.serverId);
774
- if (chatSync !== undefined) {
775
- const globalChannelIds = new Set(ctx.game.chat
1138
+ if (chatSync !== undefined && ctx.game.chat !== undefined) {
1139
+ const chat = ctx.game.chat;
1140
+ const globalChannelIds = new Set(chat
776
1141
  .channels()
777
1142
  .filter((channel) => channel.kind === "global")
778
1143
  .map((channel) => channel.id));
@@ -792,7 +1157,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
792
1157
  if (seenRemoteChat.has(message.id))
793
1158
  continue;
794
1159
  seenRemoteChat.add(message.id);
795
- ctx.game.chat.send(message.fromUserId, message.channelId, message.body);
1160
+ chat.send(message.fromUserId, message.channelId, message.body);
796
1161
  }
797
1162
  }));
798
1163
  }
@@ -810,7 +1175,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
810
1175
  void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
811
1176
  }
812
1177
  };
813
- }, [ctx, multiplayer]);
1178
+ }, [ctx, multiplayer, playable]);
814
1179
  useEffect(() => {
815
1180
  wrapperRef.current?.focus();
816
1181
  }, [ctx]);
@@ -826,16 +1191,32 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
826
1191
  const rigKind = resolveRigKind(cameraConfig);
827
1192
  if (rigKind === "none" || playable.presentation === "hud") {
828
1193
  const GameUI = playable.GameUI;
829
- return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
1194
+ return (_jsx("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
830
1195
  if (event.code === "F2" && devtoolsEnabled) {
831
1196
  event.preventDefault();
832
- setDevtoolsOpen((current) => !current);
1197
+ f2HeldRef.current = true;
1198
+ f2ChordedRef.current = false;
1199
+ return;
1200
+ }
1201
+ if (f2HeldRef.current) {
1202
+ f2ChordedRef.current = true;
833
1203
  return;
834
1204
  }
835
1205
  if (event.code === "Tab" || event.code === "Space")
836
1206
  event.preventDefault();
837
1207
  tracker.handleDown(event.code);
838
- }, onKeyUp: (event) => tracker.handleUp(event.code), onBlur: () => tracker.reset(), children: [_jsx(HudOnlyDriver, { ctx: ctx, playable: playable, tracker: tracker, onRuntimeError: reportRuntimeError }), _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }), devtoolsEnabled ? (_jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer })) : null, _jsx(DiagnosticOverlay, { diagnostics: diagnostics })] }));
1208
+ }, onKeyUp: (event) => {
1209
+ if (event.code === "F2" && devtoolsEnabled) {
1210
+ f2HeldRef.current = false;
1211
+ if (!f2ChordedRef.current)
1212
+ setDevtoolsOpen((current) => !current);
1213
+ return;
1214
+ }
1215
+ tracker.handleUp(event.code);
1216
+ }, onBlur: () => {
1217
+ f2HeldRef.current = false;
1218
+ tracker.reset();
1219
+ }, onPointerDown: () => audioEngine.resume(), onPointerMove: trackPointerAxis, onPointerLeave: deactivatePointerAxis, onPointerCancel: deactivatePointerAxis, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsx(HudOnlyDriver, { ctx: ctx, playable: playable, tracker: tracker, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError }), !orientationGate && coarsePointer && touchScheme !== null && touchScheme.gestures !== null && playControlsActive(ctx) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: 0, onPrimaryTap: () => undefined })) : null, _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: graphics.uiScale, children: orientationGate ? null : _jsx(GameUI, {}) }) }) }), orientationGateEl, devtoolsEnabled ? (_jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer })) : null, _jsx(DiagnosticOverlay, { diagnostics: diagnostics, gameName: playable.game.name })] }) }));
839
1220
  }
840
1221
  const firstPerson = rigKind === "first";
841
1222
  const showReticle = (firstPerson && playable.camera?.firstPerson?.reticle !== false) || rigKind === "shoulder";
@@ -847,6 +1228,9 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
847
1228
  ? "health"
848
1229
  : worldBars.statId ?? "health";
849
1230
  const barsRoles = worldBars === undefined || worldBars === true || worldBars === false ? undefined : worldBars.roles;
1231
+ const barsMaxDistance = worldBars === undefined || worldBars === true || worldBars === false
1232
+ ? undefined
1233
+ : worldBars.maxDistance;
850
1234
  const resolveEntityRole = (entity) => playable.content.entityById?.(entity.name)?.role;
851
1235
  const pointer = playable.pointer;
852
1236
  const pointerUsesLeft = pointer !== undefined && (pointer.select === true || pointer.moveCommand !== undefined);
@@ -864,6 +1248,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
864
1248
  const effectiveSky = backdropSky ?? worldSky;
865
1249
  const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
866
1250
  const lighting = playable.lighting;
1251
+ const orthographic = playable.camera?.projection === "orthographic";
867
1252
  const localXY = (event) => {
868
1253
  const rect = wrapperRef.current?.getBoundingClientRect();
869
1254
  return rect === undefined
@@ -885,12 +1270,15 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
885
1270
  }
886
1271
  commitSelection(selectWithinRect(candidates, rect));
887
1272
  };
1273
+ /** DOM UI (menus, HUD buttons) sits in the same wrapper as the canvas — only canvas-targeted clicks are world input. */
1274
+ const isWorldPointerTarget = (event) => event.target instanceof HTMLCanvasElement;
888
1275
  const handlePointerDown = (event) => {
889
1276
  wrapperRef.current?.focus();
1277
+ trackPointerAxis(event);
890
1278
  audioEngine.resume();
891
1279
  if (contextMenu !== null)
892
1280
  setContextMenu(null);
893
- if (event.button === 0) {
1281
+ if (event.button === 0 && isWorldPointerTarget(event)) {
894
1282
  const point = localXY(event);
895
1283
  pointerDownRef.current = point;
896
1284
  if (pointer?.select === true)
@@ -898,6 +1286,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
898
1286
  }
899
1287
  };
900
1288
  const handlePointerMove = (event) => {
1289
+ trackPointerAxis(event);
901
1290
  if (pointer?.select !== true)
902
1291
  return;
903
1292
  const start = marqueeStartRef.current;
@@ -963,6 +1352,8 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
963
1352
  const handleContextMenu = (event) => {
964
1353
  if (pointer === undefined)
965
1354
  return;
1355
+ if (!isWorldPointerTarget(event))
1356
+ return;
966
1357
  event.preventDefault();
967
1358
  const hit = pointerService.worldHit();
968
1359
  if (hit === null)
@@ -996,33 +1387,87 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
996
1387
  ctx.game.commands.run(verb.command, contextVerbInput(state.menu, verb));
997
1388
  }
998
1389
  };
999
- return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
1000
- if (event.code === "F2" && devtoolsEnabled) {
1001
- event.preventDefault();
1002
- document.exitPointerLock?.();
1003
- setDevtoolsOpen((current) => !current);
1004
- return;
1005
- }
1006
- if (event.code === "Tab" || event.code === "Space")
1007
- event.preventDefault();
1008
- tracker.handleDown(event.code);
1009
- }, onKeyUp: (event) => tracker.handleUp(event.code), onBlur: () => tracker.reset(), onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onContextMenu: handleContextMenu, onWheel: (event) => {
1010
- if (ctx === null || !event.shiftKey)
1011
- return;
1012
- event.preventDefault();
1013
- if (event.deltaY < 0 && ctx.game.commands.has("ui.hotbarScrollNext")) {
1014
- ctx.game.commands.run("ui.hotbarScrollNext", {});
1015
- }
1016
- else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
1017
- ctx.game.commands.run("ui.hotbarScrollPrev", {});
1018
- }
1019
- }, children: [_jsxs(Canvas, { camera: {
1020
- fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
1021
- near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
1022
- far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
1023
- }, shadows: playable.shadows ?? true, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, effectiveSky === undefined ? (lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] }))) : effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time })) : backdropSky !== undefined ? (_jsx(SkyDaylight, { sky: backdropSky })) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds }), WorldOverlay !== undefined ? _jsx(WorldOverlay, {}) : null, barsStatId !== null ? (_jsx(WorldEntityBars, { statId: barsStatId, roles: barsRoles, resolveRole: resolveEntityRole })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldTelegraphs, {}), _jsx(WorldFloatText, {}), _jsx(ProjectileTracers, {}), _jsx(CombatCameraShake, {}), _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
1024
- cameraDraggingRef.current = dragging;
1025
- } }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand }), _jsx(DevtoolsRendererProbe, {})] }), coarsePointer && touchScheme !== null && (touchScheme.gestures !== null || touchScheme.look) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: playable.camera?.firstPerson?.maxPitch ?? 1.45, onPrimaryTap: () => {
1026
- primaryClickRef.current = true;
1027
- } })) : null, _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }), showReticle ? _jsx(Reticle, {}) : null, coarsePointer && touchScheme !== null && (touchScheme.joystick !== null || touchScheme.buttons.length > 0) ? (_jsx(TouchControlsDock, { scheme: touchScheme, sink: touchSink })) : null, marquee !== null ? _jsx(MarqueeBox, { rect: marquee }) : null, contextMenu !== null ? (_jsx(ContextMenuView, { menu: contextMenu.menu, x: contextMenu.x, y: contextMenu.y, onPick: handleVerbPick, onClose: () => setContextMenu(null) })) : null, devtoolsEnabled ? (_jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer })) : null, _jsx(DiagnosticOverlay, { diagnostics: diagnostics })] }));
1390
+ const controlsActive = playControlsActive(ctx);
1391
+ const settingsDisabled = playable.settings === false;
1392
+ const settingsConfig = playable.settings === false || playable.settings === undefined ? {} : playable.settings;
1393
+ const settingsSurface = settingsDisabled ? false : settingsConfig.surface ?? false;
1394
+ const settingsVariant = settingsConfig.variant ?? "panel";
1395
+ const hideCategories = settingsDisabled ? BUILT_IN_SETTING_CATEGORIES : settingsConfig.hide ?? [];
1396
+ const fovControlEnabled = !orthographic && playable.camera?.playerFov?.control !== false;
1397
+ const settingsHostsFov = !settingsDisabled && !hideCategories.includes("gameplay") && fovControlEnabled;
1398
+ const settingsActions = settingsDisabled
1399
+ ? []
1400
+ : (settingsConfig.actions ?? []).map((action) => ({
1401
+ id: action.id,
1402
+ label: action.label,
1403
+ kind: action.kind ?? "default",
1404
+ description: action.description,
1405
+ run: () => action.run(ctx),
1406
+ }));
1407
+ const touchScale = compact ? 0.88 : 1;
1408
+ const dockMounted = !poster &&
1409
+ !orientationGate &&
1410
+ coarsePointer &&
1411
+ controlsActive &&
1412
+ touchScheme !== null &&
1413
+ (touchScheme.joystick !== null || touchScheme.buttons.length > 0);
1414
+ return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.audio?.buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: playable.audio?.buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, hideBindings: settingsConfig.hideBindings ?? [], overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, ...(poster && posterFrozen ? { "data-poster-ready": "" } : {}), className: "relative h-full w-full bg-neutral-950 outline-none", style: {
1415
+ "--jg-hud-dock-clearance": `${dockMounted ? touchDockClearance(touchScheme, touchScale) : 0}px`,
1416
+ }, onKeyDown: (event) => {
1417
+ if (event.code === "F2" && devtoolsEnabled) {
1418
+ event.preventDefault();
1419
+ f2HeldRef.current = true;
1420
+ f2ChordedRef.current = false;
1421
+ return;
1422
+ }
1423
+ if (f2HeldRef.current) {
1424
+ f2ChordedRef.current = true;
1425
+ return;
1426
+ }
1427
+ if (event.code === "Tab" || event.code === "Space")
1428
+ event.preventDefault();
1429
+ tracker.handleDown(event.code);
1430
+ }, onKeyUp: (event) => {
1431
+ if (event.code === "F2" && devtoolsEnabled) {
1432
+ f2HeldRef.current = false;
1433
+ if (!f2ChordedRef.current) {
1434
+ document.exitPointerLock?.();
1435
+ setDevtoolsOpen((current) => !current);
1436
+ }
1437
+ return;
1438
+ }
1439
+ tracker.handleUp(event.code);
1440
+ }, onBlur: () => {
1441
+ f2HeldRef.current = false;
1442
+ tracker.reset();
1443
+ }, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: deactivatePointerAxis, onPointerCancel: deactivatePointerAxis, onContextMenu: handleContextMenu, onWheel: (event) => {
1444
+ if (ctx === null || !event.shiftKey)
1445
+ return;
1446
+ event.preventDefault();
1447
+ if (event.deltaY < 0 && ctx.game.commands.has("ui.hotbarScrollNext")) {
1448
+ ctx.game.commands.run("ui.hotbarScrollNext", {});
1449
+ }
1450
+ else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
1451
+ ctx.game.commands.run("ui.hotbarScrollPrev", {});
1452
+ }
1453
+ }, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsxs(Canvas, { frameloop: poster && posterFrozen ? "demand" : "always", orthographic: orthographic, camera: orthographic
1454
+ ? {
1455
+ zoom: playable.camera?.frustum?.zoom ?? CAMERA_FRUSTUM_DEFAULTS.zoom,
1456
+ near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
1457
+ far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
1458
+ }
1459
+ : {
1460
+ fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
1461
+ near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
1462
+ far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
1463
+ }, shadows: graphics.shadows, dpr: graphics.dpr, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? _jsx(WorldOverlay, {}) : null, barsStatId !== null ? (_jsx(WorldEntityBars, { statId: barsStatId, roles: barsRoles, resolveRole: resolveEntityRole, ...(barsMaxDistance === undefined ? {} : { maxDistance: barsMaxDistance }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldTelegraphs, {}), _jsx(WorldFloatText, {}), _jsx(ProjectileTracers, {}), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(CombatCameraShake, {}), _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
1464
+ cameraDraggingRef.current = dragging;
1465
+ } }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: () => {
1466
+ if (posterSettledRef.current)
1467
+ return;
1468
+ posterSettledRef.current = true;
1469
+ setPosterFrozen(true);
1470
+ } }), _jsx(DevtoolsRendererProbe, {}), playable.postProcessing !== undefined && playable.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: playable.postProcessing })) : null] }), !poster && !orientationGate && coarsePointer && controlsActive && touchScheme !== null && (touchScheme.gestures !== null || touchScheme.look) ? (_jsx(TouchPlaySurface, { scheme: touchScheme, sink: touchSink, yawRef: yawRef, pitchRef: pitchRef, maxPitch: playable.camera?.firstPerson?.maxPitch ?? 1.45, onPrimaryTap: () => {
1471
+ primaryClickRef.current = true;
1472
+ } })) : null, _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: graphics.uiScale, children: orientationGate ? null : _jsx(GameUI, {}) }) }) }), !poster && !orientationGate && showReticle ? _jsx(Reticle, {}) : null, dockMounted && touchScheme !== null ? (_jsx(TouchControlsDock, { scheme: touchScheme, sink: touchSink, scale: touchScale })) : null, orientationGateEl, marquee !== null ? _jsx(MarqueeBox, { rect: marquee }) : null, contextMenu !== null ? (_jsx(ContextMenuView, { menu: contextMenu.menu, x: contextMenu.x, y: contextMenu.y, onPick: handleVerbPick, onClose: () => setContextMenu(null) })) : null, devtoolsEnabled ? (_jsx(DevtoolsOverlay, { open: devtoolsOpen, ctx: ctx, playable: playable, multiplayer: multiplayer })) : null, poster ? null : _jsx(DiagnosticOverlay, { diagnostics: diagnostics, gameName: playable.game.name }), poster || orthographic || settingsHostsFov ? null : _jsx(PlayerFovSlider, {}), poster ? null : _jsx(SettingsChrome, {})] }) }) })] }) }));
1028
1473
  }