@jgengine/shell 0.1.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.
@@ -0,0 +1,6 @@
1
+ import type { ShellMultiplayer } from "./multiplayer.js";
2
+ import type { PlayableGame } from "./registry.js";
3
+ export declare function GamePlayerShell({ playable, multiplayer, }: {
4
+ playable: PlayableGame;
5
+ multiplayer?: ShellMultiplayer | null;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,383 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Canvas, useFrame, useLoader } from "@react-three/fiber";
3
+ import { Component, useEffect, useMemo, useRef, useState } from "react";
4
+ import * as THREE from "three";
5
+ import { createActionStateTracker, toActionStateBindingMap, } from "@jgengine/core/input/actionBindings";
6
+ import { advancePlayerMotion, createEmptyMovementKeys, createPlayerMotionState, resolveMovementIntent, } from "@jgengine/core/movement/movementModel";
7
+ import { createGameContext } from "@jgengine/core/runtime/gameContext";
8
+ import { useGameContext } from "@jgengine/react/provider";
9
+ import { useSceneEntities, useSceneObjects, usePlayer, useTarget } from "@jgengine/react/hooks";
10
+ import { GameProvider } from "@jgengine/react/provider";
11
+ import { GAME_SIM_FRAME_PRIORITY, GameOrbitCamera } from "./camera/index.js";
12
+ const DEV_USER_ID = "dev-player";
13
+ const TURN_SPEED = 2.4;
14
+ const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
15
+ const GROUND_SIZE = 160;
16
+ const GROUND_SEGMENTS = 80;
17
+ function errorToDiagnostic(error, phase) {
18
+ if (error instanceof Error) {
19
+ return { phase, message: error.message, stack: error.stack };
20
+ }
21
+ return { phase, message: typeof error === "string" ? error : JSON.stringify(error) };
22
+ }
23
+ function logRuntimeError(error, phase) {
24
+ const diagnostic = errorToDiagnostic(error, phase);
25
+ console.error(`[jgengine:${phase}] ${diagnostic.message}`, error);
26
+ return diagnostic;
27
+ }
28
+ function findHotbarSlotActions(input) {
29
+ 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 }];
32
+ });
33
+ }
34
+ function hotbarIdFor(playable) {
35
+ const declarations = Object.entries(playable.game.inventories ?? {});
36
+ const hud = declarations.find(([, declaration]) => declaration.hud === "hotbar");
37
+ return (hud ?? declarations[0])?.[0] ?? null;
38
+ }
39
+ function executeHotbarSlot(ctx, hotbarId, slot, yaw) {
40
+ const stack = ctx.player.inventory.state(hotbarId).slots[slot];
41
+ if (stack === undefined || stack === null)
42
+ return { ok: false, error: `Hotbar slot ${slot + 1} is empty` };
43
+ const result = ctx.item.use.use({
44
+ from: ctx.player.userId,
45
+ itemId: stack.itemId,
46
+ inventoryId: hotbarId,
47
+ aim: { yaw, pitch: 0 },
48
+ });
49
+ return result.error === undefined ? { ok: true } : { ok: false, error: result.error };
50
+ }
51
+ function colorFromId(id) {
52
+ let hash = 0;
53
+ for (let index = 0; index < id.length; index += 1)
54
+ hash = (hash * 31 + id.charCodeAt(index)) >>> 0;
55
+ return `hsl(${hash % 360}, 65%, 55%)`;
56
+ }
57
+ function EntitySprite({ sprite }) {
58
+ const texture = useLoader(THREE.TextureLoader, sprite.url);
59
+ useEffect(() => {
60
+ texture.colorSpace = THREE.SRGBColorSpace;
61
+ texture.anisotropy = 4;
62
+ texture.needsUpdate = true;
63
+ }, [texture]);
64
+ 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
+ }
66
+ function EntityMarker({ entity, sprite, isLocal, targeted, onSelect, }) {
67
+ const color = isLocal ? "#4ade80" : entity.role === "npc" ? colorFromId(entity.name) : "#9ca3af";
68
+ return (_jsxs("group", { position: [entity.position[0], entity.position[1], entity.position[2]], "rotation-y": entity.rotationY, onPointerDown: (event) => {
69
+ event.stopPropagation();
70
+ if (!isLocal)
71
+ 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] }));
73
+ }
74
+ function GroundPlane() {
75
+ const geometry = useMemo(() => {
76
+ const next = new THREE.PlaneGeometry(GROUND_SIZE, GROUND_SIZE, GROUND_SEGMENTS, GROUND_SEGMENTS);
77
+ const positions = next.attributes.position;
78
+ for (let index = 0; index < positions.count; index += 1) {
79
+ const x = positions.getX(index);
80
+ const y = positions.getY(index);
81
+ const height = Math.sin(x * 0.16) * 0.12 +
82
+ Math.cos(y * 0.11) * 0.1 +
83
+ Math.sin((x + y) * 0.05) * 0.16;
84
+ positions.setZ(index, height);
85
+ }
86
+ next.computeVertexNormals();
87
+ return next;
88
+ }, []);
89
+ return (_jsx("mesh", { "rotation-x": -Math.PI / 2, geometry: geometry, children: _jsx("meshStandardMaterial", { color: "#283729", roughness: 0.9, metalness: 0 }) }));
90
+ }
91
+ function RockField() {
92
+ const rocks = useMemo(() => Array.from({ length: 18 }, (_, index) => {
93
+ const angle = index * 2.399963229728653;
94
+ const radius = 10 + ((index * 17) % 58);
95
+ return {
96
+ id: `rock-${index}`,
97
+ x: Math.cos(angle) * radius,
98
+ z: Math.sin(angle) * radius,
99
+ scale: 0.45 + ((index * 13) % 11) / 10,
100
+ rotation: angle,
101
+ };
102
+ }), []);
103
+ 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
+ }
105
+ function WorldView({ entitySprites }) {
106
+ const ctx = useGameContext();
107
+ const entities = useSceneEntities();
108
+ const objects = useSceneObjects();
109
+ const player = usePlayer();
110
+ const targetId = useTarget(player.userId);
111
+ const handleSelect = (entity) => {
112
+ const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
113
+ ctx.scene.entity.setTarget(player.userId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
114
+ };
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)))] }));
116
+ }
117
+ function RemotePlayers({ rows }) {
118
+ 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
+ }
120
+ function FrameDriver({ ctx, playable, tracker, yawRef, primaryClickRef, onRuntimeError, multiplayer, serverIdRef, }) {
121
+ const motionRef = useRef(createPlayerMotionState());
122
+ const hasReportedTickError = useRef(false);
123
+ const slotActions = useMemo(() => findHotbarSlotActions(playable.game.input), [playable]);
124
+ const hotbarId = useMemo(() => hotbarIdFor(playable), [playable]);
125
+ useFrame((_state, rawDt) => {
126
+ try {
127
+ const dt = Math.min(rawDt, 0.05);
128
+ if (tracker.isDown("turnLeft"))
129
+ yawRef.current += TURN_SPEED * dt;
130
+ if (tracker.isDown("turnRight"))
131
+ yawRef.current -= TURN_SPEED * dt;
132
+ const playerId = ctx.player.userId;
133
+ const player = ctx.scene.entity.get(playerId);
134
+ const forwardX = Math.sin(yawRef.current);
135
+ const forwardZ = Math.cos(yawRef.current);
136
+ if (player !== null) {
137
+ const keys = createEmptyMovementKeys();
138
+ keys.w = tracker.isDown("moveForward");
139
+ keys.s = tracker.isDown("moveBack");
140
+ keys.a = tracker.isDown("moveLeft");
141
+ keys.d = tracker.isDown("moveRight");
142
+ keys.shift = tracker.isDown("sprint");
143
+ keys.space = tracker.isDown("jump");
144
+ const intent = resolveMovementIntent(keys, true);
145
+ const motion = motionRef.current;
146
+ const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, player.movement.walkSpeed ?? 2, rawDt);
147
+ ctx.scene.entity.setPose(playerId, {
148
+ position: [player.position[0] + step.stepX, motion.jumpOffset, player.position[2] + step.stepZ],
149
+ rotationY: intent.moving
150
+ ? Math.atan2(motion.horizontalVelocityX, motion.horizontalVelocityZ)
151
+ : player.rotationY,
152
+ });
153
+ }
154
+ playable.loop.onTick(ctx, dt);
155
+ if (tracker.wasPressed("tabTarget")) {
156
+ if (ctx.game.commands.has("target.cycle"))
157
+ ctx.game.commands.run("target.cycle", {});
158
+ else
159
+ ctx.scene.entity.cycleTarget(playerId, { filter: "hostile" });
160
+ }
161
+ if (tracker.wasPressed("clearTarget")) {
162
+ if (ctx.game.commands.has("target.clear"))
163
+ ctx.game.commands.run("target.clear", {});
164
+ else
165
+ ctx.scene.entity.setTarget(playerId, null);
166
+ }
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, {});
175
+ }
176
+ }
177
+ if (hotbarId !== null) {
178
+ for (const { action, slot } of slotActions) {
179
+ if (!tracker.wasPressed(action))
180
+ continue;
181
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
182
+ if (!result.ok)
183
+ console.warn(`[jgengine:item-use] ${result.error}`);
184
+ }
185
+ const usePrimary = tracker.wasPressed("useAbility") || (primaryClickRef.current && hotbarId !== null);
186
+ if (usePrimary) {
187
+ primaryClickRef.current = false;
188
+ const slots = ctx.player.inventory.state(hotbarId).slots;
189
+ const preferred = playable.hotbarSelection?.() ?? -1;
190
+ const slot = preferred >= 0 && slots[preferred] !== null && slots[preferred] !== undefined
191
+ ? preferred
192
+ : slots.findIndex((stack) => stack !== null);
193
+ if (slot >= 0) {
194
+ const result = executeHotbarSlot(ctx, hotbarId, slot, yawRef.current);
195
+ if (!result.ok)
196
+ console.warn(`[jgengine:item-use] ${result.error}`);
197
+ }
198
+ }
199
+ }
200
+ tracker.endFrame();
201
+ const serverId = serverIdRef.current;
202
+ if (multiplayer !== null && serverId !== null) {
203
+ const focus = ctx.scene.entity.get(playerId);
204
+ if (focus !== null) {
205
+ multiplayer.backend.presenceSync.syncPose(serverId, {
206
+ x: focus.position[0],
207
+ y: focus.position[1],
208
+ z: focus.position[2],
209
+ rotationY: focus.rotationY,
210
+ rotationPitch: 0,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (!hasReportedTickError.current) {
217
+ hasReportedTickError.current = true;
218
+ onRuntimeError(error, "tick");
219
+ }
220
+ }
221
+ }, GAME_SIM_FRAME_PRIORITY);
222
+ return null;
223
+ }
224
+ class GameUiErrorBoundary extends Component {
225
+ state = { failed: false };
226
+ static getDerivedStateFromError() {
227
+ return { failed: true };
228
+ }
229
+ componentDidCatch(error) {
230
+ this.props.onRuntimeError(error, "ui-render");
231
+ }
232
+ render() {
233
+ if (this.state.failed)
234
+ return null;
235
+ return this.props.children;
236
+ }
237
+ }
238
+ function DiagnosticOverlay({ diagnostics }) {
239
+ if (diagnostics.length === 0)
240
+ return null;
241
+ const latest = diagnostics[diagnostics.length - 1];
242
+ return (_jsxs("div", { className: "pointer-events-auto absolute right-4 top-4 z-50 max-w-lg rounded border border-red-400/60 bg-red-950/95 p-3 text-xs text-red-50 shadow-2xl", children: [_jsx("div", { className: "mb-1 font-semibold uppercase tracking-wide text-red-200", children: "JG engine error" }), _jsxs("div", { className: "font-mono text-[11px] text-red-100", children: ["[", latest.phase, "] ", latest.message] }), latest.stack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.stack })) : null] }));
243
+ }
244
+ export function GamePlayerShell({ playable, multiplayer = null, }) {
245
+ const [ctx, setCtx] = useState(null);
246
+ const [diagnostics, setDiagnostics] = useState([]);
247
+ const [remotePlayers, setRemotePlayers] = useState([]);
248
+ const wrapperRef = useRef(null);
249
+ const yawRef = useRef(0);
250
+ const serverIdRef = useRef(null);
251
+ const cameraDraggingRef = useRef(false);
252
+ const primaryClickRef = useRef(false);
253
+ const pointerDownRef = useRef(null);
254
+ const tracker = useMemo(() => createActionStateTracker(toActionStateBindingMap(playable.game.input ?? {})), [playable]);
255
+ const userId = multiplayer?.userId ?? DEV_USER_ID;
256
+ const reportRuntimeError = (error, phase) => {
257
+ const diagnostic = logRuntimeError(error, phase);
258
+ setDiagnostics((current) => [...current.slice(-4), { ...diagnostic, id: Date.now() + current.length }]);
259
+ };
260
+ useEffect(() => {
261
+ playable.game.scene.clear();
262
+ setDiagnostics([]);
263
+ try {
264
+ const context = createGameContext({
265
+ definition: playable.game,
266
+ content: playable.content,
267
+ player: { userId, isNew: true },
268
+ });
269
+ playable.loop.onInit(context);
270
+ playable.loop.onNewPlayer(context);
271
+ setCtx(context);
272
+ }
273
+ catch (error) {
274
+ reportRuntimeError(error, "init");
275
+ setCtx(null);
276
+ }
277
+ return () => {
278
+ playable.game.scene.clear();
279
+ setCtx(null);
280
+ };
281
+ }, [playable, userId]);
282
+ useEffect(() => {
283
+ if (ctx === null || multiplayer === null)
284
+ return;
285
+ let disposed = false;
286
+ const cleanups = [];
287
+ void multiplayer.backend.transport
288
+ .joinServer({ gameId: multiplayer.gameId })
289
+ .then((joined) => {
290
+ if (disposed) {
291
+ void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId });
292
+ return;
293
+ }
294
+ serverIdRef.current = joined.serverId;
295
+ cleanups.push(multiplayer.backend.presenceSync.subscribe(joined.serverId, (rows) => {
296
+ setRemotePlayers(rows.filter((row) => row.userId !== multiplayer.userId));
297
+ }));
298
+ const seen = new Set();
299
+ let injecting = false;
300
+ for (const action of multiplayer.feedActions) {
301
+ cleanups.push(ctx.game.feed.subscribe(action, (entry) => {
302
+ if (injecting)
303
+ return;
304
+ void multiplayer.backend
305
+ .pushFeedEntry({
306
+ serverId: joined.serverId,
307
+ action,
308
+ entry: { at: entry.at, data: entry.data, from: multiplayer.userId },
309
+ })
310
+ .catch(() => undefined);
311
+ }));
312
+ const remoteUnsub = multiplayer.backend.feeds?.subscribeFeed({ serverId: joined.serverId, action }, (view) => {
313
+ for (const raw of view.entries) {
314
+ const remote = raw;
315
+ if (typeof remote.from !== "string" || remote.from === multiplayer.userId)
316
+ continue;
317
+ const key = `${action}|${remote.from}|${remote.at ?? 0}`;
318
+ if (seen.has(key))
319
+ continue;
320
+ seen.add(key);
321
+ injecting = true;
322
+ ctx.game.feed.push(action, remote.data);
323
+ injecting = false;
324
+ }
325
+ });
326
+ if (remoteUnsub !== undefined)
327
+ cleanups.push(remoteUnsub);
328
+ }
329
+ })
330
+ .catch(() => undefined);
331
+ return () => {
332
+ disposed = true;
333
+ for (const cleanup of cleanups)
334
+ cleanup();
335
+ const serverId = serverIdRef.current;
336
+ serverIdRef.current = null;
337
+ setRemotePlayers([]);
338
+ if (serverId !== null) {
339
+ void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
340
+ }
341
+ };
342
+ }, [ctx, multiplayer]);
343
+ useEffect(() => {
344
+ wrapperRef.current?.focus();
345
+ }, [ctx]);
346
+ if (ctx === null)
347
+ return _jsx("div", { className: "h-full w-full bg-neutral-950" });
348
+ const GameUI = playable.GameUI;
349
+ const WorldOverlay = playable.WorldOverlay;
350
+ return (_jsxs("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: (event) => {
351
+ if (event.code === "Tab" || event.code === "Space")
352
+ event.preventDefault();
353
+ tracker.handleDown(event.code);
354
+ }, onKeyUp: (event) => tracker.handleUp(event.code), onBlur: () => tracker.reset(), onPointerDown: (event) => {
355
+ wrapperRef.current?.focus();
356
+ if (event.button === 0) {
357
+ pointerDownRef.current = { x: event.clientX, y: event.clientY };
358
+ }
359
+ }, onPointerUp: (event) => {
360
+ if (event.button !== 0 || pointerDownRef.current === null)
361
+ return;
362
+ const start = pointerDownRef.current;
363
+ pointerDownRef.current = null;
364
+ const dx = event.clientX - start.x;
365
+ const dy = event.clientY - start.y;
366
+ const moved = dx * dx + dy * dy;
367
+ if (!cameraDraggingRef.current && moved <= PRIMARY_CLICK_MOVE_THRESHOLD_PX * PRIMARY_CLICK_MOVE_THRESHOLD_PX) {
368
+ primaryClickRef.current = true;
369
+ }
370
+ }, onWheel: (event) => {
371
+ if (ctx === null || !event.shiftKey)
372
+ return;
373
+ event.preventDefault();
374
+ if (event.deltaY < 0 && ctx.game.commands.has("ui.hotbarScrollNext")) {
375
+ ctx.game.commands.run("ui.hotbarScrollNext", {});
376
+ }
377
+ else if (event.deltaY > 0 && ctx.game.commands.has("ui.hotbarScrollPrev")) {
378
+ ctx.game.commands.run("ui.hotbarScrollPrev", {});
379
+ }
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) => {
381
+ 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 })] }));
383
+ }
@@ -0,0 +1,8 @@
1
+ import { type GameContext } from "@jgengine/core/runtime/gameContext";
2
+ import type { PlayableGame } from "./registry.js";
3
+ export type UiPreviewScenario = (ctx: GameContext, playable: PlayableGame) => void;
4
+ export declare const defaultUiScenario: UiPreviewScenario;
5
+ export declare function GameUiPreview({ playable, scenario, }: {
6
+ playable: PlayableGame;
7
+ scenario?: UiPreviewScenario;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { createGameContext } from "@jgengine/core/runtime/gameContext";
4
+ import { GameProvider } from "@jgengine/react/provider";
5
+ const PREVIEW_USER_ID = "ui-preview";
6
+ const TICK_STEP = 1 / 60;
7
+ function runTicks(ctx, playable, seconds) {
8
+ const steps = Math.round(seconds / TICK_STEP);
9
+ for (let index = 0; index < steps; index += 1) {
10
+ playable.loop.onTick(ctx, TICK_STEP);
11
+ }
12
+ }
13
+ export const defaultUiScenario = (ctx, playable) => {
14
+ runTicks(ctx, playable, 4);
15
+ if (ctx.game.commands.has("target.cycle"))
16
+ ctx.game.commands.run("target.cycle", {});
17
+ else
18
+ ctx.scene.entity.cycleTarget(PREVIEW_USER_ID, { filter: "hostile" });
19
+ const declarations = Object.entries(playable.game.inventories ?? {});
20
+ const hotbarId = (declarations.find(([, declaration]) => declaration.hud === "hotbar") ??
21
+ declarations[0])?.[0];
22
+ if (hotbarId !== undefined) {
23
+ const slots = ctx.player.inventory.state(hotbarId).slots;
24
+ const slot = slots.findIndex((stack) => stack !== null && stack !== undefined);
25
+ if (slot >= 0) {
26
+ ctx.item.use.use({
27
+ from: PREVIEW_USER_ID,
28
+ itemId: slots[slot].itemId,
29
+ inventoryId: hotbarId,
30
+ aim: { yaw: 0, pitch: 0 },
31
+ });
32
+ }
33
+ }
34
+ runTicks(ctx, playable, 0.5);
35
+ };
36
+ export function GameUiPreview({ playable, scenario = defaultUiScenario, }) {
37
+ const [ctx, setCtx] = useState(null);
38
+ useEffect(() => {
39
+ playable.game.scene.clear();
40
+ const context = createGameContext({
41
+ definition: playable.game,
42
+ content: playable.content,
43
+ player: { userId: PREVIEW_USER_ID, isNew: true },
44
+ });
45
+ playable.loop.onInit(context);
46
+ playable.loop.onNewPlayer(context);
47
+ try {
48
+ scenario(context, playable);
49
+ }
50
+ catch (error) {
51
+ console.error("[jgengine:ui-preview] scenario failed", error);
52
+ }
53
+ setCtx(context);
54
+ return () => {
55
+ playable.game.scene.clear();
56
+ setCtx(null);
57
+ };
58
+ }, [playable, scenario]);
59
+ if (ctx === null)
60
+ return _jsx("div", { className: "h-full w-full bg-neutral-900" });
61
+ const GameUI = playable.GameUI;
62
+ return (_jsx("div", { "data-ui-preview-ready": true, className: "relative h-full w-full overflow-hidden", style: { background: "linear-gradient(180deg, #2a3d33 0%, #1a2320 55%, #141b18 100%)" }, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }));
63
+ }
@@ -0,0 +1,17 @@
1
+ import { type MutableRefObject } from "react";
2
+ import { type Camera, Vector3 } from "three";
3
+ import type { SceneEntity } from "@jgengine/core/scene/entityStore";
4
+ import { type CameraFollowState, type OrbitCameraConfig, type Vec3 } from "./orbitCameraMath.js";
5
+ export type CameraFollowListener = (state: CameraFollowState) => void;
6
+ export interface GameOrbitCameraProps {
7
+ yawRef: MutableRefObject<number>;
8
+ config?: Partial<OrbitCameraConfig>;
9
+ followEntityId?: string;
10
+ /** Override orbit target derived from the followed entity. */
11
+ resolveFollowTarget?: (entity: SceneEntity) => Vec3;
12
+ onDragChange?: (dragging: boolean) => void;
13
+ onCameraFollow?: CameraFollowListener;
14
+ }
15
+ export declare function GameOrbitCamera({ yawRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, }: GameOrbitCameraProps): import("react").JSX.Element;
16
+ /** Seed orbit target before controls mount (demo spawn at origin). */
17
+ export declare function seedOrbitCameraTarget(camera: Camera, target: Vector3, distance: number, height: number): void;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { OrbitControls } from "@react-three/drei";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import { useEffect, useRef } from "react";
5
+ import { MOUSE, Vector3 } from "three";
6
+ import { useGameContext } from "@jgengine/react/provider";
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, }) {
10
+ const config = resolveOrbitCameraConfig(configPatch);
11
+ const controlsRef = useRef(null);
12
+ const runtimeRef = useRef(null);
13
+ const draggingRef = useRef(false);
14
+ const { userId } = usePlayer();
15
+ const ctx = useGameContext();
16
+ const camera = useThree((state) => state.camera);
17
+ const followId = followEntityId ?? userId;
18
+ useEffect(() => {
19
+ const entity = ctx.scene.entity.get(followId);
20
+ if (entity === null || runtimeRef.current !== null)
21
+ return;
22
+ const seeded = seedOrbitFollowState({ entityPosition: entity.position, config });
23
+ runtimeRef.current = seeded;
24
+ camera.position.set(seeded.camera.x, seeded.camera.y, seeded.camera.z);
25
+ camera.lookAt(seeded.target.x, seeded.target.y, seeded.target.z);
26
+ }, [camera, config, ctx, followId]);
27
+ useFrame((_, delta) => {
28
+ const controls = controlsRef.current;
29
+ const entity = ctx.scene.entity.get(followId);
30
+ if (controls === null || entity === null)
31
+ return;
32
+ if (runtimeRef.current === null) {
33
+ runtimeRef.current = seedOrbitFollowState({ entityPosition: entity.position, config });
34
+ camera.position.set(runtimeRef.current.camera.x, runtimeRef.current.camera.y, runtimeRef.current.camera.z);
35
+ controls.target.set(runtimeRef.current.target.x, runtimeRef.current.target.y, runtimeRef.current.target.z);
36
+ }
37
+ const runtime = runtimeRef.current;
38
+ const previousTarget = runtime.target;
39
+ const desiredTarget = resolveFollowTarget?.(entity) ?? resolveFollowTargetFromPosition(entity.position, config);
40
+ const stepped = orbitFollowStep({
41
+ state: runtime,
42
+ desiredTarget,
43
+ deltaSeconds: delta,
44
+ config,
45
+ dragging: draggingRef.current,
46
+ });
47
+ controls.target.set(stepped.target.x, stepped.target.y, stepped.target.z);
48
+ if (draggingRef.current) {
49
+ camera.position.x += stepped.target.x - previousTarget.x;
50
+ camera.position.y += stepped.target.y - previousTarget.y;
51
+ camera.position.z += stepped.target.z - previousTarget.z;
52
+ }
53
+ else {
54
+ camera.position.set(stepped.camera.x, stepped.camera.y, stepped.camera.z);
55
+ }
56
+ controls.update();
57
+ runtimeRef.current = {
58
+ target: stepped.target,
59
+ camera: { x: camera.position.x, y: camera.position.y, z: camera.position.z },
60
+ lockedDistance: draggingRef.current ? controls.getDistance() : stepped.lockedDistance,
61
+ };
62
+ const [px, , pz] = entity.position;
63
+ yawRef.current = orbitYawFromCamera(camera.position.x, camera.position.z, px, pz);
64
+ onCameraFollow?.({
65
+ entityId: followId,
66
+ target: stepped.target,
67
+ camera: runtimeRef.current.camera,
68
+ distance: runtimeRef.current.lockedDistance ?? stepped.distance,
69
+ });
70
+ }, 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: () => {
72
+ draggingRef.current = true;
73
+ onDragChange?.(true);
74
+ }, onEnd: () => {
75
+ draggingRef.current = false;
76
+ onDragChange?.(false);
77
+ if (controlsRef.current !== null && runtimeRef.current !== null) {
78
+ runtimeRef.current.lockedDistance = controlsRef.current.getDistance();
79
+ }
80
+ } }));
81
+ }
82
+ /** Seed orbit target before controls mount (demo spawn at origin). */
83
+ export function seedOrbitCameraTarget(camera, target, distance, height) {
84
+ camera.position.set(target.x, height, target.z - distance);
85
+ camera.lookAt(target);
86
+ }
@@ -0,0 +1,2 @@
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";
@@ -0,0 +1,2 @@
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";