@jgengine/react 0.14.0 → 0.15.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 +500 -1
- package/dist/accessibility.d.ts +39 -0
- package/dist/accessibility.js +61 -0
- package/dist/achievements.d.ts +50 -0
- package/dist/achievements.js +94 -0
- package/dist/cameraShake.d.ts +52 -0
- package/dist/cameraShake.js +53 -0
- package/dist/coachMarks.d.ts +72 -0
- package/dist/coachMarks.js +136 -0
- package/dist/codex.d.ts +25 -0
- package/dist/codex.js +62 -0
- package/dist/comboMeter.d.ts +63 -0
- package/dist/comboMeter.js +93 -0
- package/dist/components.js +4 -3
- package/dist/creditsScreen.d.ts +19 -0
- package/dist/creditsScreen.js +31 -0
- package/dist/cutscene.d.ts +66 -0
- package/dist/cutscene.js +132 -0
- package/dist/damageDirection.d.ts +51 -0
- package/dist/damageDirection.js +103 -0
- package/dist/dialogueView.d.ts +69 -0
- package/dist/dialogueView.js +111 -0
- package/dist/entityFrames.d.ts +114 -0
- package/dist/entityFrames.js +55 -0
- package/dist/fastTravel.d.ts +36 -0
- package/dist/fastTravel.js +103 -0
- package/dist/floatingText.d.ts +84 -0
- package/dist/floatingText.js +61 -0
- package/dist/gameViewport.js +8 -12
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +85 -0
- package/dist/hudTheme.d.ts +104 -7
- package/dist/hudTheme.js +300 -2
- package/dist/i18n.d.ts +29 -0
- package/dist/i18n.js +44 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +34 -0
- package/dist/interactionPrompt.d.ts +80 -0
- package/dist/interactionPrompt.js +112 -0
- package/dist/keybindingMenu.d.ts +59 -0
- package/dist/keybindingMenu.js +146 -0
- package/dist/killFeed.d.ts +63 -0
- package/dist/killFeed.js +88 -0
- package/dist/map.d.ts +123 -2
- package/dist/map.js +239 -36
- package/dist/menuRouter.d.ts +51 -0
- package/dist/menuRouter.js +49 -0
- package/dist/modals.d.ts +131 -0
- package/dist/modals.js +168 -0
- package/dist/notifications.d.ts +39 -0
- package/dist/notifications.js +111 -0
- package/dist/objectiveBanner.d.ts +62 -0
- package/dist/objectiveBanner.js +129 -0
- package/dist/photoMode.d.ts +21 -0
- package/dist/photoMode.js +39 -0
- package/dist/previewFixtures.d.ts +30 -0
- package/dist/previewFixtures.js +46 -0
- package/dist/questTracker.d.ts +24 -0
- package/dist/questTracker.js +31 -0
- package/dist/quickMenu.d.ts +39 -0
- package/dist/quickMenu.js +79 -0
- package/dist/radialMenu.d.ts +50 -0
- package/dist/radialMenu.js +69 -0
- package/dist/saveSlots.d.ts +81 -0
- package/dist/saveSlots.js +138 -0
- package/dist/scoreboard.d.ts +67 -0
- package/dist/scoreboard.js +71 -0
- package/dist/settings.d.ts +9 -3
- package/dist/settings.js +10 -4
- package/dist/shopGrid.d.ts +70 -0
- package/dist/shopGrid.js +129 -0
- package/dist/statusEffectBar.d.ts +52 -0
- package/dist/statusEffectBar.js +57 -0
- package/dist/streetGeometryPreview.d.ts +7 -0
- package/dist/streetGeometryPreview.js +119 -0
- package/dist/talentTree.d.ts +60 -0
- package/dist/talentTree.js +217 -0
- package/dist/timerReadout.d.ts +90 -0
- package/dist/timerReadout.js +111 -0
- package/dist/useDebouncedCommit.d.ts +54 -0
- package/dist/useDebouncedCommit.js +81 -0
- package/dist/waveHud.d.ts +49 -0
- package/dist/waveHud.js +53 -0
- package/dist/waypointMarkers.d.ts +80 -0
- package/dist/waypointMarkers.js +116 -0
- package/package.json +3 -3
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-ui/SKILL.md +5 -3
- package/skills/jgengine-ui/api.md +708 -19
- package/skills/jgengine-ui/capabilities.md +362 -1
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useReducer, useEffect } from "react";
|
|
3
|
+
import { GameIcon } from "./gameIcons.js";
|
|
4
|
+
import { HudFrame } from "./hudFrame.js";
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to a {@link SaveSlots} index and re-render on any change (write,
|
|
7
|
+
* clear, rename, restore). Returns the live list of slots in menu order. Wire it
|
|
8
|
+
* into a save-select screen and mutate the model in event handlers — the menu
|
|
9
|
+
* re-renders itself.
|
|
10
|
+
*
|
|
11
|
+
* @capability use-save-slots React hook binding a save-slots metadata index — re-renders on change and returns the ordered slot list
|
|
12
|
+
*/
|
|
13
|
+
export function useSaveSlots(model) {
|
|
14
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
15
|
+
useEffect(() => model.subscribe(bump), [model]);
|
|
16
|
+
return model.list();
|
|
17
|
+
}
|
|
18
|
+
function resolveTheme(theme) {
|
|
19
|
+
return {
|
|
20
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
21
|
+
cardBg: theme?.cardBg ?? "var(--jg-frame-bg, linear-gradient(180deg, rgba(20,24,32,0.86), rgba(10,12,16,0.9)))",
|
|
22
|
+
cardBorder: theme?.cardBorder ?? "var(--jg-frame-border, 1px solid rgba(255,255,255,0.12))",
|
|
23
|
+
text: theme?.text ?? "#f1f5f9",
|
|
24
|
+
muted: theme?.muted ?? "rgba(203,213,225,0.72)",
|
|
25
|
+
chipBg: theme?.chipBg ?? "rgba(148,163,184,0.16)",
|
|
26
|
+
fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
27
|
+
radius: theme?.radius ?? "var(--jg-frame-radius, 12px)",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago",
|
|
32
|
+
* "2d ago"). Presentation-only helper — the model stores raw ms.
|
|
33
|
+
*/
|
|
34
|
+
export function relativeSavedAt(savedAt, nowMs = Date.now()) {
|
|
35
|
+
const delta = Math.max(0, nowMs - savedAt);
|
|
36
|
+
const s = Math.floor(delta / 1000);
|
|
37
|
+
if (s < 45)
|
|
38
|
+
return "just now";
|
|
39
|
+
const m = Math.floor(s / 60);
|
|
40
|
+
if (m < 60)
|
|
41
|
+
return `${m}m ago`;
|
|
42
|
+
const h = Math.floor(m / 60);
|
|
43
|
+
if (h < 24)
|
|
44
|
+
return `${h}h ago`;
|
|
45
|
+
const d = Math.floor(h / 24);
|
|
46
|
+
return `${d}d ago`;
|
|
47
|
+
}
|
|
48
|
+
const rowButton = (accent, primary) => ({
|
|
49
|
+
pointerEvents: "auto",
|
|
50
|
+
cursor: "pointer",
|
|
51
|
+
borderRadius: 8,
|
|
52
|
+
border: primary ? "none" : "1px solid rgba(148,163,184,0.28)",
|
|
53
|
+
background: primary ? accent : "rgba(15,20,28,0.6)",
|
|
54
|
+
color: primary ? "#0b1220" : "#e2e8f0",
|
|
55
|
+
fontSize: 12,
|
|
56
|
+
fontWeight: 700,
|
|
57
|
+
letterSpacing: 0.4,
|
|
58
|
+
padding: "7px 12px",
|
|
59
|
+
display: "inline-flex",
|
|
60
|
+
alignItems: "center",
|
|
61
|
+
gap: 6,
|
|
62
|
+
});
|
|
63
|
+
/** One filled slot's meta rendered as chips. */
|
|
64
|
+
function MetaChips({ slot, t, metaIcons, }) {
|
|
65
|
+
const entries = Object.entries(slot.meta);
|
|
66
|
+
if (entries.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
return (_jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8 }, children: entries.map(([key, value]) => {
|
|
69
|
+
const icon = metaIcons?.[key];
|
|
70
|
+
return (_jsxs("span", { "data-save-slot-chip": key, style: {
|
|
71
|
+
display: "inline-flex",
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
gap: 5,
|
|
74
|
+
background: t.chipBg,
|
|
75
|
+
color: t.muted,
|
|
76
|
+
borderRadius: 999,
|
|
77
|
+
padding: "3px 9px",
|
|
78
|
+
fontSize: 11,
|
|
79
|
+
fontWeight: 600,
|
|
80
|
+
lineHeight: 1.3,
|
|
81
|
+
}, children: [icon !== undefined ? _jsx(GameIcon, { name: icon, size: 12, color: t.accent }) : null, _jsx("span", { style: { opacity: 0.7, textTransform: "uppercase", letterSpacing: 0.6, fontSize: 9 }, children: key }), _jsx("span", { style: { color: t.text }, children: String(value) })] }, key));
|
|
82
|
+
}) }));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A drop-in save-select / profile menu that renders a {@link SaveSlots} index as
|
|
86
|
+
* a column of cards. A filled slot shows its name, the free-string `meta` the
|
|
87
|
+
* game supplied as chips, and a relative "saved" time, with Load and Delete
|
|
88
|
+
* actions; an empty slot shows a "New Game" affordance. A top-level Continue
|
|
89
|
+
* button targets the newest save (`model.mostRecent()`). This is a starting-point
|
|
90
|
+
* game-front-end building block — the game owns final art, wording, and layout;
|
|
91
|
+
* reskin it with {@link SaveSlotMenuTheme} or HudTheme CSS vars. Presentation
|
|
92
|
+
* only: all state lives in the core model, and `meta` keys are never interpreted.
|
|
93
|
+
*
|
|
94
|
+
* @capability save-slot-menu drop-in save-select / profile menu that renders a core save-slots index as New / Continue / Load / Delete cards with free-string meta chips and relative save times, theme-skinnable
|
|
95
|
+
*/
|
|
96
|
+
export function SaveSlotMenu({ model, onLoad, onNew, onDelete, onContinue, title = "Select Save", theme, now = Date.now, metaIcons, className, style, }) {
|
|
97
|
+
const slots = useSaveSlots(model);
|
|
98
|
+
const t = resolveTheme(theme);
|
|
99
|
+
const nowMs = now();
|
|
100
|
+
const recent = model.mostRecent();
|
|
101
|
+
return (_jsxs("div", { className: className, "data-save-slot-menu": "", style: {
|
|
102
|
+
display: "flex",
|
|
103
|
+
flexDirection: "column",
|
|
104
|
+
gap: 12,
|
|
105
|
+
width: "min(92vw, 460px)",
|
|
106
|
+
fontFamily: t.fontFamily,
|
|
107
|
+
color: t.text,
|
|
108
|
+
...style,
|
|
109
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 800, letterSpacing: 0.4 }, children: title }), onContinue !== undefined && recent !== null ? (_jsxs("button", { type: "button", "data-save-slot-continue": "", style: rowButton(t.accent, true), onClick: () => onContinue(recent), children: [_jsx(GameIcon, { name: "skip", size: 14, color: "#0b1220" }), "Continue"] })) : null] }), slots.map((slot) => {
|
|
110
|
+
const isRecent = recent !== null && slot.id === recent.id;
|
|
111
|
+
return (_jsx(HudFrame, { interactive: true, variation: "glass", padding: 14, style: {
|
|
112
|
+
background: t.cardBg,
|
|
113
|
+
border: isRecent ? `1px solid ${t.accent}` : t.cardBorder,
|
|
114
|
+
borderRadius: t.radius,
|
|
115
|
+
boxShadow: isRecent ? `0 0 0 1px ${t.accent}, 0 8px 26px rgba(0,0,0,0.4)` : "0 6px 20px rgba(0,0,0,0.35)",
|
|
116
|
+
}, children: slot.empty ? (_jsxs("button", { type: "button", "data-save-slot-new": "", onClick: () => onNew?.(slot), style: {
|
|
117
|
+
pointerEvents: "auto",
|
|
118
|
+
cursor: "pointer",
|
|
119
|
+
width: "100%",
|
|
120
|
+
display: "flex",
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
gap: 12,
|
|
123
|
+
background: "transparent",
|
|
124
|
+
border: "none",
|
|
125
|
+
color: t.muted,
|
|
126
|
+
padding: "6px 2px",
|
|
127
|
+
textAlign: "left",
|
|
128
|
+
}, children: [_jsx("span", { style: {
|
|
129
|
+
display: "inline-flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
width: 40,
|
|
133
|
+
height: 40,
|
|
134
|
+
borderRadius: 10,
|
|
135
|
+
border: `1px dashed ${t.muted}`,
|
|
136
|
+
}, children: _jsx(GameIcon, { name: "star", size: 18, color: t.muted }) }), _jsxs("span", { children: [_jsx("span", { style: { display: "block", fontSize: 15, fontWeight: 700, color: t.text }, children: "New Game" }), _jsxs("span", { style: { fontSize: 11 }, children: ["Empty slot \u00B7 ", slot.id] })] })] })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 10 }, children: [_jsx("span", { "data-save-slot-name": "", style: { fontSize: 16, fontWeight: 800 }, children: slot.name ?? slot.id }), slot.savedAt !== undefined ? (_jsx("span", { style: { fontSize: 11, fontWeight: 600, color: t.accent, whiteSpace: "nowrap" }, children: relativeSavedAt(slot.savedAt, nowMs) })) : null] }), _jsx(MetaChips, { slot: slot, t: t, metaIcons: metaIcons }), _jsxs("div", { style: { display: "flex", gap: 8, marginTop: 12 }, children: [_jsxs("button", { type: "button", "data-save-slot-load": "", style: { ...rowButton(t.accent, true), flex: 1, justifyContent: "center" }, onClick: () => onLoad?.(slot), children: [_jsx(GameIcon, { name: "chest", size: 14, color: "#0b1220" }), "Load"] }), _jsxs("button", { type: "button", "data-save-slot-delete": "", style: rowButton(t.accent, false), onClick: () => onDelete?.(slot), "aria-label": `Delete ${slot.name ?? slot.id}`, children: [_jsx(GameIcon, { name: "cross", size: 14 }), "Delete"] })] })] })) }, slot.id));
|
|
137
|
+
})] }));
|
|
138
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type RankableRow, type RankedEntry, type RankLeaderboardOptions } from "@jgengine/core/game/leaderboardRank";
|
|
3
|
+
import { type GameIconName } from "./gameIcons.js";
|
|
4
|
+
/** The medal tokens {@link medalFor} emits for the podium. */
|
|
5
|
+
export type MedalToken = "gold" | "silver" | "bronze";
|
|
6
|
+
/** Reskin tokens for {@link Scoreboard}. Every color/spacing value reads a `--jg-*` token first. */
|
|
7
|
+
export interface ScoreboardTheme {
|
|
8
|
+
/** Panel background. Default reads `--jg-frame-bg`. */
|
|
9
|
+
bg?: string;
|
|
10
|
+
/** Panel border. Default reads `--jg-frame-border`. */
|
|
11
|
+
border?: string;
|
|
12
|
+
/** Panel corner radius. Default reads `--jg-frame-radius`. */
|
|
13
|
+
radius?: string;
|
|
14
|
+
/** Row / header text color. */
|
|
15
|
+
text?: string;
|
|
16
|
+
/** Muted text (rank column, header labels). */
|
|
17
|
+
muted?: string;
|
|
18
|
+
/** Accent used for the local-row highlight. Default reads `--jg-accent`. */
|
|
19
|
+
accent?: string;
|
|
20
|
+
/** Background wash behind the local ("you") row. */
|
|
21
|
+
localBg?: string;
|
|
22
|
+
/** Font family. */
|
|
23
|
+
fontFamily?: string;
|
|
24
|
+
/** Podium medal colors keyed by token. */
|
|
25
|
+
medals?: Record<MedalToken, string>;
|
|
26
|
+
}
|
|
27
|
+
/** Props for {@link Scoreboard}. Pass either `entries` (already ranked) or `rows` + `options`. */
|
|
28
|
+
export interface ScoreboardProps {
|
|
29
|
+
/** Already-ranked entries from {@link rankLeaderboard}. Takes precedence over `rows`. */
|
|
30
|
+
entries?: readonly RankedEntry[];
|
|
31
|
+
/** Raw rows to rank in-component. Ignored when `entries` is given. */
|
|
32
|
+
rows?: readonly RankableRow[];
|
|
33
|
+
/** Ranking options applied to `rows`. Ignored when `entries` is given. */
|
|
34
|
+
options?: RankLeaderboardOptions;
|
|
35
|
+
/** Optional heading rendered above the table (e.g. "TOP RUNNERS"). */
|
|
36
|
+
title?: ReactNode;
|
|
37
|
+
/** Label for the score column header. Default `"Score"`. */
|
|
38
|
+
scoreLabel?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Map a `userId` to a display name. Falls back to the entry's `label`, then the
|
|
41
|
+
* raw `userId`. The model never carries display names it does not own.
|
|
42
|
+
*/
|
|
43
|
+
nameFor?: (entry: RankedEntry) => ReactNode;
|
|
44
|
+
/** Format a raw score for display. Default `String(value)`. */
|
|
45
|
+
formatScore?: (value: number) => ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Podium icon for a medal token. Default: a filled star tinted by the medal
|
|
48
|
+
* color. Return `null` to render just the numeric rank.
|
|
49
|
+
*/
|
|
50
|
+
medalIcon?: (medal: MedalToken) => GameIconName | null;
|
|
51
|
+
/** Reskin tokens. */
|
|
52
|
+
theme?: ScoreboardTheme;
|
|
53
|
+
className?: string;
|
|
54
|
+
style?: CSSProperties;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A reskinnable ranked-score table for the {@link rankLeaderboard} selector: a rank
|
|
58
|
+
* column (medal icon/color for the top three via {@link medalFor}, otherwise the
|
|
59
|
+
* number), a name column, and a score column. The local player's row (`isLocal`) is
|
|
60
|
+
* washed and accented so "you" stands out. Feed it already-ranked `entries`, or raw
|
|
61
|
+
* `rows` plus `options` and it ranks them for you. All colors and spacing come from
|
|
62
|
+
* {@link ScoreboardTheme} / HudTheme `--jg-*` tokens — the component reads the model
|
|
63
|
+
* and never styles by game meaning.
|
|
64
|
+
*
|
|
65
|
+
* @capability scoreboard-table reskinnable ranked-score / leaderboard table over the rankLeaderboard selector — medal-colored podium, local-row highlight, HudTheme-token driven
|
|
66
|
+
*/
|
|
67
|
+
export declare function Scoreboard({ entries, rows, options, title, scoreLabel, nameFor, formatScore, medalIcon, theme, className, style, }: ScoreboardProps): ReactNode;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import {} from "react";
|
|
3
|
+
import { medalFor, rankLeaderboard, } from "@jgengine/core/game/leaderboardRank";
|
|
4
|
+
import { GameIcon } from "./gameIcons.js";
|
|
5
|
+
function resolveTheme(theme) {
|
|
6
|
+
return {
|
|
7
|
+
bg: theme?.bg ?? "var(--jg-frame-bg, linear-gradient(180deg, rgba(20,24,32,0.92), rgba(10,12,16,0.95)))",
|
|
8
|
+
border: theme?.border ?? "var(--jg-frame-border, 1px solid rgba(255,255,255,0.12))",
|
|
9
|
+
radius: theme?.radius ?? "var(--jg-frame-radius, 12px)",
|
|
10
|
+
text: theme?.text ?? "#f1f5f9",
|
|
11
|
+
muted: theme?.muted ?? "rgba(203,213,225,0.65)",
|
|
12
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
13
|
+
localBg: theme?.localBg ?? "rgba(56,189,248,0.16)",
|
|
14
|
+
fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
15
|
+
medals: theme?.medals ?? { gold: "#f5c542", silver: "#cbd5e1", bronze: "#cd7f32" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A reskinnable ranked-score table for the {@link rankLeaderboard} selector: a rank
|
|
20
|
+
* column (medal icon/color for the top three via {@link medalFor}, otherwise the
|
|
21
|
+
* number), a name column, and a score column. The local player's row (`isLocal`) is
|
|
22
|
+
* washed and accented so "you" stands out. Feed it already-ranked `entries`, or raw
|
|
23
|
+
* `rows` plus `options` and it ranks them for you. All colors and spacing come from
|
|
24
|
+
* {@link ScoreboardTheme} / HudTheme `--jg-*` tokens — the component reads the model
|
|
25
|
+
* and never styles by game meaning.
|
|
26
|
+
*
|
|
27
|
+
* @capability scoreboard-table reskinnable ranked-score / leaderboard table over the rankLeaderboard selector — medal-colored podium, local-row highlight, HudTheme-token driven
|
|
28
|
+
*/
|
|
29
|
+
export function Scoreboard({ entries, rows, options, title, scoreLabel = "Score", nameFor, formatScore, medalIcon, theme, className, style, }) {
|
|
30
|
+
const t = resolveTheme(theme);
|
|
31
|
+
const ranked = entries ?? rankLeaderboard(rows ?? [], options ?? {});
|
|
32
|
+
const cell = { padding: "0.5em 0.75em", textAlign: "left", whiteSpace: "nowrap" };
|
|
33
|
+
const headCell = {
|
|
34
|
+
...cell,
|
|
35
|
+
fontSize: "0.72em",
|
|
36
|
+
fontWeight: 700,
|
|
37
|
+
letterSpacing: "0.08em",
|
|
38
|
+
textTransform: "uppercase",
|
|
39
|
+
color: t.muted,
|
|
40
|
+
};
|
|
41
|
+
return (_jsxs("div", { className: className, "data-jgui": "scoreboard", style: {
|
|
42
|
+
display: "inline-flex",
|
|
43
|
+
flexDirection: "column",
|
|
44
|
+
gap: "0.5em",
|
|
45
|
+
background: t.bg,
|
|
46
|
+
border: t.border,
|
|
47
|
+
borderRadius: t.radius,
|
|
48
|
+
padding: "0.75em",
|
|
49
|
+
color: t.text,
|
|
50
|
+
fontFamily: t.fontFamily,
|
|
51
|
+
minWidth: 280,
|
|
52
|
+
...style,
|
|
53
|
+
}, children: [title !== undefined ? (_jsx("div", { "data-jgui": "scoreboard-title", style: { fontSize: "0.95em", fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", padding: "0 0.25em" }, children: title })) : null, _jsxs("table", { style: { borderCollapse: "collapse", width: "100%", fontSize: "0.95em" }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...headCell, textAlign: "center", width: "3em" }, children: "#" }), _jsx("th", { style: headCell, children: "Name" }), _jsx("th", { style: { ...headCell, textAlign: "right" }, children: scoreLabel })] }) }), _jsx("tbody", { children: ranked.map((entry, index) => {
|
|
54
|
+
const medal = medalFor(entry.rank);
|
|
55
|
+
const medalColor = medal !== null ? t.medals[medal] : undefined;
|
|
56
|
+
const icon = medal !== null ? (medalIcon ? medalIcon(medal) : "star") : null;
|
|
57
|
+
const name = nameFor ? nameFor(entry) : entry.label ?? entry.userId;
|
|
58
|
+
const rowKey = `${entry.userId}:${index}`;
|
|
59
|
+
return (_jsxs("tr", { "data-scoreboard-row": entry.userId, "data-rank": entry.rank, "data-local": entry.isLocal ? "" : undefined, "data-tie": entry.isTie ? "" : undefined, style: {
|
|
60
|
+
background: entry.isLocal ? t.localBg : "transparent",
|
|
61
|
+
boxShadow: entry.isLocal ? `inset 3px 0 0 ${t.accent}` : undefined,
|
|
62
|
+
}, children: [_jsx("td", { style: { ...cell, textAlign: "center" }, children: _jsxs("span", { style: {
|
|
63
|
+
display: "inline-flex",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
justifyContent: "center",
|
|
66
|
+
gap: "0.25em",
|
|
67
|
+
color: medalColor ?? t.muted,
|
|
68
|
+
fontWeight: medal !== null ? 800 : 600,
|
|
69
|
+
}, children: [icon !== null ? _jsx(GameIcon, { name: icon, size: 16, color: medalColor }) : null, entry.rank] }) }), _jsx("td", { style: { ...cell, color: entry.isLocal ? t.accent : t.text, fontWeight: entry.isLocal ? 700 : 500 }, children: name }), _jsx("td", { style: { ...cell, textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 600 }, children: formatScore ? formatScore(entry.value) : entry.value })] }, rowKey));
|
|
70
|
+
}) })] })] }));
|
|
71
|
+
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -65,11 +65,17 @@ export declare function SettingsControllerProvider({ controller, children, }: {
|
|
|
65
65
|
export declare function useSettings(): SettingsController;
|
|
66
66
|
/** True when the game has any setting or game-action to show — gate your own settings entry on it. */
|
|
67
67
|
export declare function useHasSettings(): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Default HudTheme-token skin for {@link SettingsTrigger} — the 8×8 chrome button
|
|
70
|
+
* games re-authored seven times. Pass your own `className` only to override placement
|
|
71
|
+
* or brand chrome; the gear glyph already scales with `text-base` / `1em`.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SETTINGS_TRIGGER_CLASSNAME = "pointer-events-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--jg-edge-bright)] bg-[var(--jg-surface)]/80 text-base text-[var(--jg-text-dim)] shadow-[0_1px_2px_rgba(0,0,0,0.6)] transition hover:bg-[var(--jg-surface-deep)] hover:text-[var(--jg-accent)]";
|
|
68
74
|
/**
|
|
69
75
|
* Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
|
|
70
|
-
* the themed settings menu. Headless: pass `className`
|
|
71
|
-
* `children` to replace the
|
|
72
|
-
* has no settings to show, so it never leaves a dead button behind.
|
|
76
|
+
* the themed settings menu. Headless: pass `className` only when overriding the
|
|
77
|
+
* default HudTheme skin; pass `children` to replace the gear glyph. Renders nothing
|
|
78
|
+
* when the game has no settings to show, so it never leaves a dead button behind.
|
|
73
79
|
*/
|
|
74
80
|
export declare function SettingsTrigger({ className, children, label, }: {
|
|
75
81
|
className?: string;
|
package/dist/settings.js
CHANGED
|
@@ -47,13 +47,19 @@ export function useHasSettings() {
|
|
|
47
47
|
const s = useSettings();
|
|
48
48
|
return s.categories.length > 0 || s.actions.length > 0;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Default HudTheme-token skin for {@link SettingsTrigger} — the 8×8 chrome button
|
|
52
|
+
* games re-authored seven times. Pass your own `className` only to override placement
|
|
53
|
+
* or brand chrome; the gear glyph already scales with `text-base` / `1em`.
|
|
54
|
+
*/
|
|
55
|
+
export const SETTINGS_TRIGGER_CLASSNAME = "pointer-events-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--jg-edge-bright)] bg-[var(--jg-surface)]/80 text-base text-[var(--jg-text-dim)] shadow-[0_1px_2px_rgba(0,0,0,0.6)] transition hover:bg-[var(--jg-surface-deep)] hover:text-[var(--jg-accent)]";
|
|
50
56
|
/**
|
|
51
57
|
* Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
|
|
52
|
-
* the themed settings menu. Headless: pass `className`
|
|
53
|
-
* `children` to replace the
|
|
54
|
-
* has no settings to show, so it never leaves a dead button behind.
|
|
58
|
+
* the themed settings menu. Headless: pass `className` only when overriding the
|
|
59
|
+
* default HudTheme skin; pass `children` to replace the gear glyph. Renders nothing
|
|
60
|
+
* when the game has no settings to show, so it never leaves a dead button behind.
|
|
55
61
|
*/
|
|
56
|
-
export function SettingsTrigger({ className, children, label = "Settings", }) {
|
|
62
|
+
export function SettingsTrigger({ className = SETTINGS_TRIGGER_CLASSNAME, children, label = "Settings", }) {
|
|
57
63
|
const settings = useSettings();
|
|
58
64
|
if (settings.categories.length === 0 && settings.actions.length === 0)
|
|
59
65
|
return null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type WalletState } from "@jgengine/core/economy/wallet";
|
|
3
|
+
import type { ShopStock, ShopStockEntry } from "@jgengine/core/economy/shopStock";
|
|
4
|
+
import { type GameIconName } from "./gameIcons.js";
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to a shop's stock and re-render whenever it changes (buy, sell, restock, price, add,
|
|
7
|
+
* remove, restore). Returns the current entry list — a detached snapshot safe to map over.
|
|
8
|
+
*
|
|
9
|
+
* @capability use-shop-stock React hook binding a shop-stock model — re-renders on any stock change and returns the entry list
|
|
10
|
+
*/
|
|
11
|
+
export declare function useShopStock(shop: ShopStock): ShopStockEntry[];
|
|
12
|
+
/** Reskin tokens for {@link ShopGrid}. Falls back to shared `--jg-*` HudTheme tokens, then neutral defaults. */
|
|
13
|
+
export interface ShopGridTheme {
|
|
14
|
+
/** Card background. Default reads `--jg-slot-bg`. */
|
|
15
|
+
cardBg?: string;
|
|
16
|
+
/** Card border. Default reads `--jg-slot-border`. */
|
|
17
|
+
cardBorder?: string;
|
|
18
|
+
/** Card corner radius. Default reads `--jg-slot-radius`. */
|
|
19
|
+
cardRadius?: string;
|
|
20
|
+
/** Accent for icons/prices/Buy button. Default reads `--jg-accent`. */
|
|
21
|
+
accent?: string;
|
|
22
|
+
/** Primary text color. */
|
|
23
|
+
text?: string;
|
|
24
|
+
/** Muted/secondary text color. */
|
|
25
|
+
muted?: string;
|
|
26
|
+
/** Font family. */
|
|
27
|
+
fontFamily?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Props for {@link ShopGrid}. */
|
|
30
|
+
export interface ShopGridProps {
|
|
31
|
+
/** The vendor stock model to render. */
|
|
32
|
+
shop: ShopStock;
|
|
33
|
+
/** The caller-owned wallet the grid buys/sells against. The shop never owns it. */
|
|
34
|
+
wallet: WalletState;
|
|
35
|
+
/** Called with the new wallet after a successful buy or sell — the caller adopts it. */
|
|
36
|
+
onWalletChange: (wallet: WalletState) => void;
|
|
37
|
+
/** Reskin tokens. */
|
|
38
|
+
theme?: ShopGridTheme;
|
|
39
|
+
/** Optional per-currency icon overrides, keyed by the free-string currency. */
|
|
40
|
+
currencyIcons?: Record<string, GameIconName>;
|
|
41
|
+
/** Optional entry→icon picker. Defaults to keyword matching on `kind`/`id`. */
|
|
42
|
+
iconFor?: (entry: ShopStockEntry) => GameIconName;
|
|
43
|
+
/** Show a Sell button on entries that carry a `sellPrice`. Default `false`. */
|
|
44
|
+
showSell?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Currencies to show in the wallet readout, in order. Default: every currency referenced by the
|
|
47
|
+
* stock (prices + sell prices), in first-seen order.
|
|
48
|
+
*/
|
|
49
|
+
walletCurrencies?: readonly string[];
|
|
50
|
+
/** Panel header label. Default `"Shop"`. */
|
|
51
|
+
title?: ReactNode;
|
|
52
|
+
/** Human label for an entry — default is the capitalized `kind`. */
|
|
53
|
+
labelFor?: (entry: ShopStockEntry) => ReactNode;
|
|
54
|
+
/** Minimum card width (CSS length) for the responsive grid. Default `"140px"`. */
|
|
55
|
+
minCardWidth?: string;
|
|
56
|
+
className?: string;
|
|
57
|
+
style?: CSSProperties;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A drop-in vendor/shop grid: item cards (icon, name, price with a currency glyph, stock count or
|
|
61
|
+
* "∞") over a caller-owned wallet, plus a wallet balance readout. Each card's Buy button disables
|
|
62
|
+
* when the wallet cannot afford it (`shop.canAfford`) or the item is sold out; an optional Sell
|
|
63
|
+
* button appears for entries with a `sellPrice`. Buying/selling routes through the core shop model
|
|
64
|
+
* and hands the debited/credited wallet back via `onWalletChange` — the component never owns the
|
|
65
|
+
* wallet. Purely token-driven presentation (shared `--jg-*` HudTheme tokens, reskinnable via
|
|
66
|
+
* {@link ShopGridTheme}); `kind` and `currency` are free strings the game styles, never interpreted.
|
|
67
|
+
*
|
|
68
|
+
* @capability shop-grid-host drop-in vendor/shop grid over a caller-owned wallet — item cards with icon/price/stock, afford-aware Buy, optional Sell, and a balance readout, token-themed
|
|
69
|
+
*/
|
|
70
|
+
export declare function ShopGrid({ shop, wallet, onWalletChange, theme, currencyIcons, iconFor, showSell, walletCurrencies, title, labelFor, minCardWidth, className, style, }: ShopGridProps): ReactNode;
|
package/dist/shopGrid.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer } from "react";
|
|
3
|
+
import { balance } from "@jgengine/core/economy/wallet";
|
|
4
|
+
import { GameIcon, iconForItemId } from "./gameIcons.js";
|
|
5
|
+
import { HudFrame } from "./hudFrame.js";
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to a shop's stock and re-render whenever it changes (buy, sell, restock, price, add,
|
|
8
|
+
* remove, restore). Returns the current entry list — a detached snapshot safe to map over.
|
|
9
|
+
*
|
|
10
|
+
* @capability use-shop-stock React hook binding a shop-stock model — re-renders on any stock change and returns the entry list
|
|
11
|
+
*/
|
|
12
|
+
export function useShopStock(shop) {
|
|
13
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
14
|
+
useEffect(() => shop.subscribe(bump), [shop]);
|
|
15
|
+
return shop.list();
|
|
16
|
+
}
|
|
17
|
+
function resolveTheme(theme) {
|
|
18
|
+
return {
|
|
19
|
+
cardBg: theme?.cardBg ?? "var(--jg-slot-bg, rgba(12,14,20,0.72))",
|
|
20
|
+
cardBorder: theme?.cardBorder ?? "var(--jg-slot-border, 1px solid rgba(255,255,255,0.12))",
|
|
21
|
+
cardRadius: theme?.cardRadius ?? "var(--jg-slot-radius, 10px)",
|
|
22
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
23
|
+
text: theme?.text ?? "#f1f5f9",
|
|
24
|
+
muted: theme?.muted ?? "rgba(203,213,225,0.72)",
|
|
25
|
+
fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Default currency → icon mapping via {@link iconForItemId} (which knows "gold"/"gem"/… keywords). */
|
|
29
|
+
function currencyIcon(currency, override) {
|
|
30
|
+
return override?.[currency] ?? iconForItemId(currency) ?? "coin";
|
|
31
|
+
}
|
|
32
|
+
/** Default entry → icon mapping from the free-string `kind`, falling back to a chest. */
|
|
33
|
+
function entryIcon(entry, iconFor) {
|
|
34
|
+
if (iconFor !== undefined)
|
|
35
|
+
return iconFor(entry);
|
|
36
|
+
return iconForItemId(entry.kind) ?? iconForItemId(entry.id) ?? "chest";
|
|
37
|
+
}
|
|
38
|
+
function capitalize(value) {
|
|
39
|
+
return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
|
|
40
|
+
}
|
|
41
|
+
function referencedCurrencies(entries) {
|
|
42
|
+
const seen = [];
|
|
43
|
+
const add = (currency) => {
|
|
44
|
+
if (!seen.includes(currency))
|
|
45
|
+
seen.push(currency);
|
|
46
|
+
};
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
add(entry.price.currency);
|
|
49
|
+
if (entry.sellPrice !== undefined)
|
|
50
|
+
add(entry.sellPrice.currency);
|
|
51
|
+
}
|
|
52
|
+
return seen;
|
|
53
|
+
}
|
|
54
|
+
function PriceTag({ price, icon, accent, muted, }) {
|
|
55
|
+
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: muted, fontVariantNumeric: "tabular-nums" }, children: [_jsx(GameIcon, { name: icon, size: 15, color: accent }), _jsx("span", { style: { fontWeight: 700, color: "inherit" }, children: price.amount })] }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A drop-in vendor/shop grid: item cards (icon, name, price with a currency glyph, stock count or
|
|
59
|
+
* "∞") over a caller-owned wallet, plus a wallet balance readout. Each card's Buy button disables
|
|
60
|
+
* when the wallet cannot afford it (`shop.canAfford`) or the item is sold out; an optional Sell
|
|
61
|
+
* button appears for entries with a `sellPrice`. Buying/selling routes through the core shop model
|
|
62
|
+
* and hands the debited/credited wallet back via `onWalletChange` — the component never owns the
|
|
63
|
+
* wallet. Purely token-driven presentation (shared `--jg-*` HudTheme tokens, reskinnable via
|
|
64
|
+
* {@link ShopGridTheme}); `kind` and `currency` are free strings the game styles, never interpreted.
|
|
65
|
+
*
|
|
66
|
+
* @capability shop-grid-host drop-in vendor/shop grid over a caller-owned wallet — item cards with icon/price/stock, afford-aware Buy, optional Sell, and a balance readout, token-themed
|
|
67
|
+
*/
|
|
68
|
+
export function ShopGrid({ shop, wallet, onWalletChange, theme, currencyIcons, iconFor, showSell = false, walletCurrencies, title = "Shop", labelFor, minCardWidth = "140px", className, style, }) {
|
|
69
|
+
const entries = useShopStock(shop);
|
|
70
|
+
const t = resolveTheme(theme);
|
|
71
|
+
const currencies = walletCurrencies ?? referencedCurrencies(entries);
|
|
72
|
+
const onBuy = (entry) => {
|
|
73
|
+
const result = shop.buy(entry.id, wallet);
|
|
74
|
+
if (result.ok)
|
|
75
|
+
onWalletChange(result.wallet);
|
|
76
|
+
};
|
|
77
|
+
const onSell = (entry) => {
|
|
78
|
+
const result = shop.sell(entry.id, wallet);
|
|
79
|
+
if (result.ok)
|
|
80
|
+
onWalletChange(result.wallet);
|
|
81
|
+
};
|
|
82
|
+
const walletReadout = (_jsx("div", { "data-shop-grid-wallet": true, style: { display: "flex", flexWrap: "wrap", gap: 12, alignItems: "center" }, children: currencies.map((currency) => (_jsxs("span", { "data-shop-grid-balance": currency, style: { display: "inline-flex", alignItems: "center", gap: 5, color: t.text, fontVariantNumeric: "tabular-nums", fontWeight: 700 }, children: [_jsx(GameIcon, { name: currencyIcon(currency, currencyIcons), size: 18, color: t.accent }), balance(wallet, currency)] }, currency))) }));
|
|
83
|
+
return (_jsx(HudFrame, { variation: "themed", title: title, aside: walletReadout, interactive: true, className: className, style: { fontFamily: t.fontFamily, color: t.text, ...style }, children: _jsx("div", { "data-shop-grid": true, style: {
|
|
84
|
+
display: "grid",
|
|
85
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${minCardWidth}, 1fr))`,
|
|
86
|
+
gap: 10,
|
|
87
|
+
}, children: entries.map((entry) => {
|
|
88
|
+
const soldOut = entry.qty !== null && entry.qty <= 0;
|
|
89
|
+
const affordable = shop.canAfford(entry.id, wallet);
|
|
90
|
+
const buyDisabled = soldOut || !affordable;
|
|
91
|
+
return (_jsxs("div", { "data-shop-item": entry.id, "data-kind": entry.kind, style: {
|
|
92
|
+
display: "flex",
|
|
93
|
+
flexDirection: "column",
|
|
94
|
+
gap: 6,
|
|
95
|
+
padding: 10,
|
|
96
|
+
background: t.cardBg,
|
|
97
|
+
border: t.cardBorder,
|
|
98
|
+
borderRadius: t.cardRadius,
|
|
99
|
+
opacity: soldOut ? 0.55 : 1,
|
|
100
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6 }, children: [_jsx("div", { style: {
|
|
101
|
+
display: "grid",
|
|
102
|
+
placeItems: "center",
|
|
103
|
+
width: 40,
|
|
104
|
+
height: 40,
|
|
105
|
+
borderRadius: 8,
|
|
106
|
+
background: "rgba(0,0,0,0.28)",
|
|
107
|
+
}, children: _jsx(GameIcon, { name: entryIcon(entry, iconFor), size: 28, color: t.accent }) }), _jsx("span", { "data-shop-item-qty": true, style: { fontSize: 12, fontWeight: 700, color: t.muted, fontVariantNumeric: "tabular-nums" }, children: entry.qty === null ? "∞" : `x${entry.qty}` })] }), _jsx("div", { style: { fontSize: 13, fontWeight: 700, color: t.text, lineHeight: 1.2 }, children: labelFor?.(entry) ?? capitalize(entry.kind) }), _jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6 }, children: [_jsx(PriceTag, { price: entry.price, icon: currencyIcon(entry.price.currency, currencyIcons), accent: t.accent, muted: t.text }), entry.sellPrice !== undefined ? (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 3, fontSize: 11, color: t.muted }, children: ["sell", _jsx(PriceTag, { price: entry.sellPrice, icon: currencyIcon(entry.sellPrice.currency, currencyIcons), accent: t.muted, muted: t.muted })] })) : null] }), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { type: "button", "data-shop-buy": entry.id, disabled: buyDisabled, onClick: () => onBuy(entry), style: {
|
|
108
|
+
flex: 1,
|
|
109
|
+
padding: "7px 8px",
|
|
110
|
+
borderRadius: 8,
|
|
111
|
+
border: "none",
|
|
112
|
+
fontWeight: 800,
|
|
113
|
+
fontSize: 12,
|
|
114
|
+
letterSpacing: "0.03em",
|
|
115
|
+
cursor: buyDisabled ? "not-allowed" : "pointer",
|
|
116
|
+
color: buyDisabled ? t.muted : "#0b1017",
|
|
117
|
+
background: buyDisabled ? "rgba(255,255,255,0.08)" : t.accent,
|
|
118
|
+
}, children: soldOut ? "Sold Out" : "Buy" }), showSell && entry.sellPrice !== undefined ? (_jsx("button", { type: "button", "data-shop-sell": entry.id, onClick: () => onSell(entry), style: {
|
|
119
|
+
padding: "7px 10px",
|
|
120
|
+
borderRadius: 8,
|
|
121
|
+
border: t.cardBorder,
|
|
122
|
+
fontWeight: 700,
|
|
123
|
+
fontSize: 12,
|
|
124
|
+
cursor: "pointer",
|
|
125
|
+
color: t.text,
|
|
126
|
+
background: "transparent",
|
|
127
|
+
}, children: "Sell" })) : null] })] }, entry.id));
|
|
128
|
+
}) }) }));
|
|
129
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type StatusEffectView } from "@jgengine/core/combat";
|
|
3
|
+
import { type GameIconName } from "./gameIcons.js";
|
|
4
|
+
import { type IconSchool } from "./iconTreatment.js";
|
|
5
|
+
/**
|
|
6
|
+
* `StatusEffectBar` (#status-effect-hud): a drop-in timeline HUD row that renders active
|
|
7
|
+
* buffs/debuffs as painted icons, each wrapped in a radial countdown ring and a stack-count badge,
|
|
8
|
+
* driven straight off the engine's existing status model (`StatusInstance` → `StatusEffectView`).
|
|
9
|
+
* A game passes its live effect views and gets a polished, reskinnable effect bar — no re-deriving
|
|
10
|
+
* ring math or timers. `kind` is a free string the game styles: the component never interprets
|
|
11
|
+
* buff-vs-debuff semantics, it only maps `kind` to an icon/color/label the game can fully override.
|
|
12
|
+
* Ring, track, and frame read `HudTheme` tokens (`--jg-accent`, `--jg-slot-*`), so a theme change
|
|
13
|
+
* reskins the whole row.
|
|
14
|
+
*/
|
|
15
|
+
/** How a `kind` resolves to a glyph: a `GameIcon` name, an explicit node, or `null` for the fallback. */
|
|
16
|
+
export type StatusIconResolver = (kind: string) => GameIconName | ReactNode | null;
|
|
17
|
+
/** Props for {@link StatusEffectBar}. */
|
|
18
|
+
export interface StatusEffectBarProps {
|
|
19
|
+
/** Live effect views from the status model (`toStatusEffectViews(...)`). Rendered in order. */
|
|
20
|
+
effects: readonly StatusEffectView[];
|
|
21
|
+
/** Icon face size in px. Default 44. */
|
|
22
|
+
size?: number;
|
|
23
|
+
/** Gap between icons in px. Default 8. */
|
|
24
|
+
gap?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a `kind` to its glyph. Default: the matching `GameIcon` keyword, else an hourglass.
|
|
27
|
+
* Return a `GameIconName`, any `ReactNode`, or `null` to fall through to the default.
|
|
28
|
+
*/
|
|
29
|
+
iconFor?: StatusIconResolver;
|
|
30
|
+
/** Ring color per `kind`. Default reads `--jg-accent`. Style debuffs red / buffs green here. */
|
|
31
|
+
colorFor?: (kind: string) => string;
|
|
32
|
+
/** School gradient behind the glyph per `kind`. Default inferred from the `kind` keyword. */
|
|
33
|
+
schoolFor?: (kind: string) => IconSchool;
|
|
34
|
+
/** Tooltip/`aria-label` per effect. Default: the `kind`, with a `xN` suffix when stacked. */
|
|
35
|
+
labelFor?: (effect: StatusEffectView) => string;
|
|
36
|
+
/** Show the remaining-seconds readout under each icon. Default true. */
|
|
37
|
+
showTimer?: boolean;
|
|
38
|
+
/** Ring stroke width in px. Default 3. */
|
|
39
|
+
ringWidth?: number;
|
|
40
|
+
className?: string;
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A horizontal timeline of active status effects — one countdown-ring icon per effect. Presentation
|
|
45
|
+
* only and pure over its `effects` prop, so it renders straight off the status model's snapshot.
|
|
46
|
+
* `kind` is a free string the game styles (icon / color / label); the component never interprets
|
|
47
|
+
* buff-vs-debuff semantics. Ring, track, and frame read `HudTheme` tokens, so a theme change reskins
|
|
48
|
+
* the whole row.
|
|
49
|
+
*
|
|
50
|
+
* @capability status-effect-bar timeline HUD row of active statuses — icon + radial countdown ring + stack badge, driven off the status model, HudTheme-skinned
|
|
51
|
+
*/
|
|
52
|
+
export declare function StatusEffectBar({ effects, size, gap, iconFor, colorFor, schoolFor, labelFor, showTimer, ringWidth, className, style, }: StatusEffectBarProps): ReactNode;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import {} from "react";
|
|
3
|
+
import { statusEffectRemainingFraction } from "@jgengine/core/combat";
|
|
4
|
+
import { iconForItemId, isGameIconName } from "./gameIcons.js";
|
|
5
|
+
import { IconTreatment, schoolForItem } from "./iconTreatment.js";
|
|
6
|
+
const FALLBACK_ICON = "hourglass";
|
|
7
|
+
function defaultIcon(kind) {
|
|
8
|
+
return iconForItemId(kind) ?? FALLBACK_ICON;
|
|
9
|
+
}
|
|
10
|
+
function resolveGlyph(kind, iconFor) {
|
|
11
|
+
const custom = iconFor?.(kind) ?? null;
|
|
12
|
+
if (custom === null)
|
|
13
|
+
return { icon: defaultIcon(kind) };
|
|
14
|
+
if (typeof custom === "string")
|
|
15
|
+
return isGameIconName(custom) ? { icon: custom } : { icon: defaultIcon(kind) };
|
|
16
|
+
return { node: custom };
|
|
17
|
+
}
|
|
18
|
+
function defaultLabel(effect) {
|
|
19
|
+
return effect.stacks > 1 ? `${effect.kind} x${effect.stacks}` : effect.kind;
|
|
20
|
+
}
|
|
21
|
+
function remainingSeconds(remainingMs) {
|
|
22
|
+
const seconds = remainingMs / 1000;
|
|
23
|
+
if (seconds >= 10)
|
|
24
|
+
return `${Math.ceil(seconds)}`;
|
|
25
|
+
if (seconds >= 1)
|
|
26
|
+
return seconds.toFixed(1);
|
|
27
|
+
return remainingMs > 0 ? "<1" : "0";
|
|
28
|
+
}
|
|
29
|
+
/** A single effect icon: painted face + stack badge, overlaid with its radial countdown ring. */
|
|
30
|
+
function StatusEffectIcon({ effect, size, ringColor, school, glyph, label, showTimer, ringWidth, }) {
|
|
31
|
+
const fraction = statusEffectRemainingFraction(effect);
|
|
32
|
+
const radius = (size - ringWidth) / 2;
|
|
33
|
+
const circumference = 2 * Math.PI * radius;
|
|
34
|
+
const dashOffset = circumference * (1 - fraction);
|
|
35
|
+
return (_jsxs("div", { "data-status-effect": effect.kind, "data-remaining-fraction": fraction.toFixed(4), title: label, "aria-label": label, style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 3 }, children: [_jsxs("div", { style: { position: "relative", width: size, height: size }, children: [_jsx(IconTreatment, { ...(glyph.node !== undefined ? { glyph: glyph.node } : glyph.icon !== undefined ? { icon: glyph.icon } : {}), school: school, size: size, count: effect.stacks }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", style: { position: "absolute", inset: 0, transform: "rotate(-90deg)", pointerEvents: "none", overflow: "visible" }, children: [_jsx("circle", { cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: "rgba(0,0,0,0.55)", strokeWidth: ringWidth }), _jsx("circle", { "data-countdown-ring": "", cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: ringColor, strokeWidth: ringWidth, strokeLinecap: "round", strokeDasharray: circumference, strokeDashoffset: dashOffset, style: { transition: "stroke-dashoffset 200ms linear" } })] })] }), showTimer ? (_jsx("span", { "data-status-timer": "", style: {
|
|
36
|
+
fontSize: 10,
|
|
37
|
+
fontWeight: 700,
|
|
38
|
+
fontVariantNumeric: "tabular-nums",
|
|
39
|
+
color: "var(--jg-bar-text, #f5f7fa)",
|
|
40
|
+
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
|
41
|
+
lineHeight: 1,
|
|
42
|
+
}, children: remainingSeconds(effect.remainingMs) })) : null] }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A horizontal timeline of active status effects — one countdown-ring icon per effect. Presentation
|
|
46
|
+
* only and pure over its `effects` prop, so it renders straight off the status model's snapshot.
|
|
47
|
+
* `kind` is a free string the game styles (icon / color / label); the component never interprets
|
|
48
|
+
* buff-vs-debuff semantics. Ring, track, and frame read `HudTheme` tokens, so a theme change reskins
|
|
49
|
+
* the whole row.
|
|
50
|
+
*
|
|
51
|
+
* @capability status-effect-bar timeline HUD row of active statuses — icon + radial countdown ring + stack badge, driven off the status model, HudTheme-skinned
|
|
52
|
+
*/
|
|
53
|
+
export function StatusEffectBar({ effects, size = 44, gap = 8, iconFor, colorFor, schoolFor, labelFor, showTimer = true, ringWidth = 3, className, style, }) {
|
|
54
|
+
if (effects.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
return (_jsx("div", { className: className, "data-status-effect-bar": "", style: { display: "flex", alignItems: "flex-start", gap, ...style }, children: effects.map((effect) => (_jsx(StatusEffectIcon, { effect: effect, size: size, ringColor: colorFor?.(effect.kind) ?? "var(--jg-accent, #38bdf8)", school: schoolFor?.(effect.kind) ?? schoolForItem(effect.kind), glyph: resolveGlyph(effect.kind, iconFor), label: labelFor?.(effect) ?? defaultLabel(effect), showTimer: showTimer, ringWidth: ringWidth }, effect.id))) }));
|
|
57
|
+
}
|