@jgengine/react 0.9.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.
- package/CHANGELOG.md +21 -1
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +14 -5
- package/dist/chatBubbles.js +2 -1
- package/dist/components.js +1 -1
- package/dist/gameViewport.d.ts +54 -0
- package/dist/gameViewport.js +340 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +26 -10
- package/dist/hudLayout.d.ts +21 -3
- package/dist/hudLayout.js +33 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +4 -0
- package/dist/rotateDevice.d.ts +24 -0
- package/dist/rotateDevice.js +83 -0
- package/dist/settings.d.ts +3 -0
- package/dist/social.d.ts +3 -3
- package/dist/social.js +31 -10
- package/llms.txt +111 -20
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,7 +11,27 @@ 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
|
-
##
|
|
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.
|
|
15
35
|
|
|
16
36
|
## 0.9.0
|
|
17
37
|
|
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
|
-
|
|
40
|
+
const chat = ctx.game.chat;
|
|
41
|
+
if (chat === undefined)
|
|
42
|
+
return null;
|
|
43
|
+
const submit = (event) => {
|
|
41
44
|
event.preventDefault();
|
|
42
|
-
const result =
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/chatBubbles.js
CHANGED
|
@@ -4,6 +4,7 @@ const DEFAULT_CHAT_BUBBLE_CHANNEL = "proximity";
|
|
|
4
4
|
const DEFAULT_CHAT_BUBBLE_TTL_MS = 4000;
|
|
5
5
|
const DEFAULT_CHAT_BUBBLE_LIMIT = 8;
|
|
6
6
|
const CHAT_BUBBLE_TICK_MS = 500;
|
|
7
|
+
const EMPTY_CHAT_MESSAGES = [];
|
|
7
8
|
export function latestChatBubbles(messages, nowMs, ttlMs) {
|
|
8
9
|
const cutoff = nowMs - ttlMs;
|
|
9
10
|
const latestByUser = new Map();
|
|
@@ -28,7 +29,7 @@ export function useChatBubbles(options) {
|
|
|
28
29
|
const channelId = options?.channelId ?? DEFAULT_CHAT_BUBBLE_CHANNEL;
|
|
29
30
|
const ttlMs = options?.ttlMs ?? DEFAULT_CHAT_BUBBLE_TTL_MS;
|
|
30
31
|
const limit = options?.limit ?? DEFAULT_CHAT_BUBBLE_LIMIT;
|
|
31
|
-
const messages = useGameStore((ctx) => ctx.game.chat
|
|
32
|
+
const messages = useGameStore((ctx) => ctx.game.chat?.history(channelId, { limit }) ?? EMPTY_CHAT_MESSAGES);
|
|
32
33
|
const [now, setNow] = useState(() => Date.now());
|
|
33
34
|
const hasLiveMessage = messages.some((message) => message.at > now - ttlMs);
|
|
34
35
|
useEffect(() => {
|
package/dist/components.js
CHANGED
|
@@ -30,7 +30,7 @@ export function CurrencyPill({ currencyId, className }) {
|
|
|
30
30
|
export function ProximityPrompt({ prompt, className, }) {
|
|
31
31
|
const display = prompt.display;
|
|
32
32
|
if (display.kind === "keybind") {
|
|
33
|
-
return (
|
|
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] }));
|
|
34
34
|
}
|
|
35
35
|
if (display.kind === "gauge") {
|
|
36
36
|
return (_jsx("span", { className: className, "data-prompt": "gauge", "data-gauge": display.gaugeId, children: display.gaugeId }));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mobile-composition runtime. `GameViewportProvider` allocates the live
|
|
3
|
+
* game viewport once — tracking `visualViewport`, safe-area insets, orientation
|
|
4
|
+
* and layout mode — and hosts a registry every UI subsystem publishes its
|
|
5
|
+
* occupied rectangle to. HUD panels, touch-control zones, and system chrome all
|
|
6
|
+
* register here so the engine can detect forbidden overlaps instead of each
|
|
7
|
+
* subsystem independently claiming an edge.
|
|
8
|
+
*/
|
|
9
|
+
import { type CSSProperties, type ReactNode, type RefObject } from "react";
|
|
10
|
+
import { type GameLayoutMode, type GameViewportLayout, type LayoutCollision, type LayoutRect, type LayoutRegion } from "@jgengine/core/ui/gameLayout";
|
|
11
|
+
import type { LayoutOrientation } from "@jgengine/core/ui/orientation";
|
|
12
|
+
import type { HudPlatform } from "@jgengine/core/ui/hudScale";
|
|
13
|
+
/** A region registration: the full `LayoutRegion` plus the live element (dev outlining), rect measured by the shell/react side. */
|
|
14
|
+
export interface RegionRecord extends LayoutRegion {
|
|
15
|
+
element?: HTMLElement | null;
|
|
16
|
+
}
|
|
17
|
+
/** A region descriptor without its measured rectangle — the caller supplies geometry through `useRegisterLayoutRegion`. */
|
|
18
|
+
export type LayoutRegionSpec = Omit<LayoutRegion, "rect">;
|
|
19
|
+
/** Live viewport rectangles: the layout viewport and the visible `visualViewport`. */
|
|
20
|
+
export interface ViewportMetrics {
|
|
21
|
+
layout: LayoutRect;
|
|
22
|
+
visual: LayoutRect;
|
|
23
|
+
}
|
|
24
|
+
/** Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. */
|
|
25
|
+
export declare function useViewportMetrics(): ViewportMetrics;
|
|
26
|
+
/**
|
|
27
|
+
* Provides the shared game viewport layout to everything it wraps. Mount it
|
|
28
|
+
* once around the whole game presentation (world, HUD, controls, system UI) so
|
|
29
|
+
* every subsystem reads one coordinated geometry and registers its rect for
|
|
30
|
+
* collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*`
|
|
31
|
+
* / `--jg-safe-*` CSS variables on its root.
|
|
32
|
+
*/
|
|
33
|
+
export declare function GameViewportProvider({ platforms, className, style, children, }: {
|
|
34
|
+
platforms?: readonly HudPlatform[];
|
|
35
|
+
className?: string;
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
/** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
|
|
40
|
+
export declare function useGameViewportLayout(): GameViewportLayout;
|
|
41
|
+
/** The resolved explicit composition mode (`desktop-wide` … `mobile-portrait`). */
|
|
42
|
+
export declare function useGameLayoutMode(): GameLayoutMode;
|
|
43
|
+
/** The live device orientation. */
|
|
44
|
+
export declare function useGameOrientation(): LayoutOrientation;
|
|
45
|
+
/** Rectangles reserved by touch controls and system UI — HUD placement should avoid these. */
|
|
46
|
+
export declare function useReservedControlZones(): readonly LayoutRect[];
|
|
47
|
+
/** Live forbidden/warned region collisions (empty outside a provider). */
|
|
48
|
+
export declare function useLayoutCollisions(): readonly LayoutCollision[];
|
|
49
|
+
/**
|
|
50
|
+
* Register the element behind `ref` as a layout region and keep its measured
|
|
51
|
+
* rectangle live (ResizeObserver + viewport changes). No-op outside a
|
|
52
|
+
* `GameViewportProvider`, so a component using it still works in isolation.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useRegisterLayoutRegion(spec: LayoutRegionSpec, ref: RefObject<HTMLElement | null>, enabled?: boolean): void;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shared mobile-composition runtime. `GameViewportProvider` allocates the live
|
|
4
|
+
* game viewport once — tracking `visualViewport`, safe-area insets, orientation
|
|
5
|
+
* and layout mode — and hosts a registry every UI subsystem publishes its
|
|
6
|
+
* occupied rectangle to. HUD panels, touch-control zones, and system chrome all
|
|
7
|
+
* register here so the engine can detect forbidden overlaps instead of each
|
|
8
|
+
* subsystem independently claiming an edge.
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
11
|
+
import { computeGameplayRect, detectLayoutCollisions, formatLayoutCollisions, resolveLayoutMode, orientationOf, } from "@jgengine/core/ui/gameLayout";
|
|
12
|
+
import { useDisplayProfile } from "./display.js";
|
|
13
|
+
function sameRect(a, b) {
|
|
14
|
+
return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
|
|
15
|
+
}
|
|
16
|
+
function sameRegion(a, b) {
|
|
17
|
+
return (a.kind === b.kind &&
|
|
18
|
+
a.collisionPolicy === b.collisionPolicy &&
|
|
19
|
+
a.priority === b.priority &&
|
|
20
|
+
a.collisionGroup === b.collisionGroup &&
|
|
21
|
+
a.element === b.element &&
|
|
22
|
+
(a.allowOverlapWith ?? []).join("+") === (b.allowOverlapWith ?? []).join("+") &&
|
|
23
|
+
sameRect(a.rect, b.rect));
|
|
24
|
+
}
|
|
25
|
+
const EMPTY_REGIONS = [];
|
|
26
|
+
function createRegionRegistry() {
|
|
27
|
+
const records = new Map();
|
|
28
|
+
const listeners = new Set();
|
|
29
|
+
let snapshot = EMPTY_REGIONS;
|
|
30
|
+
let frame = 0;
|
|
31
|
+
const flush = () => {
|
|
32
|
+
frame = 0;
|
|
33
|
+
for (const listener of [...listeners])
|
|
34
|
+
listener();
|
|
35
|
+
};
|
|
36
|
+
const emit = () => {
|
|
37
|
+
snapshot = [...records.values()];
|
|
38
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
39
|
+
for (const listener of [...listeners])
|
|
40
|
+
listener();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (frame !== 0)
|
|
44
|
+
return;
|
|
45
|
+
frame = requestAnimationFrame(flush);
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
set(region) {
|
|
49
|
+
const prev = records.get(region.id);
|
|
50
|
+
if (prev !== undefined && sameRegion(prev, region))
|
|
51
|
+
return;
|
|
52
|
+
records.set(region.id, region);
|
|
53
|
+
emit();
|
|
54
|
+
},
|
|
55
|
+
remove(id) {
|
|
56
|
+
if (records.delete(id))
|
|
57
|
+
emit();
|
|
58
|
+
},
|
|
59
|
+
list: () => snapshot,
|
|
60
|
+
subscribe(listener) {
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const RegionRegistryContext = createContext(null);
|
|
69
|
+
const GameLayoutContext = createContext(null);
|
|
70
|
+
const SERVER_METRICS = {
|
|
71
|
+
layout: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
72
|
+
visual: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
73
|
+
};
|
|
74
|
+
function readViewportMetrics() {
|
|
75
|
+
if (typeof window === "undefined")
|
|
76
|
+
return SERVER_METRICS;
|
|
77
|
+
const layoutWidth = window.innerWidth;
|
|
78
|
+
const layoutHeight = window.innerHeight;
|
|
79
|
+
const vv = window.visualViewport;
|
|
80
|
+
const visual = vv
|
|
81
|
+
? {
|
|
82
|
+
left: vv.offsetLeft,
|
|
83
|
+
top: vv.offsetTop,
|
|
84
|
+
right: vv.offsetLeft + vv.width,
|
|
85
|
+
bottom: vv.offsetTop + vv.height,
|
|
86
|
+
}
|
|
87
|
+
: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight };
|
|
88
|
+
return { layout: { left: 0, top: 0, right: layoutWidth, bottom: layoutHeight }, visual };
|
|
89
|
+
}
|
|
90
|
+
let cachedMetrics = SERVER_METRICS;
|
|
91
|
+
function metricsSnapshot() {
|
|
92
|
+
const next = readViewportMetrics();
|
|
93
|
+
if (!sameRect(next.layout, cachedMetrics.layout) || !sameRect(next.visual, cachedMetrics.visual)) {
|
|
94
|
+
cachedMetrics = next;
|
|
95
|
+
}
|
|
96
|
+
return cachedMetrics;
|
|
97
|
+
}
|
|
98
|
+
function subscribeViewport(onChange) {
|
|
99
|
+
if (typeof window === "undefined")
|
|
100
|
+
return () => undefined;
|
|
101
|
+
window.addEventListener("resize", onChange);
|
|
102
|
+
window.addEventListener("orientationchange", onChange);
|
|
103
|
+
const vv = window.visualViewport;
|
|
104
|
+
vv?.addEventListener("resize", onChange);
|
|
105
|
+
vv?.addEventListener("scroll", onChange);
|
|
106
|
+
return () => {
|
|
107
|
+
window.removeEventListener("resize", onChange);
|
|
108
|
+
window.removeEventListener("orientationchange", onChange);
|
|
109
|
+
vv?.removeEventListener("resize", onChange);
|
|
110
|
+
vv?.removeEventListener("scroll", onChange);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. */
|
|
114
|
+
export function useViewportMetrics() {
|
|
115
|
+
return useSyncExternalStore(subscribeViewport, metricsSnapshot, () => SERVER_METRICS);
|
|
116
|
+
}
|
|
117
|
+
function measureSafeArea(probe) {
|
|
118
|
+
if (probe === null || typeof getComputedStyle !== "function")
|
|
119
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
120
|
+
const style = getComputedStyle(probe);
|
|
121
|
+
const px = (value) => {
|
|
122
|
+
const n = Number.parseFloat(value);
|
|
123
|
+
return Number.isFinite(n) ? n : 0;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
top: px(style.paddingTop),
|
|
127
|
+
right: px(style.paddingRight),
|
|
128
|
+
bottom: px(style.paddingBottom),
|
|
129
|
+
left: px(style.paddingLeft),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const SAFE_AREA_PROBE_STYLE = {
|
|
133
|
+
position: "fixed",
|
|
134
|
+
top: 0,
|
|
135
|
+
left: 0,
|
|
136
|
+
width: 0,
|
|
137
|
+
height: 0,
|
|
138
|
+
visibility: "hidden",
|
|
139
|
+
pointerEvents: "none",
|
|
140
|
+
paddingTop: "env(safe-area-inset-top, 0px)",
|
|
141
|
+
paddingRight: "env(safe-area-inset-right, 0px)",
|
|
142
|
+
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
143
|
+
paddingLeft: "env(safe-area-inset-left, 0px)",
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Provides the shared game viewport layout to everything it wraps. Mount it
|
|
147
|
+
* once around the whole game presentation (world, HUD, controls, system UI) so
|
|
148
|
+
* every subsystem reads one coordinated geometry and registers its rect for
|
|
149
|
+
* collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*`
|
|
150
|
+
* / `--jg-safe-*` CSS variables on its root.
|
|
151
|
+
*/
|
|
152
|
+
export function GameViewportProvider({ platforms, className, style, children, }) {
|
|
153
|
+
const registry = useMemo(createRegionRegistry, []);
|
|
154
|
+
const metrics = useViewportMetrics();
|
|
155
|
+
const { coarsePointer } = useDisplayProfile();
|
|
156
|
+
const probeRef = useRef(null);
|
|
157
|
+
const [safeArea, setSafeArea] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
158
|
+
const rootRef = useRef(null);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const update = () => {
|
|
161
|
+
const next = measureSafeArea(probeRef.current);
|
|
162
|
+
setSafeArea((prev) => prev.top === next.top && prev.right === next.right && prev.bottom === next.bottom && prev.left === next.left
|
|
163
|
+
? prev
|
|
164
|
+
: next);
|
|
165
|
+
};
|
|
166
|
+
update();
|
|
167
|
+
if (typeof window === "undefined")
|
|
168
|
+
return;
|
|
169
|
+
window.addEventListener("resize", update);
|
|
170
|
+
window.addEventListener("orientationchange", update);
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener("resize", update);
|
|
173
|
+
window.removeEventListener("orientationchange", update);
|
|
174
|
+
};
|
|
175
|
+
}, [metrics]);
|
|
176
|
+
const regions = useSyncExternalStore(registry.subscribe, registry.list, () => EMPTY_REGIONS);
|
|
177
|
+
const mobileSupported = platforms?.includes("mobile") ?? true;
|
|
178
|
+
const visual = metrics.visual;
|
|
179
|
+
const vw = Math.max(0, visual.right - visual.left);
|
|
180
|
+
const vh = Math.max(0, visual.bottom - visual.top);
|
|
181
|
+
const layout = useMemo(() => {
|
|
182
|
+
const orientation = vw > 0 || vh > 0 ? orientationOf(vw, vh) : orientationOf(metrics.layout.right, metrics.layout.bottom);
|
|
183
|
+
const mode = resolveLayoutMode({
|
|
184
|
+
width: vw > 0 ? vw : metrics.layout.right,
|
|
185
|
+
height: vh > 0 ? vh : metrics.layout.bottom,
|
|
186
|
+
coarsePointer,
|
|
187
|
+
mobileSupported,
|
|
188
|
+
});
|
|
189
|
+
const reserved = regions
|
|
190
|
+
.filter((region) => region.kind === "control" || region.kind === "system")
|
|
191
|
+
.map((region) => region.rect);
|
|
192
|
+
const gameplayRect = computeGameplayRect(metrics.layout, safeArea, reserved);
|
|
193
|
+
return {
|
|
194
|
+
viewport: metrics.layout,
|
|
195
|
+
visualViewport: visual,
|
|
196
|
+
safeArea,
|
|
197
|
+
mode,
|
|
198
|
+
orientation,
|
|
199
|
+
regions,
|
|
200
|
+
controlZones: reserved,
|
|
201
|
+
gameplayRect,
|
|
202
|
+
};
|
|
203
|
+
}, [regions, metrics.layout, visual, vw, vh, coarsePointer, mobileSupported, safeArea]);
|
|
204
|
+
const collisions = useMemo(() => detectLayoutCollisions(regions), [regions]);
|
|
205
|
+
const forbidden = useMemo(() => collisions.filter((c) => c.severity === "forbid"), [collisions]);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const root = rootRef.current;
|
|
208
|
+
if (root === null)
|
|
209
|
+
return;
|
|
210
|
+
if (forbidden.length === 0) {
|
|
211
|
+
root.removeAttribute("data-jg-layout-collision");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const report = JSON.stringify(forbidden);
|
|
215
|
+
root.setAttribute("data-jg-layout-collision", report);
|
|
216
|
+
console.warn(`[jgengine] ${formatLayoutCollisions(forbidden)}\n mode=${layout.mode} viewport=${Math.round(vw)}x${Math.round(vh)} safe=${safeArea.top}/${safeArea.right}/${safeArea.bottom}/${safeArea.left}`);
|
|
217
|
+
}, [forbidden, layout.mode, vw, vh, safeArea]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const ids = new Set();
|
|
220
|
+
for (const collision of forbidden) {
|
|
221
|
+
ids.add(collision.a);
|
|
222
|
+
ids.add(collision.b);
|
|
223
|
+
}
|
|
224
|
+
const marked = [];
|
|
225
|
+
for (const region of regions) {
|
|
226
|
+
if (region.element != null && ids.has(region.id)) {
|
|
227
|
+
region.element.setAttribute("data-jg-collision", "");
|
|
228
|
+
marked.push(region.element);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return () => {
|
|
232
|
+
for (const element of marked)
|
|
233
|
+
element.removeAttribute("data-jg-collision");
|
|
234
|
+
};
|
|
235
|
+
}, [forbidden, regions]);
|
|
236
|
+
const rootStyle = {
|
|
237
|
+
position: "relative",
|
|
238
|
+
width: "100%",
|
|
239
|
+
height: "100%",
|
|
240
|
+
"--jg-viewport-width": `${metrics.layout.right}px`,
|
|
241
|
+
"--jg-viewport-height": `${metrics.layout.bottom}px`,
|
|
242
|
+
"--jg-visual-viewport-width": `${Math.round(vw)}px`,
|
|
243
|
+
"--jg-visual-viewport-height": `${Math.round(vh)}px`,
|
|
244
|
+
"--jg-visual-viewport-offset-top": `${Math.round(visual.top)}px`,
|
|
245
|
+
"--jg-visual-viewport-offset-left": `${Math.round(visual.left)}px`,
|
|
246
|
+
"--jg-safe-top": `${safeArea.top}px`,
|
|
247
|
+
"--jg-safe-right": `${safeArea.right}px`,
|
|
248
|
+
"--jg-safe-bottom": `${safeArea.bottom}px`,
|
|
249
|
+
"--jg-safe-left": `${safeArea.left}px`,
|
|
250
|
+
...style,
|
|
251
|
+
};
|
|
252
|
+
return (_jsx(RegionRegistryContext.Provider, { value: registry, children: _jsx(GameLayoutContext.Provider, { value: layout, children: _jsxs("div", { ref: rootRef, "data-jg-viewport": "", "data-jg-layout-mode": layout.mode, "data-jg-orientation": layout.orientation, className: className, style: rootStyle, children: [_jsx("div", { ref: probeRef, "aria-hidden": true, style: SAFE_AREA_PROBE_STYLE }), children] }) }) }));
|
|
253
|
+
}
|
|
254
|
+
/** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
|
|
255
|
+
export function useGameViewportLayout() {
|
|
256
|
+
const layout = useContext(GameLayoutContext);
|
|
257
|
+
const metrics = useViewportMetrics();
|
|
258
|
+
return (layout ?? {
|
|
259
|
+
viewport: metrics.layout,
|
|
260
|
+
visualViewport: metrics.visual,
|
|
261
|
+
safeArea: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
262
|
+
mode: resolveLayoutMode({ width: metrics.layout.right, height: metrics.layout.bottom, coarsePointer: false }),
|
|
263
|
+
orientation: orientationOf(metrics.layout.right, metrics.layout.bottom),
|
|
264
|
+
regions: [],
|
|
265
|
+
controlZones: [],
|
|
266
|
+
gameplayRect: metrics.layout,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** The resolved explicit composition mode (`desktop-wide` … `mobile-portrait`). */
|
|
270
|
+
export function useGameLayoutMode() {
|
|
271
|
+
return useGameViewportLayout().mode;
|
|
272
|
+
}
|
|
273
|
+
/** The live device orientation. */
|
|
274
|
+
export function useGameOrientation() {
|
|
275
|
+
return useGameViewportLayout().orientation;
|
|
276
|
+
}
|
|
277
|
+
/** Rectangles reserved by touch controls and system UI — HUD placement should avoid these. */
|
|
278
|
+
export function useReservedControlZones() {
|
|
279
|
+
return useGameViewportLayout().controlZones;
|
|
280
|
+
}
|
|
281
|
+
/** Live forbidden/warned region collisions (empty outside a provider). */
|
|
282
|
+
export function useLayoutCollisions() {
|
|
283
|
+
const layout = useContext(GameLayoutContext);
|
|
284
|
+
return useMemo(() => (layout === null ? [] : detectLayoutCollisions(layout.regions)), [layout]);
|
|
285
|
+
}
|
|
286
|
+
function specKey(spec) {
|
|
287
|
+
return [
|
|
288
|
+
spec.id,
|
|
289
|
+
spec.kind,
|
|
290
|
+
spec.collisionPolicy,
|
|
291
|
+
spec.priority ?? "",
|
|
292
|
+
spec.collisionGroup ?? "",
|
|
293
|
+
(spec.allowOverlapWith ?? []).join("+"),
|
|
294
|
+
].join("|");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Register the element behind `ref` as a layout region and keep its measured
|
|
298
|
+
* rectangle live (ResizeObserver + viewport changes). No-op outside a
|
|
299
|
+
* `GameViewportProvider`, so a component using it still works in isolation.
|
|
300
|
+
*/
|
|
301
|
+
export function useRegisterLayoutRegion(spec, ref, enabled = true) {
|
|
302
|
+
const registry = useContext(RegionRegistryContext);
|
|
303
|
+
const specRef = useRef(spec);
|
|
304
|
+
specRef.current = spec;
|
|
305
|
+
const key = specKey(spec);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const element = ref.current;
|
|
308
|
+
if (registry === null || element === null || !enabled) {
|
|
309
|
+
registry?.remove(specRef.current.id);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const measure = () => {
|
|
313
|
+
const rect = element.getBoundingClientRect();
|
|
314
|
+
registry.set({
|
|
315
|
+
...specRef.current,
|
|
316
|
+
element,
|
|
317
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
measure();
|
|
321
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
322
|
+
observer?.observe(element);
|
|
323
|
+
if (typeof window !== "undefined") {
|
|
324
|
+
window.addEventListener("resize", measure);
|
|
325
|
+
window.addEventListener("orientationchange", measure);
|
|
326
|
+
window.visualViewport?.addEventListener("resize", measure);
|
|
327
|
+
window.visualViewport?.addEventListener("scroll", measure);
|
|
328
|
+
}
|
|
329
|
+
return () => {
|
|
330
|
+
observer?.disconnect();
|
|
331
|
+
if (typeof window !== "undefined") {
|
|
332
|
+
window.removeEventListener("resize", measure);
|
|
333
|
+
window.removeEventListener("orientationchange", measure);
|
|
334
|
+
window.visualViewport?.removeEventListener("resize", measure);
|
|
335
|
+
window.visualViewport?.removeEventListener("scroll", measure);
|
|
336
|
+
}
|
|
337
|
+
registry.remove(specRef.current.id);
|
|
338
|
+
};
|
|
339
|
+
}, [registry, ref, key, enabled]);
|
|
340
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export declare function useGamePhase(): {
|
|
|
32
32
|
phase: GamePhase;
|
|
33
33
|
setPhase: (phase: GamePhase) => void;
|
|
34
34
|
};
|
|
35
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
36
|
+
export declare function useOptionalGamePhase(): GamePhase;
|
|
35
37
|
export declare function useSceneEntities(): readonly SceneEntity[];
|
|
36
38
|
export declare function useSceneObjects(): readonly SceneObject[];
|
|
37
39
|
export declare function useWorldItems(): readonly WorldItemRecord[];
|
package/dist/hooks.js
CHANGED
|
@@ -3,7 +3,8 @@ 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
5
|
import { gamePhase, setGamePhase } from "@jgengine/core/game/gamePhase";
|
|
6
|
-
import { useGameContext } from "./provider.js";
|
|
6
|
+
import { useGameContext, useOptionalGameContext } from "./provider.js";
|
|
7
|
+
const noopSubscribe = () => () => undefined;
|
|
7
8
|
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
8
9
|
export function useGameStore(selector, isEqual = Object.is) {
|
|
9
10
|
const ctx = useGameContext();
|
|
@@ -30,6 +31,12 @@ export function useGamePhase() {
|
|
|
30
31
|
const setPhase = useCallback((next) => setGamePhase(ctx, next), [ctx]);
|
|
31
32
|
return { phase, setPhase };
|
|
32
33
|
}
|
|
34
|
+
/** Live run phase that degrades to `"playing"` when rendered outside a `GameProvider` (component showcases, previews), so phase-gated chrome never throws. */
|
|
35
|
+
export function useOptionalGamePhase() {
|
|
36
|
+
const ctx = useOptionalGameContext();
|
|
37
|
+
const getSnapshot = useCallback(() => (ctx === null ? "playing" : gamePhase(ctx)), [ctx]);
|
|
38
|
+
return useSyncExternalStore(ctx?.subscribe ?? noopSubscribe, getSnapshot, getSnapshot);
|
|
39
|
+
}
|
|
33
40
|
export function useSceneEntities() {
|
|
34
41
|
return useGameStore((ctx) => ctx.scene.entity.list());
|
|
35
42
|
}
|
|
@@ -67,27 +74,34 @@ export function useFeed({ action, limit }) {
|
|
|
67
74
|
export function useQuestJournal() {
|
|
68
75
|
return useGameStore((ctx) => ctx.game.quest.list(ctx.player.userId));
|
|
69
76
|
}
|
|
77
|
+
const EMPTY_FRIENDS = [];
|
|
78
|
+
const EMPTY_PARTY = [];
|
|
79
|
+
const EMPTY_WORLD_INVITES = [];
|
|
80
|
+
const EMPTY_CHAT = [];
|
|
81
|
+
const EMPTY_FRIEND_REQUESTS = [];
|
|
82
|
+
const EMPTY_PARTY_INVITES = [];
|
|
83
|
+
const OFFLINE_PRESENCE = { online: false };
|
|
70
84
|
export function useFriends() {
|
|
71
|
-
return useGameStore((ctx) => ctx.game.social
|
|
85
|
+
return useGameStore((ctx) => ctx.game.social?.friends.list(ctx.player.userId) ?? EMPTY_FRIENDS);
|
|
72
86
|
}
|
|
73
87
|
export function useParty() {
|
|
74
|
-
return useGameStore((ctx) => ctx.game.social
|
|
88
|
+
return useGameStore((ctx) => ctx.game.social?.party.list(ctx.player.userId) ?? EMPTY_PARTY);
|
|
75
89
|
}
|
|
76
90
|
export function usePresence(userId) {
|
|
77
|
-
return useGameStore((ctx) => ctx.game.social
|
|
91
|
+
return useGameStore((ctx) => ctx.game.social?.presence.get(userId) ?? OFFLINE_PRESENCE);
|
|
78
92
|
}
|
|
79
93
|
export function useWorldInvites() {
|
|
80
|
-
return useGameStore((ctx) => ctx.game.social
|
|
94
|
+
return useGameStore((ctx) => ctx.game.social?.worldInvites.listFor(ctx.player.userId) ?? EMPTY_WORLD_INVITES);
|
|
81
95
|
}
|
|
82
96
|
export function useChat(channelId, options) {
|
|
83
97
|
const limit = options?.limit ?? 50;
|
|
84
|
-
return useGameStore((ctx) => ctx.game.chat
|
|
98
|
+
return useGameStore((ctx) => ctx.game.chat?.history(channelId, { limit, viewerUserId: ctx.player.userId }) ?? EMPTY_CHAT);
|
|
85
99
|
}
|
|
86
100
|
export function useFriendRequests() {
|
|
87
|
-
return useGameStore((ctx) => ctx.game.social
|
|
101
|
+
return useGameStore((ctx) => ctx.game.social?.friends.requestsFor(ctx.player.userId) ?? EMPTY_FRIEND_REQUESTS);
|
|
88
102
|
}
|
|
89
103
|
export function usePartyInvites() {
|
|
90
|
-
return useGameStore((ctx) => ctx.game.social
|
|
104
|
+
return useGameStore((ctx) => ctx.game.social?.party.invitesFor(ctx.player.userId) ?? EMPTY_PARTY_INVITES);
|
|
91
105
|
}
|
|
92
106
|
/**
|
|
93
107
|
* Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and
|
|
@@ -134,11 +148,13 @@ export function useWorldBrowser(options) {
|
|
|
134
148
|
return useMemo(() => ({ listings, loading, error, refresh: () => setRefreshTick((tick) => tick + 1) }), [listings, loading, error]);
|
|
135
149
|
}
|
|
136
150
|
export function useRoster(userId) {
|
|
137
|
-
return useGameStore((ctx) => ctx.game.roster
|
|
151
|
+
return useGameStore((ctx) => ctx.game.roster?.list(userId ?? ctx.player.userId) ?? EMPTY_ROSTER);
|
|
138
152
|
}
|
|
153
|
+
const EMPTY_ROSTER = [];
|
|
139
154
|
export function useLeaderboard(stat, options) {
|
|
140
|
-
return useGameStore((ctx) => ctx.game.leaderboard
|
|
155
|
+
return useGameStore((ctx) => ctx.game.leaderboard?.getTop(stat, options) ?? EMPTY_LEADERBOARD);
|
|
141
156
|
}
|
|
157
|
+
const EMPTY_LEADERBOARD = [];
|
|
142
158
|
export function useLocalPlayerDead(healthStatId = "health") {
|
|
143
159
|
return useGameStore((ctx) => {
|
|
144
160
|
const player = localPlayerEntity(ctx);
|