@jgengine/react 0.13.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 +529 -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/hudLayout.js +138 -9
- 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/panels.d.ts +10 -2
- package/dist/panels.js +9 -3
- 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/provider.d.ts +15 -0
- package/dist/provider.js +17 -1
- 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 +7 -3
- 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 +8 -3
- package/skills/jgengine-ui/api.md +732 -23
- package/skills/jgengine-ui/capabilities.md +371 -2
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
package/dist/codex.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
const ALL = "__all";
|
|
4
|
+
const MASK = "???";
|
|
5
|
+
/**
|
|
6
|
+
* Codex / bestiary gallery — category tabs, a responsive grid of entry cards
|
|
7
|
+
* (discovered vs. locked, secret entries masked until found), and a header
|
|
8
|
+
* summarizing completion. Feed it `codex.list()`; wire `onSelect` to a detail
|
|
9
|
+
* pane.
|
|
10
|
+
*
|
|
11
|
+
* @capability codex-gallery codex/bestiary gallery with category tabs, discovered/locked cards, secret masking, and a completion header
|
|
12
|
+
*/
|
|
13
|
+
export function Codex({ entries, title = "Codex", columns = 3, maskSecrets = true, renderIcon, onSelect, emptyLabel = "Nothing discovered yet.", className, style, }) {
|
|
14
|
+
const categories = useMemo(() => {
|
|
15
|
+
const seen = [];
|
|
16
|
+
for (const entry of entries)
|
|
17
|
+
if (entry.category !== undefined && !seen.includes(entry.category))
|
|
18
|
+
seen.push(entry.category);
|
|
19
|
+
return seen;
|
|
20
|
+
}, [entries]);
|
|
21
|
+
const [tab, setTab] = useState(ALL);
|
|
22
|
+
const shown = tab === ALL ? entries : entries.filter((entry) => entry.category === tab);
|
|
23
|
+
const discovered = entries.reduce((count, entry) => (entry.discovered ? count + 1 : count), 0);
|
|
24
|
+
const tabStyle = (active) => ({
|
|
25
|
+
borderRadius: 6,
|
|
26
|
+
border: `1px solid ${active ? "rgba(56,189,248,0.6)" : "rgba(148,163,184,0.35)"}`,
|
|
27
|
+
background: active ? "rgba(56,189,248,0.2)" : "transparent",
|
|
28
|
+
color: active ? "#bae6fd" : "rgba(226,232,240,0.75)",
|
|
29
|
+
padding: "3px 9px",
|
|
30
|
+
fontSize: 11,
|
|
31
|
+
fontWeight: 600,
|
|
32
|
+
cursor: "pointer",
|
|
33
|
+
});
|
|
34
|
+
return (_jsxs("div", { className: className, "data-codex": true, style: {
|
|
35
|
+
width: columns * 116 + 28,
|
|
36
|
+
borderRadius: 14,
|
|
37
|
+
padding: 14,
|
|
38
|
+
background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(11,14,19,0.97))",
|
|
39
|
+
border: "1px solid var(--jg-ring, rgba(148,163,184,0.32))",
|
|
40
|
+
color: "#e2e8f0",
|
|
41
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
42
|
+
...style,
|
|
43
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [discovered, "/", entries.length, " discovered"] })] }), categories.length > 0 ? (_jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 5, marginBottom: 10 }, children: [_jsx("button", { type: "button", "data-codex-tab": ALL, style: tabStyle(tab === ALL), onClick: () => setTab(ALL), children: "All" }), categories.map((category) => (_jsx("button", { type: "button", "data-codex-tab": category, style: tabStyle(tab === category), onClick: () => setTab(category), children: category }, category)))] })) : null, shown.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 8 }, children: shown.map((entry) => {
|
|
44
|
+
const masked = maskSecrets && entry.secret === true && !entry.discovered;
|
|
45
|
+
const name = masked ? MASK : entry.name;
|
|
46
|
+
const icon = renderIcon?.(entry) ?? (masked ? "🔒" : entry.discovered ? entry.icon ?? "📖" : "◻");
|
|
47
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", "data-codex-entry": entry.id, "data-discovered": entry.discovered, onClick: () => onSelect?.(entry.id), style: {
|
|
48
|
+
width: "100%",
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
gap: 4,
|
|
53
|
+
padding: "12px 6px",
|
|
54
|
+
borderRadius: 10,
|
|
55
|
+
border: `1px solid ${entry.discovered ? "rgba(56,189,248,0.35)" : "rgba(148,163,184,0.2)"}`,
|
|
56
|
+
background: entry.discovered ? "rgba(56,189,248,0.08)" : "rgba(148,163,184,0.05)",
|
|
57
|
+
color: "#e2e8f0",
|
|
58
|
+
cursor: onSelect !== undefined ? "pointer" : "default",
|
|
59
|
+
opacity: entry.discovered ? 1 : 0.7,
|
|
60
|
+
}, children: [_jsx("span", { style: { fontSize: 24, filter: entry.discovered ? "none" : "grayscale(1) brightness(0.7)" }, children: icon }), _jsx("span", { style: { fontSize: 11, fontWeight: entry.discovered ? 700 : 500, textAlign: "center" }, children: name }), entry.discovered && entry.description !== undefined ? (_jsx("span", { style: { fontSize: 10, color: "rgba(203,213,225,0.7)", textAlign: "center" }, children: entry.description })) : null] }) }, entry.id));
|
|
61
|
+
}) }))] }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { ComboMeter, ComboMeterView } from "@jgengine/core/combat/comboMeter";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a combo meter and re-render it each animation frame so the decay
|
|
5
|
+
* window bar drains live, returning the pooled view to draw right now. Reading the
|
|
6
|
+
* view each frame advances the meter's clock and fires window expiry, so a combo
|
|
7
|
+
* that times out visibly drops even with no further hits. The returned object is
|
|
8
|
+
* the model's pooled view — read its fields immediately, do not retain it.
|
|
9
|
+
*
|
|
10
|
+
* @capability use-combo-meter React hook binding a combo meter — re-renders per frame so the decay-window bar drains live and returns the current combo view
|
|
11
|
+
*/
|
|
12
|
+
export declare function useComboMeter(meter: ComboMeter, animate?: boolean): ComboMeterView;
|
|
13
|
+
/** Reskin tokens for {@link ComboMeterHud}. */
|
|
14
|
+
export interface ComboMeterTheme {
|
|
15
|
+
/** Accent used when no tier is active (below the first threshold). Default reads `--jg-accent`. */
|
|
16
|
+
accent?: string;
|
|
17
|
+
/** Combo count text color. Default near-white. */
|
|
18
|
+
count?: string;
|
|
19
|
+
/** Tier label + multiplier text color. Default reads the active tier color. */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** Font family. Default the HUD sans stack. */
|
|
22
|
+
fontFamily?: string;
|
|
23
|
+
/** Count font size (CSS length). Default `"clamp(2rem, 6vw, 4rem)"`. */
|
|
24
|
+
countSize?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Props for {@link ComboMeterHud}. */
|
|
27
|
+
export interface ComboMeterHudProps {
|
|
28
|
+
/** The combo meter to render. */
|
|
29
|
+
meter: ComboMeter;
|
|
30
|
+
/**
|
|
31
|
+
* Per-tier color keyed by the free-string tier id the game configured
|
|
32
|
+
* ("good"/"great"/"savage", …). The active tier's color drives the count glow,
|
|
33
|
+
* label, and draining window bar. Missing ids fall back to the accent.
|
|
34
|
+
*/
|
|
35
|
+
tierColors?: Record<string, string>;
|
|
36
|
+
/** Base reskin tokens. */
|
|
37
|
+
theme?: ComboMeterTheme;
|
|
38
|
+
/**
|
|
39
|
+
* Drive re-renders on an animation frame so the window bar drains live. Default
|
|
40
|
+
* `true`. Turn off if the game already re-renders the HUD each frame.
|
|
41
|
+
*/
|
|
42
|
+
animate?: boolean;
|
|
43
|
+
/** Hide the whole HUD while the combo count is zero. Default `true`. */
|
|
44
|
+
hideWhenEmpty?: boolean;
|
|
45
|
+
/** Width of the draining window bar (any CSS length). Default `220`. */
|
|
46
|
+
barWidth?: number | string;
|
|
47
|
+
className?: string;
|
|
48
|
+
style?: CSSProperties;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A drop-in HUD for a core {@link ComboMeter}: the big live combo count ("×12"),
|
|
52
|
+
* the current free-string tier label, the derived score multiplier, and a draining
|
|
53
|
+
* window bar (reusing the shared atomic bar) that empties as the decay window runs
|
|
54
|
+
* out and re-fills on every hit. The active tier's color — supplied by the game via
|
|
55
|
+
* `tierColors`, keyed on the free-string tier id the model never interprets — drives
|
|
56
|
+
* the count glow, the label, and the bar fill, so a game reskins the escalation
|
|
57
|
+
* ("good" → "great" → "savage") without the engine hardcoding any tier meaning.
|
|
58
|
+
* Presentation only: all counting, timing, tiers, and the multiplier live in the
|
|
59
|
+
* core model.
|
|
60
|
+
*
|
|
61
|
+
* @capability combo-meter-hud drop-in HUD for a core combo meter — big live count, free-string tier label, derived multiplier, and a draining decay-window bar, per-tier colored from a caller map and HudTheme-token skinnable
|
|
62
|
+
*/
|
|
63
|
+
export declare function ComboMeterHud({ meter, tierColors, theme, animate, hideWhenEmpty, barWidth, className, style, }: ComboMeterHudProps): ReactNode;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
3
|
+
import { StaminaBar } from "./bars.js";
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe to a combo meter and re-render it each animation frame so the decay
|
|
6
|
+
* window bar drains live, returning the pooled view to draw right now. Reading the
|
|
7
|
+
* view each frame advances the meter's clock and fires window expiry, so a combo
|
|
8
|
+
* that times out visibly drops even with no further hits. The returned object is
|
|
9
|
+
* the model's pooled view — read its fields immediately, do not retain it.
|
|
10
|
+
*
|
|
11
|
+
* @capability use-combo-meter React hook binding a combo meter — re-renders per frame so the decay-window bar drains live and returns the current combo view
|
|
12
|
+
*/
|
|
13
|
+
export function useComboMeter(meter, animate = true) {
|
|
14
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
15
|
+
const rafRef = useRef(0);
|
|
16
|
+
useEffect(() => meter.subscribe(bump), [meter]);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!animate)
|
|
19
|
+
return;
|
|
20
|
+
let running = true;
|
|
21
|
+
const loop = () => {
|
|
22
|
+
if (!running)
|
|
23
|
+
return;
|
|
24
|
+
bump();
|
|
25
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
26
|
+
};
|
|
27
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
28
|
+
return () => {
|
|
29
|
+
running = false;
|
|
30
|
+
cancelAnimationFrame(rafRef.current);
|
|
31
|
+
};
|
|
32
|
+
}, [meter, animate]);
|
|
33
|
+
return meter.view();
|
|
34
|
+
}
|
|
35
|
+
function resolveTheme(theme) {
|
|
36
|
+
return {
|
|
37
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
38
|
+
count: theme?.count ?? "#f8fafc",
|
|
39
|
+
label: theme?.label,
|
|
40
|
+
fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
41
|
+
countSize: theme?.countSize ?? "clamp(2rem, 6vw, 4rem)",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A drop-in HUD for a core {@link ComboMeter}: the big live combo count ("×12"),
|
|
46
|
+
* the current free-string tier label, the derived score multiplier, and a draining
|
|
47
|
+
* window bar (reusing the shared atomic bar) that empties as the decay window runs
|
|
48
|
+
* out and re-fills on every hit. The active tier's color — supplied by the game via
|
|
49
|
+
* `tierColors`, keyed on the free-string tier id the model never interprets — drives
|
|
50
|
+
* the count glow, the label, and the bar fill, so a game reskins the escalation
|
|
51
|
+
* ("good" → "great" → "savage") without the engine hardcoding any tier meaning.
|
|
52
|
+
* Presentation only: all counting, timing, tiers, and the multiplier live in the
|
|
53
|
+
* core model.
|
|
54
|
+
*
|
|
55
|
+
* @capability combo-meter-hud drop-in HUD for a core combo meter — big live count, free-string tier label, derived multiplier, and a draining decay-window bar, per-tier colored from a caller map and HudTheme-token skinnable
|
|
56
|
+
*/
|
|
57
|
+
export function ComboMeterHud({ meter, tierColors, theme, animate = true, hideWhenEmpty = true, barWidth = 220, className, style, }) {
|
|
58
|
+
const view = useComboMeter(meter, animate);
|
|
59
|
+
const t = resolveTheme(theme);
|
|
60
|
+
if (hideWhenEmpty && view.count <= 0)
|
|
61
|
+
return null;
|
|
62
|
+
const tierColor = view.tier !== null ? tierColors?.[view.tier] ?? t.accent : t.accent;
|
|
63
|
+
const labelColor = t.label ?? tierColor;
|
|
64
|
+
return (_jsxs("div", { className: className, "data-combo-meter": "", "data-combo-tier": view.tier ?? "", "data-combo-count": view.count, style: {
|
|
65
|
+
display: "inline-flex",
|
|
66
|
+
flexDirection: "column",
|
|
67
|
+
alignItems: "flex-start",
|
|
68
|
+
gap: 6,
|
|
69
|
+
fontFamily: t.fontFamily,
|
|
70
|
+
...style,
|
|
71
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 12 }, children: [_jsxs("span", { "data-combo-count-value": "", style: {
|
|
72
|
+
fontSize: t.countSize,
|
|
73
|
+
fontWeight: 900,
|
|
74
|
+
lineHeight: 1,
|
|
75
|
+
letterSpacing: "0.02em",
|
|
76
|
+
fontVariantNumeric: "tabular-nums",
|
|
77
|
+
color: t.count,
|
|
78
|
+
textShadow: `0 0 0.4em ${tierColor}, 0 3px 14px rgba(0,0,0,0.6)`,
|
|
79
|
+
}, children: ["\u00D7", view.count] }), view.tier !== null ? (_jsx("span", { "data-combo-tier-label": "", style: {
|
|
80
|
+
fontSize: "clamp(0.9rem, 2.4vw, 1.4rem)",
|
|
81
|
+
fontWeight: 800,
|
|
82
|
+
letterSpacing: "0.14em",
|
|
83
|
+
textTransform: "uppercase",
|
|
84
|
+
color: labelColor,
|
|
85
|
+
textShadow: "0 2px 10px rgba(0,0,0,0.6)",
|
|
86
|
+
}, children: view.tier })) : null] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx(StaminaBar, { value: view.fraction * 100, max: 100, fill: tierColor, showValue: false, width: barWidth, shape: "pill" }), _jsxs("span", { "data-combo-multiplier": "", style: {
|
|
87
|
+
fontSize: 14,
|
|
88
|
+
fontWeight: 800,
|
|
89
|
+
fontVariantNumeric: "tabular-nums",
|
|
90
|
+
color: labelColor,
|
|
91
|
+
textShadow: "0 1px 6px rgba(0,0,0,0.6)",
|
|
92
|
+
}, children: [view.multiplier.toFixed(2), "\u00D7 score"] })] })] }));
|
|
93
|
+
}
|
package/dist/components.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
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
|
+
import { lootDropsOf } from "@jgengine/core/game/events";
|
|
5
6
|
import { rollCheck } from "@jgengine/core/stats/rollCheck";
|
|
6
7
|
import { dialogueSlot } from "@jgengine/core/game/dialogue";
|
|
7
8
|
import { useGameContext } from "./provider.js";
|
|
@@ -142,9 +143,9 @@ export function CaptureOdds({ chance, className, fillClassName, }) {
|
|
|
142
143
|
return (_jsxs("div", { className: className, role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "data-capture-odds": percent, children: [_jsx("div", { className: fillClassName, style: { width: `${percent}%`, height: "100%" } }), _jsxs("span", { "data-capture-odds-label": true, style: { position: "relative", zIndex: 1 }, children: [percent, "%"] })] }));
|
|
143
144
|
}
|
|
144
145
|
function defaultToast(entry) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
return
|
|
146
|
+
const drops = lootDropsOf(entry.data);
|
|
147
|
+
if (drops.length > 0) {
|
|
148
|
+
return drops.map((drop) => `${drop.item ?? drop.currency ?? "item"} ×${drop.count}`).join(" ");
|
|
148
149
|
}
|
|
149
150
|
return typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data);
|
|
150
151
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { CreditsDocument, CreditsEntry } from "@jgengine/core/game/credits";
|
|
3
|
+
/**
|
|
4
|
+
* Renders a {@link CreditsDocument} as headed sections of credited lines. Skinned entirely through
|
|
5
|
+
* the `--jg-*` HudTheme tokens, so it inherits the game's look from the surrounding theme block;
|
|
6
|
+
* pass `renderEntry` to art-direct the line itself. Links render only when an entry carries `href`,
|
|
7
|
+
* and always open in a new tab with `noreferrer`.
|
|
8
|
+
*
|
|
9
|
+
* This is a building block, not a finished credits screen — the game owns placement, backdrop, the
|
|
10
|
+
* back affordance, and whether it scrolls or rolls.
|
|
11
|
+
*
|
|
12
|
+
* @capability credits-screen drop-in credits view over a CreditsDocument — headed sections, optional links, HudTheme-skinned
|
|
13
|
+
*/
|
|
14
|
+
export declare function CreditsScreen({ document, className, style, renderEntry, }: {
|
|
15
|
+
document: CreditsDocument;
|
|
16
|
+
className?: string;
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
renderEntry?: (entry: CreditsEntry, index: number) => ReactNode;
|
|
19
|
+
}): ReactNode;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import {} from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Renders a {@link CreditsDocument} as headed sections of credited lines. Skinned entirely through
|
|
5
|
+
* the `--jg-*` HudTheme tokens, so it inherits the game's look from the surrounding theme block;
|
|
6
|
+
* pass `renderEntry` to art-direct the line itself. Links render only when an entry carries `href`,
|
|
7
|
+
* and always open in a new tab with `noreferrer`.
|
|
8
|
+
*
|
|
9
|
+
* This is a building block, not a finished credits screen — the game owns placement, backdrop, the
|
|
10
|
+
* back affordance, and whether it scrolls or rolls.
|
|
11
|
+
*
|
|
12
|
+
* @capability credits-screen drop-in credits view over a CreditsDocument — headed sections, optional links, HudTheme-skinned
|
|
13
|
+
*/
|
|
14
|
+
export function CreditsScreen({ document, className, style, renderEntry, }) {
|
|
15
|
+
return (_jsxs("div", { className: className, "data-jg": "credits-screen", style: { display: "flex", flexDirection: "column", gap: 22, color: "var(--jg-text, #f5f7fa)", ...style }, children: [document.title !== undefined && (_jsx("h2", { style: {
|
|
16
|
+
margin: 0,
|
|
17
|
+
fontFamily: "var(--jg-font-display, inherit)",
|
|
18
|
+
fontSize: 26,
|
|
19
|
+
fontWeight: 800,
|
|
20
|
+
letterSpacing: "0.24em",
|
|
21
|
+
textTransform: "uppercase",
|
|
22
|
+
}, children: document.title })), document.sections.map((section) => (_jsxs("section", { "data-jg": "credits-section", style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("h3", { style: {
|
|
23
|
+
margin: 0,
|
|
24
|
+
fontFamily: "var(--jg-font-display, inherit)",
|
|
25
|
+
fontSize: 11,
|
|
26
|
+
fontWeight: 700,
|
|
27
|
+
letterSpacing: "0.24em",
|
|
28
|
+
textTransform: "uppercase",
|
|
29
|
+
color: "var(--jg-accent, #38bdf8)",
|
|
30
|
+
}, children: section.heading }), section.entries.map((entry, index) => (_jsx("div", { "data-jg": "credits-entry", style: { display: "flex", gap: 10, alignItems: "baseline" }, children: renderEntry !== undefined ? (renderEntry(entry, index)) : (_jsxs(_Fragment, { children: [entry.href === undefined ? (_jsx("span", { style: { fontSize: 14 }, children: entry.label })) : (_jsx("a", { href: entry.href, target: "_blank", rel: "noreferrer", style: { fontSize: 14, color: "inherit", textDecoration: "underline" }, children: entry.label })), entry.detail !== undefined && (_jsx("span", { style: { fontSize: 12, color: "var(--jg-text-dim, #94a3b8)" }, children: entry.detail }))] })) }, `${entry.label}-${index}`)))] }, section.heading))), document.notice !== undefined && (_jsx("p", { style: { margin: 0, fontSize: 12, color: "var(--jg-text-dim, #94a3b8)", whiteSpace: "pre-line" }, children: document.notice }))] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { SequenceDirector, SequenceState } from "@jgengine/core/scene/sequenceDirector";
|
|
3
|
+
/** The live playback state plus bound controls returned by {@link useSequenceDirector}. */
|
|
4
|
+
export interface SequenceDirectorBinding extends SequenceState {
|
|
5
|
+
/** Start or resume playback. */
|
|
6
|
+
play: () => void;
|
|
7
|
+
/** Freeze the playhead. */
|
|
8
|
+
pause: () => void;
|
|
9
|
+
/** Fast-forward to the end, emitting remaining cues. */
|
|
10
|
+
skip: () => void;
|
|
11
|
+
/** Jump the playhead to `ms`. */
|
|
12
|
+
seek: (ms: number) => void;
|
|
13
|
+
/** Halt and rewind to the start. */
|
|
14
|
+
stop: () => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Bind a {@link SequenceDirector} to a component: subscribe to its state, and while
|
|
18
|
+
* it is playing run a `requestAnimationFrame` loop that ticks the director each frame
|
|
19
|
+
* off the same injected clock — so a game drops in one hook and the cutscene advances
|
|
20
|
+
* itself. Returns the current playback state (playhead, progress, playing/done) plus
|
|
21
|
+
* bound `play`/`pause`/`skip`/`seek`/`stop`. Cue side effects belong on
|
|
22
|
+
* `director.onCue`, not here — the hook never interprets a cue.
|
|
23
|
+
*
|
|
24
|
+
* @capability use-sequence-director React hook that drives a cutscene director's per-frame tick loop and exposes playhead/progress + play/pause/skip controls
|
|
25
|
+
*/
|
|
26
|
+
export declare function useSequenceDirector(director: SequenceDirector): SequenceDirectorBinding;
|
|
27
|
+
/** Reskin tokens for {@link CutsceneLetterbox}. */
|
|
28
|
+
export interface CutsceneLetterboxTheme {
|
|
29
|
+
/** Cinematic bar color. Default solid black. */
|
|
30
|
+
barColor?: string;
|
|
31
|
+
/** Height of each bar as a fraction of the viewport height, in `[0, 0.5]`. Default `0.12`. */
|
|
32
|
+
barFraction?: number;
|
|
33
|
+
/** Caption panel background. */
|
|
34
|
+
captionBackground?: string;
|
|
35
|
+
/** Caption text color. */
|
|
36
|
+
captionText?: string;
|
|
37
|
+
/** Skip button accent. Default reads `--jg-accent`. */
|
|
38
|
+
accent?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Props for {@link CutsceneLetterbox}. */
|
|
41
|
+
export interface CutsceneLetterboxProps {
|
|
42
|
+
/** When `true`, the cinematic bars are drawn in; when `false` they retract off-screen. */
|
|
43
|
+
active: boolean;
|
|
44
|
+
/** Optional caption / dialogue node shown centered above the lower bar. */
|
|
45
|
+
caption?: ReactNode;
|
|
46
|
+
/** Skip handler. When provided, a Skip button renders in the top bar. */
|
|
47
|
+
onSkip?: () => void;
|
|
48
|
+
/** Skip button label. Default `"Skip"`. */
|
|
49
|
+
skipLabel?: string;
|
|
50
|
+
/** Elapsed fraction `[0, 1]` for the thin progress line under the caption; omit to hide it. */
|
|
51
|
+
progress?: number;
|
|
52
|
+
/** Reskin tokens. */
|
|
53
|
+
theme?: CutsceneLetterboxTheme;
|
|
54
|
+
className?: string;
|
|
55
|
+
style?: CSSProperties;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A drop-in cinematic letterbox overlay: black bars slide in top and bottom while a
|
|
59
|
+
* cutscene is `active`, an optional caption/dialogue line sits above the lower bar with
|
|
60
|
+
* an optional progress line, and a Skip button rides the top bar. Presentation only —
|
|
61
|
+
* it never touches the director; wire `active`/`caption`/`progress`/`onSkip` from
|
|
62
|
+
* {@link useSequenceDirector} and your `onCue` handler. Reskin via {@link CutsceneLetterboxTheme}.
|
|
63
|
+
*
|
|
64
|
+
* @capability cutscene-letterbox reskinnable cinematic letterbox + skip overlay for a cutscene — animated bars, caption/dialogue slot, progress line, Skip button
|
|
65
|
+
*/
|
|
66
|
+
export declare function CutsceneLetterbox({ active, caption, onSkip, skipLabel, progress, theme, className, style, }: CutsceneLetterboxProps): ReactNode;
|
package/dist/cutscene.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Bind a {@link SequenceDirector} to a component: subscribe to its state, and while
|
|
5
|
+
* it is playing run a `requestAnimationFrame` loop that ticks the director each frame
|
|
6
|
+
* off the same injected clock — so a game drops in one hook and the cutscene advances
|
|
7
|
+
* itself. Returns the current playback state (playhead, progress, playing/done) plus
|
|
8
|
+
* bound `play`/`pause`/`skip`/`seek`/`stop`. Cue side effects belong on
|
|
9
|
+
* `director.onCue`, not here — the hook never interprets a cue.
|
|
10
|
+
*
|
|
11
|
+
* @capability use-sequence-director React hook that drives a cutscene director's per-frame tick loop and exposes playhead/progress + play/pause/skip controls
|
|
12
|
+
*/
|
|
13
|
+
export function useSequenceDirector(director) {
|
|
14
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
15
|
+
useEffect(() => director.subscribe(bump), [director]);
|
|
16
|
+
const playing = director.state().playing;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!playing || typeof requestAnimationFrame === "undefined")
|
|
19
|
+
return;
|
|
20
|
+
let raf = 0;
|
|
21
|
+
const frame = () => {
|
|
22
|
+
director.tick();
|
|
23
|
+
raf = requestAnimationFrame(frame);
|
|
24
|
+
};
|
|
25
|
+
raf = requestAnimationFrame(frame);
|
|
26
|
+
return () => cancelAnimationFrame(raf);
|
|
27
|
+
}, [director, playing]);
|
|
28
|
+
const state = director.state();
|
|
29
|
+
return {
|
|
30
|
+
...state,
|
|
31
|
+
play: () => director.play(),
|
|
32
|
+
pause: () => director.pause(),
|
|
33
|
+
skip: () => director.skip(),
|
|
34
|
+
seek: (ms) => director.seek(ms),
|
|
35
|
+
stop: () => director.stop(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function resolveTheme(theme) {
|
|
39
|
+
return {
|
|
40
|
+
barColor: theme?.barColor ?? "#000",
|
|
41
|
+
barFraction: Math.max(0, Math.min(0.5, theme?.barFraction ?? 0.12)),
|
|
42
|
+
captionBackground: theme?.captionBackground ?? "rgba(6,9,14,0.62)",
|
|
43
|
+
captionText: theme?.captionText ?? "#f1f5f9",
|
|
44
|
+
accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A drop-in cinematic letterbox overlay: black bars slide in top and bottom while a
|
|
49
|
+
* cutscene is `active`, an optional caption/dialogue line sits above the lower bar with
|
|
50
|
+
* an optional progress line, and a Skip button rides the top bar. Presentation only —
|
|
51
|
+
* it never touches the director; wire `active`/`caption`/`progress`/`onSkip` from
|
|
52
|
+
* {@link useSequenceDirector} and your `onCue` handler. Reskin via {@link CutsceneLetterboxTheme}.
|
|
53
|
+
*
|
|
54
|
+
* @capability cutscene-letterbox reskinnable cinematic letterbox + skip overlay for a cutscene — animated bars, caption/dialogue slot, progress line, Skip button
|
|
55
|
+
*/
|
|
56
|
+
export function CutsceneLetterbox({ active, caption, onSkip, skipLabel = "Skip", progress, theme, className, style, }) {
|
|
57
|
+
const t = resolveTheme(theme);
|
|
58
|
+
const barHeight = `${(t.barFraction * 100).toFixed(3)}vh`;
|
|
59
|
+
const shift = active ? "0%" : "-100%";
|
|
60
|
+
const shiftDown = active ? "0%" : "100%";
|
|
61
|
+
return (_jsxs("div", { className: className, "data-cutscene-letterbox": active ? "active" : "idle", style: {
|
|
62
|
+
position: "fixed",
|
|
63
|
+
inset: 0,
|
|
64
|
+
zIndex: 70,
|
|
65
|
+
pointerEvents: "none",
|
|
66
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
67
|
+
...style,
|
|
68
|
+
}, children: [_jsx("div", { "data-cutscene-bar": "top", style: {
|
|
69
|
+
position: "absolute",
|
|
70
|
+
top: 0,
|
|
71
|
+
left: 0,
|
|
72
|
+
right: 0,
|
|
73
|
+
height: barHeight,
|
|
74
|
+
background: t.barColor,
|
|
75
|
+
transform: `translateY(${shift})`,
|
|
76
|
+
transition: "transform 500ms cubic-bezier(0.22,1,0.36,1)",
|
|
77
|
+
display: "flex",
|
|
78
|
+
alignItems: "center",
|
|
79
|
+
justifyContent: "flex-end",
|
|
80
|
+
padding: "0 20px",
|
|
81
|
+
}, children: onSkip !== undefined ? (_jsxs("button", { type: "button", "data-cutscene-skip": true, onClick: onSkip, style: {
|
|
82
|
+
pointerEvents: "auto",
|
|
83
|
+
display: "inline-flex",
|
|
84
|
+
alignItems: "center",
|
|
85
|
+
gap: 6,
|
|
86
|
+
border: "1px solid rgba(148,163,184,0.4)",
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
background: "rgba(15,23,42,0.7)",
|
|
89
|
+
color: t.accent,
|
|
90
|
+
fontSize: 12.5,
|
|
91
|
+
fontWeight: 700,
|
|
92
|
+
letterSpacing: 0.4,
|
|
93
|
+
padding: "6px 14px",
|
|
94
|
+
cursor: "pointer",
|
|
95
|
+
}, children: [skipLabel, " ", _jsx("span", { "aria-hidden": true, style: { opacity: 0.8 }, children: "\u23ED" })] })) : null }), _jsx("div", { "data-cutscene-bar": "bottom", style: {
|
|
96
|
+
position: "absolute",
|
|
97
|
+
bottom: 0,
|
|
98
|
+
left: 0,
|
|
99
|
+
right: 0,
|
|
100
|
+
height: barHeight,
|
|
101
|
+
background: t.barColor,
|
|
102
|
+
transform: `translateY(${shiftDown})`,
|
|
103
|
+
transition: "transform 500ms cubic-bezier(0.22,1,0.36,1)",
|
|
104
|
+
} }), active && (caption !== undefined || progress !== undefined) ? (_jsxs("div", { "data-cutscene-caption": true, style: {
|
|
105
|
+
position: "absolute",
|
|
106
|
+
left: "50%",
|
|
107
|
+
bottom: `calc(${barHeight} + 28px)`,
|
|
108
|
+
transform: "translateX(-50%)",
|
|
109
|
+
maxWidth: "min(78vw, 720px)",
|
|
110
|
+
textAlign: "center",
|
|
111
|
+
color: t.captionText,
|
|
112
|
+
}, children: [caption !== undefined ? (_jsx("div", { style: {
|
|
113
|
+
background: t.captionBackground,
|
|
114
|
+
borderRadius: 12,
|
|
115
|
+
padding: "12px 20px",
|
|
116
|
+
fontSize: 18,
|
|
117
|
+
lineHeight: 1.5,
|
|
118
|
+
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
|
|
119
|
+
boxShadow: "0 10px 40px rgba(0,0,0,0.45)",
|
|
120
|
+
}, children: caption })) : null, progress !== undefined ? (_jsx("div", { "data-cutscene-progress": true, style: {
|
|
121
|
+
marginTop: 10,
|
|
122
|
+
height: 3,
|
|
123
|
+
borderRadius: 3,
|
|
124
|
+
background: "rgba(148,163,184,0.25)",
|
|
125
|
+
overflow: "hidden",
|
|
126
|
+
}, children: _jsx("div", { style: {
|
|
127
|
+
height: "100%",
|
|
128
|
+
width: `${Math.max(0, Math.min(1, progress)) * 100}%`,
|
|
129
|
+
background: t.accent,
|
|
130
|
+
transition: "width 120ms linear",
|
|
131
|
+
} }) })) : null] })) : null] }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { DamageDirectionTracker, DamageIndicator } from "@jgengine/core/vfx/damageDirection";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a damage-direction tracker and animate. Because indicators fade
|
|
5
|
+
* continuously on a clock (not just on discrete events), this drives a
|
|
6
|
+
* `requestAnimationFrame` loop while any indicator is live and stops when the
|
|
7
|
+
* screen is clear, then wakes again on the next hit. Returns the tracker's
|
|
8
|
+
* current `active()` array (reused between frames — copy if you retain it).
|
|
9
|
+
*
|
|
10
|
+
* @capability use-damage-direction React hook that animates a damage-direction tracker's fade with requestAnimationFrame and re-renders its live indicators
|
|
11
|
+
*/
|
|
12
|
+
export declare function useDamageDirection(tracker: DamageDirectionTracker): readonly DamageIndicator[];
|
|
13
|
+
/** Reskin tokens for {@link DamageDirectionOverlay}. */
|
|
14
|
+
export interface DamageDirectionTheme {
|
|
15
|
+
/** Fallback arc color when a `kind` isn't in {@link DamageDirectionOverlayProps.colors}. Default reads `--jg-health` (danger red). */
|
|
16
|
+
color?: string;
|
|
17
|
+
/** Radius of the arc band from screen center, in px. Default `120`. */
|
|
18
|
+
radius?: number;
|
|
19
|
+
/** Angular half-width of each arc, in radians. Default `0.42` (~48° wide). */
|
|
20
|
+
arcSpread?: number;
|
|
21
|
+
/** Arc stroke thickness in px at full intensity. Default `18`. */
|
|
22
|
+
thickness?: number;
|
|
23
|
+
/** Extra px an arc pushes outward as it flares (scale feel). Default `18`. */
|
|
24
|
+
flare?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Props for {@link DamageDirectionOverlay}. */
|
|
27
|
+
export interface DamageDirectionOverlayProps {
|
|
28
|
+
/** The tracker to visualize. */
|
|
29
|
+
tracker: DamageDirectionTracker;
|
|
30
|
+
/** Per-`kind` arc colors, e.g. `{ fire: "#ff7a1a", crit: "#ffd23f" }`. Falls back to the theme color. */
|
|
31
|
+
colors?: Record<string, string>;
|
|
32
|
+
/** Draw a small dot at screen center so bearings read against a fixed reference. Default `true`. */
|
|
33
|
+
showReticle?: boolean;
|
|
34
|
+
/** Reskin tokens. */
|
|
35
|
+
theme?: DamageDirectionTheme;
|
|
36
|
+
className?: string;
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A drop-in "hit-from" overlay: transient red (by default) arcs that flare around
|
|
41
|
+
* a center reticle in the direction each recent hit came from and fade out over
|
|
42
|
+
* time, driven entirely by a {@link DamageDirectionTracker}. Opacity and outward
|
|
43
|
+
* push scale with each indicator's eased intensity; color comes from the hit's
|
|
44
|
+
* `kind` (via `colors`) or the theme's danger color. Renders full-screen,
|
|
45
|
+
* `pointer-events: none`, so it never intercepts input — a game drops it into its
|
|
46
|
+
* HUD and calls `tracker.registerHit(...)` on damage. Reskin via
|
|
47
|
+
* {@link DamageDirectionTheme}.
|
|
48
|
+
*
|
|
49
|
+
* @capability damage-direction-overlay drop-in hit-from damage-direction overlay — SVG arcs flaring around a center reticle toward each recent hit and fading over time, colored per `kind`/HudTheme, pointer-events none
|
|
50
|
+
*/
|
|
51
|
+
export declare function DamageDirectionOverlay({ tracker, colors, showReticle, theme, className, style, }: DamageDirectionOverlayProps): ReactNode;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a damage-direction tracker and animate. Because indicators fade
|
|
5
|
+
* continuously on a clock (not just on discrete events), this drives a
|
|
6
|
+
* `requestAnimationFrame` loop while any indicator is live and stops when the
|
|
7
|
+
* screen is clear, then wakes again on the next hit. Returns the tracker's
|
|
8
|
+
* current `active()` array (reused between frames — copy if you retain it).
|
|
9
|
+
*
|
|
10
|
+
* @capability use-damage-direction React hook that animates a damage-direction tracker's fade with requestAnimationFrame and re-renders its live indicators
|
|
11
|
+
*/
|
|
12
|
+
export function useDamageDirection(tracker) {
|
|
13
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
14
|
+
const rafRef = useRef(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let stopped = false;
|
|
17
|
+
const tick = () => {
|
|
18
|
+
if (stopped)
|
|
19
|
+
return;
|
|
20
|
+
bump();
|
|
21
|
+
rafRef.current = tracker.count() > 0 ? requestAnimationFrame(tick) : null;
|
|
22
|
+
};
|
|
23
|
+
// Any change (new hit, clear, restore) paints immediately and (re)starts the
|
|
24
|
+
// fade loop if it had idled — so a hit shows even when rAF is throttled.
|
|
25
|
+
const off = tracker.subscribe(() => {
|
|
26
|
+
bump();
|
|
27
|
+
if (rafRef.current === null)
|
|
28
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
29
|
+
});
|
|
30
|
+
if (tracker.count() > 0)
|
|
31
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
32
|
+
return () => {
|
|
33
|
+
stopped = true;
|
|
34
|
+
off();
|
|
35
|
+
if (rafRef.current !== null)
|
|
36
|
+
cancelAnimationFrame(rafRef.current);
|
|
37
|
+
};
|
|
38
|
+
}, [tracker]);
|
|
39
|
+
return tracker.active();
|
|
40
|
+
}
|
|
41
|
+
const VIEWBOX = 300;
|
|
42
|
+
const CENTER = VIEWBOX / 2;
|
|
43
|
+
/** SVG path for an annular arc segment centered on the reticle at `angle` (radians, 0 = up, clockwise). */
|
|
44
|
+
function arcPath(angle, radius, half, thickness) {
|
|
45
|
+
const inner = Math.max(0, radius - thickness / 2);
|
|
46
|
+
const outer = radius + thickness / 2;
|
|
47
|
+
// Screen bearing: 0 rad points up (−Y), increasing clockwise.
|
|
48
|
+
const a0 = angle - half;
|
|
49
|
+
const a1 = angle + half;
|
|
50
|
+
const p = (a, r) => [
|
|
51
|
+
CENTER + Math.sin(a) * r,
|
|
52
|
+
CENTER - Math.cos(a) * r,
|
|
53
|
+
];
|
|
54
|
+
const [ox0, oy0] = p(a0, outer);
|
|
55
|
+
const [ox1, oy1] = p(a1, outer);
|
|
56
|
+
const [ix1, iy1] = p(a1, inner);
|
|
57
|
+
const [ix0, iy0] = p(a0, inner);
|
|
58
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
59
|
+
return [
|
|
60
|
+
`M ${ox0.toFixed(2)} ${oy0.toFixed(2)}`,
|
|
61
|
+
`A ${outer} ${outer} 0 ${large} 1 ${ox1.toFixed(2)} ${oy1.toFixed(2)}`,
|
|
62
|
+
`L ${ix1.toFixed(2)} ${iy1.toFixed(2)}`,
|
|
63
|
+
`A ${inner} ${inner} 0 ${large} 0 ${ix0.toFixed(2)} ${iy0.toFixed(2)}`,
|
|
64
|
+
"Z",
|
|
65
|
+
].join(" ");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A drop-in "hit-from" overlay: transient red (by default) arcs that flare around
|
|
69
|
+
* a center reticle in the direction each recent hit came from and fade out over
|
|
70
|
+
* time, driven entirely by a {@link DamageDirectionTracker}. Opacity and outward
|
|
71
|
+
* push scale with each indicator's eased intensity; color comes from the hit's
|
|
72
|
+
* `kind` (via `colors`) or the theme's danger color. Renders full-screen,
|
|
73
|
+
* `pointer-events: none`, so it never intercepts input — a game drops it into its
|
|
74
|
+
* HUD and calls `tracker.registerHit(...)` on damage. Reskin via
|
|
75
|
+
* {@link DamageDirectionTheme}.
|
|
76
|
+
*
|
|
77
|
+
* @capability damage-direction-overlay drop-in hit-from damage-direction overlay — SVG arcs flaring around a center reticle toward each recent hit and fading over time, colored per `kind`/HudTheme, pointer-events none
|
|
78
|
+
*/
|
|
79
|
+
export function DamageDirectionOverlay({ tracker, colors, showReticle = true, theme, className, style, }) {
|
|
80
|
+
const indicators = useDamageDirection(tracker);
|
|
81
|
+
const color = theme?.color ?? "var(--jg-health, #e5484d)";
|
|
82
|
+
const radius = theme?.radius ?? 120;
|
|
83
|
+
const arcSpread = theme?.arcSpread ?? 0.42;
|
|
84
|
+
const thickness = theme?.thickness ?? 18;
|
|
85
|
+
const flare = theme?.flare ?? 18;
|
|
86
|
+
return (_jsx("div", { className: className, "data-damage-direction": true, style: { position: "fixed", inset: 0, zIndex: 55, pointerEvents: "none", ...style }, children: _jsxs("svg", { viewBox: `0 0 ${VIEWBOX} ${VIEWBOX}`, preserveAspectRatio: "xMidYMid meet", "aria-hidden": true, style: {
|
|
87
|
+
position: "absolute",
|
|
88
|
+
top: "50%",
|
|
89
|
+
left: "50%",
|
|
90
|
+
width: (radius + thickness + flare) * 2,
|
|
91
|
+
height: (radius + thickness + flare) * 2,
|
|
92
|
+
transform: "translate(-50%, -50%)",
|
|
93
|
+
overflow: "visible",
|
|
94
|
+
}, children: [indicators.map((indicator, i) => {
|
|
95
|
+
const eased = indicator.intensity;
|
|
96
|
+
if (eased <= 0)
|
|
97
|
+
return null;
|
|
98
|
+
const fill = colors?.[indicator.kind] ?? color;
|
|
99
|
+
const r = radius + flare * eased;
|
|
100
|
+
const th = thickness * (0.55 + 0.45 * eased);
|
|
101
|
+
return (_jsx("path", { "data-damage-arc": indicator.kind, d: arcPath(indicator.angle, r, arcSpread, th), fill: fill, opacity: Math.min(1, 0.15 + 0.85 * eased), style: { filter: `drop-shadow(0 0 ${6 * eased}px ${fill})` } }, i));
|
|
102
|
+
}), showReticle ? (_jsx("circle", { "data-damage-reticle": true, cx: CENTER, cy: CENTER, r: 3.5, fill: "none", stroke: "var(--jg-ring, rgba(226,232,240,0.7))", strokeWidth: 2 })) : null] }) }));
|
|
103
|
+
}
|