@jgengine/shell 0.3.0 → 0.4.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.
@@ -1,7 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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
3
  import { Component, useEffect, useMemo, useRef, useState } from "react";
4
4
  import * as THREE from "three";
5
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
5
6
  import { createActionStateTracker, hotbarSlotActionIndex, resolveActionCommand, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
6
7
  import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
7
8
  import { advancePlayerMotion, createEmptyMovementKeys, createPlayerMotionState, resolveMovementIntent, } from "@jgengine/core/movement/movementModel";
@@ -10,6 +11,8 @@ import { useGameContext } from "@jgengine/react/provider";
10
11
  import { useSceneEntities, useSceneObjects, usePlayer, useTarget } from "@jgengine/react/hooks";
11
12
  import { GameProvider } from "@jgengine/react/provider";
12
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";
13
16
  const DEV_USER_ID = "dev-player";
14
17
  const TURN_SPEED = 2.4;
15
18
  const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
@@ -51,7 +54,7 @@ function hotbarIdFor(playable) {
51
54
  const hud = declarations.find(([, declaration]) => declaration.hud === "hotbar");
52
55
  return (hud ?? declarations[0])?.[0] ?? null;
53
56
  }
54
- function executeHotbarSlot(ctx, hotbarId, slot, yaw) {
57
+ function executeHotbarSlot(ctx, hotbarId, slot, yaw, pitch) {
55
58
  const stack = ctx.player.inventory.state(hotbarId).slots[slot];
56
59
  if (stack === undefined || stack === null)
57
60
  return { ok: false, error: `Hotbar slot ${slot + 1} is empty` };
@@ -59,7 +62,7 @@ function executeHotbarSlot(ctx, hotbarId, slot, yaw) {
59
62
  from: ctx.player.userId,
60
63
  itemId: stack.itemId,
61
64
  inventoryId: hotbarId,
62
- aim: { yaw, pitch: 0 },
65
+ aim: { yaw, pitch },
63
66
  });
64
67
  return result.error === undefined ? { ok: true } : { ok: false, error: result.error };
65
68
  }
@@ -78,13 +81,27 @@ function EntitySprite({ sprite }) {
78
81
  }, [texture]);
79
82
  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 }) }));
80
83
  }
81
- function EntityMarker({ entity, sprite, isLocal, targeted, onSelect, }) {
84
+ function EntityModel({ model }) {
85
+ const gltf = useLoader(GLTFLoader, model.url);
86
+ const scene = useMemo(() => gltf.scene.clone(true), [gltf]);
87
+ const scale = model.scale ?? 1;
88
+ return _jsx("primitive", { object: scene, "position-y": model.y ?? 0, scale: [scale, scale, scale] });
89
+ }
90
+ function resolveModel(value, assets) {
91
+ if (value === undefined)
92
+ return undefined;
93
+ if (typeof value !== "string")
94
+ return value;
95
+ const url = assets.resolve(value)?.url;
96
+ return url === undefined ? undefined : { url };
97
+ }
98
+ function EntityMarker({ entity, model, sprite, isLocal, targeted, onSelect, }) {
82
99
  const color = isLocal ? "#4ade80" : entity.role === "npc" ? colorFromId(entity.name) : "#9ca3af";
83
100
  return (_jsxs("group", { position: [entity.position[0], entity.position[1], entity.position[2]], "rotation-y": entity.rotationY, onPointerDown: (event) => {
84
101
  event.stopPropagation();
85
102
  if (!isLocal)
86
103
  onSelect(entity);
87
- }, children: [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] }));
104
+ }, children: [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] }));
88
105
  }
89
106
  function GroundPlane() {
90
107
  const geometry = useMemo(() => {
@@ -117,7 +134,7 @@ function RockField() {
117
134
  }), []);
118
135
  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))) }));
119
136
  }
120
- function WorldView({ entitySprites }) {
137
+ function WorldView({ entitySprites, entityModels, objectModels, assets, }) {
121
138
  const ctx = useGameContext();
122
139
  const entities = useSceneEntities();
123
140
  const objects = useSceneObjects();
@@ -127,12 +144,16 @@ function WorldView({ entitySprites }) {
127
144
  const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
128
145
  ctx.scene.entity.setTarget(player.userId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
129
146
  };
130
- return (_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, sprite: entitySprites?.[entity.name], isLocal: entity.id === player.userId, targeted: entity.id === targetId, onSelect: handleSelect }, entity.id))), objects.map((object) => (_jsxs("mesh", { position: [object.position[0], object.position[1] + 0.5, object.position[2]], "rotation-y": object.rotationY, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { color: colorFromId(object.catalogId) })] }, object.instanceId)))] }));
147
+ return (_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, 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) => {
148
+ const model = resolveModel(objectModels?.[object.catalogId], assets) ??
149
+ resolveModel(object.catalogId, assets);
150
+ 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));
151
+ })] }));
131
152
  }
132
153
  function RemotePlayers({ rows }) {
133
154
  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))) }));
134
155
  }
135
- function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
156
+ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
136
157
  const motionRef = useRef(createPlayerMotionState());
137
158
  const hasReportedTickError = useRef(false);
138
159
  const slotActions = useMemo(() => findHotbarSlotActions(playable.game.input), [playable]);
@@ -201,7 +222,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
201
222
  for (const { action, slot } of slotActions) {
202
223
  if (!tracker.wasPressed(action))
203
224
  continue;
204
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
225
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
205
226
  if (!result.ok)
206
227
  console.warn(`[jgengine:item-use] ${result.error}`);
207
228
  }
@@ -214,7 +235,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
214
235
  ? preferred
215
236
  : slots.findIndex((stack) => stack !== null);
216
237
  if (slot >= 0) {
217
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
238
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
218
239
  if (!result.ok)
219
240
  console.warn(`[jgengine:item-use] ${result.error}`);
220
241
  }
@@ -230,7 +251,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
230
251
  y: focus.position[1],
231
252
  z: focus.position[2],
232
253
  rotationY: focus.rotationY,
233
- rotationPitch: 0,
254
+ rotationPitch: pitchRef.current,
234
255
  });
235
256
  }
236
257
  }
@@ -270,6 +291,7 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
270
291
  const [remotePlayers, setRemotePlayers] = useState([]);
271
292
  const wrapperRef = useRef(null);
272
293
  const yawRef = useRef(0);
294
+ const pitchRef = useRef(0);
273
295
  const serverIdRef = useRef(null);
274
296
  const cameraDraggingRef = useRef(false);
275
297
  const primaryClickRef = useRef(false);
@@ -370,6 +392,14 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
370
392
  return _jsx("div", { className: "h-full w-full bg-neutral-950" });
371
393
  const GameUI = playable.GameUI;
372
394
  const WorldOverlay = playable.WorldOverlay;
395
+ const firstPerson = playable.camera?.perspective === "first";
396
+ const showReticle = firstPerson && playable.camera?.firstPerson?.reticle !== false;
397
+ const worldBars = playable.worldHealthBars;
398
+ const barsStatId = worldBars === undefined || worldBars === false
399
+ ? null
400
+ : worldBars === true
401
+ ? "health"
402
+ : worldBars.statId ?? "health";
373
403
  return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
374
404
  if (event.code === "Tab" || event.code === "Space")
375
405
  event.preventDefault();
@@ -400,7 +430,7 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
400
430
  else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
401
431
  ctx.game.commands.run("ui.hotbarScrollPrev", {});
402
432
  }
403
- }, 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 }), WorldOverlay !== undefined ? _jsx(WorldOverlay, {}) : null, _jsx(GameOrbitCamera, { yawRef: yawRef, config: playable.camera, followEntityId: playable.camera?.followEntityId, onCameraFollow: playable.camera?.onCameraFollow, onDragChange: (dragging) => {
433
+ }, 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, assets: playable.game.assets }), 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) => {
404
434
  cameraDraggingRef.current = dragging;
405
- } })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, primaryClickRef: primaryClickRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef })] }), _jsx(GameUiErrorBoundary, { onRuntimeError: reportRuntimeError, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }), _jsx(DiagnosticOverlay, { diagnostics: diagnostics })] }));
435
+ } }))] }), _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 })] }));
406
436
  }
@@ -0,0 +1,9 @@
1
+ import { type MutableRefObject } from "react";
2
+ import type { FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
3
+ export interface GameFirstPersonCameraProps {
4
+ yawRef: MutableRefObject<number>;
5
+ pitchRef: MutableRefObject<number>;
6
+ config?: FirstPersonCameraConfig;
7
+ followEntityId?: string;
8
+ }
9
+ export declare function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, }: GameFirstPersonCameraProps): import("react").JSX.Element | null;
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import { useEffect, useRef } from "react";
4
+ import * as THREE from "three";
5
+ import { useGameContext } from "@jgengine/react/provider";
6
+ import { usePlayer } from "@jgengine/react/hooks";
7
+ import { GAME_SIM_FRAME_PRIORITY, ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
8
+ const DEFAULT_EYE_HEIGHT = 1.6;
9
+ const DEFAULT_SENSITIVITY = 0.0025;
10
+ const DEFAULT_MAX_PITCH = 1.45;
11
+ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, }) {
12
+ const eyeHeight = config?.eyeHeight ?? DEFAULT_EYE_HEIGHT;
13
+ const sensitivity = config?.sensitivity ?? DEFAULT_SENSITIVITY;
14
+ const maxPitch = config?.maxPitch ?? DEFAULT_MAX_PITCH;
15
+ const { userId } = usePlayer();
16
+ const ctx = useGameContext();
17
+ const camera = useThree((state) => state.camera);
18
+ const domElement = useThree((state) => state.gl.domElement);
19
+ const followId = followEntityId ?? userId;
20
+ useEffect(() => {
21
+ const requestLock = () => {
22
+ if (document.pointerLockElement !== domElement)
23
+ void domElement.requestPointerLock?.();
24
+ };
25
+ const onMove = (event) => {
26
+ if (document.pointerLockElement !== domElement)
27
+ return;
28
+ yawRef.current -= event.movementX * sensitivity;
29
+ pitchRef.current = Math.max(-maxPitch, Math.min(maxPitch, pitchRef.current - event.movementY * sensitivity));
30
+ };
31
+ domElement.addEventListener("click", requestLock);
32
+ window.addEventListener("mousemove", onMove);
33
+ return () => {
34
+ domElement.removeEventListener("click", requestLock);
35
+ window.removeEventListener("mousemove", onMove);
36
+ };
37
+ }, [domElement, sensitivity, maxPitch, yawRef, pitchRef]);
38
+ useFrame(() => {
39
+ const entity = ctx.scene.entity.get(followId);
40
+ if (entity === null)
41
+ return;
42
+ const cosPitch = Math.cos(pitchRef.current);
43
+ camera.position.set(entity.position[0], entity.position[1] + eyeHeight, entity.position[2]);
44
+ camera.lookAt(camera.position.x + Math.sin(yawRef.current) * cosPitch, camera.position.y + Math.sin(pitchRef.current), camera.position.z + Math.cos(yawRef.current) * cosPitch);
45
+ }, ORBIT_CAMERA_FRAME_PRIORITY);
46
+ if (config?.viewmodel === false)
47
+ return null;
48
+ return _jsx(FirstPersonViewmodel, { camera: camera });
49
+ }
50
+ function FirstPersonViewmodel({ camera }) {
51
+ const groupRef = useRef(null);
52
+ useFrame(() => {
53
+ const group = groupRef.current;
54
+ if (group === null)
55
+ return;
56
+ group.position.copy(camera.position);
57
+ group.quaternion.copy(camera.quaternion);
58
+ group.translateX(0.34);
59
+ group.translateY(-0.26);
60
+ group.translateZ(-0.72);
61
+ }, GAME_SIM_FRAME_PRIORITY);
62
+ return (_jsxs("group", { ref: groupRef, children: [_jsxs("mesh", { position: [0, 0, -0.22], children: [_jsx("boxGeometry", { args: [0.09, 0.11, 0.55] }), _jsx("meshStandardMaterial", { color: "#22262d", metalness: 0.6, roughness: 0.35 })] }), _jsxs("mesh", { position: [0, -0.13, 0.04], rotation: [0.35, 0, 0], children: [_jsx("boxGeometry", { args: [0.08, 0.18, 0.11] }), _jsx("meshStandardMaterial", { color: "#33373f", metalness: 0.4, roughness: 0.5 })] }), _jsxs("mesh", { position: [0, 0.03, -0.52], rotation: [Math.PI / 2, 0, 0], children: [_jsx("cylinderGeometry", { args: [0.022, 0.022, 0.18, 10] }), _jsx("meshStandardMaterial", { color: "#0e0f12", metalness: 0.7, roughness: 0.3 })] })] }));
63
+ }
@@ -5,6 +5,8 @@ import { type CameraFollowState, type OrbitCameraConfig, type Vec3 } from "./orb
5
5
  export type CameraFollowListener = (state: CameraFollowState) => void;
6
6
  export interface GameOrbitCameraProps {
7
7
  yawRef: MutableRefObject<number>;
8
+ /** Written each frame with the camera's look elevation (radians) for aim.pitch. */
9
+ pitchRef?: MutableRefObject<number>;
8
10
  config?: Partial<OrbitCameraConfig>;
9
11
  followEntityId?: string;
10
12
  /** Override orbit target derived from the followed entity. */
@@ -12,6 +14,6 @@ export interface GameOrbitCameraProps {
12
14
  onDragChange?: (dragging: boolean) => void;
13
15
  onCameraFollow?: CameraFollowListener;
14
16
  }
15
- export declare function GameOrbitCamera({ yawRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, }: GameOrbitCameraProps): import("react").JSX.Element;
17
+ export declare function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, }: GameOrbitCameraProps): import("react").JSX.Element;
16
18
  /** Seed orbit target before controls mount (demo spawn at origin). */
17
19
  export declare function seedOrbitCameraTarget(camera: Camera, target: Vector3, distance: number, height: number): void;
@@ -5,8 +5,8 @@ import { useEffect, useRef } from "react";
5
5
  import { MOUSE, Vector3 } from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { usePlayer } from "@jgengine/react/hooks";
8
- import { ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
9
- export function GameOrbitCamera({ yawRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, }) {
8
+ import { cameraLookPitch, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
9
+ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, }) {
10
10
  const config = resolveOrbitCameraConfig(configPatch);
11
11
  const controlsRef = useRef(null);
12
12
  const runtimeRef = useRef(null);
@@ -61,6 +61,9 @@ export function GameOrbitCamera({ yawRef, config: configPatch, followEntityId, r
61
61
  };
62
62
  const [px, , pz] = entity.position;
63
63
  yawRef.current = orbitYawFromCamera(camera.position.x, camera.position.z, px, pz);
64
+ if (pitchRef !== undefined) {
65
+ pitchRef.current = cameraLookPitch({ x: camera.position.x, y: camera.position.y, z: camera.position.z }, stepped.target);
66
+ }
64
67
  onCameraFollow?.({
65
68
  entityId: followId,
66
69
  target: stepped.target,
@@ -68,7 +71,7 @@ export function GameOrbitCamera({ yawRef, config: configPatch, followEntityId, r
68
71
  distance: runtimeRef.current.lockedDistance ?? stepped.distance,
69
72
  });
70
73
  }, ORBIT_CAMERA_FRAME_PRIORITY);
71
- return (_jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: config.dampingFactor, rotateSpeed: config.rotateSpeed, zoomSpeed: config.zoomSpeed, enablePan: false, enableRotate: true, enableZoom: true, minDistance: config.minDistance, maxDistance: config.maxDistance, maxPolarAngle: Math.PI / 2.05, minPolarAngle: 0.12, screenSpacePanning: false, mouseButtons: { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: undefined }, onStart: () => {
74
+ return (_jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: config.dampingFactor, rotateSpeed: config.rotateSpeed, zoomSpeed: config.zoomSpeed, enablePan: false, enableRotate: true, enableZoom: true, minDistance: config.minDistance, maxDistance: config.maxDistance, maxPolarAngle: config.maxPolarAngle, minPolarAngle: config.minPolarAngle, screenSpacePanning: false, mouseButtons: { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: undefined }, onStart: () => {
72
75
  draggingRef.current = true;
73
76
  onDragChange?.(true);
74
77
  }, onEnd: () => {
@@ -1,2 +1,2 @@
1
1
  export { GameOrbitCamera, type CameraFollowListener, type GameOrbitCameraProps } from "./GameOrbitCamera.js";
2
- export { cameraFollowStep, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, type CameraFollowState, type OrbitCameraConfig, type OrbitFollowRuntimeState, type ResolvedOrbitCameraConfig, type Vec3, } from "./orbitCameraMath.js";
2
+ export { cameraFollowStep, cameraLookPitch, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, type CameraFollowState, type OrbitCameraConfig, type OrbitFollowRuntimeState, type ResolvedOrbitCameraConfig, type Vec3, } from "./orbitCameraMath.js";
@@ -1,2 +1,2 @@
1
1
  export { GameOrbitCamera } from "./GameOrbitCamera.js";
2
- export { cameraFollowStep, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, } from "./orbitCameraMath.js";
2
+ export { cameraFollowStep, cameraLookPitch, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, } from "./orbitCameraMath.js";
@@ -1,5 +1,7 @@
1
1
  /** Horizontal facing derived from an orbit camera orbiting a target on the XZ plane. */
2
2
  export declare function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number;
3
+ /** Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera. */
4
+ export declare function cameraLookPitch(camera: Vec3, target: Vec3): number;
3
5
  export interface Vec3 {
4
6
  x: number;
5
7
  y: number;
@@ -34,6 +36,9 @@ export interface OrbitCameraConfig {
34
36
  dragTargetSmoothing?: number;
35
37
  /** Exponential smoothing when re-locking orbit distance. */
36
38
  distanceSmoothing?: number;
39
+ /** Vertical clamp (three.js polar angle, radians): 0 = top-down over the head, PI/2 = level, >PI/2 = look up from below the target. Widen to allow top-down or vertical aim; leave unset for the standard chase feel. */
40
+ minPolarAngle?: number;
41
+ maxPolarAngle?: number;
37
42
  }
38
43
  /** Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA. */
39
44
  export interface ResolvedOrbitCameraConfig {
@@ -51,6 +56,8 @@ export interface ResolvedOrbitCameraConfig {
51
56
  targetSmoothing: number;
52
57
  dragTargetSmoothing: number;
53
58
  distanceSmoothing: number;
59
+ minPolarAngle: number;
60
+ maxPolarAngle: number;
54
61
  }
55
62
  export declare const DEFAULT_ORBIT_CAMERA: ResolvedOrbitCameraConfig;
56
63
  /** Run simulation/movement before orbit follow so poses are current. */
@@ -4,6 +4,11 @@ export function orbitYawFromCamera(cameraX, cameraZ, targetX, targetZ) {
4
4
  const dz = targetZ - cameraZ;
5
5
  return Math.atan2(dx, dz);
6
6
  }
7
+ /** Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera. */
8
+ export function cameraLookPitch(camera, target) {
9
+ const horizontal = Math.hypot(target.x - camera.x, target.z - camera.z);
10
+ return Math.atan2(target.y - camera.y, horizontal);
11
+ }
7
12
  export const DEFAULT_ORBIT_CAMERA = {
8
13
  minDistance: 4,
9
14
  maxDistance: 28,
@@ -18,6 +23,8 @@ export const DEFAULT_ORBIT_CAMERA = {
18
23
  targetSmoothing: 8,
19
24
  dragTargetSmoothing: 11,
20
25
  distanceSmoothing: 5,
26
+ minPolarAngle: 0.12,
27
+ maxPolarAngle: Math.PI / 2.05,
21
28
  };
22
29
  /** Run simulation/movement before orbit follow so poses are current. */
23
30
  export const GAME_SIM_FRAME_PRIORITY = 0;
@@ -0,0 +1,14 @@
1
+ export declare function WorldEntityBars({ statId, height }: {
2
+ statId: string;
3
+ height?: number;
4
+ }): import("react").JSX.Element;
5
+ export declare function WorldFloatText({ height, lifeMs }: {
6
+ height?: number;
7
+ lifeMs?: number;
8
+ }): import("react").JSX.Element;
9
+ export declare function ProjectileTracers({ lifeMs }: {
10
+ lifeMs?: number;
11
+ }): import("react").JSX.Element;
12
+ export declare function Reticle({ className }: {
13
+ className?: string;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,78 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Html, Line } from "@react-three/drei";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import * as THREE from "three";
5
+ import { useGameContext } from "@jgengine/react/provider";
6
+ import { useEntityStat, useSceneEntities } from "@jgengine/react/hooks";
7
+ const MUZZLE_HEIGHT = 1.4;
8
+ function EntityBar({ entity, statId, height }) {
9
+ const stat = useEntityStat(entity.id, statId);
10
+ if (stat === null)
11
+ return null;
12
+ const range = stat.max - stat.min;
13
+ const percent = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
14
+ return (_jsx(Html, { position: [entity.position[0], entity.position[1] + height, entity.position[2]], center: true, distanceFactor: 12, zIndexRange: [20, 0], children: _jsx("div", { className: "h-2.5 w-28 overflow-hidden rounded-sm border border-black/70 bg-black/70 shadow", children: _jsx("div", { className: "h-full bg-gradient-to-r from-rose-600 to-red-400", style: { width: `${percent * 100}%` } }) }) }));
15
+ }
16
+ export function WorldEntityBars({ statId, height = 2.2 }) {
17
+ const ctx = useGameContext();
18
+ const entities = useSceneEntities();
19
+ const playerId = ctx.player.userId;
20
+ return (_jsx(_Fragment, { children: entities.map((entity) => entity.id === playerId ? null : (_jsx(EntityBar, { entity: entity, statId: statId, height: height }, entity.id))) }));
21
+ }
22
+ function FloatTextItem({ text, kind, lifeMs }) {
23
+ const [shown, setShown] = useState(false);
24
+ useEffect(() => {
25
+ const raf = requestAnimationFrame(() => setShown(true));
26
+ return () => cancelAnimationFrame(raf);
27
+ }, []);
28
+ return (_jsx("span", { className: kind === "heal" ? "text-emerald-300" : "text-amber-200", style: {
29
+ display: "inline-block",
30
+ fontWeight: 800,
31
+ fontSize: "18px",
32
+ textShadow: "0 1px 3px rgba(0,0,0,0.95)",
33
+ transition: `transform ${lifeMs}ms ease-out, opacity ${lifeMs}ms ease-out`,
34
+ transform: shown ? "translateY(-30px)" : "translateY(0)",
35
+ opacity: shown ? 0 : 1,
36
+ }, children: text }));
37
+ }
38
+ export function WorldFloatText({ height = 1.9, lifeMs = 950 }) {
39
+ const ctx = useGameContext();
40
+ const [floaters, setFloaters] = useState([]);
41
+ const nextId = useRef(0);
42
+ useEffect(() => {
43
+ return ctx.game.events.on("entity.floatText", (event) => {
44
+ const id = nextId.current++;
45
+ setFloaters((current) => [
46
+ ...current,
47
+ { id, position: event.position, text: event.text, kind: event.kind },
48
+ ]);
49
+ window.setTimeout(() => setFloaters((current) => current.filter((floater) => floater.id !== id)), lifeMs);
50
+ });
51
+ }, [ctx, lifeMs]);
52
+ return (_jsx(_Fragment, { children: floaters.map((floater) => (_jsx(Html, { position: [floater.position[0], floater.position[1] + height, floater.position[2]], center: true, distanceFactor: 12, zIndexRange: [30, 0], children: _jsx(FloatTextItem, { text: floater.text, kind: floater.kind, lifeMs: lifeMs }) }, floater.id))) }));
53
+ }
54
+ export function ProjectileTracers({ lifeMs = 130 }) {
55
+ const ctx = useGameContext();
56
+ const [tracers, setTracers] = useState([]);
57
+ const nextId = useRef(0);
58
+ useEffect(() => {
59
+ return ctx.game.events.on("projectile.settled", (event) => {
60
+ const id = nextId.current++;
61
+ setTracers((current) => [
62
+ ...current,
63
+ {
64
+ id,
65
+ points: [
66
+ new THREE.Vector3(event.origin[0], event.origin[1] + MUZZLE_HEIGHT, event.origin[2]),
67
+ new THREE.Vector3(event.at[0], event.at[1], event.at[2]),
68
+ ],
69
+ },
70
+ ]);
71
+ window.setTimeout(() => setTracers((current) => current.filter((tracer) => tracer.id !== id)), lifeMs);
72
+ });
73
+ }, [ctx, lifeMs]);
74
+ return (_jsx(_Fragment, { children: tracers.map((tracer) => (_jsx(Line, { points: tracer.points, color: "#fde68a", lineWidth: 2, transparent: true, opacity: 0.85 }, tracer.id))) }));
75
+ }
76
+ export function Reticle({ className }) {
77
+ return (_jsx("div", { className: className ?? "pointer-events-none absolute inset-0 z-20 flex items-center justify-center", children: _jsxs("div", { className: "relative h-6 w-6", children: [_jsx("span", { className: "absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/90 shadow" }), _jsx("span", { className: "absolute left-1/2 top-0 h-2 w-px -translate-x-1/2 bg-white/80" }), _jsx("span", { className: "absolute bottom-0 left-1/2 h-2 w-px -translate-x-1/2 bg-white/80" }), _jsx("span", { className: "absolute left-0 top-1/2 h-px w-2 -translate-y-1/2 bg-white/80" }), _jsx("span", { className: "absolute right-0 top-1/2 h-px w-2 -translate-y-1/2 bg-white/80" })] }) }));
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -18,14 +18,14 @@
18
18
  }
19
19
  },
20
20
  "scripts": {
21
- "build": "tsc -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
22
- "check-types": "tsc --noEmit -p tsconfig.json",
21
+ "build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
22
+ "check-types": "tsgo --noEmit -p tsconfig.json",
23
23
  "test": "bun test src"
24
24
  },
25
25
  "dependencies": {
26
- "@jgengine/core": "^0.3.0",
27
- "@jgengine/react": "^0.3.0",
28
- "@jgengine/ws": "^0.3.0"
26
+ "@jgengine/core": "^0.4.0",
27
+ "@jgengine/react": "^0.4.0",
28
+ "@jgengine/ws": "^0.4.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@react-three/drei": "^10.0.0",