@jgengine/react 0.14.0 → 0.15.0-next.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 +483 -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,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic close-up gallery of the shared road/junction mesh seam.
|
|
3
|
+
* @capability world-intersections inspect turns and junction meshes in a deterministic preview fixture
|
|
4
|
+
*/
|
|
5
|
+
export declare function StreetGeometryPreview({ className }: {
|
|
6
|
+
className?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { buildIntersectionMarkings, buildTrimmedIntersections, } from "@jgengine/core/world/roads";
|
|
3
|
+
const armRoad = (angle, width) => ({
|
|
4
|
+
path: [
|
|
5
|
+
[0, 0],
|
|
6
|
+
[Math.sin(angle) * 36, Math.cos(angle) * 36],
|
|
7
|
+
],
|
|
8
|
+
width,
|
|
9
|
+
});
|
|
10
|
+
const cases = [
|
|
11
|
+
{
|
|
12
|
+
name: "90 degree turn",
|
|
13
|
+
streets: [armRoad(Math.PI / 2, 8), armRoad(0, 8)],
|
|
14
|
+
junction: { x: 0, z: 0, arms: [{ angle: Math.PI / 2, width: 8 }, { angle: 0, width: 8 }] },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "45 degree turn",
|
|
18
|
+
streets: [armRoad(Math.PI / 2, 8), armRoad(Math.PI / 4, 8)],
|
|
19
|
+
junction: { x: 0, z: 0, arms: [{ angle: Math.PI / 2, width: 8 }, { angle: Math.PI / 4, width: 8 }] },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "unequal T",
|
|
23
|
+
streets: [
|
|
24
|
+
{ path: [[-36, 0], [0, 0], [36, 0]], width: 18 },
|
|
25
|
+
armRoad(0, 8),
|
|
26
|
+
],
|
|
27
|
+
junction: {
|
|
28
|
+
x: 0,
|
|
29
|
+
z: 0,
|
|
30
|
+
arms: [{ angle: Math.PI / 2, width: 18 }, { angle: -Math.PI / 2, width: 18 }, { angle: 0, width: 8 }],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "unequal cross",
|
|
35
|
+
streets: [
|
|
36
|
+
{ path: [[-36, 0], [0, 0], [36, 0]], width: 18 },
|
|
37
|
+
{ path: [[0, -36], [0, 0], [0, 36]], width: 8 },
|
|
38
|
+
],
|
|
39
|
+
junction: {
|
|
40
|
+
x: 0,
|
|
41
|
+
z: 0,
|
|
42
|
+
arms: [
|
|
43
|
+
{ angle: Math.PI / 2, width: 18 },
|
|
44
|
+
{ angle: -Math.PI / 2, width: 18 },
|
|
45
|
+
{ angle: 0, width: 8 },
|
|
46
|
+
{ angle: Math.PI, width: 8 },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "five way",
|
|
52
|
+
streets: [
|
|
53
|
+
armRoad(0, 8),
|
|
54
|
+
armRoad(1.12, 18),
|
|
55
|
+
armRoad(2.42, 10),
|
|
56
|
+
armRoad(3.72, 14),
|
|
57
|
+
armRoad(5.05, 8),
|
|
58
|
+
],
|
|
59
|
+
junction: {
|
|
60
|
+
x: 0,
|
|
61
|
+
z: 0,
|
|
62
|
+
arms: [
|
|
63
|
+
{ angle: 0, width: 8 },
|
|
64
|
+
{ angle: 1.12, width: 18 },
|
|
65
|
+
{ angle: 2.42, width: 10 },
|
|
66
|
+
{ angle: 3.72, width: 14 },
|
|
67
|
+
{ angle: 5.05, width: 8 },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
function triangles(ribbon, scale, cx, cy) {
|
|
73
|
+
const result = [];
|
|
74
|
+
for (let i = 0; i < ribbon.indices.length; i += 3) {
|
|
75
|
+
const points = [];
|
|
76
|
+
for (let k = 0; k < 3; k += 1) {
|
|
77
|
+
const vertex = ribbon.indices[i + k] * 3;
|
|
78
|
+
points.push(`${cx + ribbon.positions[vertex] * scale},${cy + ribbon.positions[vertex + 2] * scale}`);
|
|
79
|
+
}
|
|
80
|
+
result.push(points.join(" "));
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Deterministic close-up gallery of the shared road/junction mesh seam.
|
|
86
|
+
* @capability world-intersections inspect turns and junction meshes in a deterministic preview fixture
|
|
87
|
+
*/
|
|
88
|
+
export function StreetGeometryPreview({ className }) {
|
|
89
|
+
return (_jsx("div", { className: className, style: { minHeight: "100%", background: "radial-gradient(circle at 78% 68%, #25425a 0%, #142b40 28%, #0b1d2e 62%, #07131f 100%)", color: "#dbeafe", padding: 18, fontFamily: "ui-monospace, monospace" }, children: _jsxs("div", { style: { maxWidth: 1320, margin: "0 auto" }, children: [_jsx("h1", { style: { margin: "0 0 6px", fontSize: 24 }, children: "Street geometry close-ups" }), _jsx("p", { style: { margin: "0 0 20px", color: "#7dd3fc", fontSize: 13 }, children: "Compact carriageway-union junctions: 45\u00B0/90\u00B0 turns, unequal T, unequal cross, five-way. Pale = sidewalks; blue = pavement; cream = markings." }), _jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }, children: [cases.map((entry) => {
|
|
90
|
+
const patternId = `grid-${entry.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
91
|
+
const streets = entry.streets.map((street) => ({
|
|
92
|
+
...street,
|
|
93
|
+
sidewalks: { left: 2.2, right: 2.2 },
|
|
94
|
+
markings: { lines: [{ offset: 0, width: 0.2 }], stopLine: true },
|
|
95
|
+
}));
|
|
96
|
+
const geometry = buildTrimmedIntersections(streets, [entry.junction], () => 0, {
|
|
97
|
+
curbReturnRadius: 2,
|
|
98
|
+
apronMargin: 0.25,
|
|
99
|
+
filletSegments: 10,
|
|
100
|
+
});
|
|
101
|
+
const surface = geometry.junctions[0];
|
|
102
|
+
const markings = buildIntersectionMarkings(geometry, () => 0, {
|
|
103
|
+
mouthClearance: 1.25,
|
|
104
|
+
dashLength: 4.8,
|
|
105
|
+
dashGap: 4,
|
|
106
|
+
});
|
|
107
|
+
let extent = 0;
|
|
108
|
+
for (let i = 3; i < surface.positions.length; i += 3) {
|
|
109
|
+
extent = Math.max(extent, Math.hypot(surface.positions[i], surface.positions[i + 2]));
|
|
110
|
+
}
|
|
111
|
+
return (_jsxs("section", { style: { background: "#10263a", border: "1px solid #285174", borderRadius: 10, overflow: "hidden" }, children: [_jsxs("div", { style: { padding: "8px 12px 3px", display: "flex", justifyContent: "space-between", gap: 8 }, children: [_jsx("strong", { style: { fontSize: 14 }, children: entry.name }), _jsxs("span", { style: { color: "#67e8f9", fontSize: 11 }, children: ["extent ", extent.toFixed(1)] })] }), _jsxs("svg", { viewBox: "0 0 280 205", role: "img", "aria-label": `${entry.name} road mesh`, style: { display: "block", width: "100%", height: "auto" }, children: [_jsx("defs", { children: _jsxs("pattern", { id: patternId, width: "20", height: "20", patternUnits: "userSpaceOnUse", children: [_jsx("rect", { width: "20", height: "20", fill: "#0d2234" }), _jsx("path", { d: "M 20 0 L 0 0 0 20", fill: "none", stroke: "#1d4b68", strokeWidth: "0.8" })] }) }), _jsx("rect", { width: "280", height: "205", fill: `url(#${patternId})` }), geometry.sidewalks.flatMap((ribbon, ri) => triangles(ribbon, 2.65, 140, 102).map((points, ti) => (_jsx("polygon", { points: points, fill: "#d8c9aa", stroke: "#d8c9aa", strokeWidth: "0.35" }, `s-r-${ri}-${ti}`)))), geometry.sidewalkAprons.flatMap((junction, ji) => triangles(junction, 2.65, 140, 102).map((points, ti) => (_jsx("polygon", { points: points, fill: "#d8c9aa", stroke: "#d8c9aa", strokeWidth: "0.35" }, `s-j-${ji}-${ti}`)))), geometry.ribbons.flatMap((ribbon, ri) => triangles(ribbon, 2.65, 140, 102).map((points, ti) => (_jsx("polygon", { points: points, fill: "#52677d", stroke: "#52677d", strokeWidth: "0.35" }, `r-${ri}-${ti}`)))), triangles(surface, 2.65, 140, 102).map((points, ti) => (_jsx("polygon", { points: points, fill: "#2479a4", stroke: "#2479a4", strokeWidth: "0.35" }, `j-${ti}`))), markings.flatMap((marking, mi) => triangles(marking, 2.65, 140, 102).map((points, ti) => (_jsx("polygon", { points: points, fill: "#f8fafc" }, `m-${mi}-${ti}`))))] })] }, entry.name));
|
|
112
|
+
}), _jsxs("section", { style: { minHeight: 238, background: "linear-gradient(145deg, #18354c, #10263a)", border: "1px solid #356487", borderRadius: 10, padding: 18 }, children: [_jsx("strong", { style: { display: "block", marginBottom: 8, fontSize: 15 }, children: "Shared geometry contract" }), _jsx("p", { style: { margin: "0 0 14px", color: "#8ed8ef", fontSize: 12, lineHeight: 1.5 }, children: "Every panel is built from the same renderer-neutral meshes consumed by the Three.js city." }), [
|
|
113
|
+
["#d8c9aa", "Sidewalk annulus", "No pavement overlap"],
|
|
114
|
+
["#2479a4", "Junction apron", "Bounded by road widths"],
|
|
115
|
+
["#f8fafc", "Lane guidance", "Mouth clearance + stop lines"],
|
|
116
|
+
["#4ade80", "Terrain drape", "Every vertex sampled"],
|
|
117
|
+
["#fbbf24", "Offset continuity", "Positive and negative"],
|
|
118
|
+
].map(([color, label, detail]) => (_jsxs("div", { style: { display: "grid", gridTemplateColumns: "12px 1fr auto", alignItems: "center", gap: 9, marginTop: 9, fontSize: 11 }, children: [_jsx("span", { style: { width: 10, height: 10, borderRadius: 2, background: color, boxShadow: `0 0 8px ${color}` } }), _jsx("span", { style: { color: "#e2edf5" }, children: label }), _jsx("span", { style: { color: "#82a9c2" }, children: detail })] }, label)))] })] })] }) }));
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { TalentNodeDef, TalentTree as TalentTreeModel } from "@jgengine/core/game/talents";
|
|
3
|
+
import { type TalentTreeView } from "@jgengine/core/game/talentTreeView";
|
|
4
|
+
import { type GameIconName } from "./gameIcons.js";
|
|
5
|
+
import { type HudFrameVariation } from "./hudFrame.js";
|
|
6
|
+
/** How a node id resolves to display art: a built-in {@link GameIconName}, any node, or `null` for the default. */
|
|
7
|
+
export type TalentIcon = GameIconName | ReactNode | null;
|
|
8
|
+
/** Props for {@link TalentTree}. */
|
|
9
|
+
export interface TalentTreeProps<TStat extends string = string> {
|
|
10
|
+
/**
|
|
11
|
+
* A precomputed render view (from `talentTreeView` or `talentTreeViewFrom`). Supply this to drive the
|
|
12
|
+
* widget from *any* unlock rule — a currency threshold, a level, a quest flag — not just point-spend.
|
|
13
|
+
* When given, `nodes`/`tree` are ignored. Otherwise pass `nodes` + `tree` for the point-spend path.
|
|
14
|
+
*/
|
|
15
|
+
view?: TalentTreeView;
|
|
16
|
+
/** The tree's node definitions — the same array passed to `createTalentTree`. Used when `view` is omitted. */
|
|
17
|
+
nodes?: readonly TalentNodeDef<TStat>[];
|
|
18
|
+
/** The live talent-tree instance whose ranks/points drive node state. Used when `view` is omitted. */
|
|
19
|
+
tree?: TalentTreeModel<TStat>;
|
|
20
|
+
/** Fired with a node id when the player clicks a node that is currently allocatable. Wire it to your unlock/allocate effect (e.g. `tree.allocate`, or spend cash, or set a flag). */
|
|
21
|
+
onLearn?: (nodeId: string) => void;
|
|
22
|
+
/** Show the points-remaining badge in the frame header. Default `true`; pass `false` for trees with no point currency. */
|
|
23
|
+
showPoints?: boolean;
|
|
24
|
+
/** Resolve a node id to its icon. Default: keyword-match the id, else the node's initials. */
|
|
25
|
+
icon?: (nodeId: string) => TalentIcon;
|
|
26
|
+
/** Resolve a node id to a human label (tooltip title + accessible name). Default: the id. */
|
|
27
|
+
label?: (nodeId: string) => ReactNode;
|
|
28
|
+
/** Resolve a branch key to a column header. Default: the branch string (blank branch → no header). */
|
|
29
|
+
branchLabel?: (branch: string) => ReactNode;
|
|
30
|
+
/** Frame title. Default `Talents`. Pass `null` to render without a frame header. */
|
|
31
|
+
title?: ReactNode;
|
|
32
|
+
/** {@link HudFrame} skin. Default `themed`. */
|
|
33
|
+
variation?: HudFrameVariation;
|
|
34
|
+
/** Node tile diameter in px. Default 54. */
|
|
35
|
+
nodeSize?: number;
|
|
36
|
+
/** Horizontal gap between node columns in px. Default 30. */
|
|
37
|
+
columnGap?: number;
|
|
38
|
+
/** Vertical gap between tiers in px. Default 46. */
|
|
39
|
+
tierGap?: number;
|
|
40
|
+
className?: string;
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drop-in talent / skill-tree widget over the existing progression model
|
|
45
|
+
* (`@jgengine/core/game/talents` + its `talentTreeView` selector). The game passes its node
|
|
46
|
+
* definitions and a live `createTalentTree` instance; the widget lays nodes out by branch column and
|
|
47
|
+
* prerequisite-depth tier, draws SVG prerequisite edges, styles each node learned/available/locked/maxed,
|
|
48
|
+
* shows an icon + rank badge, and calls `onLearn(nodeId)` when the player clicks a node that is
|
|
49
|
+
* currently allocatable. All layout, edge-drawing, and eligibility come from the model/selector — the
|
|
50
|
+
* widget never re-derives topology. Unskinned and HudTheme-token driven; node ids/branches are opaque
|
|
51
|
+
* game data the game supplies icons and labels for.
|
|
52
|
+
*
|
|
53
|
+
* For the point-spend path pass `nodes` + a live `tree`. To drive the tree from *any* other unlock
|
|
54
|
+
* rule (a currency threshold, a level, a quest flag), build a view with `talentTreeViewFrom` and pass
|
|
55
|
+
* it as `view` — the same widget then renders a money-gated upgrade tree or a condition-unlocked
|
|
56
|
+
* ability web, no renderer change.
|
|
57
|
+
*
|
|
58
|
+
* @capability talent-tree drop-in skill/talent/upgrade-tree widget — pass `nodes`+`tree` for point-spend or a precomputed `view` for any unlock rule; branch/tier layout, SVG prerequisite edges, learned/available/locked/maxed styling, icon + rank, onLearn
|
|
59
|
+
*/
|
|
60
|
+
export declare function TalentTree<TStat extends string = string>({ view: viewProp, nodes, tree, onLearn, icon, label, branchLabel, title, variation, nodeSize, columnGap, tierGap, showPoints, className, style, }: TalentTreeProps<TStat>): ReactNode;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import {} from "react";
|
|
3
|
+
import { talentTreeView, } from "@jgengine/core/game/talentTreeView";
|
|
4
|
+
import { GameIcon, iconForAction, iconForItemId, isGameIconName } from "./gameIcons.js";
|
|
5
|
+
import { HudFrame } from "./hudFrame.js";
|
|
6
|
+
const STATE_STYLE = {
|
|
7
|
+
locked: {
|
|
8
|
+
border: "1px solid rgba(148,163,184,0.28)",
|
|
9
|
+
bg: "var(--jg-slot-bg, rgba(12,14,20,0.72))",
|
|
10
|
+
opacity: 0.42,
|
|
11
|
+
glow: "none",
|
|
12
|
+
},
|
|
13
|
+
available: {
|
|
14
|
+
border: "2px solid var(--jg-accent, #38bdf8)",
|
|
15
|
+
bg: "var(--jg-slot-bg, rgba(12,14,20,0.72))",
|
|
16
|
+
opacity: 1,
|
|
17
|
+
glow: "0 0 0 3px color-mix(in srgb, var(--jg-accent, #38bdf8) 22%, transparent)",
|
|
18
|
+
},
|
|
19
|
+
learned: {
|
|
20
|
+
border: "2px solid var(--jg-accent, #38bdf8)",
|
|
21
|
+
bg: "color-mix(in srgb, var(--jg-accent, #38bdf8) 18%, rgba(12,14,20,0.8))",
|
|
22
|
+
opacity: 1,
|
|
23
|
+
glow: "0 0 10px color-mix(in srgb, var(--jg-accent, #38bdf8) 30%, transparent)",
|
|
24
|
+
},
|
|
25
|
+
maxed: {
|
|
26
|
+
border: "2px solid var(--jg-accent, #38bdf8)",
|
|
27
|
+
bg: "color-mix(in srgb, var(--jg-accent, #38bdf8) 34%, rgba(12,14,20,0.85))",
|
|
28
|
+
opacity: 1,
|
|
29
|
+
glow: "0 0 14px color-mix(in srgb, var(--jg-accent, #38bdf8) 45%, transparent)",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
/** @internal Default icon: keyword-resolve the id through the shared game-icon rules, else render initials. */
|
|
33
|
+
function DefaultNodeGlyph({ id, size }) {
|
|
34
|
+
const resolved = iconForItemId(id) ?? iconForAction(id);
|
|
35
|
+
if (resolved !== null)
|
|
36
|
+
return _jsx(GameIcon, { name: resolved, size: size });
|
|
37
|
+
const initials = id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 2).toUpperCase() || "?";
|
|
38
|
+
return _jsx("span", { style: { fontSize: size * 0.5, fontWeight: 800, letterSpacing: 0.5 }, children: initials });
|
|
39
|
+
}
|
|
40
|
+
const BRANCH_GAP = 34;
|
|
41
|
+
const HEADER_H = 22;
|
|
42
|
+
/** @internal Assign each node a grid cell by branch column and tier row, then resolve to pixel centers. */
|
|
43
|
+
function place(view, nodeSize, columnGap, tierGap) {
|
|
44
|
+
const cellW = nodeSize + columnGap;
|
|
45
|
+
const cellH = nodeSize + tierGap;
|
|
46
|
+
// Bucket nodes by branch, then by tier, to size each branch and slot nodes within a tier row.
|
|
47
|
+
const perBranch = new Map();
|
|
48
|
+
for (const branch of view.branches)
|
|
49
|
+
perBranch.set(branch, []);
|
|
50
|
+
for (const node of view.nodes)
|
|
51
|
+
perBranch.get(node.branch).push(node);
|
|
52
|
+
const x = new Map();
|
|
53
|
+
const y = new Map();
|
|
54
|
+
const headers = [];
|
|
55
|
+
const padX = columnGap / 2;
|
|
56
|
+
const padY = HEADER_H;
|
|
57
|
+
let colCursor = 0; // running column index across all branches
|
|
58
|
+
let branchIndex = 0;
|
|
59
|
+
for (const branch of view.branches) {
|
|
60
|
+
const branchNodes = perBranch.get(branch);
|
|
61
|
+
const byTier = new Map();
|
|
62
|
+
for (const node of branchNodes) {
|
|
63
|
+
const bucket = byTier.get(node.tier) ?? [];
|
|
64
|
+
bucket.push(node);
|
|
65
|
+
byTier.set(node.tier, bucket);
|
|
66
|
+
}
|
|
67
|
+
const branchWidth = Math.max(1, ...[...byTier.values()].map((b) => b.length));
|
|
68
|
+
const branchBaseX = padX + colCursor * cellW + branchIndex * BRANCH_GAP;
|
|
69
|
+
for (const [tier, bucket] of byTier) {
|
|
70
|
+
// Center this tier's nodes within the branch's column span.
|
|
71
|
+
const offset = (branchWidth - bucket.length) / 2;
|
|
72
|
+
bucket.forEach((node, i) => {
|
|
73
|
+
const cx = branchBaseX + (offset + i) * cellW + nodeSize / 2;
|
|
74
|
+
const cy = padY + tier * cellH + nodeSize / 2;
|
|
75
|
+
x.set(node.id, cx);
|
|
76
|
+
y.set(node.id, cy);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const cx = branchBaseX + (branchWidth * cellW) / 2;
|
|
80
|
+
headers.push({ branch, cx, top: 0 });
|
|
81
|
+
colCursor += branchWidth;
|
|
82
|
+
branchIndex += 1;
|
|
83
|
+
}
|
|
84
|
+
const totalCols = colCursor;
|
|
85
|
+
const width = padX * 2 + totalCols * cellW + Math.max(0, view.branches.length - 1) * BRANCH_GAP;
|
|
86
|
+
const height = padY + Math.max(1, view.tiers) * cellH;
|
|
87
|
+
return { x, y, width, height, headers };
|
|
88
|
+
}
|
|
89
|
+
/** @internal One SVG prerequisite edge, colored by whether it is currently met. */
|
|
90
|
+
function Edge({ x1, y1, x2, y2, met }) {
|
|
91
|
+
return (_jsx("line", { x1: x1, y1: y1, x2: x2, y2: y2, stroke: met ? "var(--jg-accent, #38bdf8)" : "rgba(148,163,184,0.32)", strokeWidth: met ? 3 : 2, strokeLinecap: "round", strokeDasharray: met ? undefined : "5 6", opacity: met ? 0.9 : 0.7 }));
|
|
92
|
+
}
|
|
93
|
+
/** @internal A single talent node tile — icon, rank badge, state styling, click-to-learn. */
|
|
94
|
+
function TalentNode({ node, cx, cy, size, glyph, label, onLearn, }) {
|
|
95
|
+
const s = STATE_STYLE[node.state];
|
|
96
|
+
const interactive = node.allocatable && onLearn !== undefined;
|
|
97
|
+
const showRank = node.maxRank > 1 || node.rank > 0;
|
|
98
|
+
const labelText = typeof label === "string" ? label : node.id;
|
|
99
|
+
return (_jsxs("button", { type: "button", "data-talent-node": node.id, "data-talent-state": node.state, "aria-label": `${labelText} — ${node.state}${node.maxRank > 1 ? `, rank ${node.rank} of ${node.maxRank}` : ""}`, title: labelText, disabled: !interactive, onClick: interactive ? () => onLearn(node.id) : undefined, style: {
|
|
100
|
+
position: "absolute",
|
|
101
|
+
left: cx - size / 2,
|
|
102
|
+
top: cy - size / 2,
|
|
103
|
+
width: size,
|
|
104
|
+
height: size,
|
|
105
|
+
display: "flex",
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "center",
|
|
108
|
+
padding: 0,
|
|
109
|
+
borderRadius: "var(--jg-slot-radius, 12px)",
|
|
110
|
+
border: s.border,
|
|
111
|
+
background: s.bg,
|
|
112
|
+
color: "var(--jg-bar-text, #f4f6fb)",
|
|
113
|
+
opacity: s.opacity,
|
|
114
|
+
boxShadow: interactive ? `${s.glow}, 0 0 0 2px color-mix(in srgb, var(--jg-accent, #38bdf8) 40%, transparent)` : s.glow,
|
|
115
|
+
cursor: interactive ? "pointer" : "default",
|
|
116
|
+
pointerEvents: "auto",
|
|
117
|
+
boxSizing: "border-box",
|
|
118
|
+
transition: "box-shadow 120ms ease, opacity 120ms ease",
|
|
119
|
+
}, children: [_jsx("span", { "aria-hidden": true, style: { display: "flex", alignItems: "center", justifyContent: "center" }, children: glyph }), showRank ? (_jsxs("span", { "data-talent-rank": "", style: {
|
|
120
|
+
position: "absolute",
|
|
121
|
+
right: -6,
|
|
122
|
+
bottom: -6,
|
|
123
|
+
minWidth: 20,
|
|
124
|
+
height: 18,
|
|
125
|
+
padding: "0 4px",
|
|
126
|
+
borderRadius: 9,
|
|
127
|
+
background: node.state === "maxed" ? "var(--jg-accent, #38bdf8)" : "rgba(6,10,16,0.92)",
|
|
128
|
+
color: node.state === "maxed" ? "#06121b" : "var(--jg-bar-text, #f4f6fb)",
|
|
129
|
+
border: "1px solid var(--jg-accent, #38bdf8)",
|
|
130
|
+
fontSize: 10.5,
|
|
131
|
+
fontWeight: 800,
|
|
132
|
+
fontVariantNumeric: "tabular-nums",
|
|
133
|
+
display: "flex",
|
|
134
|
+
alignItems: "center",
|
|
135
|
+
justifyContent: "center",
|
|
136
|
+
boxSizing: "border-box",
|
|
137
|
+
}, children: [node.rank, "/", node.maxRank] })) : null] }));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Drop-in talent / skill-tree widget over the existing progression model
|
|
141
|
+
* (`@jgengine/core/game/talents` + its `talentTreeView` selector). The game passes its node
|
|
142
|
+
* definitions and a live `createTalentTree` instance; the widget lays nodes out by branch column and
|
|
143
|
+
* prerequisite-depth tier, draws SVG prerequisite edges, styles each node learned/available/locked/maxed,
|
|
144
|
+
* shows an icon + rank badge, and calls `onLearn(nodeId)` when the player clicks a node that is
|
|
145
|
+
* currently allocatable. All layout, edge-drawing, and eligibility come from the model/selector — the
|
|
146
|
+
* widget never re-derives topology. Unskinned and HudTheme-token driven; node ids/branches are opaque
|
|
147
|
+
* game data the game supplies icons and labels for.
|
|
148
|
+
*
|
|
149
|
+
* For the point-spend path pass `nodes` + a live `tree`. To drive the tree from *any* other unlock
|
|
150
|
+
* rule (a currency threshold, a level, a quest flag), build a view with `talentTreeViewFrom` and pass
|
|
151
|
+
* it as `view` — the same widget then renders a money-gated upgrade tree or a condition-unlocked
|
|
152
|
+
* ability web, no renderer change.
|
|
153
|
+
*
|
|
154
|
+
* @capability talent-tree drop-in skill/talent/upgrade-tree widget — pass `nodes`+`tree` for point-spend or a precomputed `view` for any unlock rule; branch/tier layout, SVG prerequisite edges, learned/available/locked/maxed styling, icon + rank, onLearn
|
|
155
|
+
*/
|
|
156
|
+
export function TalentTree({ view: viewProp, nodes, tree, onLearn, icon, label, branchLabel, title = "Talents", variation = "themed", nodeSize = 54, columnGap = 30, tierGap = 46, showPoints = true, className, style, }) {
|
|
157
|
+
if (viewProp === undefined && (nodes === undefined || tree === undefined)) {
|
|
158
|
+
throw new Error("TalentTree requires either a `view` prop or both `nodes` and `tree`.");
|
|
159
|
+
}
|
|
160
|
+
const view = viewProp ?? talentTreeView(nodes, tree);
|
|
161
|
+
const layout = place(view, nodeSize, columnGap, tierGap);
|
|
162
|
+
const resolveGlyph = (id) => {
|
|
163
|
+
const custom = icon?.(id);
|
|
164
|
+
if (custom === undefined || custom === null)
|
|
165
|
+
return _jsx(DefaultNodeGlyph, { id: id, size: nodeSize * 0.5 });
|
|
166
|
+
if (typeof custom === "string" && isGameIconName(custom))
|
|
167
|
+
return _jsx(GameIcon, { name: custom, size: nodeSize * 0.5 });
|
|
168
|
+
return custom;
|
|
169
|
+
};
|
|
170
|
+
const resolveLabel = (id) => label?.(id) ?? id;
|
|
171
|
+
const resolveBranch = (branch) => branchLabel?.(branch) ?? (branch === "" ? null : branch);
|
|
172
|
+
const centerOf = (id) => {
|
|
173
|
+
const x = layout.x.get(id);
|
|
174
|
+
const y = layout.y.get(id);
|
|
175
|
+
return x === undefined || y === undefined ? null : { x, y };
|
|
176
|
+
};
|
|
177
|
+
const body = (_jsxs("div", { "data-talent-tree": "", style: { position: "relative", width: layout.width, height: layout.height, margin: "0 auto" }, children: [layout.headers.map((header) => {
|
|
178
|
+
const text = resolveBranch(header.branch);
|
|
179
|
+
if (text === null)
|
|
180
|
+
return null;
|
|
181
|
+
return (_jsx("div", { "data-talent-branch": header.branch, style: {
|
|
182
|
+
position: "absolute",
|
|
183
|
+
left: header.cx,
|
|
184
|
+
top: header.top,
|
|
185
|
+
transform: "translateX(-50%)",
|
|
186
|
+
fontSize: 10,
|
|
187
|
+
fontWeight: 700,
|
|
188
|
+
letterSpacing: 1.4,
|
|
189
|
+
textTransform: "uppercase",
|
|
190
|
+
opacity: 0.7,
|
|
191
|
+
color: "var(--jg-bar-text, #f4f6fb)",
|
|
192
|
+
whiteSpace: "nowrap",
|
|
193
|
+
}, children: text }, `h-${header.branch}`));
|
|
194
|
+
}), _jsx("svg", { width: layout.width, height: layout.height, style: { position: "absolute", inset: 0, pointerEvents: "none", overflow: "visible" }, "aria-hidden": true, children: view.nodes.flatMap((node) => {
|
|
195
|
+
const to = centerOf(node.id);
|
|
196
|
+
if (to === null)
|
|
197
|
+
return [];
|
|
198
|
+
return node.requires.flatMap((edge) => {
|
|
199
|
+
const from = centerOf(edge.from);
|
|
200
|
+
if (from === null)
|
|
201
|
+
return [];
|
|
202
|
+
return [
|
|
203
|
+
_jsx(Edge, { x1: from.x, y1: from.y, x2: to.x, y2: to.y, met: edge.met }, `${edge.from}->${node.id}`),
|
|
204
|
+
];
|
|
205
|
+
});
|
|
206
|
+
}) }), view.nodes.map((node) => {
|
|
207
|
+
const center = centerOf(node.id);
|
|
208
|
+
if (center === null)
|
|
209
|
+
return null;
|
|
210
|
+
return (_jsx(TalentNode, { node: node, cx: center.x, cy: center.y, size: nodeSize, glyph: resolveGlyph(node.id), label: resolveLabel(node.id), ...(onLearn === undefined ? {} : { onLearn }) }, node.id));
|
|
211
|
+
})] }));
|
|
212
|
+
const pointsBadge = showPoints ? (_jsxs("span", { "data-talent-points": "", style: { fontVariantNumeric: "tabular-nums" }, children: [view.pointsAvailable, " pt", view.pointsAvailable === 1 ? "" : "s"] })) : undefined;
|
|
213
|
+
if (title === null) {
|
|
214
|
+
return (_jsx("div", { className: className, style: style, children: body }));
|
|
215
|
+
}
|
|
216
|
+
return (_jsx(HudFrame, { variation: variation, title: title, ...(pointsBadge === undefined ? {} : { aside: pointsBadge }), interactive: true, padding: 16, ...(className === undefined ? {} : { className }), ...(style === undefined ? {} : { style }), children: body }));
|
|
217
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { TimerRead, TimerSet } from "@jgengine/core/time/timerSet";
|
|
3
|
+
/**
|
|
4
|
+
* Which value a readout leads with. `"auto"` follows the timer's direction —
|
|
5
|
+
* remaining for a countdown, elapsed for a countup charge.
|
|
6
|
+
*/
|
|
7
|
+
export type TimerReadoutSource = "auto" | "remaining" | "elapsed";
|
|
8
|
+
/** Digital text format for a timer readout. */
|
|
9
|
+
export type TimerFormat = "mm:ss" | "m:ss.d" | "ss.d";
|
|
10
|
+
/**
|
|
11
|
+
* Format a millisecond duration as digital timer text — `mm:ss` (default),
|
|
12
|
+
* `m:ss.d` (with tenths), or `ss.d` (seconds + tenths). Negative inputs clamp
|
|
13
|
+
* to zero. No genre meaning: the same helper serves a round clock, a respawn
|
|
14
|
+
* countdown, or an ability charge readout.
|
|
15
|
+
*
|
|
16
|
+
* @capability timer-readout-format format milliseconds as digital timer text (mm:ss / m:ss.d / ss.d)
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatTimerMs(ms: number, format?: TimerFormat): string;
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe to a single timer and re-read it every animation frame while
|
|
21
|
+
* mounted, so a HUD readout stays live without the game hand-rolling interval
|
|
22
|
+
* math. Reuses one read object (allocation-aware). Returns `null` for an unknown
|
|
23
|
+
* id. Pass `active={false}` to freeze the per-frame tick (e.g. an off-screen HUD).
|
|
24
|
+
*
|
|
25
|
+
* @capability use-timer React hook binding one timer to a component with a per-frame re-read
|
|
26
|
+
*/
|
|
27
|
+
export declare function useTimerRead(timer: TimerSet, id: string, active?: boolean): TimerRead | null;
|
|
28
|
+
/** Props for {@link TimerReadout}. */
|
|
29
|
+
export interface TimerReadoutProps {
|
|
30
|
+
/** The timer set to read from. */
|
|
31
|
+
timer: TimerSet;
|
|
32
|
+
/** Id of the timer to display (free string). */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Which value to show. Default `"auto"` (remaining for countdown, elapsed for countup). */
|
|
35
|
+
source?: TimerReadoutSource;
|
|
36
|
+
/** Digital format. Default `"mm:ss"`. */
|
|
37
|
+
format?: TimerFormat;
|
|
38
|
+
/** Text shown when the timer id is unknown. Default `"--:--"`. */
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
/** Freeze the per-frame tick when false. Default `true`. */
|
|
41
|
+
active?: boolean;
|
|
42
|
+
className?: string;
|
|
43
|
+
style?: CSSProperties;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A live digital timer readout (mm:ss / m:ss.d) driven off a {@link TimerSet}.
|
|
47
|
+
* It re-reads every frame and formats remaining or elapsed time — a round clock,
|
|
48
|
+
* a respawn countdown, or an ability charge, all the same widget. HudTheme-skinned
|
|
49
|
+
* via `--jg-bar-text`; reskin with `style` or the theme tokens.
|
|
50
|
+
*
|
|
51
|
+
* @capability timer-readout live digital mm:ss/m:ss.d timer readout bound to a TimerSet
|
|
52
|
+
*/
|
|
53
|
+
export declare function TimerReadout({ timer, id, source, format, placeholder, active, className, style, }: TimerReadoutProps): ReactNode;
|
|
54
|
+
/** Props for {@link TimerRing}. */
|
|
55
|
+
export interface TimerRingProps {
|
|
56
|
+
/** The timer set to read from. */
|
|
57
|
+
timer: TimerSet;
|
|
58
|
+
/** Id of the timer to display (free string). */
|
|
59
|
+
id: string;
|
|
60
|
+
/** Outer diameter in px. Default `72`. */
|
|
61
|
+
size?: number;
|
|
62
|
+
/** Ring stroke thickness in px. Default `8`. */
|
|
63
|
+
thickness?: number;
|
|
64
|
+
/**
|
|
65
|
+
* How the arc maps to time. `"fill"` (default) sweeps from empty to full as
|
|
66
|
+
* time passes (a charge/cooldown filling); `"drain"` starts full and empties
|
|
67
|
+
* (a countdown running out).
|
|
68
|
+
*/
|
|
69
|
+
mode?: "fill" | "drain";
|
|
70
|
+
/** Ring color. Default the HudTheme accent token. */
|
|
71
|
+
color?: string;
|
|
72
|
+
/** Track (unfilled) color. Default the HudTheme bar-track token. */
|
|
73
|
+
trackColor?: string;
|
|
74
|
+
/** Optional center content (e.g. a {@link TimerReadout} or an icon). */
|
|
75
|
+
children?: ReactNode;
|
|
76
|
+
/** Freeze the per-frame tick when false. Default `true`. */
|
|
77
|
+
active?: boolean;
|
|
78
|
+
className?: string;
|
|
79
|
+
style?: CSSProperties;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A radial charge/cooldown ring driven off a {@link TimerSet}'s `progress01`.
|
|
83
|
+
* Re-reads every frame and sweeps an SVG arc as the timer advances — ability
|
|
84
|
+
* charge, cooldown wipe, respawn ring, capture progress, all the same widget.
|
|
85
|
+
* `mode` chooses filling vs. draining; center `children` can hold a readout.
|
|
86
|
+
* HudTheme-skinned via `--jg-accent` / `--jg-bar-track`.
|
|
87
|
+
*
|
|
88
|
+
* @capability timer-ring radial SVG charge/cooldown ring bound to a TimerSet's progress
|
|
89
|
+
*/
|
|
90
|
+
export declare function TimerRing({ timer, id, size, thickness, mode, color, trackColor, children, active, className, style, }: TimerRingProps): ReactNode;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
3
|
+
function pad2(n) {
|
|
4
|
+
return n < 10 ? `0${n}` : `${n}`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Format a millisecond duration as digital timer text — `mm:ss` (default),
|
|
8
|
+
* `m:ss.d` (with tenths), or `ss.d` (seconds + tenths). Negative inputs clamp
|
|
9
|
+
* to zero. No genre meaning: the same helper serves a round clock, a respawn
|
|
10
|
+
* countdown, or an ability charge readout.
|
|
11
|
+
*
|
|
12
|
+
* @capability timer-readout-format format milliseconds as digital timer text (mm:ss / m:ss.d / ss.d)
|
|
13
|
+
*/
|
|
14
|
+
export function formatTimerMs(ms, format = "mm:ss") {
|
|
15
|
+
const clamped = ms > 0 ? ms : 0;
|
|
16
|
+
const totalSeconds = clamped / 1000;
|
|
17
|
+
if (format === "ss.d") {
|
|
18
|
+
return `${Math.floor(totalSeconds)}.${Math.floor((clamped % 1000) / 100)}`;
|
|
19
|
+
}
|
|
20
|
+
const whole = Math.floor(totalSeconds);
|
|
21
|
+
const minutes = Math.floor(whole / 60);
|
|
22
|
+
const seconds = whole % 60;
|
|
23
|
+
if (format === "m:ss.d") {
|
|
24
|
+
const tenths = Math.floor((clamped % 1000) / 100);
|
|
25
|
+
return `${minutes}:${pad2(seconds)}.${tenths}`;
|
|
26
|
+
}
|
|
27
|
+
return `${pad2(minutes)}:${pad2(seconds)}`;
|
|
28
|
+
}
|
|
29
|
+
function pickValue(read, source) {
|
|
30
|
+
if (source === "remaining")
|
|
31
|
+
return read.remainingMs;
|
|
32
|
+
if (source === "elapsed")
|
|
33
|
+
return read.elapsedMs;
|
|
34
|
+
return read.direction === "up" ? read.elapsedMs : read.remainingMs;
|
|
35
|
+
}
|
|
36
|
+
const BLANK = {
|
|
37
|
+
id: "",
|
|
38
|
+
direction: "down",
|
|
39
|
+
remainingMs: 0,
|
|
40
|
+
elapsedMs: 0,
|
|
41
|
+
durationMs: 0,
|
|
42
|
+
progress01: 0,
|
|
43
|
+
running: false,
|
|
44
|
+
expired: false,
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to a single timer and re-read it every animation frame while
|
|
48
|
+
* mounted, so a HUD readout stays live without the game hand-rolling interval
|
|
49
|
+
* math. Reuses one read object (allocation-aware). Returns `null` for an unknown
|
|
50
|
+
* id. Pass `active={false}` to freeze the per-frame tick (e.g. an off-screen HUD).
|
|
51
|
+
*
|
|
52
|
+
* @capability use-timer React hook binding one timer to a component with a per-frame re-read
|
|
53
|
+
*/
|
|
54
|
+
export function useTimerRead(timer, id, active = true) {
|
|
55
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
56
|
+
const out = useRef({ ...BLANK });
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!active)
|
|
59
|
+
return;
|
|
60
|
+
let raf = 0;
|
|
61
|
+
const loop = () => {
|
|
62
|
+
bump();
|
|
63
|
+
raf = requestAnimationFrame(loop);
|
|
64
|
+
};
|
|
65
|
+
raf = requestAnimationFrame(loop);
|
|
66
|
+
return () => cancelAnimationFrame(raf);
|
|
67
|
+
}, [active]);
|
|
68
|
+
return timer.read(id, out.current);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A live digital timer readout (mm:ss / m:ss.d) driven off a {@link TimerSet}.
|
|
72
|
+
* It re-reads every frame and formats remaining or elapsed time — a round clock,
|
|
73
|
+
* a respawn countdown, or an ability charge, all the same widget. HudTheme-skinned
|
|
74
|
+
* via `--jg-bar-text`; reskin with `style` or the theme tokens.
|
|
75
|
+
*
|
|
76
|
+
* @capability timer-readout live digital mm:ss/m:ss.d timer readout bound to a TimerSet
|
|
77
|
+
*/
|
|
78
|
+
export function TimerReadout({ timer, id, source = "auto", format = "mm:ss", placeholder = "--:--", active = true, className, style, }) {
|
|
79
|
+
const read = useTimerRead(timer, id, active);
|
|
80
|
+
const text = read === null ? placeholder : formatTimerMs(pickValue(read, source), format);
|
|
81
|
+
return (_jsx("span", { className: className, style: {
|
|
82
|
+
fontVariantNumeric: "tabular-nums",
|
|
83
|
+
fontWeight: 700,
|
|
84
|
+
color: "var(--jg-bar-text, #e2e8f0)",
|
|
85
|
+
...style,
|
|
86
|
+
}, children: text }));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A radial charge/cooldown ring driven off a {@link TimerSet}'s `progress01`.
|
|
90
|
+
* Re-reads every frame and sweeps an SVG arc as the timer advances — ability
|
|
91
|
+
* charge, cooldown wipe, respawn ring, capture progress, all the same widget.
|
|
92
|
+
* `mode` chooses filling vs. draining; center `children` can hold a readout.
|
|
93
|
+
* HudTheme-skinned via `--jg-accent` / `--jg-bar-track`.
|
|
94
|
+
*
|
|
95
|
+
* @capability timer-ring radial SVG charge/cooldown ring bound to a TimerSet's progress
|
|
96
|
+
*/
|
|
97
|
+
export function TimerRing({ timer, id, size = 72, thickness = 8, mode = "fill", color = "var(--jg-accent, #38bdf8)", trackColor = "var(--jg-bar-track, rgba(148,163,184,0.25))", children, active = true, className, style, }) {
|
|
98
|
+
const read = useTimerRead(timer, id, active);
|
|
99
|
+
const fill = read === null ? 0 : mode === "drain" ? 1 - read.progress01 : read.progress01;
|
|
100
|
+
const radius = (size - thickness) / 2;
|
|
101
|
+
const circumference = 2 * Math.PI * radius;
|
|
102
|
+
const dashOffset = circumference * (1 - Math.max(0, Math.min(1, fill)));
|
|
103
|
+
return (_jsxs("div", { className: className, style: { position: "relative", width: size, height: size, display: "inline-flex", ...style }, children: [_jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, style: { transform: "rotate(-90deg)" }, children: [_jsx("circle", { cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: trackColor, strokeWidth: thickness }), _jsx("circle", { cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: color, strokeWidth: thickness, strokeLinecap: "round", strokeDasharray: circumference, strokeDashoffset: dashOffset })] }), children !== undefined && (_jsx("div", { style: {
|
|
104
|
+
position: "absolute",
|
|
105
|
+
inset: 0,
|
|
106
|
+
display: "flex",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
justifyContent: "center",
|
|
109
|
+
fontSize: Math.max(11, size * 0.2),
|
|
110
|
+
}, children: children }))] }));
|
|
111
|
+
}
|