@jgengine/react 0.8.0 → 0.10.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/chat.d.ts +3 -3
  3. package/dist/chat.js +14 -5
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +46 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +36 -23
  8. package/dist/display.d.ts +2 -1
  9. package/dist/display.js +3 -2
  10. package/dist/dragLayer.d.ts +6 -1
  11. package/dist/dragLayer.js +64 -31
  12. package/dist/engineStore.d.ts +1 -1
  13. package/dist/engineStore.js +10 -2
  14. package/dist/fogOverlay.d.ts +21 -0
  15. package/dist/fogOverlay.js +34 -0
  16. package/dist/gameViewport.d.ts +54 -0
  17. package/dist/gameViewport.js +340 -0
  18. package/dist/hooks.d.ts +11 -1
  19. package/dist/hooks.js +89 -21
  20. package/dist/hudLayout.d.ts +79 -0
  21. package/dist/hudLayout.js +518 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/index.d.ts +7 -0
  25. package/dist/index.js +7 -0
  26. package/dist/liveBind.d.ts +4 -10
  27. package/dist/liveBind.js +28 -16
  28. package/dist/map.d.ts +27 -7
  29. package/dist/map.js +152 -43
  30. package/dist/preview.d.ts +6 -0
  31. package/dist/preview.js +1 -0
  32. package/dist/provider.d.ts +2 -0
  33. package/dist/provider.js +4 -0
  34. package/dist/rotateDevice.d.ts +24 -0
  35. package/dist/rotateDevice.js +83 -0
  36. package/dist/selectSnapshot.d.ts +7 -0
  37. package/dist/selectSnapshot.js +16 -0
  38. package/dist/settings.d.ts +78 -0
  39. package/dist/settings.js +61 -0
  40. package/dist/skillCheckPaint.d.ts +4 -0
  41. package/dist/skillCheckPaint.js +28 -0
  42. package/dist/social.d.ts +3 -3
  43. package/dist/social.js +31 -10
  44. package/llms.txt +858 -860
  45. package/package.json +9 -5
package/CHANGELOG.md CHANGED
@@ -11,6 +11,42 @@ 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
+ ## 0.10.0
15
+
16
+ ### Migrate
17
+
18
+ - **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
19
+ - **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
20
+ - **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
21
+ - **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
22
+
23
+ ### Added
24
+
25
+ - Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
26
+ - Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
27
+ - Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
28
+ - HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
29
+ - Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
30
+ - Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
31
+
32
+ ### Changed
33
+
34
+ - First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
35
+
36
+ ## 0.9.0
37
+
38
+ ### Added
39
+
40
+ - 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.
41
+ - 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.
42
+ - 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.
43
+ - 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).
44
+ - 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.
45
+
46
+ ### Changed
47
+
48
+ - 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"`).
49
+
14
50
  ## 0.8.0
15
51
 
16
52
  Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
package/dist/chat.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare function ChatInput({ channelId, className, inputClassName, button
23
23
  sendLabel?: ReactNode;
24
24
  onSent?: (message: ChatMessage) => void;
25
25
  onRejected?: (reason: string) => void;
26
- }): import("react").JSX.Element;
26
+ }): import("react").JSX.Element | null;
27
27
  export declare function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }: {
28
28
  channels?: readonly string[];
29
29
  active: string;
@@ -32,7 +32,7 @@ export declare function ChannelTabs({ channels, active, onSelect, className, tab
32
32
  tabClassName?: string;
33
33
  activeTabClassName?: string;
34
34
  renderTab?: (channelId: string, isActive: boolean) => ReactNode;
35
- }): import("react").JSX.Element;
35
+ }): import("react").JSX.Element | null;
36
36
  export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: {
37
37
  channels?: readonly string[];
38
38
  initialChannel?: string;
@@ -49,4 +49,4 @@ export declare function ChatPanel({ channels, initialChannel, limit, className,
49
49
  placeholder?: string;
50
50
  renderMessage?: (message: ChatMessage) => ReactNode;
51
51
  onRejected?: (reason: string) => void;
52
- }): import("react").JSX.Element;
52
+ }): import("react").JSX.Element | null;
package/dist/chat.js CHANGED
@@ -37,21 +37,27 @@ export function ChatLog({ channelId, limit, className, messageClassName, renderM
37
37
  export function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }) {
38
38
  const ctx = useGameContext();
39
39
  const [value, setValue] = useState("");
40
- function submit(event) {
40
+ const chat = ctx.game.chat;
41
+ if (chat === undefined)
42
+ return null;
43
+ const submit = (event) => {
41
44
  event.preventDefault();
42
- const result = ctx.game.chat.send(ctx.player.userId, channelId, value);
45
+ const result = chat.send(ctx.player.userId, channelId, value);
43
46
  if ("reason" in result) {
44
47
  onRejected?.(result.reason);
45
48
  return;
46
49
  }
47
50
  setValue("");
48
51
  onSent?.(result.message);
49
- }
52
+ };
50
53
  return (_jsxs("form", { className: className, "data-chat-input": channelId, onSubmit: submit, children: [_jsx("input", { className: inputClassName, type: "text", value: value, placeholder: placeholder, onChange: (event) => setValue(event.target.value) }), _jsx("button", { type: "submit", className: buttonClassName, "data-chat-send": true, children: sendLabel ?? "Send" })] }));
51
54
  }
52
55
  export function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }) {
53
56
  const ctx = useGameContext();
54
- const ids = channels ?? ctx.game.chat.channels().map((def) => def.id);
57
+ const chat = ctx.game.chat;
58
+ if (chat === undefined)
59
+ return null;
60
+ const ids = channels ?? chat.channels().map((def) => def.id);
55
61
  return (_jsx("div", { className: className, role: "tablist", "data-chat-tabs": true, children: ids.map((channelId) => {
56
62
  const isActive = channelId === active;
57
63
  const classes = [tabClassName, isActive ? activeTabClassName : undefined]
@@ -62,7 +68,10 @@ export function ChannelTabs({ channels, active, onSelect, className, tabClassNam
62
68
  }
63
69
  export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }) {
64
70
  const ctx = useGameContext();
65
- const ids = channels ?? ctx.game.chat.channels().map((def) => def.id);
71
+ const chat = ctx.game.chat;
72
+ const ids = channels ?? chat?.channels().map((def) => def.id) ?? [];
66
73
  const [active, setActive] = useState(initialChannel ?? ids[0] ?? "global");
74
+ if (chat === undefined)
75
+ return null;
67
76
  return (_jsxs("section", { className: className, "data-chat-panel": true, children: [_jsx(ChannelTabs, { channels: ids, active: active, onSelect: setActive, className: tabsClassName, tabClassName: tabClassName, activeTabClassName: activeTabClassName }), _jsx(ChatLog, { channelId: active, limit: limit, className: logClassName, messageClassName: messageClassName, renderMessage: renderMessage }), _jsx(ChatInput, { channelId: active, className: inputClassName, inputClassName: inputFieldClassName, buttonClassName: sendButtonClassName, placeholder: placeholder, onRejected: onRejected })] }));
68
77
  }
@@ -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,46 @@
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
+ const EMPTY_CHAT_MESSAGES = [];
8
+ export function latestChatBubbles(messages, nowMs, ttlMs) {
9
+ const cutoff = nowMs - ttlMs;
10
+ const latestByUser = new Map();
11
+ for (const message of messages) {
12
+ if (message.at <= cutoff)
13
+ continue;
14
+ const current = latestByUser.get(message.fromUserId);
15
+ if (current === undefined || message.at > current.at) {
16
+ latestByUser.set(message.fromUserId, message);
17
+ }
18
+ }
19
+ return [...latestByUser.values()]
20
+ .sort((a, b) => a.at - b.at)
21
+ .map((message) => ({
22
+ id: message.id,
23
+ fromUserId: message.fromUserId,
24
+ body: message.body,
25
+ at: message.at,
26
+ }));
27
+ }
28
+ export function useChatBubbles(options) {
29
+ const channelId = options?.channelId ?? DEFAULT_CHAT_BUBBLE_CHANNEL;
30
+ const ttlMs = options?.ttlMs ?? DEFAULT_CHAT_BUBBLE_TTL_MS;
31
+ const limit = options?.limit ?? DEFAULT_CHAT_BUBBLE_LIMIT;
32
+ const messages = useGameStore((ctx) => ctx.game.chat?.history(channelId, { limit }) ?? EMPTY_CHAT_MESSAGES);
33
+ const [now, setNow] = useState(() => Date.now());
34
+ const hasLiveMessage = messages.some((message) => message.at > now - ttlMs);
35
+ useEffect(() => {
36
+ if (!hasLiveMessage)
37
+ return undefined;
38
+ const id = setInterval(() => setNow(Date.now()), CHAT_BUBBLE_TICK_MS);
39
+ return () => clearInterval(id);
40
+ }, [hasLiveMessage]);
41
+ return useMemo(() => latestChatBubbles(messages, now, ttlMs), [messages, now, ttlMs]);
42
+ }
43
+ export function useEntityChatBubble(instanceId, options) {
44
+ const bubbles = useChatBubbles(options);
45
+ return bubbles.find((bubble) => bubble.fromUserId === instanceId) ?? null;
46
+ }
@@ -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;
@@ -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
@@ -28,7 +30,7 @@ export function CurrencyPill({ currencyId, className }) {
28
30
  export function ProximityPrompt({ prompt, className, }) {
29
31
  const display = prompt.display;
30
32
  if (display.kind === "keybind") {
31
- return (_jsx("span", { className: className, "data-prompt": "keybind", "data-action": display.actionId, children: _jsx("kbd", { children: display.actionId }) }));
33
+ return (_jsxs("span", { className: className, "data-prompt": "keybind", "data-action": display.actionId, children: [_jsx("kbd", { children: display.actionId }), display.label !== undefined ? _jsx("span", { "data-prompt-label": true, children: display.label }) : null] }));
32
34
  }
33
35
  if (display.kind === "gauge") {
34
36
  return (_jsx("span", { className: className, "data-prompt": "gauge", "data-gauge": display.gaugeId, children: display.gaugeId }));
@@ -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 [, tick] = useState(0);
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
- tick((n) => n + 1);
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
- const elapsed = Math.max(0, ctx.time.now() - startedAt);
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 [, tick] = useState(0);
92
+ const stepRefs = useRef(new Map());
78
93
  useEffect(() => {
79
94
  let frame;
80
95
  const step = () => {
81
- tick((n) => n + 1);
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
- const elapsed = Math.max(0, ctx.time.now() - startedAt);
88
- const active = pendingQteStep(steps, elapsed);
89
- return (_jsx("div", { className: className, "data-qte": true, children: steps.map((step) => {
90
- const isActive = active?.id === step.id;
91
- const isDone = elapsed > step.windowEnd;
92
- const classes = [stepClassName, isActive ? activeClassName : isDone ? doneClassName : undefined]
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-width layout (collapse side panels), portrait
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-width layout (collapse side panels), portrait
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 };
@@ -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: { id: payload.id, value: payload.value, rotation: payload.rotation ?? 0 },
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
- setState((prev) => prev.payload === null
31
- ? prev
32
- : { ...prev, point: { x: moveEvent.clientX, y: moveEvent.clientY } });
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) => prev.payload === null
40
- ? prev
41
- : {
42
- ...prev,
43
- payload: {
44
- ...prev.payload,
45
- rotation: (((prev.payload.rotation + quarterTurns) % 4) + 4) % 4,
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
- setState((prev) => ({ ...prev, overTarget: target, overCell: cell }));
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
- setState((prev) => {
65
- if (prev.payload !== null) {
66
- info = {
67
- payload: prev.payload,
68
- target: prev.overTarget,
69
- cell: prev.overCell,
70
- point: prev.point,
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.state.point;
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,
@@ -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;
@@ -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
- return selector(useEngineState(store));
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
+ }