@jgengine/shell 0.6.0 → 0.8.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 (118) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/README.md +26 -1
  3. package/dist/GameHost.d.ts +12 -0
  4. package/dist/GameHost.js +33 -0
  5. package/dist/GamePlayer.d.ts +11 -0
  6. package/dist/GamePlayer.js +24 -0
  7. package/dist/GamePlayerShell.d.ts +33 -1
  8. package/dist/GamePlayerShell.js +641 -59
  9. package/dist/audio/AudioComponents.d.ts +12 -0
  10. package/dist/audio/AudioComponents.js +53 -0
  11. package/dist/audio/audioEngine.d.ts +23 -0
  12. package/dist/audio/audioEngine.js +110 -0
  13. package/dist/behaviour.d.ts +31 -0
  14. package/dist/behaviour.js +63 -0
  15. package/dist/camera/GameCameraRig.d.ts +16 -0
  16. package/dist/camera/GameCameraRig.js +56 -0
  17. package/dist/camera/GameFirstPersonCamera.js +2 -0
  18. package/dist/camera/GameInspectionCamera.d.ts +6 -0
  19. package/dist/camera/GameInspectionCamera.js +24 -0
  20. package/dist/camera/GameOrbitCamera.d.ts +3 -1
  21. package/dist/camera/GameOrbitCamera.js +4 -2
  22. package/dist/camera/cameraRigs.d.ts +26 -0
  23. package/dist/camera/cameraRigs.js +522 -0
  24. package/dist/camera/index.d.ts +7 -0
  25. package/dist/camera/index.js +7 -0
  26. package/dist/camera/inspectionCameraMath.d.ts +25 -0
  27. package/dist/camera/inspectionCameraMath.js +44 -0
  28. package/dist/camera/rigMath.d.ts +169 -0
  29. package/dist/camera/rigMath.js +393 -0
  30. package/dist/camera/rigResolve.d.ts +15 -0
  31. package/dist/camera/rigResolve.js +52 -0
  32. package/dist/camera/shakeChannel.d.ts +29 -0
  33. package/dist/camera/shakeChannel.js +38 -0
  34. package/dist/defineGame.d.ts +16 -0
  35. package/dist/defineGame.js +51 -0
  36. package/dist/devtools/DevtoolsOverlay.d.ts +14 -0
  37. package/dist/devtools/DevtoolsOverlay.js +334 -0
  38. package/dist/environment/Daylight.d.ts +50 -0
  39. package/dist/environment/Daylight.js +100 -0
  40. package/dist/environment/EnvironmentScene.js +18 -26
  41. package/dist/environment/GroundPad.d.ts +7 -0
  42. package/dist/environment/GroundPad.js +12 -0
  43. package/dist/environment/daylightCycle.d.ts +26 -0
  44. package/dist/environment/daylightCycle.js +116 -0
  45. package/dist/environment/groundPadMath.d.ts +13 -0
  46. package/dist/environment/groundPadMath.js +10 -0
  47. package/dist/environment/index.d.ts +2 -0
  48. package/dist/environment/index.js +2 -0
  49. package/dist/map/MapMarkerBeacons.d.ts +12 -0
  50. package/dist/map/MapMarkerBeacons.js +16 -0
  51. package/dist/map/index.d.ts +2 -0
  52. package/dist/map/index.js +2 -0
  53. package/dist/map/terrainMap.d.ts +23 -0
  54. package/dist/map/terrainMap.js +79 -0
  55. package/dist/materialOverride.d.ts +8 -0
  56. package/dist/materialOverride.js +32 -0
  57. package/dist/multiplayer.d.ts +16 -9
  58. package/dist/multiplayer.js +62 -12
  59. package/dist/pointer/PointerOverlays.d.ts +12 -0
  60. package/dist/pointer/PointerOverlays.js +17 -0
  61. package/dist/pointer/PointerProbe.d.ts +4 -0
  62. package/dist/pointer/PointerProbe.js +40 -0
  63. package/dist/pointer/pointerService.d.ts +26 -0
  64. package/dist/pointer/pointerService.js +89 -0
  65. package/dist/registry.d.ts +4 -1
  66. package/dist/registry.js +3 -1
  67. package/dist/render/modelRender.d.ts +14 -0
  68. package/dist/render/modelRender.js +79 -0
  69. package/dist/replay/useSessionRecorder.d.ts +14 -0
  70. package/dist/replay/useSessionRecorder.js +22 -0
  71. package/dist/structures/PlacementGhost.d.ts +8 -0
  72. package/dist/structures/PlacementGhost.js +11 -0
  73. package/dist/structures/index.d.ts +2 -0
  74. package/dist/structures/index.js +2 -0
  75. package/dist/structures.d.ts +1 -0
  76. package/dist/structures.js +1 -0
  77. package/dist/terrain/CarvedTerrain.d.ts +22 -0
  78. package/dist/terrain/CarvedTerrain.js +19 -0
  79. package/dist/terrain/EditableGround.d.ts +11 -0
  80. package/dist/terrain/EditableGround.js +38 -0
  81. package/dist/terrain/TerraformBrushCursor.d.ts +8 -0
  82. package/dist/terrain/TerraformBrushCursor.js +13 -0
  83. package/dist/terrain/index.d.ts +4 -1
  84. package/dist/terrain/index.js +4 -1
  85. package/dist/terrain/terrainMath.d.ts +15 -1
  86. package/dist/terrain/terrainMath.js +87 -1
  87. package/dist/touch/TouchControlsOverlay.d.ts +18 -0
  88. package/dist/touch/TouchControlsOverlay.js +173 -0
  89. package/dist/vision/FrustumSensorHud.d.ts +21 -0
  90. package/dist/vision/FrustumSensorHud.js +67 -0
  91. package/dist/vision/HiddenStateProbeHud.d.ts +11 -0
  92. package/dist/vision/HiddenStateProbeHud.js +11 -0
  93. package/dist/vision/RevealVision.d.ts +28 -0
  94. package/dist/vision/RevealVision.js +53 -0
  95. package/dist/water/OceanShader.d.ts +1 -1
  96. package/dist/water/OceanShader.js +3 -3
  97. package/dist/weather/FireSpreadLayer.d.ts +14 -0
  98. package/dist/weather/FireSpreadLayer.js +68 -0
  99. package/dist/weather/index.d.ts +1 -0
  100. package/dist/weather/index.js +1 -0
  101. package/dist/world/DataObjects.d.ts +44 -0
  102. package/dist/world/DataObjects.js +75 -0
  103. package/dist/world/GridWorldScene.d.ts +10 -0
  104. package/dist/world/GridWorldScene.js +54 -0
  105. package/dist/world/InstancedJoints.d.ts +14 -0
  106. package/dist/world/InstancedJoints.js +32 -0
  107. package/dist/world/WorldHud.d.ts +7 -1
  108. package/dist/world/WorldHud.js +76 -14
  109. package/dist/world/WorldItems.d.ts +5 -0
  110. package/dist/world/WorldItems.js +50 -0
  111. package/dist/world/floatTextStyle.d.ts +14 -0
  112. package/dist/world/floatTextStyle.js +39 -0
  113. package/llms.txt +2382 -0
  114. package/package.json +10 -5
  115. package/dist/demo/demoGame.d.ts +0 -2
  116. package/dist/demo/demoGame.js +0 -208
  117. package/dist/demo/environmentShowcase.d.ts +0 -2
  118. package/dist/demo/environmentShowcase.js +0 -15
@@ -1,23 +1,49 @@
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 } from "react";
3
+ import { Component, 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 { createActionStateTracker, hotbarSlotActionIndex, resolveActionCommand, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
6
+ import { actionRepeatMs, createActionStateTracker, hotbarSlotActionIndex, resolveActionCommand, shouldDispatchAction, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
7
+ import { deriveTouchScheme, withTouchCodes } from "@jgengine/core/input/touchScheme";
8
+ import { buildContextMenu, contextVerbInput, } from "@jgengine/core/interaction/contextMenu";
7
9
  import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
8
- import { advancePlayerMotion, createEmptyMovementKeys, createPlayerMotionState, resolveMovementIntent, } from "@jgengine/core/movement/movementModel";
10
+ import { aimToPoint } from "@jgengine/core/input/pointer";
11
+ 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";
9
14
  import { createGameContext } from "@jgengine/core/runtime/gameContext";
15
+ import { groundFieldFor } from "@jgengine/core/world/terrain";
16
+ import { objectVisualScale } from "@jgengine/core/scene/objectStore";
17
+ import { DEFAULT_PICKUP_RADIUS, WORLD_ITEM_ENTITY_NAME } from "@jgengine/core/game/worldItem";
10
18
  import { useGameContext } from "@jgengine/react/provider";
19
+ import { useDisplayProfile } from "@jgengine/react/display";
11
20
  import { useSceneEntities, useSceneObjects, usePlayer, useTarget } from "@jgengine/react/hooks";
12
21
  import { GameProvider } from "@jgengine/react/provider";
13
- import { GAME_SIM_FRAME_PRIORITY, GameOrbitCamera } from "./camera/index.js";
14
- import { GameFirstPersonCamera } from "./camera/GameFirstPersonCamera.js";
15
- import { ProjectileTracers, Reticle, WorldEntityBars, WorldFloatText } from "./world/WorldHud.js";
22
+ import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
23
+ import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
24
+ import { devtools } from "@jgengine/core/devtools/devtools";
25
+ import { AudioListener, EntityAudioEmitters, ObjectAudioEmitters } from "./audio/AudioComponents.js";
26
+ import { createAudioEngine } from "./audio/audioEngine.js";
27
+ import { DevtoolsOverlay, DevtoolsRendererProbe, withDevtoolsLatency } from "./devtools/DevtoolsOverlay.js";
28
+ import { GAME_SIM_FRAME_PRIORITY, GameCameraRig, resolveRigKind, rtsPanKeysConflict } from "./camera/index.js";
29
+ import { SkyDaylight, TimeOfDayDaylight } from "./environment.js";
30
+ import { EnvironmentScene } from "./environment/EnvironmentScene.js";
31
+ import { applyMaterialOverride } from "./materialOverride.js";
32
+ import { PointerProbe } from "./pointer/PointerProbe.js";
33
+ import { applyPaintTexture, cloneModelScene, createPaintCanvas, standardMaterialsOf, syncPaintCanvas, } from "./render/modelRender.js";
34
+ import { MarqueeBox, ContextMenuView } from "./pointer/PointerOverlays.js";
35
+ import { createPointerService, POINTER_ENTITY_KEY, POINTER_OBJECT_KEY, } from "./pointer/pointerService.js";
36
+ import { CombatCameraShake, ProjectileTracers, Reticle, WorldEntityBars, WorldFloatText, WorldTelegraphs, } from "./world/WorldHud.js";
37
+ import { GridWorldScene } from "./world/GridWorldScene.js";
38
+ import { WorldItems } from "./world/WorldItems.js";
39
+ import { TouchControlsDock, TouchPlaySurface } from "./touch/TouchControlsOverlay.js";
16
40
  const DEV_USER_ID = "dev-player";
17
41
  const TURN_SPEED = 2.4;
18
42
  const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
19
43
  const GROUND_SIZE = 160;
20
44
  const GROUND_SEGMENTS = 80;
45
+ const DEFAULT_BACKGROUND_COLOR = "#14161b";
46
+ const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
21
47
  function errorToDiagnostic(error, phase) {
22
48
  if (error instanceof Error) {
23
49
  return { phase, message: error.message, stack: error.stack };
@@ -43,6 +69,11 @@ const RESERVED_INPUT_ACTIONS = new Set([
43
69
  "useAbility",
44
70
  "interact",
45
71
  ]);
72
+ const SHELL_MOVEMENT_ACTIONS = ["moveForward", "moveBack", "moveLeft", "moveRight", "jump"];
73
+ function shellDrivesPlayerPose(input) {
74
+ const bound = input ?? {};
75
+ return SHELL_MOVEMENT_ACTIONS.some((action) => action in bound);
76
+ }
46
77
  function findHotbarSlotActions(input) {
47
78
  return Object.keys(input ?? {}).flatMap((action) => {
48
79
  const slot = hotbarSlotActionIndex(action);
@@ -54,24 +85,125 @@ function hotbarIdFor(playable) {
54
85
  const hud = declarations.find(([, declaration]) => declaration.hud === "hotbar");
55
86
  return (hud ?? declarations[0])?.[0] ?? null;
56
87
  }
57
- function executeHotbarSlot(ctx, hotbarId, slot, yaw, pitch) {
88
+ function executeHotbarSlot(ctx, fromId, hotbarId, slot, yaw, pitch, aimOverride) {
58
89
  const stack = ctx.player.inventory.state(hotbarId).slots[slot];
59
90
  if (stack === undefined || stack === null)
60
91
  return { ok: false, error: `Hotbar slot ${slot + 1} is empty` };
61
92
  const result = ctx.item.use.use({
62
- from: ctx.player.userId,
93
+ from: fromId,
63
94
  itemId: stack.itemId,
64
95
  inventoryId: hotbarId,
65
- aim: { yaw, pitch },
96
+ aim: aimOverride ?? { yaw, pitch },
66
97
  });
67
98
  return result.error === undefined ? { ok: true } : { ok: false, error: result.error };
68
99
  }
100
+ function pointerAimFor(ctx, service) {
101
+ const hit = service.worldHit();
102
+ if (hit === null)
103
+ 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);
107
+ }
108
+ function pointerContextMenu(ctx, playable, hit) {
109
+ if (hit.entity !== null) {
110
+ const entity = ctx.scene.entity.get(hit.entity);
111
+ const verbs = entity === null ? undefined : playable.content.entityById?.(entity.name)?.verbs;
112
+ return buildContextMenu({ kind: "entity", targetId: hit.entity, verbs, point: hit.point });
113
+ }
114
+ if (hit.object !== null) {
115
+ const verbs = ctx.scene.object.catalog(hit.object)?.verbs;
116
+ return buildContextMenu({ kind: "object", targetId: hit.object, verbs, point: hit.point });
117
+ }
118
+ return null;
119
+ }
120
+ /** Actions from `input` currently held down, for `ctx.input.publish` (#164.1); includes reserved movement/jump actions. */
121
+ export function heldActionsFor(tracker, actions) {
122
+ return actions.filter((action) => tracker.isDown(action));
123
+ }
124
+ /** Whether a bound action should fire this frame: on press, or on repeat interval while held (shared by `FrameDriver` and `HudOnlyDriver`). */
125
+ export function shouldFireBoundAction(tracker, action, input, repeatFiredAt, now) {
126
+ return shouldDispatchAction({
127
+ pressed: tracker.wasPressed(action),
128
+ down: tracker.isDown(action),
129
+ repeatMs: actionRepeatMs(input?.[action]),
130
+ lastFiredAt: repeatFiredAt.get(action) ?? null,
131
+ now,
132
+ });
133
+ }
134
+ /** 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);
137
+ 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;
161
+ }
162
+ /** The world's declared sky, when its world feature is an environment with one (#196.1). */
163
+ export function resolveWorldSky(world) {
164
+ return world?.kind === "environment" ? world.sky : undefined;
165
+ }
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
+ }
69
189
  function colorFromId(id) {
70
190
  let hash = 0;
71
191
  for (let index = 0; index < id.length; index += 1)
72
192
  hash = (hash * 31 + id.charCodeAt(index)) >>> 0;
73
193
  return `hsl(${hash % 360}, 65%, 55%)`;
74
194
  }
195
+ function ConfiguredLighting({ lighting }) {
196
+ 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
+ lighting.hemisphere.skyColor ?? "#bfe3ff",
198
+ lighting.hemisphere.groundColor ?? "#4c6b34",
199
+ 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)))] }));
201
+ }
202
+ function BackdropFog({ fog }) {
203
+ if (fog === undefined)
204
+ return null;
205
+ return fog.density !== undefined ? (_jsx("fogExp2", { attach: "fog", args: [fog.color ?? DEFAULT_BACKDROP_FOG_COLOR, fog.density] })) : (_jsx("fog", { attach: "fog", args: [fog.color ?? DEFAULT_BACKDROP_FOG_COLOR, fog.near ?? 10, fog.far ?? 200] }));
206
+ }
75
207
  function EntitySprite({ sprite }) {
76
208
  const texture = useLoader(THREE.TextureLoader, sprite.url);
77
209
  useEffect(() => {
@@ -81,9 +213,10 @@ function EntitySprite({ sprite }) {
81
213
  }, [texture]);
82
214
  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 }) }));
83
215
  }
84
- function EntityModel({ model }) {
216
+ function EntityModel({ model, instanceId }) {
85
217
  const gltf = useLoader(GLTFLoader, model.url);
86
- const scene = useMemo(() => gltf.scene.clone(true), [gltf]);
218
+ const ctx = useGameContext();
219
+ const material = model.material;
87
220
  const scale = model.scale ?? 1;
88
221
  const baseY = model.y ?? 0;
89
222
  const dims = model.dims;
@@ -91,6 +224,73 @@ function EntityModel({ model }) {
91
224
  const position = centered
92
225
  ? [-scale * dims.center.x, baseY - scale * dims.minY, -scale * dims.center.z]
93
226
  : [0, baseY, 0];
227
+ const scene = useMemo(() => {
228
+ const cloned = cloneModelScene(gltf.scene);
229
+ if (material !== undefined)
230
+ applyMaterialOverride(cloned, material);
231
+ return cloned;
232
+ }, [gltf, material]);
233
+ const animation = model.animation;
234
+ const mixerRef = useRef(null);
235
+ const animationPausedRef = useRef(false);
236
+ useEffect(() => {
237
+ if (animation === undefined || gltf.animations.length === 0) {
238
+ mixerRef.current = null;
239
+ return;
240
+ }
241
+ const mixer = new THREE.AnimationMixer(scene);
242
+ const clip = (animation.clip !== undefined ? THREE.AnimationClip.findByName(gltf.animations, animation.clip) : undefined) ??
243
+ gltf.animations[0];
244
+ const action = mixer.clipAction(clip);
245
+ action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
246
+ action.clampWhenFinished = animation.loop === false;
247
+ action.timeScale = animation.timeScale ?? 1;
248
+ action.enabled = true;
249
+ action.paused = animation.paused === true;
250
+ action.play();
251
+ if (animation.time !== undefined)
252
+ action.time = animation.time;
253
+ mixer.update(0);
254
+ mixerRef.current = mixer;
255
+ animationPausedRef.current = animation.paused === true;
256
+ return () => {
257
+ mixer.stopAllAction();
258
+ mixerRef.current = null;
259
+ };
260
+ }, [scene, gltf, animation?.clip, animation?.loop, animation?.timeScale, animation?.paused, animation?.time]);
261
+ const paintCanvasRef = useRef(null);
262
+ const paintDrawnCountRef = useRef(0);
263
+ const paintVersionRef = useRef(-1);
264
+ useEffect(() => {
265
+ paintCanvasRef.current = null;
266
+ paintDrawnCountRef.current = 0;
267
+ paintVersionRef.current = -1;
268
+ }, [scene]);
269
+ useFrame((_state, delta) => {
270
+ if (mixerRef.current !== null && !animationPausedRef.current)
271
+ mixerRef.current.update(delta);
272
+ if (instanceId === undefined)
273
+ return;
274
+ const paint = ctx.scene.entity.paint;
275
+ const version = paint.version(instanceId);
276
+ if (version === paintVersionRef.current)
277
+ return;
278
+ paintVersionRef.current = version;
279
+ const strokes = paint.strokes(instanceId);
280
+ if (paintCanvasRef.current === null) {
281
+ if (strokes.length === 0)
282
+ return;
283
+ const materials = standardMaterialsOf(scene);
284
+ const seed = materials[0];
285
+ if (seed === undefined)
286
+ return;
287
+ const paintCanvas = createPaintCanvas(seed);
288
+ paintCanvasRef.current = paintCanvas;
289
+ applyPaintTexture(scene, paintCanvas);
290
+ }
291
+ const seedColor = standardMaterialsOf(scene)[0]?.color ?? new THREE.Color("#ffffff");
292
+ paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, seedColor, strokes, paintDrawnCountRef.current);
293
+ });
94
294
  return _jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] });
95
295
  }
96
296
  function resolveModel(value, assets) {
@@ -103,13 +303,19 @@ function resolveModel(value, assets) {
103
303
  return undefined;
104
304
  return ref.dims === undefined ? { url: ref.url } : { url: ref.url, dims: ref.dims };
105
305
  }
106
- function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, onSelect, }) {
306
+ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, onSelect, }) {
107
307
  const color = isLocal ? "#4ade80" : entity.role === "npc" ? colorFromId(entity.name) : "#9ca3af";
108
- return (_jsxs("group", { position: [entity.position[0], entity.position[1], entity.position[2]], "rotation-y": entity.rotationY, onPointerDown: (event) => {
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) => {
109
309
  event.stopPropagation();
110
310
  if (!isLocal)
111
311
  onSelect(entity);
112
- }, children: [custom !== undefined && custom !== null ? (custom) : model !== undefined ? (_jsx(EntityModel, { model: model })) : 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] }));
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] }));
313
+ }
314
+ function ObjectMarker({ object, custom, model, style, }) {
315
+ const [scaleX, scaleY, scaleZ] = objectVisualScale(object.visual);
316
+ const color = object.visual?.color ?? style?.color ?? colorFromId(object.catalogId);
317
+ 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 })] })) }));
113
319
  }
114
320
  function GroundPlane() {
115
321
  const geometry = useMemo(() => {
@@ -142,43 +348,70 @@ function RockField() {
142
348
  }), []);
143
349
  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))) }));
144
350
  }
145
- function WorldView({ entitySprites, entityModels, objectModels, environment: Environment, assets, renderEntity, }) {
351
+ function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment: Environment, assets, renderEntity, renderObject, selectedIds, }) {
146
352
  const ctx = useGameContext();
147
353
  const entities = useSceneEntities();
148
354
  const objects = useSceneObjects();
149
355
  const player = usePlayer();
150
356
  const targetId = useTarget(player.userId);
357
+ const controlledId = ctx.player.possession.active(player.userId);
151
358
  const handleSelect = (entity) => {
152
359
  const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
153
- ctx.scene.entity.setTarget(player.userId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
360
+ ctx.scene.entity.setTarget(controlledId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
154
361
  };
155
- 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.map((entity) => (_jsx(EntityMarker, { entity: entity, custom: renderEntity?.(entity), model: resolveModel(entityModels?.[entity.name], assets), sprite: entitySprites?.[entity.name], isLocal: entity.id === player.userId, targeted: entity.id === targetId, onSelect: handleSelect }, entity.id))), objects.map((object) => {
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
363
+ .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) => {
156
365
  const model = resolveModel(objectModels?.[object.catalogId], assets) ??
157
366
  resolveModel(object.catalogId, assets);
158
- return (_jsx("group", { position: [object.position[0], object.position[1], object.position[2]], "rotation-y": object.rotationY, children: model !== undefined ? (_jsx(EntityModel, { model: model })) : (_jsxs("mesh", { "position-y": 0.5, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { color: colorFromId(object.catalogId) })] })) }, object.instanceId));
367
+ return (_jsx(ObjectMarker, { object: object, custom: renderObject?.(object), model: model, style: objectStyles?.[object.catalogId] }, object.instanceId));
159
368
  })] }));
160
369
  }
161
370
  function RemotePlayers({ rows }) {
162
371
  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))) }));
163
372
  }
164
- function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
373
+ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, }) {
165
374
  const motionRef = useRef(createPlayerMotionState());
375
+ const voxelBodyRef = useRef(null);
376
+ const solidCacheRef = useRef({ count: -1, set: new Set() });
166
377
  const hasReportedTickError = useRef(false);
378
+ const repeatFiredAtRef = useRef(new Map());
167
379
  const slotActions = useMemo(() => findHotbarSlotActions(playable.game.input), [playable]);
168
380
  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]);
389
+ const autoPickupRadius = useMemo(() => {
390
+ const cfg = playable.worldItem?.autoPickup;
391
+ if (cfg === undefined || cfg === false)
392
+ return null;
393
+ const fallback = playable.worldItem?.pickupRadius ?? DEFAULT_PICKUP_RADIUS;
394
+ return cfg === true ? fallback : cfg.radius ?? fallback;
395
+ }, [playable]);
396
+ const ground = useMemo(() => groundFieldFor(playable.game.world), [playable]);
397
+ const drivesPose = useMemo(() => shellDrivesPlayerPose(playable.game.input), [playable]);
398
+ const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
399
+ const hasTerrain = useMemo(() => hasEnvironmentTerrain(playable.game.world), [playable]);
169
400
  useFrame((_state, rawDt) => {
401
+ const simStart = performance.now();
170
402
  try {
171
403
  const dt = Math.min(rawDt, 0.05);
172
404
  const gameDt = ctx.time.advance(dt);
405
+ ctx.input.publish(heldActionsFor(tracker, inputActions));
173
406
  if (tracker.isDown("turnLeft"))
174
407
  yawRef.current += TURN_SPEED * dt;
175
408
  if (tracker.isDown("turnRight"))
176
409
  yawRef.current -= TURN_SPEED * dt;
177
- const playerId = ctx.player.userId;
410
+ const playerId = ctx.player.possession.active(ctx.player.userId);
178
411
  const player = ctx.scene.entity.get(playerId);
179
412
  const forwardX = Math.sin(yawRef.current);
180
413
  const forwardZ = Math.cos(yawRef.current);
181
- if (player !== null) {
414
+ if (player !== null && drivesPose) {
182
415
  const keys = createEmptyMovementKeys();
183
416
  keys.w = tracker.isDown("moveForward");
184
417
  keys.s = tracker.isDown("moveBack");
@@ -187,15 +420,92 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
187
420
  keys.shift = tracker.isDown("sprint");
188
421
  keys.space = tracker.isDown("jump");
189
422
  const intent = resolveMovementIntent(keys, true);
190
- const motion = motionRef.current;
191
- const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, player.movement.walkSpeed ?? 2, rawDt);
192
- ctx.scene.entity.setPose(playerId, {
193
- position: [player.position[0] + step.stepX, motion.jumpOffset, player.position[2] + step.stepZ],
194
- rotationY: intent.moving
195
- ? Math.atan2(motion.horizontalVelocityX, motion.horizontalVelocityZ)
196
- : player.rotationY,
197
- dt: rawDt,
198
- });
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
+ }
501
+ }
502
+ if (autoPickupRadius !== null) {
503
+ const self = ctx.scene.entity.get(playerId);
504
+ if (self !== null) {
505
+ const nearest = ctx.scene.worldItem.nearestInRadius(self.position, autoPickupRadius);
506
+ if (nearest !== null)
507
+ ctx.scene.worldItem.pickup(nearest, ctx.player.userId);
508
+ }
199
509
  }
200
510
  playable.loop.onTick(ctx, gameDt);
201
511
  if (tracker.wasPressed("tabTarget")) {
@@ -210,10 +520,27 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
210
520
  else
211
521
  ctx.scene.entity.setTarget(playerId, null);
212
522
  }
523
+ if (pingCommand !== undefined && tracker.wasPressed("ping")) {
524
+ const hit = pointerService.worldHit();
525
+ if (hit !== null && ctx.game.commands.has(pingCommand)) {
526
+ ctx.game.commands.run(pingCommand, {
527
+ point: hit.point,
528
+ entity: hit.entity,
529
+ object: hit.object,
530
+ normal: hit.normal,
531
+ });
532
+ }
533
+ }
534
+ const aimOverride = pointerAim ? pointerAimFor(ctx, pointerService) : undefined;
535
+ const commandAim = aimOverride ?? { yaw: yawRef.current, pitch: pitchRef.current };
536
+ const nowMs = performance.now();
213
537
  for (const action of Object.keys(playable.game.input ?? {})) {
214
- if (!tracker.wasPressed(action))
538
+ const pressed = tracker.wasPressed(action);
539
+ if (action === "ping" && pingCommand !== undefined)
215
540
  continue;
216
541
  if (action === "interact") {
542
+ if (!pressed)
543
+ continue;
217
544
  const prompts = playable.prompts?.(ctx);
218
545
  const focus = prompts === undefined ? null : ctx.scene.entity.get(playerId);
219
546
  if (prompts !== undefined && focus !== null) {
@@ -224,15 +551,16 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
224
551
  }
225
552
  continue;
226
553
  }
227
- const command = resolveActionCommand(action, (name) => ctx.game.commands.has(name), RESERVED_INPUT_ACTIONS);
228
- if (command !== null)
229
- ctx.game.commands.run(command, {});
554
+ if (!shouldFireBoundAction(tracker, action, playable.game.input, repeatFiredAtRef.current, nowMs))
555
+ continue;
556
+ repeatFiredAtRef.current.set(action, nowMs);
557
+ dispatchBoundAction(ctx, action, yawRef.current, pitchRef.current, commandAim);
230
558
  }
231
559
  if (hotbarId !== null) {
232
560
  for (const { action, slot } of slotActions) {
233
561
  if (!tracker.wasPressed(action))
234
562
  continue;
235
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
563
+ const result = executeHotbarSlot(ctx, playerId, hotbarId, slot, yawRef.current, pitchRef.current, aimOverride);
236
564
  if (!result.ok)
237
565
  console.warn(`[jgengine:item-use] ${result.error}`);
238
566
  }
@@ -245,7 +573,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
245
573
  ? preferred
246
574
  : slots.findIndex((stack) => stack !== null);
247
575
  if (slot >= 0) {
248
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
576
+ const result = executeHotbarSlot(ctx, playerId, hotbarId, slot, yawRef.current, pitchRef.current, aimOverride);
249
577
  if (!result.ok)
250
578
  console.warn(`[jgengine:item-use] ${result.error}`);
251
579
  }
@@ -272,9 +600,52 @@ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef
272
600
  onRuntimeError(error, "tick");
273
601
  }
274
602
  }
603
+ devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
275
604
  }, GAME_SIM_FRAME_PRIORITY);
276
605
  return null;
277
606
  }
607
+ function HudOnlyDriver({ ctx, playable, tracker, onRuntimeError, }) {
608
+ const hasReportedTickError = useRef(false);
609
+ const repeatFiredAtRef = useRef(new Map());
610
+ const lastFrameRef = useRef(null);
611
+ const inputActions = useMemo(() => Object.keys(playable.game.input ?? {}), [playable]);
612
+ useEffect(() => {
613
+ let frameId;
614
+ const tick = (now) => {
615
+ frameId = requestAnimationFrame(tick);
616
+ const last = lastFrameRef.current;
617
+ lastFrameRef.current = now;
618
+ if (last === null)
619
+ return;
620
+ const rawDt = (now - last) / 1000;
621
+ const simStart = performance.now();
622
+ try {
623
+ const dt = Math.min(rawDt, 0.05);
624
+ const gameDt = ctx.time.advance(dt);
625
+ ctx.input.publish(heldActionsFor(tracker, inputActions));
626
+ playable.loop.onTick(ctx, gameDt);
627
+ const nowMs = performance.now();
628
+ for (const action of inputActions) {
629
+ if (!shouldFireBoundAction(tracker, action, playable.game.input, repeatFiredAtRef.current, nowMs))
630
+ continue;
631
+ repeatFiredAtRef.current.set(action, nowMs);
632
+ dispatchBoundAction(ctx, action, 0, 0, { yaw: 0, pitch: 0 });
633
+ }
634
+ tracker.endFrame();
635
+ }
636
+ catch (error) {
637
+ if (!hasReportedTickError.current) {
638
+ hasReportedTickError.current = true;
639
+ onRuntimeError(error, "tick");
640
+ }
641
+ }
642
+ devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
643
+ };
644
+ frameId = requestAnimationFrame(tick);
645
+ return () => cancelAnimationFrame(frameId);
646
+ }, [ctx, playable, tracker, onRuntimeError, inputActions]);
647
+ return null;
648
+ }
278
649
  class GameUiErrorBoundary extends Component {
279
650
  state = { failed: false };
280
651
  static getDerivedStateFromError() {
@@ -295,7 +666,10 @@ function DiagnosticOverlay({ diagnostics }) {
295
666
  const latest = diagnostics[diagnostics.length - 1];
296
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] }));
297
668
  }
298
- export function GamePlayerShell({ playable, multiplayer = null, }) {
669
+ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, }) {
670
+ const multiplayer = useMemo(() => (rawMultiplayer === null ? null : withDevtoolsLatency(rawMultiplayer)), [rawMultiplayer]);
671
+ const devtoolsEnabled = playable.devtools !== false;
672
+ const [devtoolsOpen, setDevtoolsOpen] = useState(false);
299
673
  const [ctx, setCtx] = useState(null);
300
674
  const [diagnostics, setDiagnostics] = useState([]);
301
675
  const [remotePlayers, setRemotePlayers] = useState([]);
@@ -306,7 +680,22 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
306
680
  const cameraDraggingRef = useRef(false);
307
681
  const primaryClickRef = useRef(false);
308
682
  const pointerDownRef = useRef(null);
309
- const tracker = useMemo(() => createActionStateTracker(toActionStateBindingMap(playable.game.input ?? {})), [playable]);
683
+ const marqueeStartRef = useRef(null);
684
+ const pointerService = useMemo(() => createPointerService(), []);
685
+ const selection = useMemo(() => createSelectionSet(), [playable]);
686
+ const [marquee, setMarquee] = useState(null);
687
+ const [selectedIds, setSelectedIds] = useState(() => new Set());
688
+ const [contextMenu, setContextMenu] = useState(null);
689
+ const tracker = useMemo(() => createActionStateTracker(toActionStateBindingMap(withTouchCodes(playable.game.input))), [playable]);
690
+ const touchScheme = useMemo(() => deriveTouchScheme(playable.game.input, {
691
+ reserved: RESERVED_INPUT_ACTIONS,
692
+ firstPerson: resolveRigKind(playable.camera) === "first",
693
+ config: playable.touch,
694
+ }), [playable]);
695
+ const { coarsePointer } = useDisplayProfile();
696
+ 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]);
698
+ useEffect(() => () => audioEngine.dispose(), [audioEngine]);
310
699
  const userId = multiplayer?.userId ?? DEV_USER_ID;
311
700
  const reportRuntimeError = (error, phase) => {
312
701
  const diagnostic = logRuntimeError(error, phase);
@@ -381,6 +770,33 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
381
770
  if (remoteUnsub !== undefined)
382
771
  cleanups.push(remoteUnsub);
383
772
  }
773
+ const chatSync = multiplayer.backend.chatSyncFor?.(joined.serverId);
774
+ if (chatSync !== undefined) {
775
+ const globalChannelIds = new Set(ctx.game.chat
776
+ .channels()
777
+ .filter((channel) => channel.kind === "global")
778
+ .map((channel) => channel.id));
779
+ const seenRemoteChat = new Set();
780
+ cleanups.push(ctx.game.events.subscribe("chat.message", (event) => {
781
+ if (event.fromUserId !== multiplayer.userId)
782
+ return;
783
+ if (!globalChannelIds.has(event.channelId))
784
+ return;
785
+ void chatSync.send(event.channelId, event.body).catch(() => undefined);
786
+ }));
787
+ for (const channelId of globalChannelIds) {
788
+ cleanups.push(chatSync.subscribe(channelId, (messages) => {
789
+ for (const message of messages) {
790
+ if (message.fromUserId === multiplayer.userId)
791
+ continue;
792
+ if (seenRemoteChat.has(message.id))
793
+ continue;
794
+ seenRemoteChat.add(message.id);
795
+ ctx.game.chat.send(message.fromUserId, message.channelId, message.body);
796
+ }
797
+ }));
798
+ }
799
+ }
384
800
  })
385
801
  .catch(() => undefined);
386
802
  return () => {
@@ -398,39 +814,199 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
398
814
  useEffect(() => {
399
815
  wrapperRef.current?.focus();
400
816
  }, [ctx]);
817
+ useSyncExternalStore(ctx?.subscribe ?? (() => () => undefined), ctx?.version ?? (() => 0), ctx?.version ?? (() => 0));
401
818
  if (ctx === null)
402
819
  return _jsx("div", { className: "h-full w-full bg-neutral-950" });
403
820
  const GameUI = playable.GameUI;
404
821
  const WorldOverlay = playable.WorldOverlay;
405
- const firstPerson = playable.camera?.perspective === "first";
406
- const showReticle = firstPerson && playable.camera?.firstPerson?.reticle !== false;
822
+ const controlledEntityId = ctx.player.possession.active(userId);
823
+ const cameraConfig = playable.camera?.followEntityId !== undefined
824
+ ? playable.camera
825
+ : { ...playable.camera, followEntityId: controlledEntityId };
826
+ const rigKind = resolveRigKind(cameraConfig);
827
+ if (rigKind === "none" || playable.presentation === "hud") {
828
+ 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) => {
830
+ if (event.code === "F2" && devtoolsEnabled) {
831
+ event.preventDefault();
832
+ setDevtoolsOpen((current) => !current);
833
+ return;
834
+ }
835
+ if (event.code === "Tab" || event.code === "Space")
836
+ event.preventDefault();
837
+ 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 })] }));
839
+ }
840
+ const firstPerson = rigKind === "first";
841
+ const showReticle = (firstPerson && playable.camera?.firstPerson?.reticle !== false) || rigKind === "shoulder";
842
+ const rtsPanKeysEnabled = !rtsPanKeysConflict(playable.game.input);
407
843
  const worldBars = playable.worldHealthBars;
408
844
  const barsStatId = worldBars === undefined || worldBars === false
409
845
  ? null
410
846
  : worldBars === true
411
847
  ? "health"
412
848
  : worldBars.statId ?? "health";
849
+ const barsRoles = worldBars === undefined || worldBars === true || worldBars === false ? undefined : worldBars.roles;
850
+ const resolveEntityRole = (entity) => playable.content.entityById?.(entity.name)?.role;
851
+ const pointer = playable.pointer;
852
+ const pointerUsesLeft = pointer !== undefined && (pointer.select === true || pointer.moveCommand !== undefined);
853
+ const selectFilter = pointer?.selectFilter;
854
+ const worldSky = resolveWorldSky(playable.game.world);
855
+ const world = playable.game.world;
856
+ const AutoEnvironment = playable.environment ??
857
+ (world?.kind === "environment"
858
+ ? () => _jsx(EnvironmentScene, { feature: world })
859
+ : world?.kind === "biomes" || world?.kind === "voxel" || world?.kind === "plots" || world?.kind === "tilemap"
860
+ ? () => _jsx(GridWorldScene, { feature: world })
861
+ : undefined);
862
+ const backdrop = playable.backdrop;
863
+ const backdropSky = backdrop?.sky !== undefined ? resolveSkyDescriptor(backdrop.sky) : undefined;
864
+ const effectiveSky = backdropSky ?? worldSky;
865
+ const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
866
+ const lighting = playable.lighting;
867
+ const localXY = (event) => {
868
+ const rect = wrapperRef.current?.getBoundingClientRect();
869
+ return rect === undefined
870
+ ? { x: event.clientX, y: event.clientY }
871
+ : { x: event.clientX - rect.left, y: event.clientY - rect.top };
872
+ };
873
+ const commitSelection = (ids) => {
874
+ selection.replace(ids);
875
+ setSelectedIds(new Set(ids));
876
+ };
877
+ const finishMarquee = (rect) => {
878
+ const candidates = [];
879
+ for (const entity of ctx.scene.entity.list()) {
880
+ if (selectFilter !== undefined && !selectFilter(entity.id))
881
+ continue;
882
+ const screen = pointerService.screenOf(entity.position);
883
+ if (screen !== null)
884
+ candidates.push({ id: entity.id, x: screen.x, y: screen.y });
885
+ }
886
+ commitSelection(selectWithinRect(candidates, rect));
887
+ };
888
+ const handlePointerDown = (event) => {
889
+ wrapperRef.current?.focus();
890
+ audioEngine.resume();
891
+ if (contextMenu !== null)
892
+ setContextMenu(null);
893
+ if (event.button === 0) {
894
+ const point = localXY(event);
895
+ pointerDownRef.current = point;
896
+ if (pointer?.select === true)
897
+ marqueeStartRef.current = point;
898
+ }
899
+ };
900
+ const handlePointerMove = (event) => {
901
+ if (pointer?.select !== true)
902
+ return;
903
+ const start = marqueeStartRef.current;
904
+ if (start === null || (event.buttons & 1) === 0)
905
+ return;
906
+ const point = localXY(event);
907
+ setMarquee(screenRect(start.x, start.y, point.x, point.y));
908
+ };
909
+ const handlePointerUp = (event) => {
910
+ if (event.button !== 0 || pointerDownRef.current === null)
911
+ return;
912
+ const start = pointerDownRef.current;
913
+ pointerDownRef.current = null;
914
+ marqueeStartRef.current = null;
915
+ const end = localXY(event);
916
+ const rect = screenRect(start.x, start.y, end.x, end.y);
917
+ const wasMarquee = marquee !== null && isMarquee(rect, PRIMARY_CLICK_MOVE_THRESHOLD_PX);
918
+ setMarquee(null);
919
+ if (pointer?.select === true && wasMarquee) {
920
+ finishMarquee(rect);
921
+ return;
922
+ }
923
+ if (cameraDraggingRef.current)
924
+ return;
925
+ if (pointer?.grabWorldItems === true) {
926
+ const hit = pointerService.worldHit();
927
+ const itemInstanceId = hit?.entity ?? null;
928
+ const record = itemInstanceId === null ? null : ctx.scene.worldItem.get(itemInstanceId);
929
+ if (record !== null && itemInstanceId !== null) {
930
+ const itemEntity = ctx.scene.entity.get(itemInstanceId);
931
+ const localPlayer = ctx.scene.entity.get(ctx.player.userId);
932
+ const pickupRadius = playable.worldItem?.pickupRadius ?? DEFAULT_PICKUP_RADIUS;
933
+ const withinRadius = itemEntity !== null &&
934
+ localPlayer !== null &&
935
+ Math.hypot(localPlayer.position[0] - itemEntity.position[0], localPlayer.position[1] - itemEntity.position[1], localPlayer.position[2] - itemEntity.position[2]) <= pickupRadius;
936
+ if (withinRadius) {
937
+ ctx.scene.worldItem.pickup(itemInstanceId, ctx.player.userId);
938
+ return;
939
+ }
940
+ }
941
+ }
942
+ if (pointer?.select === true) {
943
+ const hit = pointerService.worldHit();
944
+ if (hit !== null && hit.entity !== null && (selectFilter === undefined || selectFilter(hit.entity))) {
945
+ commitSelection([hit.entity]);
946
+ }
947
+ else {
948
+ commitSelection([]);
949
+ }
950
+ return;
951
+ }
952
+ if (pointer?.moveCommand !== undefined) {
953
+ const hit = pointerService.worldHit();
954
+ if (hit !== null && ctx.game.commands.has(pointer.moveCommand)) {
955
+ ctx.game.commands.run(pointer.moveCommand, { point: hit.point, entity: hit.entity, object: hit.object });
956
+ }
957
+ return;
958
+ }
959
+ const moved = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
960
+ if (moved <= PRIMARY_CLICK_MOVE_THRESHOLD_PX * PRIMARY_CLICK_MOVE_THRESHOLD_PX)
961
+ primaryClickRef.current = true;
962
+ };
963
+ const handleContextMenu = (event) => {
964
+ if (pointer === undefined)
965
+ return;
966
+ event.preventDefault();
967
+ const hit = pointerService.worldHit();
968
+ if (hit === null)
969
+ return;
970
+ if (pointer.contextMenu === true && (hit.entity !== null || hit.object !== null)) {
971
+ const menu = pointerContextMenu(ctx, playable, hit);
972
+ if (menu !== null) {
973
+ const point = localXY(event);
974
+ setContextMenu({ menu, x: point.x, y: point.y });
975
+ return;
976
+ }
977
+ }
978
+ if (pointer.select === true && pointer.orderCommand !== undefined && selection.size() > 0) {
979
+ if (ctx.game.commands.has(pointer.orderCommand)) {
980
+ ctx.game.commands.run(pointer.orderCommand, { selection: selection.list(), point: hit.point });
981
+ }
982
+ return;
983
+ }
984
+ if (pointer.secondaryCommand !== undefined && ctx.game.commands.has(pointer.secondaryCommand)) {
985
+ const aim = (pointer.aim === true ? pointerAimFor(ctx, pointerService) : undefined) ?? {
986
+ yaw: yawRef.current,
987
+ pitch: pitchRef.current,
988
+ };
989
+ ctx.game.commands.run(pointer.secondaryCommand, { point: hit.point, entity: hit.entity, object: hit.object, aim });
990
+ }
991
+ };
992
+ const handleVerbPick = (verb) => {
993
+ const state = contextMenu;
994
+ setContextMenu(null);
995
+ if (state !== null && ctx.game.commands.has(verb.command)) {
996
+ ctx.game.commands.run(verb.command, contextVerbInput(state.menu, verb));
997
+ }
998
+ };
413
999
  return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
414
- if (event.code === "Tab" || event.code === "Space")
1000
+ if (event.code === "F2" && devtoolsEnabled) {
415
1001
  event.preventDefault();
416
- tracker.handleDown(event.code);
417
- }, onKeyUp: (event) => tracker.handleUp(event.code), onBlur: () => tracker.reset(), onPointerDown: (event) => {
418
- wrapperRef.current?.focus();
419
- if (event.button === 0) {
420
- pointerDownRef.current = { x: event.clientX, y: event.clientY };
421
- }
422
- }, onPointerUp: (event) => {
423
- if (event.button !== 0 || pointerDownRef.current === null)
1002
+ document.exitPointerLock?.();
1003
+ setDevtoolsOpen((current) => !current);
424
1004
  return;
425
- const start = pointerDownRef.current;
426
- pointerDownRef.current = null;
427
- const dx = event.clientX - start.x;
428
- const dy = event.clientY - start.y;
429
- const moved = dx * dx + dy * dy;
430
- if (!cameraDraggingRef.current && moved <= PRIMARY_CLICK_MOVE_THRESHOLD_PX * PRIMARY_CLICK_MOVE_THRESHOLD_PX) {
431
- primaryClickRef.current = true;
432
1005
  }
433
- }, onWheel: (event) => {
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) => {
434
1010
  if (ctx === null || !event.shiftKey)
435
1011
  return;
436
1012
  event.preventDefault();
@@ -440,7 +1016,13 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
440
1016
  else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
441
1017
  ctx.game.commands.run("ui.hotbarScrollPrev", {});
442
1018
  }
443
- }, children: [_jsxs(Canvas, { camera: { fov: 55, near: 0.1, far: 300 }, children: [_jsx("color", { attach: "background", args: ["#14161b"] }), _jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 }), _jsxs(GameProvider, { context: ctx, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, environment: playable.environment, assets: playable.game.assets, renderEntity: playable.renderEntity }), WorldOverlay !== undefined ? _jsx(WorldOverlay, {}) : null, barsStatId !== null ? _jsx(WorldEntityBars, { statId: barsStatId }) : null, _jsx(WorldFloatText, {}), _jsx(ProjectileTracers, {}), firstPerson ? (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: playable.camera?.firstPerson, followEntityId: playable.camera?.followEntityId })) : (_jsx(GameOrbitCamera, { yawRef: yawRef, pitchRef: pitchRef, config: playable.camera, followEntityId: playable.camera?.followEntityId, onCameraFollow: playable.camera?.onCameraFollow, onDragChange: (dragging) => {
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) => {
444
1024
  cameraDraggingRef.current = dragging;
445
- } }))] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef })] }), _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }), showReticle ? _jsx(Reticle, {}) : null, _jsx(DiagnosticOverlay, { diagnostics: diagnostics })] }));
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 })] }));
446
1028
  }