@jgengine/shell 0.2.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,14 +1,18 @@
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 { createActionStateTracker, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
5
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
6
+ import { createActionStateTracker, hotbarSlotActionIndex, resolveActionCommand, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
7
+ import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
6
8
  import { advancePlayerMotion, createEmptyMovementKeys, createPlayerMotionState, resolveMovementIntent, } from "@jgengine/core/movement/movementModel";
7
9
  import { createGameContext } from "@jgengine/core/runtime/gameContext";
8
10
  import { useGameContext } from "@jgengine/react/provider";
9
11
  import { useSceneEntities, useSceneObjects, usePlayer, useTarget } from "@jgengine/react/hooks";
10
12
  import { GameProvider } from "@jgengine/react/provider";
11
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";
12
16
  const DEV_USER_ID = "dev-player";
13
17
  const TURN_SPEED = 2.4;
14
18
  const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
@@ -25,10 +29,24 @@ function logRuntimeError(error, phase) {
25
29
  console.error(`[jgengine:${phase}] ${diagnostic.message}`, error);
26
30
  return diagnostic;
27
31
  }
32
+ const RESERVED_INPUT_ACTIONS = new Set([
33
+ "moveForward",
34
+ "moveBack",
35
+ "moveLeft",
36
+ "moveRight",
37
+ "turnLeft",
38
+ "turnRight",
39
+ "sprint",
40
+ "jump",
41
+ "tabTarget",
42
+ "clearTarget",
43
+ "useAbility",
44
+ "interact",
45
+ ]);
28
46
  function findHotbarSlotActions(input) {
29
47
  return Object.keys(input ?? {}).flatMap((action) => {
30
- const match = /^(?:hotbar)?slot(\d+)$/i.exec(action);
31
- return match === null ? [] : [{ action, slot: Number(match[1]) - 1 }];
48
+ const slot = hotbarSlotActionIndex(action);
49
+ return slot === null ? [] : [{ action, slot }];
32
50
  });
33
51
  }
34
52
  function hotbarIdFor(playable) {
@@ -36,7 +54,7 @@ function hotbarIdFor(playable) {
36
54
  const hud = declarations.find(([, declaration]) => declaration.hud === "hotbar");
37
55
  return (hud ?? declarations[0])?.[0] ?? null;
38
56
  }
39
- function executeHotbarSlot(ctx, hotbarId, slot, yaw) {
57
+ function executeHotbarSlot(ctx, hotbarId, slot, yaw, pitch) {
40
58
  const stack = ctx.player.inventory.state(hotbarId).slots[slot];
41
59
  if (stack === undefined || stack === null)
42
60
  return { ok: false, error: `Hotbar slot ${slot + 1} is empty` };
@@ -44,7 +62,7 @@ function executeHotbarSlot(ctx, hotbarId, slot, yaw) {
44
62
  from: ctx.player.userId,
45
63
  itemId: stack.itemId,
46
64
  inventoryId: hotbarId,
47
- aim: { yaw, pitch: 0 },
65
+ aim: { yaw, pitch },
48
66
  });
49
67
  return result.error === undefined ? { ok: true } : { ok: false, error: result.error };
50
68
  }
@@ -63,13 +81,27 @@ function EntitySprite({ sprite }) {
63
81
  }, [texture]);
64
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 }) }));
65
83
  }
66
- 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, }) {
67
99
  const color = isLocal ? "#4ade80" : entity.role === "npc" ? colorFromId(entity.name) : "#9ca3af";
68
100
  return (_jsxs("group", { position: [entity.position[0], entity.position[1], entity.position[2]], "rotation-y": entity.rotationY, onPointerDown: (event) => {
69
101
  event.stopPropagation();
70
102
  if (!isLocal)
71
103
  onSelect(entity);
72
- }, 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] }));
73
105
  }
74
106
  function GroundPlane() {
75
107
  const geometry = useMemo(() => {
@@ -102,7 +134,7 @@ function RockField() {
102
134
  }), []);
103
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))) }));
104
136
  }
105
- function WorldView({ entitySprites }) {
137
+ function WorldView({ entitySprites, entityModels, objectModels, assets, }) {
106
138
  const ctx = useGameContext();
107
139
  const entities = useSceneEntities();
108
140
  const objects = useSceneObjects();
@@ -112,12 +144,16 @@ function WorldView({ entitySprites }) {
112
144
  const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
113
145
  ctx.scene.entity.setTarget(player.userId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
114
146
  };
115
- 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
+ })] }));
116
152
  }
117
153
  function RemotePlayers({ rows }) {
118
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))) }));
119
155
  }
120
- function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
156
+ function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
121
157
  const motionRef = useRef(createPlayerMotionState());
122
158
  const hasReportedTickError = useRef(false);
123
159
  const slotActions = useMemo(() => findHotbarSlotActions(playable.game.input), [playable]);
@@ -164,21 +200,29 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
164
200
  else
165
201
  ctx.scene.entity.setTarget(playerId, null);
166
202
  }
167
- const uiActions = {
168
- openBackpack: "ui.openBackpack",
169
- openCharacter: "ui.openCharacter",
170
- openAbilities: "ui.openAbilities",
171
- };
172
- for (const [action, command] of Object.entries(uiActions)) {
173
- if (tracker.wasPressed(action) && ctx.game.commands.has(command)) {
174
- ctx.game.commands.run(command, {});
203
+ for (const action of Object.keys(playable.game.input ?? {})) {
204
+ if (!tracker.wasPressed(action))
205
+ continue;
206
+ if (action === "interact") {
207
+ const prompts = playable.prompts?.(ctx);
208
+ const focus = prompts === undefined ? null : ctx.scene.entity.get(playerId);
209
+ if (prompts !== undefined && focus !== null) {
210
+ const active = resolveActivePrompt({ x: focus.position[0], z: focus.position[2] }, prompts);
211
+ if (active !== null && active.prompt.invoke !== null) {
212
+ ctx.game.commands.run(active.prompt.invoke.name, active.prompt.invoke.input);
213
+ }
214
+ }
215
+ continue;
175
216
  }
217
+ const command = resolveActionCommand(action, (name) => ctx.game.commands.has(name), RESERVED_INPUT_ACTIONS);
218
+ if (command !== null)
219
+ ctx.game.commands.run(command, {});
176
220
  }
177
221
  if (hotbarId !== null) {
178
222
  for (const { action, slot } of slotActions) {
179
223
  if (!tracker.wasPressed(action))
180
224
  continue;
181
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
225
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
182
226
  if (!result.ok)
183
227
  console.warn(`[jgengine:item-use] ${result.error}`);
184
228
  }
@@ -191,7 +235,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
191
235
  ? preferred
192
236
  : slots.findIndex((stack) => stack !== null);
193
237
  if (slot >= 0) {
194
- const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
238
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current, pitchRef.current);
195
239
  if (!result.ok)
196
240
  console.warn(`[jgengine:item-use] ${result.error}`);
197
241
  }
@@ -207,7 +251,7 @@ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntim
207
251
  y: focus.position[1],
208
252
  z: focus.position[2],
209
253
  rotationY: focus.rotationY,
210
- rotationPitch: 0,
254
+ rotationPitch: pitchRef.current,
211
255
  });
212
256
  }
213
257
  }
@@ -247,6 +291,7 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
247
291
  const [remotePlayers, setRemotePlayers] = useState([]);
248
292
  const wrapperRef = useRef(null);
249
293
  const yawRef = useRef(0);
294
+ const pitchRef = useRef(0);
250
295
  const serverIdRef = useRef(null);
251
296
  const cameraDraggingRef = useRef(false);
252
297
  const primaryClickRef = useRef(false);
@@ -347,6 +392,14 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
347
392
  return _jsx("div", { className: "h-full w-full bg-neutral-950" });
348
393
  const GameUI = playable.GameUI;
349
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";
350
403
  return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
351
404
  if (event.code === "Tab" || event.code === "Space")
352
405
  event.preventDefault();
@@ -377,7 +430,7 @@ export function GamePlayerShell({ playable, multiplayer = null, }) {
377
430
  else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
378
431
  ctx.game.commands.run("ui.hotbarScrollPrev", {});
379
432
  }
380
- }, 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) => {
381
434
  cameraDraggingRef.current = dragging;
382
- } })] }), _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 })] }));
383
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.2.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.2.0",
27
- "@jgengine/react": "^0.2.0",
28
- "@jgengine/ws": "^0.2.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",