@jgengine/react 0.7.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/chat.d.ts +52 -0
  3. package/dist/chat.js +68 -0
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +45 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +35 -22
  8. package/dist/display.d.ts +14 -0
  9. package/dist/display.js +45 -0
  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/gameIcons.d.ts +12 -0
  17. package/dist/gameIcons.js +282 -0
  18. package/dist/hooks.d.ts +59 -2
  19. package/dist/hooks.js +170 -12
  20. package/dist/hudLayout.d.ts +61 -0
  21. package/dist/hudLayout.js +488 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/identity.d.ts +65 -0
  25. package/dist/identity.js +94 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +10 -0
  28. package/dist/liveBind.d.ts +4 -10
  29. package/dist/liveBind.js +28 -16
  30. package/dist/map.d.ts +27 -7
  31. package/dist/map.js +152 -43
  32. package/dist/preview.d.ts +6 -0
  33. package/dist/preview.js +1 -0
  34. package/dist/selectSnapshot.d.ts +7 -0
  35. package/dist/selectSnapshot.js +16 -0
  36. package/dist/settings.d.ts +75 -0
  37. package/dist/settings.js +61 -0
  38. package/dist/skillCheckPaint.d.ts +4 -0
  39. package/dist/skillCheckPaint.js +28 -0
  40. package/dist/social.d.ts +108 -0
  41. package/dist/social.js +119 -0
  42. package/dist/voice.d.ts +58 -0
  43. package/dist/voice.js +130 -0
  44. package/llms.txt +1633 -0
  45. package/package.json +11 -6
package/CHANGELOG.md CHANGED
@@ -11,6 +11,69 @@ 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
+
30
+ ## 0.8.0
31
+
32
+ Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
33
+ and puzzle primitives, HUD-only presentation, cumulative leveling, and a round of
34
+ controller/camera/sensor additions. **Breaking:** the `gameui` component kit moved out of
35
+ `@jgengine/react` onto the shadcn registry.
36
+
37
+ ### Migrate
38
+
39
+ - Bump every `@jgengine/*` dependency to `^0.8.0` (the eight packages version in lockstep).
40
+ - Additive only, except for `gameui` — every other 0.7.0 API is unchanged; opt into any of the below by importing it directly.
41
+ - **Breaking:** replace any `@jgengine/react/gameui` import with the equivalent registry component (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), and swap `GameUiThemeProvider` for `--jg-*` CSS variables on a wrapper element. `GameIcon` and friends moved to `@jgengine/react/gameIcons`.
42
+ - `leveling({ thresholdMode: 'cumulative' })` is opt-in; the default `perLevel` behavior is unchanged.
43
+ - `defineGame.physics.gravity`/`jumpVelocity` are now read by the built-in kinematics controller every frame — if a game already set them expecting a no-op, jump/fall now actually reflects them; omit both to keep the previous defaults.
44
+
45
+ ### Added
46
+
47
+ - Cumulative leveling — `leveling({ thresholdMode: 'cumulative' })` tracks xp as a lifetime total that resolves upward across levels and clamps at the max-level threshold once capped (#12).
48
+ - Direction-aware pool depletion — `EffectSystem.canReceive(instanceId, effect, magnitude?)` takes an optional signed magnitude; negative checks the opposite direction and returns `pools-depleted` only when every stat in the receive order is already at max, so heals reach fully-depleted targets (#168).
49
+ - Puzzle primitives — `puzzle/cellGrid` (uniform-cell boards: line-clear, match-3 cascade, run detection) and `puzzle/fallingPiece` (rotation-state shapes, ghost drop, lock delay, classic gravity/level/score curves) for Tetris/match-3 games (#166).
50
+ - Voxel field — `world/voxelField` (`createVoxelField`, chunked block lattice, neighbors/exposedFaces, 3D DDA raycast, dirty-tracked `chunkVersion`) for voxel games and instanced renderers; assert on `field.summary()` the way environment worlds assert on `summarizeEnvironment` (#166).
51
+ - `defineGame` games may omit `assets` (an empty catalog is injected); `PlayableGame.presentation: 'hud'` mounts no 3D canvas/camera/pointer for board/card/menu games; an `environment()` world auto-renders as the shell's backdrop when `PlayableGame.environment` is unset (#166).
52
+ - Declared-action intent board — `turn/intent` `createIntentBoard` for one-turn-ahead intents (Slay-the-Spire style): declare/peek/all/consume/clear (#168).
53
+ - `turnLoop` lifecycle hooks — `config.onTurnStart`/`onTurnEnd` fire on every `advanceTurn()`; `ctx.game.turn.loop(id, config)` lazily creates/returns a notify-wrapped `TurnLoop` so every mutation (`advanceTurn`/`advancePhase`/`advanceRound`/`spend`/`gain`/`refill`/`setOrder`/...) auto-bumps `ctx.version()` with no manual wiring (#163/#168).
54
+ - `ctx.game.store` — a reactive per-game keyed store (`set`/`delete`/`get`/`has`/`subscribe`/`mapSnapshot`/`arraySnapshot`) plus `@jgengine/react`'s `useGameStore` selector hook, replacing hand-rolled module-level stores for ad-hoc reactive game state; `ctx.game.cards.pile(id, config?)` lazily creates/returns a notify-wrapped `CardPile` the same way; `createCardPile` gained an `onChange` hook for headless use; `CommandDefinition.apply` may return void for side-effect-only commands (#163).
55
+ - Camera — `sideScroll` rig (fixed lateral follow for 2.5D platformers/beat-'em-ups), a `none` rig (no camera mounted; pairs with `PlayableGame.presentation: 'hud'`), `rts.pan: false` (static backdrop camera: no pan/edge-scroll/rotate/zoom, still re-centers on the follow target), and the `observer` rig now defaults to the local player when `bind` is unset (#167).
56
+ - Sensors + session — `sensor/concealment` (`colorDistance`/`concealmentScore`/`createConcealmentSensor`), `sensor/freezeMonitor` (`createFreezeMonitor`), `session/roles` (`assignRoles`); `createRoundState`'s `RoundConfig.teams` accepts per-team roles and an optional `winCondition` that `evaluate()` checks each tick, and takes an optional `phaseOrder` for arbitrary named phase cycles (`concludeRound`/`evaluate` settle only while the current phase is neither the first nor the last entry) (#151).
57
+ - Appearance replication — presence rows carry an optional per-slot appearance channel (cosmetic ids, hex tints, model keys) alongside pose, riding the existing pose message with no protocol bump; wire `ctx.player.cosmetics.get(userId)` into the outgoing pose (#151).
58
+ - `ctx.input` — a per-frame held-action snapshot (`publish(held)`/`isDown(action)`/`held()`) without bumping `ctx.version()`; action bindings gained `repeatMs` (repeat-fire while held); every command resolved from a bound action now carries `aim`; `pointer.secondaryCommand` runs a command on right-click off the same raycast as move/ping (#164).
59
+ - Object spatial queries + entity patching — `ctx.scene.object.at`/`inBox`/`raycast`/`raycastAll` over unit-box objects; `ctx.scene.entity.update(id, patch)` for name/position/rotation/role/movement/behaviors/meta; per-instance `renderObject`/`objectStyles` overrides; `pointerService.worldHitCenter()` + pointer-lock center-ray aiming (#165).
60
+ - Controller movement config — `PlayerMovementConfig` (`mode`: free/axis/grid, `axis`, `cellSize`, `collideObjects`, `beforeCommit` pre-commit hook) for the shell-driven walk controller; `defineGame.physics.gravity`/`jumpVelocity` are honored by the built-in walk controller (distinct from the standalone `physics/physicsWorld` rigid-body sim); `ctx.player.motion.impulse`/`setVerticalVelocity`/`setY`/`takePending` (`MotionIntents`); `entity.spawnPoseOf`/`resetToSpawn`/`resetAllToSpawn` (#162).
61
+ - Model material/animation + paint — `ModelConfig.tint`/`metalness`/`roughness`/`animation` (GLTF clip playback, paused pose holds); `PointerHit.uv` + `pointerService.sampleSurface()` for material-aware picking; `ctx.scene.entity.paint` runtime paint layer, auto-rendered via a per-instance canvas texture with no per-game wiring; remote-player appearance tint recolors the shell's default capsule (#151).
62
+ - **Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` runs over any bidirectional string channel, not just a raw `WebSocket`: `TransportPipe`/`TransportPipeHandlers`/`TransportPipeFactory`, with `webSocketPipe(url, webSocketFactory?)` as the default. `createWsBackend({ userId, url?, pipe? })` — `url` stays the common case, `pipe` opens the seam to socket.io, WebRTC, and in-process loopback below.
63
+ - **Browser-safe authoritative host** — `createGameHost` and `memoryPersistence` moved from `@jgengine/node` to `@jgengine/ws/host` (zero Node dependencies; `@jgengine/node` re-exports both unchanged from `@jgengine/node/host` / `@jgengine/node/persistence`, so existing imports keep working). `@jgengine/ws/hostRouter`'s `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? })` extracts the ws wire-protocol session logic out of `createGameWsServer` into a transport-agnostic `HostRouter` (`connect(transport) → { handleRaw, close }`, `rewind`, `close`); `@jgengine/node`'s `createGameWsServer` is now a thin binding of this router onto the `ws` npm package, same public API. `loopbackPipe(router): TransportPipeFactory` connects a `createWsBackend` straight into an in-process router.
64
+ - **Socket.IO transport** — `@jgengine/ws/socketIoPipe` (`SocketIoLikeSocket` structural type, `socketIoPipe(socket)`, `createSocketIoBackend({ socket, userId, … })`) and `@jgengine/node/socketIoServer` (`attachGameSocketIoServer({ io, host, …router options }): { rewind, close }`, structural `SocketIoLikeServer`/`SocketIoLikeServerSocket`) ride the existing ws JSON protocol over socket.io's `send`/`message` frames — no socket.io dependency in either package's types. New `socketIo({ topology?, url? })` adapter in `@jgengine/core/runtime/adapter`.
65
+ - **WebRTC peer-to-peer** (`@jgengine/ws/peer`) — one browser tab hosts, authoritatively, with no server process. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? })` runs a `GameHost` + `HostRouter` in the host tab (`backend` is the host player's own loopback connection, `accept(offerCode) → Promise<answerCode>` per guest); `createPeerGuest({ userId, token?, rtc? })` offers/connects from the joining side. `encodePeerSignal`/`decodePeerSignal` turn SDP into copy-pasteable base64url codes for manual cross-machine signaling; `broadcastChannelSignaling(room)` automates it for same-origin multi-tab play, with `announcePeerHost`/`joinPeerSession` wiring host/guest to a `PeerSignaling` in one call. New `p2p({ topology?, room? })` adapter (topology defaults `"private"`).
66
+ - **LAN adapter + Fly sugar** — `lan({ topology?, port?, path? })` in `@jgengine/core/runtime/adapter` resolves through `@jgengine/shell/multiplayer`'s `resolveShellMultiplayer` to `ws(s)://<page hostname>:<port ?? 8080><path ?? /ws>` derived from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page — no URL configuration. `fly({ app, topology?, path? })` is `ws` sugar for a Fly.io deploy: resolves to `wss://<app>.fly.dev<path ?? "/ws">`. `apps/dev`'s Vite server now listens on the network (`server: { host: true }`) and exposes `?p2p=host` / `?p2p=join` query params wired through the new `resolvePeerShellMultiplayer({ gameId, role, room?, userId?, feedActions? })`.
67
+ - `ws()` (`@jgengine/core/runtime/adapter`) gained an optional `url` field, carried through by `resolveShellMultiplayer` (`args.url ?? adapter.url ?? default`).
68
+
69
+ ### Removed
70
+
71
+ - **The `gameui` component kit** (`@jgengine/react/gameui`) — the themed HUD kit (bars, slots, feedback, meters, panels, screens, reticles, icons) and its `GameUiThemeProvider`/`useGameUiTheme` theming have been removed. **Breaking** for anyone importing `@jgengine/react/gameui` (or its subpaths/barrel). The components now ship as installable shadcn registry items at `https://jgengine.com/r/<name>.json` (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), styled with Tailwind + `--jg-*` CSS variables instead of a theme object. The icon catalog (`GameIcon`, `iconForAction`, `iconForItemId`, `isGameIconName`, `GameIconName`) moved to `@jgengine/react/gameIcons`. To theme, set the `--jg-*` variables on a wrapper element (the registry's `jg-theme` presets mirror the old `ember`/`synthwave`/`fieldkit` palettes) instead of wrapping in `GameUiThemeProvider`.
72
+
73
+ ### Docs
74
+
75
+ - Added [CREDITS.md](CREDITS.md) crediting [achrefelouafi](https://github.com/achrefelouafi) for the MIT Three.js reference projects behind the procedural building, water, rain, and snow renderers, with links from the root, `@jgengine/core`, and `@jgengine/shell` READMEs.
76
+
14
77
  ## 0.7.0
15
78
 
16
79
  The engine-gaps release — 22 system-level additions across turn/tactics, cards & boards,
package/dist/chat.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ChatMessage } from "@jgengine/core/game/chat";
3
+ import type { ChatSync, ChatTransport } from "@jgengine/core/multiplayer/chatContract";
4
+ /**
5
+ * Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId))
6
+ * into the hook-shaped ChatTransport contract. Create once per sync — outside
7
+ * render or inside useMemo — so subscriptions survive re-renders.
8
+ */
9
+ export declare function chatTransportFromSync(sync: ChatSync): ChatTransport;
10
+ export declare function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: {
11
+ channelId: string;
12
+ limit?: number;
13
+ className?: string;
14
+ messageClassName?: string;
15
+ renderMessage?: (message: ChatMessage) => ReactNode;
16
+ }): import("react").JSX.Element;
17
+ export declare function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: {
18
+ channelId: string;
19
+ className?: string;
20
+ inputClassName?: string;
21
+ buttonClassName?: string;
22
+ placeholder?: string;
23
+ sendLabel?: ReactNode;
24
+ onSent?: (message: ChatMessage) => void;
25
+ onRejected?: (reason: string) => void;
26
+ }): import("react").JSX.Element;
27
+ export declare function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }: {
28
+ channels?: readonly string[];
29
+ active: string;
30
+ onSelect: (channelId: string) => void;
31
+ className?: string;
32
+ tabClassName?: string;
33
+ activeTabClassName?: string;
34
+ renderTab?: (channelId: string, isActive: boolean) => ReactNode;
35
+ }): import("react").JSX.Element;
36
+ export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: {
37
+ channels?: readonly string[];
38
+ initialChannel?: string;
39
+ limit?: number;
40
+ className?: string;
41
+ tabsClassName?: string;
42
+ tabClassName?: string;
43
+ activeTabClassName?: string;
44
+ logClassName?: string;
45
+ messageClassName?: string;
46
+ inputClassName?: string;
47
+ inputFieldClassName?: string;
48
+ sendButtonClassName?: string;
49
+ placeholder?: string;
50
+ renderMessage?: (message: ChatMessage) => ReactNode;
51
+ onRejected?: (reason: string) => void;
52
+ }): import("react").JSX.Element;
package/dist/chat.js ADDED
@@ -0,0 +1,68 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useGameContext } from "./provider.js";
4
+ import { useChat } from "./hooks.js";
5
+ /**
6
+ * Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId))
7
+ * into the hook-shaped ChatTransport contract. Create once per sync — outside
8
+ * render or inside useMemo — so subscriptions survive re-renders.
9
+ */
10
+ export function chatTransportFromSync(sync) {
11
+ return {
12
+ useMessages(channelId) {
13
+ const [messages, setMessages] = useState(undefined);
14
+ useEffect(() => {
15
+ if (channelId === "skip")
16
+ return undefined;
17
+ setMessages(undefined);
18
+ return sync.subscribe(channelId, setMessages);
19
+ }, [channelId]);
20
+ return channelId === "skip" ? undefined : messages;
21
+ },
22
+ useActions() {
23
+ return useMemo(() => ({ sendMessage: (args) => sync.send(args.channelId, args.body) }), []);
24
+ },
25
+ };
26
+ }
27
+ export function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }) {
28
+ const messages = useChat(channelId, limit === undefined ? undefined : { limit });
29
+ const scrollRef = useRef(null);
30
+ useEffect(() => {
31
+ const node = scrollRef.current;
32
+ if (node !== null)
33
+ node.scrollTop = node.scrollHeight;
34
+ }, [messages.length]);
35
+ return (_jsx("div", { ref: scrollRef, className: className, "data-chat-log": channelId, children: messages.map((message) => (_jsx("div", { className: messageClassName, "data-chat-message": true, "data-from": message.fromUserId, children: renderMessage !== undefined ? (renderMessage(message)) : (_jsxs(_Fragment, { children: [_jsx("span", { "data-chat-from": true, children: message.fromUserId }), _jsx("span", { "data-chat-body": true, children: message.body })] })) }, message.id))) }));
36
+ }
37
+ export function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }) {
38
+ const ctx = useGameContext();
39
+ const [value, setValue] = useState("");
40
+ function submit(event) {
41
+ event.preventDefault();
42
+ const result = ctx.game.chat.send(ctx.player.userId, channelId, value);
43
+ if ("reason" in result) {
44
+ onRejected?.(result.reason);
45
+ return;
46
+ }
47
+ setValue("");
48
+ onSent?.(result.message);
49
+ }
50
+ 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
+ }
52
+ export function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }) {
53
+ const ctx = useGameContext();
54
+ const ids = channels ?? ctx.game.chat.channels().map((def) => def.id);
55
+ return (_jsx("div", { className: className, role: "tablist", "data-chat-tabs": true, children: ids.map((channelId) => {
56
+ const isActive = channelId === active;
57
+ const classes = [tabClassName, isActive ? activeTabClassName : undefined]
58
+ .filter(Boolean)
59
+ .join(" ");
60
+ return (_jsx("button", { type: "button", role: "tab", "aria-selected": isActive, className: classes.length > 0 ? classes : undefined, "data-channel": channelId, onClick: () => onSelect(channelId), children: renderTab !== undefined ? renderTab(channelId, isActive) : channelId }, channelId));
61
+ }) }));
62
+ }
63
+ export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }) {
64
+ const ctx = useGameContext();
65
+ const ids = channels ?? ctx.game.chat.channels().map((def) => def.id);
66
+ const [active, setActive] = useState(initialChannel ?? ids[0] ?? "global");
67
+ 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
+ }
@@ -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
+ }
@@ -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
@@ -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);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Viewport/input-modality profile for adaptive HUDs: coarse pointer means a
3
+ * touchscreen is the primary input (mount touch controls, enlarge tap
4
+ * targets), compact means phone-scale layout — narrow width or the short
5
+ * height of a landscape phone (collapse side panels), portrait
6
+ * distinguishes the two phone orientations. Values track live media-query
7
+ * changes and are safe to read during SSR (everything false).
8
+ */
9
+ export interface DisplayProfile {
10
+ coarsePointer: boolean;
11
+ compact: boolean;
12
+ portrait: boolean;
13
+ }
14
+ export declare function useDisplayProfile(): DisplayProfile;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Viewport/input-modality profile for adaptive HUDs: coarse pointer means a
3
+ * touchscreen is the primary input (mount touch controls, enlarge tap
4
+ * targets), compact means phone-scale layout — narrow width or the short
5
+ * height of a landscape phone (collapse side panels), portrait
6
+ * distinguishes the two phone orientations. Values track live media-query
7
+ * changes and are safe to read during SSR (everything false).
8
+ */
9
+ import { useSyncExternalStore } from "react";
10
+ const QUERIES = {
11
+ coarsePointer: "(pointer: coarse)",
12
+ compact: "(max-width: 820px), (max-height: 500px)",
13
+ portrait: "(orientation: portrait)",
14
+ };
15
+ const SERVER_PROFILE = { coarsePointer: false, compact: false, portrait: false };
16
+ let cached = SERVER_PROFILE;
17
+ function readProfile() {
18
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
19
+ return SERVER_PROFILE;
20
+ const next = {
21
+ coarsePointer: window.matchMedia(QUERIES.coarsePointer).matches,
22
+ compact: window.matchMedia(QUERIES.compact).matches,
23
+ portrait: window.matchMedia(QUERIES.portrait).matches,
24
+ };
25
+ if (next.coarsePointer !== cached.coarsePointer ||
26
+ next.compact !== cached.compact ||
27
+ next.portrait !== cached.portrait) {
28
+ cached = next;
29
+ }
30
+ return cached;
31
+ }
32
+ function subscribe(onChange) {
33
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
34
+ return () => undefined;
35
+ const lists = Object.values(QUERIES).map((query) => window.matchMedia(query));
36
+ for (const list of lists)
37
+ list.addEventListener("change", onChange);
38
+ return () => {
39
+ for (const list of lists)
40
+ list.removeEventListener("change", onChange);
41
+ };
42
+ }
43
+ export function useDisplayProfile() {
44
+ return useSyncExternalStore(subscribe, readProfile, () => SERVER_PROFILE);
45
+ }
@@ -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);