@jgengine/react 0.10.0 → 0.11.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 +13 -2
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +292 -0
- package/dist/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +3 -1
- package/dist/chat.js +2 -2
- package/dist/components.d.ts +20 -0
- package/dist/components.js +28 -0
- package/dist/display.d.ts +1 -0
- package/dist/display.js +1 -0
- package/dist/engineStore.d.ts +3 -0
- package/dist/engineStore.js +3 -0
- package/dist/fogOverlay.d.ts +3 -0
- package/dist/fogOverlay.js +3 -0
- package/dist/gameViewport.js +16 -0
- package/dist/hooks.d.ts +20 -0
- package/dist/hooks.js +31 -1
- package/dist/hud.d.ts +87 -0
- package/dist/hud.js +144 -0
- package/dist/hudLayout.d.ts +19 -2
- package/dist/hudLayout.js +121 -10
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +69 -1
- package/dist/map.js +54 -1
- package/dist/save.d.ts +25 -0
- package/dist/save.js +37 -0
- package/dist/selectSnapshot.d.ts +2 -0
- package/dist/selectSnapshot.js +2 -0
- package/dist/selectionHud.d.ts +133 -0
- package/dist/selectionHud.js +182 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/startScreen.d.ts +62 -0
- package/dist/startScreen.js +62 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +9 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-ui/SKILL.md +36 -0
- package/skills/jgengine-ui/api.md +1852 -0
- package/skills/jgengine-ui/capabilities.md +142 -0
- package/skills/jgengine-ui/reference.md +500 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1724
package/dist/chat.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare function ChannelTabs({ channels, active, onSelect, className, tab
|
|
|
33
33
|
activeTabClassName?: string;
|
|
34
34
|
renderTab?: (channelId: string, isActive: boolean) => ReactNode;
|
|
35
35
|
}): import("react").JSX.Element | null;
|
|
36
|
-
export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: {
|
|
36
|
+
export declare function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }: {
|
|
37
37
|
channels?: readonly string[];
|
|
38
38
|
initialChannel?: string;
|
|
39
39
|
limit?: number;
|
|
@@ -48,5 +48,7 @@ export declare function ChatPanel({ channels, initialChannel, limit, className,
|
|
|
48
48
|
sendButtonClassName?: string;
|
|
49
49
|
placeholder?: string;
|
|
50
50
|
renderMessage?: (message: ChatMessage) => ReactNode;
|
|
51
|
+
/** Overrides a tab's label — e.g. render channel id `"proximity"` as "Nearby". */
|
|
52
|
+
renderTab?: (channelId: string, isActive: boolean) => ReactNode;
|
|
51
53
|
onRejected?: (reason: string) => void;
|
|
52
54
|
}): import("react").JSX.Element | null;
|
package/dist/chat.js
CHANGED
|
@@ -66,12 +66,12 @@ export function ChannelTabs({ channels, active, onSelect, className, tabClassNam
|
|
|
66
66
|
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));
|
|
67
67
|
}) }));
|
|
68
68
|
}
|
|
69
|
-
export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }) {
|
|
69
|
+
export function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }) {
|
|
70
70
|
const ctx = useGameContext();
|
|
71
71
|
const chat = ctx.game.chat;
|
|
72
72
|
const ids = channels ?? chat?.channels().map((def) => def.id) ?? [];
|
|
73
73
|
const [active, setActive] = useState(initialChannel ?? ids[0] ?? "global");
|
|
74
74
|
if (chat === undefined)
|
|
75
75
|
return null;
|
|
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 })] }));
|
|
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, renderTab: renderTab }), _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 })] }));
|
|
77
77
|
}
|
package/dist/components.d.ts
CHANGED
|
@@ -84,6 +84,20 @@ export declare function DialogueBox({ dialogue, onChoice, rng, className, lineCl
|
|
|
84
84
|
choiceClassName?: string;
|
|
85
85
|
checkClassName?: string;
|
|
86
86
|
}): import("react").JSX.Element;
|
|
87
|
+
/**
|
|
88
|
+
* The id of the dialogue `ctx.game.dialogue` (or a `talkable(id)` prompt) currently has open, or `null`.
|
|
89
|
+
* The read side of the `features.dialogue` bridge — a panel looks the id up in its own dialogue catalog and
|
|
90
|
+
* renders {@link DialogueBox}, with no per-game open/close store.
|
|
91
|
+
*/
|
|
92
|
+
export declare function useOpenDialogueId(): string | null;
|
|
93
|
+
/**
|
|
94
|
+
* Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke
|
|
95
|
+
* (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that
|
|
96
|
+
* replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function runDialogueChoice(commands: {
|
|
99
|
+
run(name: string, input?: unknown): unknown;
|
|
100
|
+
}, choice: DialogueChoice, result: CheckResult | null): void;
|
|
87
101
|
export declare function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: {
|
|
88
102
|
config: SkillCheckConfig;
|
|
89
103
|
startedAt: number;
|
|
@@ -106,6 +120,12 @@ export declare function CaptureOdds({ chance, className, fillClassName, }: {
|
|
|
106
120
|
className?: string;
|
|
107
121
|
fillClassName?: string;
|
|
108
122
|
}): import("react").JSX.Element;
|
|
123
|
+
/**
|
|
124
|
+
* Render `ctx.game.feed`'s entries for `action` as a newest-first toast stack — the feed-backed sibling of
|
|
125
|
+
* `@jgengine/core/game/toasts`' `createToastQueue`. Reach for this when the message source is already an engine
|
|
126
|
+
* event bound onto `ctx.game.feed` (kill feed, quest updates, loot log); reach for a `createToastQueue` when the
|
|
127
|
+
* game raises ad-hoc messages that need their own TTL independent of the feed's ring buffer.
|
|
128
|
+
*/
|
|
109
129
|
export declare function ToastStack({ action, limit, className, renderToast, }: {
|
|
110
130
|
action: string;
|
|
111
131
|
limit?: number;
|
package/dist/components.js
CHANGED
|
@@ -3,8 +3,10 @@ import { useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { evaluateSkillCheck, } from "@jgengine/core/interaction/skillCheck";
|
|
4
4
|
import { pendingQteStep } from "@jgengine/core/interaction/qte";
|
|
5
5
|
import { rollCheck } from "@jgengine/core/stats/rollCheck";
|
|
6
|
+
import { dialogueSlot } from "@jgengine/core/game/dialogue";
|
|
6
7
|
import { useGameContext } from "./provider.js";
|
|
7
8
|
import { useCurrency, useEntityStat, useFeed, useInventory, useLocalPlayerDead } from "./hooks.js";
|
|
9
|
+
import { useStore } from "./store.js";
|
|
8
10
|
import { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
|
|
9
11
|
export { paintQteStepDom, paintSkillCheckDom } from "./skillCheckPaint.js";
|
|
10
12
|
export function SlotGrid({ inventoryId, className, renderSlot, }) {
|
|
@@ -55,6 +57,26 @@ export function DialogueBox({ dialogue, onChoice, rng, className, lineClassName,
|
|
|
55
57
|
? ` (${choice.check.advantage})`
|
|
56
58
|
: ""] }))] }, choice.label))) }, index)) : (_jsxs("p", { className: lineClassName, children: [_jsx("span", { className: speakerClassName, "data-speaker": true, children: line.speaker }), _jsx("span", { children: line.text })] }, index))) }));
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The id of the dialogue `ctx.game.dialogue` (or a `talkable(id)` prompt) currently has open, or `null`.
|
|
62
|
+
* The read side of the `features.dialogue` bridge — a panel looks the id up in its own dialogue catalog and
|
|
63
|
+
* renders {@link DialogueBox}, with no per-game open/close store.
|
|
64
|
+
*/
|
|
65
|
+
export function useOpenDialogueId() {
|
|
66
|
+
return useStore(dialogueSlot, (value) => value ?? null);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Route a {@link DialogueBox} choice through the `features.dialogue` bridge: resolve the choice's invoke
|
|
70
|
+
* (honoring a skill-check `result`), run that command, and otherwise close the dialogue — the write side that
|
|
71
|
+
* replaces a per-game `onChoice` that hand-rolls `resolveDialogueInvoke` + `dialogue.close`.
|
|
72
|
+
*/
|
|
73
|
+
export function runDialogueChoice(commands, choice, result) {
|
|
74
|
+
const invoke = resolveDialogueInvoke(choice, result);
|
|
75
|
+
if (invoke !== null && invoke !== undefined)
|
|
76
|
+
commands.run(invoke.command, invoke.args ?? {});
|
|
77
|
+
else
|
|
78
|
+
commands.run("dialogue.close", {});
|
|
79
|
+
}
|
|
58
80
|
export function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }) {
|
|
59
81
|
const ctx = useGameContext();
|
|
60
82
|
const rootRef = useRef(null);
|
|
@@ -119,6 +141,12 @@ function defaultToast(entry) {
|
|
|
119
141
|
}
|
|
120
142
|
return typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data);
|
|
121
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Render `ctx.game.feed`'s entries for `action` as a newest-first toast stack — the feed-backed sibling of
|
|
146
|
+
* `@jgengine/core/game/toasts`' `createToastQueue`. Reach for this when the message source is already an engine
|
|
147
|
+
* event bound onto `ctx.game.feed` (kill feed, quest updates, loot log); reach for a `createToastQueue` when the
|
|
148
|
+
* game raises ad-hoc messages that need their own TTL independent of the feed's ring buffer.
|
|
149
|
+
*/
|
|
122
150
|
export function ToastStack({ action, limit = 4, className, renderToast, }) {
|
|
123
151
|
const entries = useFeed({ action, limit });
|
|
124
152
|
if (entries.length === 0)
|
package/dist/display.d.ts
CHANGED
package/dist/display.js
CHANGED
package/dist/engineStore.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface ReadableEngineStore<TState> {
|
|
|
5
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
|
+
/** @internal */
|
|
8
9
|
export declare function useEngineState<TState>(store: ReadableEngineStore<TState>): TState;
|
|
10
|
+
/** @internal */
|
|
9
11
|
export declare function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected, isEqual?: (previous: TSelected, next: TSelected) => boolean): TSelected;
|
|
12
|
+
/** @internal */
|
|
10
13
|
export declare function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void;
|
package/dist/engineStore.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
2
|
import { createSelectCache, readSelectSnapshot } from "./selectSnapshot.js";
|
|
3
|
+
/** @internal */
|
|
3
4
|
export function useEngineState(store) {
|
|
4
5
|
const subscribe = useCallback((onChange) => store.subscribe(() => onChange()), [store]);
|
|
5
6
|
const getSnapshot = useCallback(() => store.getState(), [store]);
|
|
6
7
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
7
8
|
}
|
|
9
|
+
/** @internal */
|
|
8
10
|
export function useEngineStore(store, selector, isEqual = Object.is) {
|
|
9
11
|
const selectorRef = useRef(selector);
|
|
10
12
|
selectorRef.current = selector;
|
|
@@ -15,6 +17,7 @@ export function useEngineStore(store, selector, isEqual = Object.is) {
|
|
|
15
17
|
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, store.getState(), (state) => selectorRef.current(state), (previous, next) => isEqualRef.current(previous, next)), [store]);
|
|
16
18
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
17
19
|
}
|
|
20
|
+
/** @internal */
|
|
18
21
|
export function useEngineEvent(store, eventName, handler) {
|
|
19
22
|
const handlerRef = useRef(handler);
|
|
20
23
|
handlerRef.current = handler;
|
package/dist/fogOverlay.d.ts
CHANGED
|
@@ -10,11 +10,14 @@ export interface FogPaintSurface {
|
|
|
10
10
|
fillRect(x: number, y: number, width: number, height: number): void;
|
|
11
11
|
clearRect(x: number, y: number, width: number, height: number): void;
|
|
12
12
|
}
|
|
13
|
+
/** @internal */
|
|
13
14
|
export declare function forEachUnrevealedFogCell(fog: FogCells, visit: (col: number, row: number) => void): number;
|
|
15
|
+
/** @internal */
|
|
14
16
|
export declare function paintFogOverlay(surface: FogPaintSurface, canvasSize: {
|
|
15
17
|
width: number;
|
|
16
18
|
height: number;
|
|
17
19
|
}, fog: FogCells, cellRect: (col: number, row: number) => FogCellRect | null, fill?: string): number;
|
|
20
|
+
/** @internal */
|
|
18
21
|
export declare function createFogDataUrl(fog: FogCells, canvasSize: {
|
|
19
22
|
width: number;
|
|
20
23
|
height: number;
|
package/dist/fogOverlay.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @internal */
|
|
1
2
|
export function forEachUnrevealedFogCell(fog, visit) {
|
|
2
3
|
let count = 0;
|
|
3
4
|
for (let row = 0; row < fog.rows; row += 1) {
|
|
@@ -10,6 +11,7 @@ export function forEachUnrevealedFogCell(fog, visit) {
|
|
|
10
11
|
}
|
|
11
12
|
return count;
|
|
12
13
|
}
|
|
14
|
+
/** @internal */
|
|
13
15
|
export function paintFogOverlay(surface, canvasSize, fog, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
|
|
14
16
|
surface.clearRect(0, 0, canvasSize.width, canvasSize.height);
|
|
15
17
|
surface.fillStyle = fill;
|
|
@@ -20,6 +22,7 @@ export function paintFogOverlay(surface, canvasSize, fog, cellRect, fill = "rgba
|
|
|
20
22
|
surface.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
21
23
|
});
|
|
22
24
|
}
|
|
25
|
+
/** @internal */
|
|
23
26
|
export function createFogDataUrl(fog, canvasSize, cellRect, fill = "rgba(11, 15, 20, 0.82)") {
|
|
24
27
|
if (typeof document === "undefined")
|
|
25
28
|
return null;
|
package/dist/gameViewport.js
CHANGED
|
@@ -67,6 +67,19 @@ function createRegionRegistry() {
|
|
|
67
67
|
}
|
|
68
68
|
const RegionRegistryContext = createContext(null);
|
|
69
69
|
const GameLayoutContext = createContext(null);
|
|
70
|
+
const INPUT_HINT_STYLE_ID = "jg-input-hints";
|
|
71
|
+
const INPUT_HINT_CSS = "@media (pointer: coarse){[data-jg-kbd-hint]{display:none!important}}";
|
|
72
|
+
/** Install the backstop rule that hides keyboard/mouse hints on touch, once per document. */
|
|
73
|
+
function ensureInputHintStylesheet() {
|
|
74
|
+
if (typeof document === "undefined")
|
|
75
|
+
return;
|
|
76
|
+
if (document.getElementById(INPUT_HINT_STYLE_ID) !== null)
|
|
77
|
+
return;
|
|
78
|
+
const style = document.createElement("style");
|
|
79
|
+
style.id = INPUT_HINT_STYLE_ID;
|
|
80
|
+
style.textContent = INPUT_HINT_CSS;
|
|
81
|
+
document.head.appendChild(style);
|
|
82
|
+
}
|
|
70
83
|
const SERVER_METRICS = {
|
|
71
84
|
layout: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
72
85
|
visual: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
@@ -156,6 +169,9 @@ export function GameViewportProvider({ platforms, className, style, children, })
|
|
|
156
169
|
const probeRef = useRef(null);
|
|
157
170
|
const [safeArea, setSafeArea] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
158
171
|
const rootRef = useRef(null);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
ensureInputHintStylesheet();
|
|
174
|
+
}, []);
|
|
159
175
|
useEffect(() => {
|
|
160
176
|
const update = () => {
|
|
161
177
|
const next = measureSafeArea(probeRef.current);
|
package/dist/hooks.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type { ClockSnapshot, SimClock } from "@jgengine/core/time/simClock";
|
|
|
19
19
|
import { type PositionedPrompt } from "@jgengine/core/interaction/proximityPrompt";
|
|
20
20
|
import { type GamePhase } from "@jgengine/core/game/gamePhase";
|
|
21
21
|
export declare function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual?: (previous: T, next: T) => boolean): T;
|
|
22
|
+
/** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
|
|
23
|
+
export declare function useGameStoreValue<T>(key: string, fallback: T): T;
|
|
22
24
|
export declare function useGame(): {
|
|
23
25
|
commands: GameContext["game"]["commands"];
|
|
24
26
|
events: GameEvents;
|
|
@@ -36,6 +38,15 @@ export declare function useGamePhase(): {
|
|
|
36
38
|
export declare function useOptionalGamePhase(): GamePhase;
|
|
37
39
|
export declare function useSceneEntities(): readonly SceneEntity[];
|
|
38
40
|
export declare function useSceneObjects(): readonly SceneObject[];
|
|
41
|
+
/**
|
|
42
|
+
* Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
|
|
43
|
+
* writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
|
|
44
|
+
* mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
|
|
45
|
+
* re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
|
|
46
|
+
*/
|
|
47
|
+
export declare function useSceneEntityIds(): readonly string[];
|
|
48
|
+
/** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
|
|
49
|
+
export declare function useSceneObjectIds(): readonly string[];
|
|
39
50
|
export declare function useWorldItems(): readonly WorldItemRecord[];
|
|
40
51
|
/** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
|
|
41
52
|
export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
|
|
@@ -90,18 +101,27 @@ export declare function useGameClock(): ClockSnapshot & {
|
|
|
90
101
|
};
|
|
91
102
|
export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
|
|
92
103
|
export declare function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean;
|
|
104
|
+
/** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
|
|
93
105
|
export declare function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean;
|
|
94
106
|
export interface AbilitySlotBindingOptions {
|
|
95
107
|
intervalMs?: number;
|
|
96
108
|
}
|
|
97
109
|
export declare function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[];
|
|
98
110
|
export declare function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null;
|
|
111
|
+
/** A rendered snapshot of an {@link EventMeter}: current value, fill fraction, active tier, and ready-to-consume flag. */
|
|
99
112
|
export interface EventMeterView {
|
|
100
113
|
value: number;
|
|
101
114
|
fraction: number;
|
|
102
115
|
tier: string | null;
|
|
103
116
|
ready: boolean;
|
|
104
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
|
|
120
|
+
* half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
|
|
121
|
+
* on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
|
|
122
|
+
*
|
|
123
|
+
* @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
|
|
124
|
+
*/
|
|
105
125
|
export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
|
|
106
126
|
type HeldKeyEventTarget = Pick<Window, "addEventListener" | "removeEventListener">;
|
|
107
127
|
export declare function createHeldKeyTracker(target: HeldKeyEventTarget): {
|
package/dist/hooks.js
CHANGED
|
@@ -16,6 +16,10 @@ export function useGameStore(selector, isEqual = Object.is) {
|
|
|
16
16
|
const getSnapshot = useCallback(() => readSelectSnapshot(cacheRef.current, ctx.version(), () => selectorRef.current(ctx), (previous, next) => isEqualRef.current(previous, next)), [ctx]);
|
|
17
17
|
return useSyncExternalStore(ctx.subscribe, getSnapshot, getSnapshot);
|
|
18
18
|
}
|
|
19
|
+
/** Subscribe to a single keyed slice of the game store, returning `fallback` until the key is set. */
|
|
20
|
+
export function useGameStoreValue(key, fallback) {
|
|
21
|
+
return useGameStore((ctx) => ctx.game.store.get(key) ?? fallback);
|
|
22
|
+
}
|
|
19
23
|
export function useGame() {
|
|
20
24
|
const ctx = useGameContext();
|
|
21
25
|
return useMemo(() => ({ commands: ctx.game.commands, events: ctx.game.events }), [ctx]);
|
|
@@ -43,6 +47,23 @@ export function useSceneEntities() {
|
|
|
43
47
|
export function useSceneObjects() {
|
|
44
48
|
return useGameStore((ctx) => ctx.scene.object.list());
|
|
45
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Membership-only entity id list: the returned array keeps a stable identity across per-frame pose
|
|
52
|
+
* writes and only changes when an entity spawns, despawns, or the store is hydrated (#625). A marker
|
|
53
|
+
* mapped from these ids reads its own live pose imperatively (useFrame), so the actor tree no longer
|
|
54
|
+
* re-reconciles every frame. Prefer this over {@link useSceneEntities} for large scenes.
|
|
55
|
+
*/
|
|
56
|
+
export function useSceneEntityIds() {
|
|
57
|
+
const ctx = useGameContext();
|
|
58
|
+
const entity = ctx.scene.entity;
|
|
59
|
+
return useSyncExternalStore(entity.subscribeMembership, entity.ids, entity.ids);
|
|
60
|
+
}
|
|
61
|
+
/** Membership-only object id list — the object counterpart of {@link useSceneEntityIds}; stable across move/rotate/setVisual, changes only on place/remove. */
|
|
62
|
+
export function useSceneObjectIds() {
|
|
63
|
+
const ctx = useGameContext();
|
|
64
|
+
const object = ctx.scene.object;
|
|
65
|
+
return useSyncExternalStore(object.subscribeMembership, object.ids, object.ids);
|
|
66
|
+
}
|
|
46
67
|
export function useWorldItems() {
|
|
47
68
|
return useGameStore((ctx) => ctx.scene.worldItem.list());
|
|
48
69
|
}
|
|
@@ -71,8 +92,9 @@ export function useCurrency(currencyId) {
|
|
|
71
92
|
export function useFeed({ action, limit }) {
|
|
72
93
|
return useGameStore((ctx) => ctx.game.feed.recent(action, limit === undefined ? undefined : { limit }));
|
|
73
94
|
}
|
|
95
|
+
const EMPTY_QUESTS = [];
|
|
74
96
|
export function useQuestJournal() {
|
|
75
|
-
return useGameStore((ctx) => ctx.game.quest
|
|
97
|
+
return useGameStore((ctx) => ctx.game.quest?.list(ctx.player.userId) ?? EMPTY_QUESTS);
|
|
76
98
|
}
|
|
77
99
|
const EMPTY_FRIENDS = [];
|
|
78
100
|
const EMPTY_PARTY = [];
|
|
@@ -191,6 +213,7 @@ export function abilityKitNeedsHeartbeat(kit, resourceAvailable) {
|
|
|
191
213
|
}
|
|
192
214
|
return false;
|
|
193
215
|
}
|
|
216
|
+
/** True when `meter`'s value/fraction/tier/ready diverge from `previous` — the re-render check `useEventMeter` polls on its heartbeat. */
|
|
194
217
|
export function eventMeterNeedsHeartbeat(meter, previous) {
|
|
195
218
|
const next = {
|
|
196
219
|
value: meter.value(),
|
|
@@ -227,6 +250,13 @@ export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
|
|
|
227
250
|
useEngineHeartbeat(options?.intervalMs ?? 80, () => abilityKitNeedsHeartbeat(kit, resourceAvailable));
|
|
228
251
|
return kit.state(slotId, resourceAvailable);
|
|
229
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render
|
|
255
|
+
* half of the ult/adrenaline and streak/combo meters (`event-meter` capability) that lets a HUD gauge re-render
|
|
256
|
+
* on tick without the game hand-rolling a `useEffect`/`setInterval` heartbeat around `meter.value()`.
|
|
257
|
+
*
|
|
258
|
+
* @capability event-meter-hud render a core event/heat meter's live value, fraction, tier, and ready state in a HUD gauge
|
|
259
|
+
*/
|
|
230
260
|
export function useEventMeter(meter, options) {
|
|
231
261
|
const previous = useRef(null);
|
|
232
262
|
useEngineHeartbeat(options?.intervalMs ?? 80, () => {
|
package/dist/hud.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
/** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
|
|
3
|
+
declare const TONES: Record<string, (t: number) => [string, string]>;
|
|
4
|
+
/** A stat tone — picks the bar's color ramp. */
|
|
5
|
+
export type StatTone = keyof typeof TONES;
|
|
6
|
+
/**
|
|
7
|
+
* A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
|
|
8
|
+
* fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
|
|
9
|
+
* Renders nothing until the stat exists.
|
|
10
|
+
*/
|
|
11
|
+
export declare function StatBar({ statId, entityId, tone, label, showValue, width, icon, style, className, }: {
|
|
12
|
+
statId?: string;
|
|
13
|
+
entityId?: string;
|
|
14
|
+
tone?: StatTone;
|
|
15
|
+
label?: string;
|
|
16
|
+
showValue?: boolean;
|
|
17
|
+
width?: number;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
className?: string;
|
|
21
|
+
}): import("react").JSX.Element | null;
|
|
22
|
+
/**
|
|
23
|
+
* A numbered hotbar bound to an inventory — glassy slots, an active-slot highlight, item id + count,
|
|
24
|
+
* and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Hotbar({ inventoryId, activeSlot, keys, slotSize, style, className, }: {
|
|
27
|
+
inventoryId: string;
|
|
28
|
+
activeSlot?: number;
|
|
29
|
+
keys?: readonly string[];
|
|
30
|
+
slotSize?: number;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
className?: string;
|
|
33
|
+
}): import("react").JSX.Element;
|
|
34
|
+
/**
|
|
35
|
+
* A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
|
|
36
|
+
* `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
|
|
37
|
+
* gauge's top of scale.
|
|
38
|
+
*/
|
|
39
|
+
export declare function Speedometer({ entityId, scale, unit, max, size, style, className, }: {
|
|
40
|
+
entityId?: string;
|
|
41
|
+
scale?: number;
|
|
42
|
+
unit?: string;
|
|
43
|
+
max?: number;
|
|
44
|
+
size?: number;
|
|
45
|
+
style?: CSSProperties;
|
|
46
|
+
className?: string;
|
|
47
|
+
}): import("react").JSX.Element;
|
|
48
|
+
/**
|
|
49
|
+
* A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
|
|
50
|
+
* pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
|
|
51
|
+
* a game opts into letting the player scrub time.
|
|
52
|
+
*/
|
|
53
|
+
export declare function Clock({ format, showDay, controls, style, className, }: {
|
|
54
|
+
format?: "24h" | "12h";
|
|
55
|
+
showDay?: boolean;
|
|
56
|
+
controls?: boolean;
|
|
57
|
+
style?: CSSProperties;
|
|
58
|
+
className?: string;
|
|
59
|
+
}): import("react").JSX.Element;
|
|
60
|
+
/**
|
|
61
|
+
* A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
|
|
62
|
+
* (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
|
|
63
|
+
*/
|
|
64
|
+
export declare function WaveBanner({ wave, label, subtitle, style, className, }: {
|
|
65
|
+
wave: number | string;
|
|
66
|
+
label?: string;
|
|
67
|
+
subtitle?: ReactNode;
|
|
68
|
+
style?: CSSProperties;
|
|
69
|
+
className?: string;
|
|
70
|
+
}): import("react").JSX.Element;
|
|
71
|
+
/** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
|
|
72
|
+
export declare function Coins({ currencyId, icon, style, className, }: {
|
|
73
|
+
currencyId: string;
|
|
74
|
+
icon?: ReactNode;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
className?: string;
|
|
77
|
+
}): import("react").JSX.Element;
|
|
78
|
+
/** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
|
|
79
|
+
export declare function Crosshair({ size, gap, thickness, color, style, className, }: {
|
|
80
|
+
size?: number;
|
|
81
|
+
gap?: number;
|
|
82
|
+
thickness?: number;
|
|
83
|
+
color?: string;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
className?: string;
|
|
86
|
+
}): import("react").JSX.Element;
|
|
87
|
+
export {};
|
package/dist/hud.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { groundSpeed } from "@jgengine/core/scene/entityStore";
|
|
4
|
+
import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
|
|
5
|
+
/**
|
|
6
|
+
* Drop-in HUD components — good-looking with zero styling, and fully opt-in: a game imports only the
|
|
7
|
+
* pieces it wants (`StatBar`, `Hotbar`, `Speedometer`, `Clock`, `WaveBanner`, `Coins`, `Crosshair`)
|
|
8
|
+
* and places them itself. The engine imposes none of these; there is no forced HUD. Styling is
|
|
9
|
+
* self-contained inline CSS (no Tailwind `@source` needed) with a shared dark-glass look; pass
|
|
10
|
+
* `style`/`className` to override. Anything reading the player defaults to the local player entity.
|
|
11
|
+
*
|
|
12
|
+
* @capability hud-components opt-in drop-in health/hotbar/speed/clock/wave/currency HUD widgets
|
|
13
|
+
*/
|
|
14
|
+
const PANEL = {
|
|
15
|
+
background: "rgba(10,12,16,0.62)",
|
|
16
|
+
backdropFilter: "blur(6px)",
|
|
17
|
+
WebkitBackdropFilter: "blur(6px)",
|
|
18
|
+
border: "1px solid rgba(255,255,255,0.10)",
|
|
19
|
+
borderRadius: 10,
|
|
20
|
+
color: "#f4f6fb",
|
|
21
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
|
|
22
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
|
|
23
|
+
};
|
|
24
|
+
/** Named color ramps for {@link StatBar} tones. `fn` maps fill fraction (0..1) → gradient stops. */
|
|
25
|
+
const TONES = {
|
|
26
|
+
health: (t) => (t > 0.5 ? ["#22c55e", "#4ade80"] : t > 0.25 ? ["#f59e0b", "#fbbf24"] : ["#dc2626", "#f87171"]),
|
|
27
|
+
mana: () => ["#2563eb", "#60a5fa"],
|
|
28
|
+
stamina: () => ["#65a30d", "#a3e635"],
|
|
29
|
+
shield: () => ["#0891b2", "#22d3ee"],
|
|
30
|
+
xp: () => ["#7c3aed", "#a78bfa"],
|
|
31
|
+
neutral: () => ["#475569", "#94a3b8"],
|
|
32
|
+
};
|
|
33
|
+
function useLocalPlayerId(entityId) {
|
|
34
|
+
return useGameStore((ctx) => entityId ?? localPlayerEntity(ctx)?.id ?? null);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored
|
|
38
|
+
* fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player).
|
|
39
|
+
* Renders nothing until the stat exists.
|
|
40
|
+
*/
|
|
41
|
+
export function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }) {
|
|
42
|
+
const id = useLocalPlayerId(entityId);
|
|
43
|
+
const stat = useEntityStat(id ?? "", statId);
|
|
44
|
+
if (id === null || stat === null)
|
|
45
|
+
return null;
|
|
46
|
+
const range = stat.max - stat.min;
|
|
47
|
+
const fraction = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
|
|
48
|
+
const [from, to] = (TONES[tone] ?? TONES.neutral)(fraction);
|
|
49
|
+
return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 8px", width, ...style }, "data-stat": statId, children: [(label !== undefined || icon !== undefined || showValue) && (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4, fontSize: 11, fontWeight: 600, letterSpacing: 0.3 }, children: [icon, _jsx("span", { style: { textTransform: "uppercase", opacity: 0.75 }, children: label ?? statId }), showValue ? (_jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums", opacity: 0.9 }, children: [Math.round(stat.current), _jsxs("span", { style: { opacity: 0.5 }, children: [" / ", Math.round(stat.max)] })] })) : null] })), _jsx("div", { style: { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.10)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", borderRadius: 999, background: `linear-gradient(90deg, ${from}, ${to})`, transition: "width 160ms ease" } }) })] }));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A numbered hotbar bound to an inventory — glassy slots, an active-slot highlight, item id + count,
|
|
53
|
+
* and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one.
|
|
54
|
+
*/
|
|
55
|
+
export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, style, className, }) {
|
|
56
|
+
const slots = useInventory(inventoryId);
|
|
57
|
+
return (_jsx("div", { className: className, style: { display: "flex", gap: 6, ...style }, "data-inventory": inventoryId, children: slots.map((slot, index) => {
|
|
58
|
+
const active = index === activeSlot;
|
|
59
|
+
return (_jsxs("div", { "data-slot": index, style: {
|
|
60
|
+
...PANEL,
|
|
61
|
+
width: slotSize,
|
|
62
|
+
height: slotSize,
|
|
63
|
+
position: "relative",
|
|
64
|
+
display: "flex",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
justifyContent: "center",
|
|
67
|
+
borderColor: active ? "rgba(56,189,248,0.9)" : "rgba(255,255,255,0.10)",
|
|
68
|
+
boxShadow: active ? "0 0 0 1px rgba(56,189,248,0.7), 0 4px 16px rgba(0,0,0,0.35)" : PANEL.boxShadow,
|
|
69
|
+
}, children: [_jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: 9, fontWeight: 700, opacity: 0.55 }, children: keys?.[index] ?? index + 1 }), slot !== null ? (_jsxs("span", { style: { fontSize: 10, textAlign: "center", lineHeight: 1.1, padding: "0 2px", wordBreak: "break-word" }, children: [slot.itemId, slot.count > 1 ? _jsxs("span", { style: { display: "block", opacity: 0.6 }, children: ["\u00D7", slot.count] }) : null] })) : null] }, index));
|
|
70
|
+
}) }));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A speedometer for an entity (defaults to the local player) — an SVG arc gauge + a digital readout.
|
|
74
|
+
* `scale` converts world units/second to your display unit (3.6 → km/h, 2.237 → mph); `max` sets the
|
|
75
|
+
* gauge's top of scale.
|
|
76
|
+
*/
|
|
77
|
+
export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, size = 96, style, className, }) {
|
|
78
|
+
const speed = useGameStore((ctx) => {
|
|
79
|
+
const entity = entityId === undefined ? localPlayerEntity(ctx) : ctx.scene.entity.get(entityId);
|
|
80
|
+
return entity === null ? 0 : groundSpeed(entity) * scale;
|
|
81
|
+
});
|
|
82
|
+
const fraction = Math.max(0, Math.min(1, speed / max));
|
|
83
|
+
// 240° sweep from -210° to +30°.
|
|
84
|
+
const startDeg = -210;
|
|
85
|
+
const sweep = 240;
|
|
86
|
+
const radius = size / 2 - 8;
|
|
87
|
+
const cx = size / 2;
|
|
88
|
+
const cy = size / 2;
|
|
89
|
+
const arc = (fromFrac, toFrac) => {
|
|
90
|
+
const a0 = ((startDeg + sweep * fromFrac) * Math.PI) / 180;
|
|
91
|
+
const a1 = ((startDeg + sweep * toFrac) * Math.PI) / 180;
|
|
92
|
+
const large = sweep * (toFrac - fromFrac) > 180 ? 1 : 0;
|
|
93
|
+
return `M ${cx + radius * Math.cos(a0)} ${cy + radius * Math.sin(a0)} A ${radius} ${radius} 0 ${large} 1 ${cx + radius * Math.cos(a1)} ${cy + radius * Math.sin(a1)}`;
|
|
94
|
+
};
|
|
95
|
+
return (_jsxs("div", { className: className, style: { ...PANEL, width: size, padding: 6, textAlign: "center", ...style }, children: [_jsxs("svg", { width: size - 12, height: (size - 12) * 0.72, viewBox: `0 0 ${size} ${size * 0.72}`, style: { display: "block", margin: "0 auto" }, children: [_jsx("path", { d: arc(0, 1), fill: "none", stroke: "rgba(255,255,255,0.12)", strokeWidth: 7, strokeLinecap: "round" }), _jsx("path", { d: arc(0, fraction), fill: "none", stroke: fraction > 0.85 ? "#f87171" : "#38bdf8", strokeWidth: 7, strokeLinecap: "round" })] }), _jsx("div", { style: { marginTop: -8, fontVariantNumeric: "tabular-nums", fontWeight: 700, fontSize: 18, lineHeight: 1 }, children: Math.round(speed) }), _jsx("div", { style: { fontSize: 9, opacity: 0.6, letterSpacing: 0.4, textTransform: "uppercase" }, children: unit })] }));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
|
|
99
|
+
* pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so
|
|
100
|
+
* a game opts into letting the player scrub time.
|
|
101
|
+
*/
|
|
102
|
+
export function Clock({ format = "24h", showDay = true, controls = false, style, className, }) {
|
|
103
|
+
const clock = useGameClock();
|
|
104
|
+
const { hour, minute, day } = clock.calendar;
|
|
105
|
+
const label = useMemo(() => {
|
|
106
|
+
const mm = minute.toString().padStart(2, "0");
|
|
107
|
+
if (format === "12h") {
|
|
108
|
+
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
|
109
|
+
return `${h12}:${mm} ${hour < 12 ? "AM" : "PM"}`;
|
|
110
|
+
}
|
|
111
|
+
return `${hour.toString().padStart(2, "0")}:${mm}`;
|
|
112
|
+
}, [hour, minute, format]);
|
|
113
|
+
return (_jsxs("div", { className: className, style: { ...PANEL, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 8, fontVariantNumeric: "tabular-nums", ...style }, children: [showDay ? _jsxs("span", { style: { fontSize: 11, opacity: 0.6 }, children: ["Day ", day + 1] }) : null, _jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: label }), controls ? (_jsxs("span", { style: { display: "inline-flex", gap: 3, marginLeft: 4 }, children: [_jsx(ClockPill, { label: clock.paused ? "▶" : "❚❚", active: false, onClick: () => (clock.paused ? clock.controls.play() : clock.controls.pause()) }), clock.speeds.map((s) => (_jsx(ClockPill, { label: `${s}×`, active: !clock.paused && clock.speed === s, onClick: () => clock.controls.setSpeed(s) }, s)))] })) : null] }));
|
|
114
|
+
}
|
|
115
|
+
function ClockPill({ label, active, onClick }) {
|
|
116
|
+
return (_jsx("button", { type: "button", onClick: onClick, style: {
|
|
117
|
+
pointerEvents: "auto",
|
|
118
|
+
cursor: "pointer",
|
|
119
|
+
fontSize: 10,
|
|
120
|
+
fontWeight: 600,
|
|
121
|
+
padding: "1px 6px",
|
|
122
|
+
borderRadius: 6,
|
|
123
|
+
border: "none",
|
|
124
|
+
color: active ? "#0a0c10" : "#cbd5e1",
|
|
125
|
+
background: active ? "#38bdf8" : "rgba(255,255,255,0.08)",
|
|
126
|
+
}, children: label }));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A wave / round banner — a bold centered pill for "WAVE 3" style callouts, with an optional subtitle
|
|
130
|
+
* (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
|
|
131
|
+
*/
|
|
132
|
+
export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
|
|
133
|
+
return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 16px", textAlign: "center", display: "inline-block", ...style }, children: [_jsx("div", { style: { fontSize: 10, letterSpacing: 2, textTransform: "uppercase", opacity: 0.65 }, children: label }), _jsx("div", { style: { fontSize: 24, fontWeight: 800, lineHeight: 1.1 }, children: wave }), subtitle !== undefined ? _jsx("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 2 }, children: subtitle }) : null] }));
|
|
134
|
+
}
|
|
135
|
+
/** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. */
|
|
136
|
+
export function Coins({ currencyId, icon = "🪙", style, className, }) {
|
|
137
|
+
const amount = useCurrency(currencyId);
|
|
138
|
+
return (_jsxs("span", { className: className, style: { ...PANEL, padding: "3px 10px", display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 700, fontVariantNumeric: "tabular-nums", ...style }, "data-currency": currencyId, children: [_jsx("span", { "aria-hidden": true, children: icon }), amount] }));
|
|
139
|
+
}
|
|
140
|
+
/** A minimal center crosshair reticle — four ticks around a gap. Purely presentational. */
|
|
141
|
+
export function Crosshair({ size = 18, gap = 5, thickness = 2, color = "rgba(255,255,255,0.85)", style, className, }) {
|
|
142
|
+
const tick = (extra) => ({ position: "absolute", background: color, borderRadius: thickness, ...extra });
|
|
143
|
+
return (_jsx("div", { className: className, style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", ...style }, children: _jsxs("div", { style: { position: "relative", width: size, height: size }, children: [_jsx("div", { style: tick({ left: "50%", top: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ left: "50%", bottom: 0, width: thickness, height: size / 2 - gap, transform: "translateX(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", left: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) }), _jsx("div", { style: tick({ top: "50%", right: 0, height: thickness, width: size / 2 - gap, transform: "translateY(-50%)" }) })] }) }));
|
|
144
|
+
}
|