@jgengine/react 0.8.0 → 0.9.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 +16 -0
- package/dist/chatBubbles.d.ts +15 -0
- package/dist/chatBubbles.js +45 -0
- package/dist/components.d.ts +1 -0
- package/dist/components.js +35 -22
- package/dist/display.d.ts +2 -1
- package/dist/display.js +3 -2
- package/dist/dragLayer.d.ts +6 -1
- package/dist/dragLayer.js +64 -31
- package/dist/engineStore.d.ts +1 -1
- package/dist/engineStore.js +10 -2
- package/dist/fogOverlay.d.ts +21 -0
- package/dist/fogOverlay.js +34 -0
- package/dist/hooks.d.ts +9 -1
- package/dist/hooks.js +63 -11
- package/dist/hudLayout.d.ts +61 -0
- package/dist/hudLayout.js +488 -0
- package/dist/hudViewport.d.ts +21 -0
- package/dist/hudViewport.js +17 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/liveBind.d.ts +4 -10
- package/dist/liveBind.js +28 -16
- package/dist/map.d.ts +27 -7
- package/dist/map.js +152 -43
- package/dist/preview.d.ts +6 -0
- package/dist/preview.js +1 -0
- package/dist/selectSnapshot.d.ts +7 -0
- package/dist/selectSnapshot.js +16 -0
- package/dist/settings.d.ts +75 -0
- package/dist/settings.js +61 -0
- package/dist/skillCheckPaint.d.ts +4 -0
- package/dist/skillCheckPaint.js +28 -0
- package/llms.txt +776 -869
- package/package.json +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,22 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
11
11
|
same data as typed values, so an updater can diff its installed version against
|
|
12
12
|
the latest and surface the migration steps.
|
|
13
13
|
|
|
14
|
+
## Unreleased
|
|
15
|
+
|
|
16
|
+
## 0.9.0
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
|
|
21
|
+
- Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
|
|
22
|
+
- HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
|
|
23
|
+
- Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
|
|
24
|
+
- Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
|
|
29
|
+
|
|
14
30
|
## 0.8.0
|
|
15
31
|
|
|
16
32
|
Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ChatMessage } from "@jgengine/core/game/chat";
|
|
2
|
+
export interface ChatBubble {
|
|
3
|
+
id: string;
|
|
4
|
+
fromUserId: string;
|
|
5
|
+
body: string;
|
|
6
|
+
at: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ChatBubblesOptions {
|
|
9
|
+
channelId?: string;
|
|
10
|
+
ttlMs?: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function latestChatBubbles(messages: readonly ChatMessage[], nowMs: number, ttlMs: number): ChatBubble[];
|
|
14
|
+
export declare function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[];
|
|
15
|
+
export declare function useEntityChatBubble(instanceId: string, options?: ChatBubblesOptions): ChatBubble | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useGameStore } from "./hooks.js";
|
|
3
|
+
const DEFAULT_CHAT_BUBBLE_CHANNEL = "proximity";
|
|
4
|
+
const DEFAULT_CHAT_BUBBLE_TTL_MS = 4000;
|
|
5
|
+
const DEFAULT_CHAT_BUBBLE_LIMIT = 8;
|
|
6
|
+
const CHAT_BUBBLE_TICK_MS = 500;
|
|
7
|
+
export function latestChatBubbles(messages, nowMs, ttlMs) {
|
|
8
|
+
const cutoff = nowMs - ttlMs;
|
|
9
|
+
const latestByUser = new Map();
|
|
10
|
+
for (const message of messages) {
|
|
11
|
+
if (message.at <= cutoff)
|
|
12
|
+
continue;
|
|
13
|
+
const current = latestByUser.get(message.fromUserId);
|
|
14
|
+
if (current === undefined || message.at > current.at) {
|
|
15
|
+
latestByUser.set(message.fromUserId, message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return [...latestByUser.values()]
|
|
19
|
+
.sort((a, b) => a.at - b.at)
|
|
20
|
+
.map((message) => ({
|
|
21
|
+
id: message.id,
|
|
22
|
+
fromUserId: message.fromUserId,
|
|
23
|
+
body: message.body,
|
|
24
|
+
at: message.at,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
export function useChatBubbles(options) {
|
|
28
|
+
const channelId = options?.channelId ?? DEFAULT_CHAT_BUBBLE_CHANNEL;
|
|
29
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_CHAT_BUBBLE_TTL_MS;
|
|
30
|
+
const limit = options?.limit ?? DEFAULT_CHAT_BUBBLE_LIMIT;
|
|
31
|
+
const messages = useGameStore((ctx) => ctx.game.chat.history(channelId, { limit }));
|
|
32
|
+
const [now, setNow] = useState(() => Date.now());
|
|
33
|
+
const hasLiveMessage = messages.some((message) => message.at > now - ttlMs);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!hasLiveMessage)
|
|
36
|
+
return undefined;
|
|
37
|
+
const id = setInterval(() => setNow(Date.now()), CHAT_BUBBLE_TICK_MS);
|
|
38
|
+
return () => clearInterval(id);
|
|
39
|
+
}, [hasLiveMessage]);
|
|
40
|
+
return useMemo(() => latestChatBubbles(messages, now, ttlMs), [messages, now, ttlMs]);
|
|
41
|
+
}
|
|
42
|
+
export function useEntityChatBubble(instanceId, options) {
|
|
43
|
+
const bubbles = useChatBubbles(options);
|
|
44
|
+
return bubbles.find((bubble) => bubble.fromUserId === instanceId) ?? null;
|
|
45
|
+
}
|
package/dist/components.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type QteStep } from "@jgengine/core/interaction/qte";
|
|
|
6
6
|
import type { FeedEntry } from "@jgengine/core/game/feed";
|
|
7
7
|
import type { StatLevelUpEvent } from "@jgengine/core/game/events";
|
|
8
8
|
import { type CheckAdvantage, type CheckResult } from "@jgengine/core/stats/rollCheck";
|
|
9
|
+
export { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
|
|
9
10
|
export declare function SlotGrid({ inventoryId, className, renderSlot, }: {
|
|
10
11
|
inventoryId: string;
|
|
11
12
|
className?: string;
|
package/dist/components.js
CHANGED
|
@@ -5,6 +5,8 @@ import { pendingQteStep } from "@jgengine/core/interaction/qte";
|
|
|
5
5
|
import { rollCheck } from "@jgengine/core/stats/rollCheck";
|
|
6
6
|
import { useGameContext } from "./provider.js";
|
|
7
7
|
import { useCurrency, useEntityStat, useFeed, useInventory, useLocalPlayerDead } from "./hooks.js";
|
|
8
|
+
import { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
|
|
9
|
+
export { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
|
|
8
10
|
export function SlotGrid({ inventoryId, className, renderSlot, }) {
|
|
9
11
|
const slots = useInventory(inventoryId);
|
|
10
12
|
return (_jsx("div", { className: className, "data-inventory": inventoryId, children: slots.map((slot, index) => (_jsx("div", { "data-slot": index, children: renderSlot !== undefined
|
|
@@ -55,45 +57,56 @@ export function DialogueBox({ dialogue, onChoice, rng, className, lineClassName,
|
|
|
55
57
|
}
|
|
56
58
|
export function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }) {
|
|
57
59
|
const ctx = useGameContext();
|
|
58
|
-
const
|
|
60
|
+
const rootRef = useRef(null);
|
|
61
|
+
const zoneRef = useRef(null);
|
|
62
|
+
const markerRef = useRef(null);
|
|
63
|
+
const [status, setStatus] = useState(null);
|
|
64
|
+
const statusKeyRef = useRef("");
|
|
59
65
|
useEffect(() => {
|
|
60
66
|
let frame;
|
|
61
67
|
const step = () => {
|
|
62
|
-
|
|
68
|
+
const elapsed = Math.max(0, ctx.time.now() - startedAt);
|
|
69
|
+
const result = evaluateSkillCheck(config, elapsed);
|
|
70
|
+
const root = rootRef.current;
|
|
71
|
+
const zone = zoneRef.current;
|
|
72
|
+
const marker = markerRef.current;
|
|
73
|
+
if (root !== null && zone !== null && marker !== null) {
|
|
74
|
+
paintSkillCheckDom(root, zone, marker, config, result);
|
|
75
|
+
}
|
|
76
|
+
if (renderStatus !== undefined) {
|
|
77
|
+
const key = `${result.success}:${result.timedOut}:${Math.round(result.markerPosition)}`;
|
|
78
|
+
if (key !== statusKeyRef.current) {
|
|
79
|
+
statusKeyRef.current = key;
|
|
80
|
+
setStatus(result);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
63
83
|
frame = requestAnimationFrame(step);
|
|
64
84
|
};
|
|
65
85
|
frame = requestAnimationFrame(step);
|
|
66
86
|
return () => cancelAnimationFrame(frame);
|
|
67
|
-
}, []);
|
|
68
|
-
|
|
69
|
-
const result = evaluateSkillCheck(config, elapsed);
|
|
70
|
-
const zoneLeft = (result.zone.start / config.trackWidth) * 100;
|
|
71
|
-
const zoneWidth = ((result.zone.end - result.zone.start) / config.trackWidth) * 100;
|
|
72
|
-
const markerLeft = (result.markerPosition / config.trackWidth) * 100;
|
|
73
|
-
return (_jsxs("div", { className: className, "data-skill-check": true, "data-in-zone": result.success, "data-timed-out": result.timedOut, children: [_jsxs("div", { className: trackClassName, "data-track": true, style: { position: "relative" }, children: [_jsx("div", { className: zoneClassName, "data-zone": true, style: { position: "absolute", left: `${zoneLeft}%`, width: `${zoneWidth}%`, height: "100%" } }), _jsx("div", { className: markerClassName, "data-marker": true, style: { position: "absolute", left: `${markerLeft}%`, height: "100%" } })] }), renderStatus?.(result)] }));
|
|
87
|
+
}, [ctx, config, startedAt, renderStatus]);
|
|
88
|
+
return (_jsxs("div", { ref: rootRef, className: className, "data-skill-check": true, "data-in-zone": "false", "data-timed-out": "false", children: [_jsxs("div", { className: trackClassName, "data-track": true, style: { position: "relative" }, children: [_jsx("div", { ref: zoneRef, className: zoneClassName, "data-zone": true, style: { position: "absolute", left: "0%", width: "0%", height: "100%" } }), _jsx("div", { ref: markerRef, className: markerClassName, "data-marker": true, style: { position: "absolute", left: "0%", height: "100%" } })] }), renderStatus !== undefined && status !== null ? renderStatus(status) : null] }));
|
|
74
89
|
}
|
|
75
90
|
export function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }) {
|
|
76
91
|
const ctx = useGameContext();
|
|
77
|
-
const
|
|
92
|
+
const stepRefs = useRef(new Map());
|
|
78
93
|
useEffect(() => {
|
|
79
94
|
let frame;
|
|
80
95
|
const step = () => {
|
|
81
|
-
|
|
96
|
+
const elapsed = Math.max(0, ctx.time.now() - startedAt);
|
|
97
|
+
const active = pendingQteStep(steps, elapsed);
|
|
98
|
+
paintQteStepDom(stepRefs.current, steps, elapsed, active?.id ?? null, stepClassName, activeClassName, doneClassName);
|
|
82
99
|
frame = requestAnimationFrame(step);
|
|
83
100
|
};
|
|
84
101
|
frame = requestAnimationFrame(step);
|
|
85
102
|
return () => cancelAnimationFrame(frame);
|
|
86
|
-
}, []);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.filter(Boolean)
|
|
94
|
-
.join(" ");
|
|
95
|
-
return (_jsx("div", { className: classes.length > 0 ? classes : undefined, "data-qte-step": step.id, "data-active": isActive, "data-done": isDone, children: step.action }, step.id));
|
|
96
|
-
}) }));
|
|
103
|
+
}, [ctx, steps, startedAt, stepClassName, activeClassName, doneClassName]);
|
|
104
|
+
return (_jsx("div", { className: className, "data-qte": true, children: steps.map((step) => (_jsx("div", { ref: (node) => {
|
|
105
|
+
if (node === null)
|
|
106
|
+
stepRefs.current.delete(step.id);
|
|
107
|
+
else
|
|
108
|
+
stepRefs.current.set(step.id, node);
|
|
109
|
+
}, className: stepClassName, "data-qte-step": step.id, "data-active": "false", "data-done": "false", children: step.action }, step.id))) }));
|
|
97
110
|
}
|
|
98
111
|
export function CaptureOdds({ chance, className, fillClassName, }) {
|
|
99
112
|
const percent = Math.round(Math.min(1, Math.max(0, chance)) * 100);
|
package/dist/display.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Viewport/input-modality profile for adaptive HUDs: coarse pointer means a
|
|
3
3
|
* touchscreen is the primary input (mount touch controls, enlarge tap
|
|
4
|
-
* targets), compact means phone-
|
|
4
|
+
* targets), compact means phone-scale layout — narrow width or the short
|
|
5
|
+
* height of a landscape phone (collapse side panels), portrait
|
|
5
6
|
* distinguishes the two phone orientations. Values track live media-query
|
|
6
7
|
* changes and are safe to read during SSR (everything false).
|
|
7
8
|
*/
|
package/dist/display.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Viewport/input-modality profile for adaptive HUDs: coarse pointer means a
|
|
3
3
|
* touchscreen is the primary input (mount touch controls, enlarge tap
|
|
4
|
-
* targets), compact means phone-
|
|
4
|
+
* targets), compact means phone-scale layout — narrow width or the short
|
|
5
|
+
* height of a landscape phone (collapse side panels), portrait
|
|
5
6
|
* distinguishes the two phone orientations. Values track live media-query
|
|
6
7
|
* changes and are safe to read during SSR (everything false).
|
|
7
8
|
*/
|
|
8
9
|
import { useSyncExternalStore } from "react";
|
|
9
10
|
const QUERIES = {
|
|
10
11
|
coarsePointer: "(pointer: coarse)",
|
|
11
|
-
compact: "(max-width: 820px)",
|
|
12
|
+
compact: "(max-width: 820px), (max-height: 500px)",
|
|
12
13
|
portrait: "(orientation: portrait)",
|
|
13
14
|
};
|
|
14
15
|
const SERVER_PROFILE = { coarsePointer: false, compact: false, portrait: false };
|
package/dist/dragLayer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
|
1
|
+
import { type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { type Cell, type Rotation } from "@jgengine/core/inventory/shapedGrid";
|
|
3
3
|
export interface DragPayload<T> {
|
|
4
4
|
id: string;
|
|
@@ -30,6 +30,10 @@ export interface DropInfo<T> {
|
|
|
30
30
|
export interface DragLayer<T> {
|
|
31
31
|
state: DragState<T>;
|
|
32
32
|
dragging: boolean;
|
|
33
|
+
pointRef: RefObject<{
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
}>;
|
|
33
37
|
beginDrag(payload: {
|
|
34
38
|
id: string;
|
|
35
39
|
value: T;
|
|
@@ -39,6 +43,7 @@ export interface DragLayer<T> {
|
|
|
39
43
|
setTarget(target: string | null, cell?: Cell | null): void;
|
|
40
44
|
endDrag(): DropInfo<T> | null;
|
|
41
45
|
cancel(): void;
|
|
46
|
+
attachGhost(element: HTMLElement | null): void;
|
|
42
47
|
}
|
|
43
48
|
export declare function useDragLayer<T>(options?: {
|
|
44
49
|
onDrop?: (info: DropInfo<T>) => void;
|
package/dist/dragLayer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { cellFromPoint, } from "@jgengine/core/inventory/shapedGrid";
|
|
4
4
|
const EMPTY_POINT = { x: 0, y: 0 };
|
|
5
5
|
export function useDragLayer(options) {
|
|
@@ -10,47 +10,70 @@ export function useDragLayer(options) {
|
|
|
10
10
|
overTarget: null,
|
|
11
11
|
overCell: null,
|
|
12
12
|
});
|
|
13
|
+
const pointRef = useRef(EMPTY_POINT);
|
|
14
|
+
const ghostRef = useRef(null);
|
|
13
15
|
const moveRef = useRef(null);
|
|
16
|
+
const payloadRef = useRef(null);
|
|
17
|
+
const overRef = useRef({ target: null, cell: null });
|
|
14
18
|
const detach = useCallback(() => {
|
|
15
19
|
if (moveRef.current !== null) {
|
|
16
20
|
window.removeEventListener("pointermove", moveRef.current);
|
|
17
21
|
moveRef.current = null;
|
|
18
22
|
}
|
|
19
23
|
}, []);
|
|
24
|
+
const writeGhost = useCallback((point, rotation) => {
|
|
25
|
+
const el = ghostRef.current;
|
|
26
|
+
if (el === null)
|
|
27
|
+
return;
|
|
28
|
+
el.style.left = `${point.x}px`;
|
|
29
|
+
el.style.top = `${point.y}px`;
|
|
30
|
+
el.style.transform = `translate(-50%, -50%) rotate(${rotation * 90}deg)`;
|
|
31
|
+
}, []);
|
|
20
32
|
const beginDrag = useCallback((payload, event) => {
|
|
21
33
|
const point = { x: event.clientX, y: event.clientY };
|
|
34
|
+
const nextPayload = { id: payload.id, value: payload.value, rotation: payload.rotation ?? 0 };
|
|
35
|
+
payloadRef.current = nextPayload;
|
|
36
|
+
overRef.current = { target: null, cell: null };
|
|
37
|
+
pointRef.current = point;
|
|
22
38
|
setState({
|
|
23
|
-
payload:
|
|
39
|
+
payload: nextPayload,
|
|
24
40
|
point,
|
|
25
41
|
origin: point,
|
|
26
42
|
overTarget: null,
|
|
27
43
|
overCell: null,
|
|
28
44
|
});
|
|
45
|
+
writeGhost(point, nextPayload.rotation);
|
|
29
46
|
const onMove = (moveEvent) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
if (payloadRef.current === null)
|
|
48
|
+
return;
|
|
49
|
+
const next = { x: moveEvent.clientX, y: moveEvent.clientY };
|
|
50
|
+
pointRef.current = next;
|
|
51
|
+
writeGhost(next, payloadRef.current.rotation);
|
|
33
52
|
};
|
|
34
53
|
detach();
|
|
35
54
|
moveRef.current = onMove;
|
|
36
55
|
window.addEventListener("pointermove", onMove);
|
|
37
|
-
}, [detach]);
|
|
56
|
+
}, [detach, writeGhost]);
|
|
38
57
|
const rotate = useCallback((quarterTurns = 1) => {
|
|
39
|
-
setState((prev) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}, []);
|
|
58
|
+
setState((prev) => {
|
|
59
|
+
if (prev.payload === null)
|
|
60
|
+
return prev;
|
|
61
|
+
const rotation = (((prev.payload.rotation + quarterTurns) % 4) + 4) % 4;
|
|
62
|
+
const payload = { ...prev.payload, rotation };
|
|
63
|
+
payloadRef.current = payload;
|
|
64
|
+
writeGhost(pointRef.current, rotation);
|
|
65
|
+
return { ...prev, payload, point: pointRef.current };
|
|
66
|
+
});
|
|
67
|
+
}, [writeGhost]);
|
|
49
68
|
const setTarget = useCallback((target, cell = null) => {
|
|
50
|
-
|
|
69
|
+
overRef.current = { target, cell };
|
|
70
|
+
setState((prev) => ({ ...prev, overTarget: target, overCell: cell, point: pointRef.current }));
|
|
51
71
|
}, []);
|
|
52
72
|
const reset = useCallback(() => {
|
|
53
73
|
detach();
|
|
74
|
+
payloadRef.current = null;
|
|
75
|
+
overRef.current = { target: null, cell: null };
|
|
76
|
+
pointRef.current = EMPTY_POINT;
|
|
54
77
|
setState({
|
|
55
78
|
payload: null,
|
|
56
79
|
point: EMPTY_POINT,
|
|
@@ -60,38 +83,48 @@ export function useDragLayer(options) {
|
|
|
60
83
|
});
|
|
61
84
|
}, [detach]);
|
|
62
85
|
const endDrag = useCallback(() => {
|
|
86
|
+
const payload = payloadRef.current;
|
|
63
87
|
let info = null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
return prev;
|
|
74
|
-
});
|
|
75
|
-
if (info !== null)
|
|
88
|
+
if (payload !== null) {
|
|
89
|
+
info = {
|
|
90
|
+
payload,
|
|
91
|
+
target: overRef.current.target,
|
|
92
|
+
cell: overRef.current.cell,
|
|
93
|
+
point: pointRef.current,
|
|
94
|
+
};
|
|
76
95
|
options?.onDrop?.(info);
|
|
96
|
+
}
|
|
77
97
|
reset();
|
|
78
98
|
return info;
|
|
79
99
|
}, [options, reset]);
|
|
100
|
+
const attachGhost = useCallback((element) => {
|
|
101
|
+
ghostRef.current = element;
|
|
102
|
+
if (element !== null && payloadRef.current !== null) {
|
|
103
|
+
writeGhost(pointRef.current, payloadRef.current.rotation);
|
|
104
|
+
}
|
|
105
|
+
}, [writeGhost]);
|
|
80
106
|
return {
|
|
81
107
|
state,
|
|
82
108
|
dragging: state.payload !== null,
|
|
109
|
+
pointRef,
|
|
83
110
|
beginDrag,
|
|
84
111
|
rotate,
|
|
85
112
|
setTarget,
|
|
86
113
|
endDrag,
|
|
87
114
|
cancel: reset,
|
|
115
|
+
attachGhost,
|
|
88
116
|
};
|
|
89
117
|
}
|
|
90
118
|
export function DragGhost({ layer, className, style, children, }) {
|
|
119
|
+
const ref = useRef(null);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
layer.attachGhost(ref.current);
|
|
122
|
+
return () => layer.attachGhost(null);
|
|
123
|
+
}, [layer, layer.state.payload]);
|
|
91
124
|
if (layer.state.payload === null)
|
|
92
125
|
return null;
|
|
93
|
-
const { x, y } = layer.
|
|
94
|
-
return (_jsx("div", { className: className, "data-drag-ghost": "", style: {
|
|
126
|
+
const { x, y } = layer.pointRef.current;
|
|
127
|
+
return (_jsx("div", { ref: ref, className: className, "data-drag-ghost": "", style: {
|
|
95
128
|
position: "fixed",
|
|
96
129
|
left: x,
|
|
97
130
|
top: y,
|
package/dist/engineStore.d.ts
CHANGED
|
@@ -6,5 +6,5 @@ export interface EventfulEngineStore<TEventMap extends object> {
|
|
|
6
6
|
on<K extends keyof TEventMap>(eventName: K, listener: (payload: TEventMap[K]) => void): () => void;
|
|
7
7
|
}
|
|
8
8
|
export declare function useEngineState<TState>(store: ReadableEngineStore<TState>): TState;
|
|
9
|
-
export declare function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected): TSelected;
|
|
9
|
+
export declare function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
|
|
10
10
|
export declare function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void;
|
package/dist/engineStore.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
2
3
|
export function useEngineState(store) {
|
|
3
4
|
const subscribe = useCallback((onChange) => store.subscribe(() => onChange()), [store]);
|
|
4
5
|
const getSnapshot = useCallback(() => store.getState(), [store]);
|
|
5
6
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
6
7
|
}
|
|
7
|
-
export function useEngineStore(store, selector) {
|
|
8
|
-
|
|
8
|
+
export function useEngineStore(store, selector, isEqual = Object.is) {
|
|
9
|
+
const selectorRef = useRef(selector);
|
|
10
|
+
selectorRef.current = selector;
|
|
11
|
+
const isEqualRef = useRef(isEqual);
|
|
12
|
+
isEqualRef.current = isEqual;
|
|
13
|
+
const cacheRef = useRef(createSelectCache());
|
|
14
|
+
const subscribe = useCallback((onChange) => store.subscribe(() => onChange()), [store]);
|
|
15
|
+
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, store.getState(), (state) => selectorRef.current(state), (previous, next) => isEqualRef.current(previous, next)), [store]);
|
|
16
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
9
17
|
}
|
|
10
18
|
export function useEngineEvent(store, eventName, handler) {
|
|
11
19
|
const handlerRef = useRef(handler);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FogCells } from "@jgengine/core/world/fog";
|
|
2
|
+
export interface FogCellRect {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface FogPaintSurface {
|
|
9
|
+
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
10
|
+
fillRect(x: number, y: number, width: number, height: number): void;
|
|
11
|
+
clearRect(x: number, y: number, width: number, height: number): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function forEachUnrevealedFogCell(fog: FogCells, visit: (col: number, row: number) => void): number;
|
|
14
|
+
export declare function paintFogOverlay(surface: FogPaintSurface, canvasSize: {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}, fog: FogCells, cellRect: (col: number, row: number) => FogCellRect | null, fill?: string): number;
|
|
18
|
+
export declare function createFogDataUrl(fog: FogCells, canvasSize: {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}, cellRect: (col: number, row: number) => FogCellRect | null, fill?: string): string | null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function forEachUnrevealedFogCell(fog, visit) {
|
|
2
|
+
let count = 0;
|
|
3
|
+
for (let row = 0; row < fog.rows; row += 1) {
|
|
4
|
+
for (let col = 0; col < fog.cols; col += 1) {
|
|
5
|
+
if (fog.revealed[row * fog.cols + col])
|
|
6
|
+
continue;
|
|
7
|
+
visit(col, row);
|
|
8
|
+
count += 1;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return count;
|
|
12
|
+
}
|
|
13
|
+
export function paintFogOverlay(surface, canvasSize, fog, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
|
|
14
|
+
surface.clearRect(0, 0, canvasSize.width, canvasSize.height);
|
|
15
|
+
surface.fillStyle = fill;
|
|
16
|
+
return forEachUnrevealedFogCell(fog, (col, row) => {
|
|
17
|
+
const rect = cellRect(col, row);
|
|
18
|
+
if (rect === null)
|
|
19
|
+
return;
|
|
20
|
+
surface.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function createFogDataUrl(fog, canvasSize, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
|
|
24
|
+
if (typeof document === "undefined")
|
|
25
|
+
return null;
|
|
26
|
+
const canvas = document.createElement("canvas");
|
|
27
|
+
canvas.width = Math.max(1, Math.floor(canvasSize.width));
|
|
28
|
+
canvas.height = Math.max(1, Math.floor(canvasSize.height));
|
|
29
|
+
const context = canvas.getContext("2d");
|
|
30
|
+
if (context === null)
|
|
31
|
+
return null;
|
|
32
|
+
paintFogOverlay(context, { width: canvas.width, height: canvas.height }, fog, cellRect, fill);
|
|
33
|
+
return canvas.toDataURL();
|
|
34
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type { RosterEntry } from "@jgengine/core/scene/roster";
|
|
|
17
17
|
import type { WorldItemRecord } from "@jgengine/core/game/worldItem";
|
|
18
18
|
import type { ClockSnapshot, SimClock } from "@jgengine/core/time/simClock";
|
|
19
19
|
import { type PositionedPrompt } from "@jgengine/core/interaction/proximityPrompt";
|
|
20
|
-
|
|
20
|
+
import { type GamePhase } from "@jgengine/core/game/gamePhase";
|
|
21
|
+
export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
|
|
21
22
|
export declare function useGame(): {
|
|
22
23
|
commands: GameContext["game"]["commands"];
|
|
23
24
|
events: GameEvents;
|
|
@@ -26,6 +27,11 @@ export declare function usePlayer(): {
|
|
|
26
27
|
userId: string;
|
|
27
28
|
isNew: boolean;
|
|
28
29
|
};
|
|
30
|
+
/** Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it. */
|
|
31
|
+
export declare function useGamePhase(): {
|
|
32
|
+
phase: GamePhase;
|
|
33
|
+
setPhase: (phase: GamePhase) => void;
|
|
34
|
+
};
|
|
29
35
|
export declare function useSceneEntities(): readonly SceneEntity[];
|
|
30
36
|
export declare function useSceneObjects(): readonly SceneObject[];
|
|
31
37
|
export declare function useWorldItems(): readonly WorldItemRecord[];
|
|
@@ -81,6 +87,8 @@ export declare function useGameClock(): ClockSnapshot & {
|
|
|
81
87
|
controls: SimClock;
|
|
82
88
|
};
|
|
83
89
|
export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
|
|
90
|
+
export declare function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean;
|
|
91
|
+
export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
|
|
84
92
|
export interface AbilitySlotBindingOptions {
|
|
85
93
|
intervalMs?: number;
|
|
86
94
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -2,11 +2,18 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
|
|
|
2
2
|
import { AxisChannel } from "@jgengine/core/input/axisInput";
|
|
3
3
|
import { browseSessions, } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
4
|
import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
|
|
5
|
+
import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
|
|
5
6
|
import { useGameContext } from "./provider.js";
|
|
6
|
-
|
|
7
|
+
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
8
|
+
export function useGameStore(selector, isEqual = Object.is) {
|
|
7
9
|
const ctx = useGameContext();
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
const selectorRef = useRef(selector);
|
|
11
|
+
selectorRef.current = selector;
|
|
12
|
+
const isEqualRef = useRef(isEqual);
|
|
13
|
+
isEqualRef.current = isEqual;
|
|
14
|
+
const cacheRef = useRef(createSelectCache());
|
|
15
|
+
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
|
|
16
|
+
return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
|
|
10
17
|
}
|
|
11
18
|
export function useGame() {
|
|
12
19
|
const ctx = useGameContext();
|
|
@@ -16,6 +23,13 @@ export function usePlayer() {
|
|
|
16
23
|
const ctx = useGameContext();
|
|
17
24
|
return useMemo(() => ({ userId: ctx.player.userId, isNew: ctx.player.isNew }), [ctx]);
|
|
18
25
|
}
|
|
26
|
+
/** Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it. */
|
|
27
|
+
export function useGamePhase() {
|
|
28
|
+
const ctx = useGameContext();
|
|
29
|
+
const phase = useGameStore((c) => gamePhase(c));
|
|
30
|
+
const setPhase = useCallback((next) => setGamePhase(ctx, next), [ctx]);
|
|
31
|
+
return { phase, setPhase };
|
|
32
|
+
}
|
|
19
33
|
export function useSceneEntities() {
|
|
20
34
|
return useGameStore((ctx) => ctx.scene.entity.list());
|
|
21
35
|
}
|
|
@@ -154,28 +168,66 @@ export function useActivePrompt(prompts) {
|
|
|
154
168
|
return resolveActivePrompt({ x: player.position[0], z: player.position[2] }, prompts);
|
|
155
169
|
});
|
|
156
170
|
}
|
|
157
|
-
function
|
|
158
|
-
const
|
|
159
|
-
|
|
171
|
+
export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
|
|
172
|
+
for (const slot of kit.snapshot(resourceAvailable)) {
|
|
173
|
+
if (slot.cooldownRemainingMs > 0 || slot.justCast || slot.groupRemainingMs > 0)
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
export function eventMeterNeedsHeartbeat(meter, previous) {
|
|
179
|
+
const next = {
|
|
180
|
+
value: meter.value(),
|
|
181
|
+
fraction: meter.fraction(),
|
|
182
|
+
tier: meter.tier(),
|
|
183
|
+
ready: meter.ready(),
|
|
184
|
+
};
|
|
185
|
+
if (previous === null)
|
|
186
|
+
return true;
|
|
187
|
+
return (previous.value !== next.value ||
|
|
188
|
+
previous.fraction !== next.fraction ||
|
|
189
|
+
previous.tier !== next.tier ||
|
|
190
|
+
previous.ready !== next.ready);
|
|
191
|
+
}
|
|
192
|
+
function useEngineHeartbeat(intervalMs, shouldTick) {
|
|
160
193
|
const [, setTick] = useState(0);
|
|
194
|
+
const shouldTickRef = useRef(shouldTick);
|
|
195
|
+
shouldTickRef.current = shouldTick;
|
|
161
196
|
useEffect(() => {
|
|
162
197
|
if (intervalMs <= 0 || typeof window === "undefined")
|
|
163
198
|
return undefined;
|
|
164
|
-
const id = window.setInterval(() =>
|
|
199
|
+
const id = window.setInterval(() => {
|
|
200
|
+
if (shouldTickRef.current())
|
|
201
|
+
setTick((current) => current + 1);
|
|
202
|
+
}, intervalMs);
|
|
165
203
|
return () => window.clearInterval(id);
|
|
166
204
|
}, [intervalMs]);
|
|
167
205
|
}
|
|
168
206
|
export function useAbilitySlots(kit, resourceAvailable, options) {
|
|
169
|
-
useEngineHeartbeat(options?.intervalMs ?? 80);
|
|
207
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
170
208
|
return kit.snapshot(resourceAvailable);
|
|
171
209
|
}
|
|
172
210
|
export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
|
|
173
|
-
useEngineHeartbeat(options?.intervalMs ?? 80);
|
|
211
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
174
212
|
return kit.state(slotId, resourceAvailable);
|
|
175
213
|
}
|
|
176
214
|
export function useEventMeter(meter, options) {
|
|
177
|
-
|
|
178
|
-
|
|
215
|
+
const previous = useRef(null);
|
|
216
|
+
useEngineHeartbeat(options?.intervalMs ?? 80, () => {
|
|
217
|
+
const changed = eventMeterNeedsHeartbeat(meter, previous.current);
|
|
218
|
+
if (changed) {
|
|
219
|
+
previous.current = {
|
|
220
|
+
value: meter.value(),
|
|
221
|
+
fraction: meter.fraction(),
|
|
222
|
+
tier: meter.tier(),
|
|
223
|
+
ready: meter.ready(),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return changed;
|
|
227
|
+
});
|
|
228
|
+
const view = { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
|
|
229
|
+
previous.current = view;
|
|
230
|
+
return view;
|
|
179
231
|
}
|
|
180
232
|
export function createHeldKeyTracker(target) {
|
|
181
233
|
const held = new Set();
|