@jgengine/shell 0.3.0 → 0.5.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All eight `@jgengine/*` packages are versioned in lockstep, so this one file
4
+ covers every release. Format follows [Keep a Changelog](https://keepachangelog.com);
5
+ each release **leads with a Migrate block** — the concrete steps to move a game
6
+ from the previous version onto the new APIs — because the point of a bump is to
7
+ let consumers pick up the better stuff, not just to list what moved.
8
+
9
+ Agents building on the published SDK can also read this programmatically:
10
+ `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
11
+ same data as typed values, so an updater can diff its installed version against
12
+ the latest and surface the migration steps.
13
+
14
+ ## Unreleased
15
+
16
+ _Nothing yet._
17
+
18
+ ## 0.5.0
19
+
20
+ An additive release: **every 0.4.0 API is unchanged**, so upgrading is only a
21
+ version bump. New pure primitives across progression, inventory slots, world
22
+ geometry, and React store bindings.
23
+
24
+ ### Migrate
25
+
26
+ - Bump every `@jgengine/*` dependency to `^0.5.0` (the eight packages version in lockstep).
27
+ - No code change is required — 0.5.0 adds surface, it doesn't move or remove any.
28
+ - Optional: replace a game's hand-rolled `progression/curves.ts` with the new `leveling(...)` track. `leveling({ xpForLevel: { kind: "power", base, exponent, round: "floor" }, maxLevel })` returns `xpForLevel`, `resolve`, and `grantXp(ctx.scene.entity.stats, userId, amount, onLevelUp?)` — a drop-in for the old `xpRequiredForLevel` / `resolveLevelProgress` / `grantXp` exports. `ctx.scene.entity.stats` satisfies the primitive's `LevelingStatAccess` structurally, so no adapter is needed.
29
+
30
+ ### Added
31
+
32
+ - `@jgengine/core/game/progression` — genre-agnostic progression primitive. `curve(spec)` / `evalCurve(spec, x)` evaluate declarative scalar curves (`const`, `linear`, `power`, `geometric`, `steps`, `piecewise`, each with optional `round`/`min`/`max`) for speed-by-level, difficulty-by-wave, loot drop-rate ramps, and similar scaling. `leveling(config)` builds the stateful XP→level track (threshold accumulation, multi-level grants, cap handling, `stat.levelUp` emit) on top of an `xpForLevel` curve.
33
+ - `@jgengine/core/inventory/slotModel` — pure slot-grid primitives (`createSlots`, `placeAt`, `removeAt`, `moveSlot`).
34
+ - `@jgengine/core/world/geometry`, `/world/interiors`, `/world/placement` — pure world primitives: grid snapping, footprint AABBs and overlap, interior/exterior spaces, and placement validation.
35
+ - `@jgengine/react/engineStore` — raw-store React bindings (`useEngineState`, `useEngineStore`, `useEngineEvent`).
36
+ - Pure/functional tiers for the `trade`, `unlocks`, `quest`, and `feed` verbs in `@jgengine/core/game`.
37
+
38
+ ## 0.4.0
39
+
40
+ Baseline release: the eight `@jgengine/*` packages (core, ws, sql, react,
41
+ convex, node, shell, assets) as the first tracked version. No migration —
42
+ this is the floor changelog entries are measured against.
@@ -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.5.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",
@@ -10,7 +10,10 @@
10
10
  "url": "git+https://github.com/Noisemaker111/jgengine.git",
11
11
  "directory": "packages/shell"
12
12
  },
13
- "files": ["dist"],
13
+ "files": [
14
+ "dist",
15
+ "CHANGELOG.md"
16
+ ],
14
17
  "exports": {
15
18
  "./*": {
16
19
  "types": "./dist/*.d.ts",
@@ -18,14 +21,14 @@
18
21
  }
19
22
  },
20
23
  "scripts": {
21
- "build": "tsc -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
22
- "check-types": "tsc --noEmit -p tsconfig.json",
24
+ "build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
25
+ "check-types": "tsgo --noEmit -p tsconfig.json",
23
26
  "test": "bun test src"
24
27
  },
25
28
  "dependencies": {
26
- "@jgengine/core": "^0.3.0",
27
- "@jgengine/react": "^0.3.0",
28
- "@jgengine/ws": "^0.3.0"
29
+ "@jgengine/core": "^0.5.0",
30
+ "@jgengine/react": "^0.5.0",
31
+ "@jgengine/ws": "^0.5.0"
29
32
  },
30
33
  "peerDependencies": {
31
34
  "@react-three/drei": "^10.0.0",