@jgengine/react 0.10.0 → 0.12.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 +39 -2
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +294 -0
- package/dist/bars.d.ts +103 -0
- package/dist/bars.js +180 -0
- package/dist/barsPreview.d.ts +4 -0
- package/dist/barsPreview.js +47 -0
- package/dist/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +3 -1
- package/dist/chat.js +2 -2
- package/dist/components.d.ts +28 -1
- package/dist/components.js +36 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +1 -0
- package/dist/engineStore.d.ts +3 -0
- package/dist/engineStore.js +3 -0
- package/dist/fogOverlay.d.ts +3 -0
- package/dist/fogOverlay.js +3 -0
- package/dist/gameViewport.js +16 -0
- package/dist/hooks.d.ts +40 -0
- package/dist/hooks.js +69 -1
- package/dist/hud.d.ts +104 -0
- package/dist/hud.js +155 -0
- package/dist/hudFrame.d.ts +47 -0
- package/dist/hudFrame.js +80 -0
- package/dist/hudLayout.d.ts +31 -2
- package/dist/hudLayout.js +127 -10
- package/dist/hudLayoutPersist.d.ts +23 -0
- package/dist/hudLayoutPersist.js +19 -0
- package/dist/hudTheme.d.ts +76 -0
- package/dist/hudTheme.js +156 -0
- package/dist/hudThemePreview.d.ts +4 -0
- package/dist/hudThemePreview.js +37 -0
- package/dist/iconTreatment.d.ts +50 -0
- package/dist/iconTreatment.js +84 -0
- package/dist/iconsPreview.d.ts +4 -0
- package/dist/iconsPreview.js +29 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +141 -11
- package/dist/map.js +138 -16
- package/dist/save.d.ts +25 -0
- package/dist/save.js +37 -0
- package/dist/selectSnapshot.d.ts +2 -0
- package/dist/selectSnapshot.js +2 -0
- package/dist/selectionHud.d.ts +139 -0
- package/dist/selectionHud.js +188 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/slots.d.ts +44 -0
- package/dist/slots.js +37 -0
- package/dist/startScreen.d.ts +62 -0
- package/dist/startScreen.js +62 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +9 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +54 -0
- package/skills/jgengine/api.md +527 -0
- package/skills/jgengine/capabilities.md +131 -0
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +41 -0
- package/skills/jgengine-ui/api.md +2007 -0
- package/skills/jgengine-ui/capabilities.md +211 -0
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +513 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1724
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# @jgengine/react
|
|
2
2
|
|
|
3
|
-
React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`.
|
|
3
|
+
React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`. Apache-2.0.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
|
|
3
|
+
import { type ActionDef, type ResolvedAction } from "@jgengine/core/ui/actionModel";
|
|
4
|
+
import { type PopoverSide, type TooltipContent } from "@jgengine/core/ui/tooltipModel";
|
|
5
|
+
/**
|
|
6
|
+
* Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
|
|
7
|
+
* the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
|
|
8
|
+
* radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
|
|
9
|
+
* is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
|
|
10
|
+
* directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
|
|
11
|
+
* chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
|
|
15
|
+
* `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
|
|
16
|
+
* and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
|
|
17
|
+
* hotkey, description, resource name). Pure, so it is unit-testable.
|
|
18
|
+
*
|
|
19
|
+
* @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
|
|
20
|
+
*/
|
|
21
|
+
export declare function actionFromAbilitySlot(slot: AbilitySlotSnapshot, extra?: Partial<ActionDef> & {
|
|
22
|
+
resourceId?: string;
|
|
23
|
+
}): ActionDef;
|
|
24
|
+
/** The intent a key press resolves to over an action bar: move focus, activate, or ignore. */
|
|
25
|
+
export interface ActionBarKeyResult {
|
|
26
|
+
handled: boolean;
|
|
27
|
+
focusId?: string;
|
|
28
|
+
activateId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
|
|
32
|
+
* activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
|
|
33
|
+
* the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
|
|
34
|
+
* thin shell over it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function actionBarKeyAction(actions: readonly ResolvedAction[], focusedId: string | null, columns: number, key: string, options?: {
|
|
37
|
+
wrap?: boolean;
|
|
38
|
+
}): ActionBarKeyResult;
|
|
39
|
+
/** A minimal keyboard-event shape — a real `KeyboardEvent`/React synthetic satisfies it. */
|
|
40
|
+
export interface ActionKeyEvent {
|
|
41
|
+
key: string;
|
|
42
|
+
preventDefault(): void;
|
|
43
|
+
}
|
|
44
|
+
/** Options for {@link useActionBar}. */
|
|
45
|
+
export interface UseActionBarOptions {
|
|
46
|
+
/** Grid width for keyboard focus math and the default grid renderer (default: number of actions). */
|
|
47
|
+
columns?: number;
|
|
48
|
+
/** Wrap focus at the grid edges (default false). */
|
|
49
|
+
wrap?: boolean;
|
|
50
|
+
/** Fired when an action is activated by click, Enter/Space, or its hotkey. */
|
|
51
|
+
onActivate?: (id: string) => void;
|
|
52
|
+
/** Set false to ignore single-key hotkeys (default true). */
|
|
53
|
+
hotkeys?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** The live action-bar model returned by {@link useActionBar}. */
|
|
56
|
+
export interface ActionBarModel {
|
|
57
|
+
actions: readonly ResolvedAction[];
|
|
58
|
+
columns: number;
|
|
59
|
+
focusedId: string | null;
|
|
60
|
+
hoveredId: string | null;
|
|
61
|
+
setFocus: (id: string | null) => void;
|
|
62
|
+
setHover: (id: string | null) => void;
|
|
63
|
+
activate: (id: string) => void;
|
|
64
|
+
onKeyDown: (event: ActionKeyEvent) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
|
|
68
|
+
* navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
|
|
69
|
+
* {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
|
|
70
|
+
*
|
|
71
|
+
* @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
|
|
72
|
+
*/
|
|
73
|
+
export declare function useActionBar(defs: readonly ActionDef[], options?: UseActionBarOptions): ActionBarModel;
|
|
74
|
+
/** Render context passed to a custom {@link ActionBarChrome} item renderer. */
|
|
75
|
+
export interface ActionItemContext {
|
|
76
|
+
focused: boolean;
|
|
77
|
+
hovered: boolean;
|
|
78
|
+
model: ActionBarModel;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
|
|
82
|
+
* cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
|
|
83
|
+
* `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
|
|
84
|
+
* `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
|
|
85
|
+
* a radial layout yourself.
|
|
86
|
+
*
|
|
87
|
+
* @capability action-button cooldown/cost/hotkey action button with complete interaction states
|
|
88
|
+
*/
|
|
89
|
+
export declare function ActionButton({ action, focused, hovered, size, showHotkey, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }: {
|
|
90
|
+
action: ResolvedAction;
|
|
91
|
+
focused?: boolean;
|
|
92
|
+
hovered?: boolean;
|
|
93
|
+
size?: number;
|
|
94
|
+
showHotkey?: boolean;
|
|
95
|
+
tabIndex?: number;
|
|
96
|
+
buttonRef?: (node: HTMLButtonElement | null) => void;
|
|
97
|
+
onActivate?: (id: string) => void;
|
|
98
|
+
onFocus?: (id: string) => void;
|
|
99
|
+
onHover?: (id: string | null) => void;
|
|
100
|
+
renderIcon?: (action: ResolvedAction) => ReactNode;
|
|
101
|
+
className?: string;
|
|
102
|
+
style?: CSSProperties;
|
|
103
|
+
}): import("react").JSX.Element;
|
|
104
|
+
/**
|
|
105
|
+
* The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
|
|
106
|
+
* tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
|
|
107
|
+
* for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
|
|
108
|
+
* a wiring/behavior layer — no panel skin — so a game art-directs freely.
|
|
109
|
+
*
|
|
110
|
+
* @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
|
|
111
|
+
*/
|
|
112
|
+
export declare function ActionBarChrome({ model, layout, itemSize, gap, ariaLabel, renderItem, className, style, }: {
|
|
113
|
+
model: ActionBarModel;
|
|
114
|
+
layout?: "grid" | "list";
|
|
115
|
+
itemSize?: number;
|
|
116
|
+
gap?: number;
|
|
117
|
+
ariaLabel?: string;
|
|
118
|
+
renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
|
|
119
|
+
className?: string;
|
|
120
|
+
style?: CSSProperties;
|
|
121
|
+
}): import("react").JSX.Element;
|
|
122
|
+
/**
|
|
123
|
+
* Convenience composition of {@link useActionBar} + glass {@link ActionBarChrome} for demos and
|
|
124
|
+
* scaffolding — not a shipped game face. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`.
|
|
125
|
+
* Games that own their UI compose the headless hook + custom chrome (or swap `renderItem`) instead
|
|
126
|
+
* of shipping this default panel.
|
|
127
|
+
*
|
|
128
|
+
* @capability action-bar optional skinned action-bar composition over the headless model — games own chrome
|
|
129
|
+
*/
|
|
130
|
+
export declare function ActionBar({ defs, columns, layout, wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }: {
|
|
131
|
+
defs: readonly ActionDef[];
|
|
132
|
+
columns?: number;
|
|
133
|
+
layout?: "grid" | "list";
|
|
134
|
+
wrap?: boolean;
|
|
135
|
+
hotkeys?: boolean;
|
|
136
|
+
itemSize?: number;
|
|
137
|
+
ariaLabel?: string;
|
|
138
|
+
onActivate?: (id: string) => void;
|
|
139
|
+
renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
|
|
140
|
+
className?: string;
|
|
141
|
+
style?: CSSProperties;
|
|
142
|
+
}): import("react").JSX.Element;
|
|
143
|
+
/**
|
|
144
|
+
* An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
|
|
145
|
+
* against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
|
|
146
|
+
* an action's hover/focus description or any anchored panel.
|
|
147
|
+
*
|
|
148
|
+
* @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
|
|
149
|
+
*/
|
|
150
|
+
export declare function Popover({ open, anchorRef, side, gap, role, id, children, className, style, }: {
|
|
151
|
+
open: boolean;
|
|
152
|
+
anchorRef: {
|
|
153
|
+
current: HTMLElement | null;
|
|
154
|
+
};
|
|
155
|
+
side?: PopoverSide;
|
|
156
|
+
gap?: number;
|
|
157
|
+
role?: string;
|
|
158
|
+
id?: string;
|
|
159
|
+
children: ReactNode;
|
|
160
|
+
className?: string;
|
|
161
|
+
style?: CSSProperties;
|
|
162
|
+
}): import("react").JSX.Element | null;
|
|
163
|
+
/**
|
|
164
|
+
* Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
|
|
165
|
+
* hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
|
|
166
|
+
* {@link Popover} for an action's hover/focus description.
|
|
167
|
+
*
|
|
168
|
+
* @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
|
|
169
|
+
*/
|
|
170
|
+
export declare function ActionTooltip({ content, className, style, }: {
|
|
171
|
+
content: TooltipContent;
|
|
172
|
+
className?: string;
|
|
173
|
+
style?: CSSProperties;
|
|
174
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { actionByHotkey, actionCooldownFromFraction, actionCost, moveGridFocus, resolveActionCollection, } from "@jgengine/core/ui/actionModel";
|
|
4
|
+
import { actionTooltip, placePopover } from "@jgengine/core/ui/tooltipModel";
|
|
5
|
+
import { Keycap } from "./keyHint.js";
|
|
6
|
+
/**
|
|
7
|
+
* Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
|
|
8
|
+
* the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
|
|
9
|
+
* radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
|
|
10
|
+
* is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
|
|
11
|
+
* directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
|
|
12
|
+
* chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
|
|
16
|
+
* `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
|
|
17
|
+
* and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
|
|
18
|
+
* hotkey, description, resource name). Pure, so it is unit-testable.
|
|
19
|
+
*
|
|
20
|
+
* @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
|
|
21
|
+
*/
|
|
22
|
+
export function actionFromAbilitySlot(slot, extra) {
|
|
23
|
+
const def = { id: slot.id };
|
|
24
|
+
if (slot.cooldownRemainingMs > 0) {
|
|
25
|
+
def.cooldown = actionCooldownFromFraction(slot.cooldownRemainingMs, slot.cooldownFraction);
|
|
26
|
+
}
|
|
27
|
+
if (slot.resourceCost > 0) {
|
|
28
|
+
const available = slot.state === "no-resource" ? 0 : slot.resourceCost;
|
|
29
|
+
def.costs = [actionCost(extra?.resourceId ?? "resource", slot.resourceCost, available)];
|
|
30
|
+
}
|
|
31
|
+
if (extra?.label !== undefined)
|
|
32
|
+
def.label = extra.label;
|
|
33
|
+
if (extra?.description !== undefined)
|
|
34
|
+
def.description = extra.description;
|
|
35
|
+
if (extra?.icon !== undefined)
|
|
36
|
+
def.icon = extra.icon;
|
|
37
|
+
if (extra?.hotkey !== undefined)
|
|
38
|
+
def.hotkey = extra.hotkey;
|
|
39
|
+
if (extra?.group !== undefined)
|
|
40
|
+
def.group = extra.group;
|
|
41
|
+
if (extra?.active !== undefined)
|
|
42
|
+
def.active = extra.active;
|
|
43
|
+
if (extra?.disabled !== undefined)
|
|
44
|
+
def.disabled = extra.disabled;
|
|
45
|
+
if (extra?.reasons !== undefined)
|
|
46
|
+
def.reasons = extra.reasons;
|
|
47
|
+
return def;
|
|
48
|
+
}
|
|
49
|
+
const ARROW_DIRECTIONS = {
|
|
50
|
+
ArrowRight: "right",
|
|
51
|
+
ArrowLeft: "left",
|
|
52
|
+
ArrowUp: "up",
|
|
53
|
+
ArrowDown: "down",
|
|
54
|
+
Home: "first",
|
|
55
|
+
End: "last",
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
|
|
59
|
+
* activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
|
|
60
|
+
* the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
|
|
61
|
+
* thin shell over it.
|
|
62
|
+
*/
|
|
63
|
+
export function actionBarKeyAction(actions, focusedId, columns, key, options) {
|
|
64
|
+
const index = focusedId === null ? -1 : actions.findIndex((a) => a.id === focusedId);
|
|
65
|
+
const dir = ARROW_DIRECTIONS[key];
|
|
66
|
+
if (dir !== undefined) {
|
|
67
|
+
const next = moveGridFocus(index, actions.length, columns, dir, { wrap: options?.wrap === true });
|
|
68
|
+
const nextId = actions[next]?.id;
|
|
69
|
+
return nextId === undefined ? { handled: false } : { handled: true, focusId: nextId };
|
|
70
|
+
}
|
|
71
|
+
if (key === "Enter" || key === " " || key === "Spacebar") {
|
|
72
|
+
return focusedId === null ? { handled: false } : { handled: true, activateId: focusedId };
|
|
73
|
+
}
|
|
74
|
+
const hotkeyId = actionByHotkey(actions, key);
|
|
75
|
+
return hotkeyId === null ? { handled: false } : { handled: true, focusId: hotkeyId, activateId: hotkeyId };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
|
|
79
|
+
* navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
|
|
80
|
+
* {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
|
|
81
|
+
*
|
|
82
|
+
* @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
|
|
83
|
+
*/
|
|
84
|
+
export function useActionBar(defs, options) {
|
|
85
|
+
const [focusedId, setFocusedId] = useState(null);
|
|
86
|
+
const [hoveredId, setHoveredId] = useState(null);
|
|
87
|
+
const actions = useMemo(() => resolveActionCollection(defs), [defs]);
|
|
88
|
+
const columns = options?.columns ?? Math.max(1, actions.length);
|
|
89
|
+
const activate = useCallback((id) => options?.onActivate?.(id), [options]);
|
|
90
|
+
const onKeyDown = useCallback((event) => {
|
|
91
|
+
const result = actionBarKeyAction(actions, focusedId, columns, event.key, { wrap: options?.wrap === true });
|
|
92
|
+
if (!result.handled)
|
|
93
|
+
return;
|
|
94
|
+
if (result.activateId !== undefined && options?.hotkeys === false && result.focusId === result.activateId) {
|
|
95
|
+
// hotkey routing suppressed
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
if (result.focusId !== undefined)
|
|
99
|
+
setFocusedId(result.focusId);
|
|
100
|
+
if (result.activateId !== undefined)
|
|
101
|
+
activate(result.activateId);
|
|
102
|
+
}
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
}, [actions, focusedId, columns, options, activate]);
|
|
105
|
+
return {
|
|
106
|
+
actions,
|
|
107
|
+
columns,
|
|
108
|
+
focusedId,
|
|
109
|
+
hoveredId,
|
|
110
|
+
setFocus: setFocusedId,
|
|
111
|
+
setHover: setHoveredId,
|
|
112
|
+
activate,
|
|
113
|
+
onKeyDown,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Slot chrome reads the shared HudTheme tokens (#1034) with the built-in look as fallbacks, so a
|
|
117
|
+
// theme restyles action slots alongside the bars/frames and a bare game is byte-identical to before.
|
|
118
|
+
const ITEM_BASE = {
|
|
119
|
+
position: "relative",
|
|
120
|
+
boxSizing: "border-box",
|
|
121
|
+
display: "flex",
|
|
122
|
+
flexDirection: "column",
|
|
123
|
+
alignItems: "center",
|
|
124
|
+
justifyContent: "center",
|
|
125
|
+
gap: 2,
|
|
126
|
+
border: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.14))",
|
|
127
|
+
borderRadius: "var(--jg-slot-radius, 8px)",
|
|
128
|
+
background: "var(--jg-slot-bg, rgba(14,17,22,0.72))",
|
|
129
|
+
color: "#eef2f8",
|
|
130
|
+
font: "600 12px/1.1 ui-sans-serif, system-ui, sans-serif",
|
|
131
|
+
cursor: "pointer",
|
|
132
|
+
userSelect: "none",
|
|
133
|
+
overflow: "hidden",
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
|
|
137
|
+
* cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
|
|
138
|
+
* `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
|
|
139
|
+
* `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
|
|
140
|
+
* a radial layout yourself.
|
|
141
|
+
*
|
|
142
|
+
* @capability action-button cooldown/cost/hotkey action button with complete interaction states
|
|
143
|
+
*/
|
|
144
|
+
export function ActionButton({ action, focused = false, hovered = false, size = 52, showHotkey = true, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }) {
|
|
145
|
+
const tip = actionTooltip(action);
|
|
146
|
+
const title = [tip.title, ...(tip.notes ?? [])].join("\n");
|
|
147
|
+
const cooldown = action.cooldown;
|
|
148
|
+
const wipeDeg = cooldown !== null && !cooldown.ready ? Math.round(cooldown.fraction * 360) : 0;
|
|
149
|
+
return (_jsxs("button", { type: "button", ref: buttonRef, className: className, "data-action-id": action.id, "data-active": action.active ? "" : undefined, "data-enabled": action.enabled ? "" : undefined, "data-focused": focused ? "" : undefined, "aria-disabled": !action.enabled, "aria-pressed": action.active, "aria-keyshortcuts": action.hotkey, "aria-label": action.label, title: title, tabIndex: tabIndex, onClick: () => onActivate?.(action.id), onFocus: () => onFocus?.(action.id), onPointerEnter: () => onHover?.(action.id), onPointerLeave: () => onHover?.(null), style: {
|
|
150
|
+
...ITEM_BASE,
|
|
151
|
+
width: size,
|
|
152
|
+
height: size,
|
|
153
|
+
opacity: action.enabled ? 1 : 0.5,
|
|
154
|
+
borderColor: action.active
|
|
155
|
+
? "rgba(56,189,248,0.95)"
|
|
156
|
+
: focused
|
|
157
|
+
? "rgba(148,197,255,0.9)"
|
|
158
|
+
: hovered
|
|
159
|
+
? "rgba(255,255,255,0.3)"
|
|
160
|
+
: "rgba(255,255,255,0.14)",
|
|
161
|
+
boxShadow: focused
|
|
162
|
+
? "0 0 0 2px rgba(96,165,250,0.7)"
|
|
163
|
+
: action.active
|
|
164
|
+
? "0 0 0 1px rgba(56,189,248,0.6)"
|
|
165
|
+
: "none",
|
|
166
|
+
transform: hovered && action.enabled ? "translateY(-1px)" : "none",
|
|
167
|
+
transition: "transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease",
|
|
168
|
+
...style,
|
|
169
|
+
}, children: [showHotkey && action.hotkey !== undefined ? (_jsx("span", { style: { position: "absolute", top: 2, left: 3 }, "aria-hidden": true, children: _jsx(Keycap, { style: { fontSize: 9, padding: "0 3px", opacity: 0.85 }, children: action.hotkey }) })) : null, _jsx("span", { style: { fontSize: renderIcon ? undefined : 18, lineHeight: 1 }, "data-action-icon": true, "aria-hidden": true, children: renderIcon ? renderIcon(action) : (action.icon ?? action.label.slice(0, 2)) }), action.costs.length > 0 ? (_jsx("span", { "data-action-costs": true, style: { display: "flex", gap: 4, fontSize: 9 }, children: action.costs.map((cost) => (_jsx("span", { style: { color: cost.met ? "rgba(147,197,253,0.9)" : "#f87171" }, children: cost.amount }, cost.resourceId))) })) : null, wipeDeg > 0 ? (_jsx("span", { "data-action-cooldown": true, "aria-hidden": true, style: {
|
|
170
|
+
position: "absolute",
|
|
171
|
+
inset: 0,
|
|
172
|
+
pointerEvents: "none",
|
|
173
|
+
background: `conic-gradient(rgba(4,6,10,0.62) ${wipeDeg}deg, transparent 0deg)`,
|
|
174
|
+
}, children: _jsx("span", { style: {
|
|
175
|
+
position: "absolute",
|
|
176
|
+
inset: 0,
|
|
177
|
+
display: "flex",
|
|
178
|
+
alignItems: "center",
|
|
179
|
+
justifyContent: "center",
|
|
180
|
+
fontSize: 13,
|
|
181
|
+
fontWeight: 700,
|
|
182
|
+
fontVariantNumeric: "tabular-nums",
|
|
183
|
+
}, children: (action.cooldown.remainingMs / 1000).toFixed(action.cooldown.remainingMs < 10000 ? 1 : 0) }) })) : null] }));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
|
|
187
|
+
* tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
|
|
188
|
+
* for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
|
|
189
|
+
* a wiring/behavior layer — no panel skin — so a game art-directs freely.
|
|
190
|
+
*
|
|
191
|
+
* @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
|
|
192
|
+
*/
|
|
193
|
+
export function ActionBarChrome({ model, layout = "grid", itemSize = 52, gap = 6, ariaLabel = "Actions", renderItem, className, style, }) {
|
|
194
|
+
const refs = useRef(new Map());
|
|
195
|
+
const { actions, focusedId, hoveredId, columns } = model;
|
|
196
|
+
const rovingId = focusedId ?? actions[0]?.id ?? null;
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (focusedId === null)
|
|
199
|
+
return;
|
|
200
|
+
refs.current.get(focusedId)?.focus();
|
|
201
|
+
}, [focusedId]);
|
|
202
|
+
const containerStyle = layout === "grid"
|
|
203
|
+
? { display: "grid", gridTemplateColumns: `repeat(${Math.max(1, columns)}, ${itemSize}px)`, gap }
|
|
204
|
+
: { display: "flex", flexDirection: "column", gap };
|
|
205
|
+
return (_jsx("div", { role: "toolbar", "aria-label": ariaLabel, "aria-orientation": layout === "list" ? "vertical" : "horizontal", className: className, "data-action-bar": "", onKeyDown: model.onKeyDown, style: { ...containerStyle, ...style }, children: actions.map((action) => {
|
|
206
|
+
const ctx = {
|
|
207
|
+
focused: focusedId === action.id,
|
|
208
|
+
hovered: hoveredId === action.id,
|
|
209
|
+
model,
|
|
210
|
+
};
|
|
211
|
+
if (renderItem !== undefined)
|
|
212
|
+
return _jsx("div", { children: renderItem(action, ctx) }, action.id);
|
|
213
|
+
return (_jsx(ActionButton, { action: action, focused: ctx.focused, hovered: ctx.hovered, size: itemSize, tabIndex: rovingId === action.id ? 0 : -1, buttonRef: (node) => {
|
|
214
|
+
if (node === null)
|
|
215
|
+
refs.current.delete(action.id);
|
|
216
|
+
else
|
|
217
|
+
refs.current.set(action.id, node);
|
|
218
|
+
}, onActivate: model.activate, onFocus: model.setFocus, onHover: model.setHover }, action.id));
|
|
219
|
+
}) }));
|
|
220
|
+
}
|
|
221
|
+
const PANEL = {
|
|
222
|
+
padding: 8,
|
|
223
|
+
borderRadius: "var(--jg-frame-radius, 12px)",
|
|
224
|
+
background: "var(--jg-frame-bg, rgba(10,12,16,0.6))",
|
|
225
|
+
backdropFilter: "blur(6px)",
|
|
226
|
+
WebkitBackdropFilter: "blur(6px)",
|
|
227
|
+
border: "var(--jg-frame-border, 1px solid rgba(255,255,255,0.1))",
|
|
228
|
+
boxShadow: "var(--jg-frame-glow, 0 4px 16px rgba(0,0,0,0.35))",
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Convenience composition of {@link useActionBar} + glass {@link ActionBarChrome} for demos and
|
|
232
|
+
* scaffolding — not a shipped game face. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`.
|
|
233
|
+
* Games that own their UI compose the headless hook + custom chrome (or swap `renderItem`) instead
|
|
234
|
+
* of shipping this default panel.
|
|
235
|
+
*
|
|
236
|
+
* @capability action-bar optional skinned action-bar composition over the headless model — games own chrome
|
|
237
|
+
*/
|
|
238
|
+
export function ActionBar({ defs, columns, layout = "grid", wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }) {
|
|
239
|
+
const model = useActionBar(defs, { columns, wrap, hotkeys, onActivate });
|
|
240
|
+
return (_jsx("div", { className: className, "data-action-panel": "", style: { ...PANEL, display: "inline-block", ...style }, children: _jsx(ActionBarChrome, { model: model, layout: layout, itemSize: itemSize, ariaLabel: ariaLabel, renderItem: renderItem }) }));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
|
|
244
|
+
* against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
|
|
245
|
+
* an action's hover/focus description or any anchored panel.
|
|
246
|
+
*
|
|
247
|
+
* @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
|
|
248
|
+
*/
|
|
249
|
+
export function Popover({ open, anchorRef, side = "top", gap = 8, role = "tooltip", id, children, className, style, }) {
|
|
250
|
+
const ref = useRef(null);
|
|
251
|
+
const [pos, setPos] = useState(null);
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!open || typeof window === "undefined")
|
|
254
|
+
return;
|
|
255
|
+
const anchor = anchorRef.current;
|
|
256
|
+
const node = ref.current;
|
|
257
|
+
if (anchor === null || node === null)
|
|
258
|
+
return;
|
|
259
|
+
const a = anchor.getBoundingClientRect();
|
|
260
|
+
const c = node.getBoundingClientRect();
|
|
261
|
+
const placement = placePopover({ x: a.left, y: a.top, width: a.width, height: a.height }, { width: c.width, height: c.height }, { width: window.innerWidth, height: window.innerHeight }, { preferred: side, gap });
|
|
262
|
+
setPos({ left: placement.left, top: placement.top });
|
|
263
|
+
}, [open, side, gap, anchorRef]);
|
|
264
|
+
if (!open)
|
|
265
|
+
return null;
|
|
266
|
+
return (_jsx("div", { ref: ref, role: role, id: id, "data-popover": "", className: className, style: {
|
|
267
|
+
position: "fixed",
|
|
268
|
+
left: pos?.left ?? -9999,
|
|
269
|
+
top: pos?.top ?? -9999,
|
|
270
|
+
zIndex: 1000,
|
|
271
|
+
pointerEvents: "none",
|
|
272
|
+
...style,
|
|
273
|
+
}, children: children }));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
|
|
277
|
+
* hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
|
|
278
|
+
* {@link Popover} for an action's hover/focus description.
|
|
279
|
+
*
|
|
280
|
+
* @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
|
|
281
|
+
*/
|
|
282
|
+
export function ActionTooltip({ content, className, style, }) {
|
|
283
|
+
return (_jsxs("div", { className: className, "data-action-tooltip": "", style: {
|
|
284
|
+
maxWidth: 240,
|
|
285
|
+
padding: "8px 10px",
|
|
286
|
+
borderRadius: 8,
|
|
287
|
+
background: "rgba(8,10,14,0.95)",
|
|
288
|
+
border: "1px solid rgba(255,255,255,0.14)",
|
|
289
|
+
color: "#eef2f8",
|
|
290
|
+
font: "500 12px/1.35 ui-sans-serif, system-ui, sans-serif",
|
|
291
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
|
292
|
+
...style,
|
|
293
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsx("strong", { style: { fontSize: 13 }, children: content.title }), content.hotkey !== undefined ? (_jsx(Keycap, { style: { marginLeft: "auto", fontSize: 10 }, children: content.hotkey })) : null] }), content.subtitle !== undefined ? (_jsx("div", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 0.4, opacity: 0.6 }, children: content.subtitle })) : null, content.body !== undefined ? _jsx("div", { style: { marginTop: 4, opacity: 0.9 }, children: content.body }) : null, content.costs !== undefined && content.costs.length > 0 ? (_jsx("div", { style: { marginTop: 4, display: "flex", gap: 8, fontSize: 11 }, children: content.costs.map((cost) => (_jsxs("span", { style: { color: cost.met ? "rgba(147,197,253,0.95)" : "#f87171" }, children: [cost.amount, " ", cost.resourceId] }, cost.resourceId))) })) : null, content.cooldown != null ? (_jsxs("div", { style: { marginTop: 4, fontSize: 11, opacity: 0.8 }, children: [(content.cooldown.remainingMs / 1000).toFixed(1), "s cooldown"] })) : null, content.notes !== undefined && content.notes.length > 0 ? (_jsx("div", { style: { marginTop: 6, paddingTop: 4, borderTop: "1px solid rgba(255,255,255,0.1)", fontSize: 11, color: "#fca5a5" }, children: content.notes.map((note) => (_jsx("div", { children: note }, note))) })) : null] }));
|
|
294
|
+
}
|
package/dist/bars.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Atomic, purpose-named vitals — one component per readout (`HealthBar`, `ShieldBar`, …), never a
|
|
4
|
+
* `tone`-switched umbrella and never a bundled combo. Every bar reads the same shared CSS custom
|
|
5
|
+
* properties (`--jg-health`, `--jg-shield`, `--jg-xp`, plus frame/shape tokens). Default token
|
|
6
|
+
* colors are scaffolding only: games own layout, framing, and art direction, and must set tokens
|
|
7
|
+
* (or replace the markup) so the HUD is unique to the pitch — the engine ships parts, not a face.
|
|
8
|
+
*
|
|
9
|
+
* Each bar accepts either an explicit `value`/`max` (pure, provider-free — for composition, tests,
|
|
10
|
+
* and preview fixtures) or binds to a live entity stat (`statId`/`entityId`, defaulting to the local
|
|
11
|
+
* player). It renders nothing until it has a value.
|
|
12
|
+
*
|
|
13
|
+
* @capability hud-vitals atomic purpose-named vitals bars (token-themed parts, not a finished HUD)
|
|
14
|
+
*/
|
|
15
|
+
/** The shared vitals token contract — CSS custom properties every atomic bar reads. */
|
|
16
|
+
export interface BarTokens {
|
|
17
|
+
/** Fill color per readout. */
|
|
18
|
+
health?: string;
|
|
19
|
+
healthLow?: string;
|
|
20
|
+
mana?: string;
|
|
21
|
+
stamina?: string;
|
|
22
|
+
shield?: string;
|
|
23
|
+
xp?: string;
|
|
24
|
+
soul?: string;
|
|
25
|
+
ammo?: string;
|
|
26
|
+
boss?: string;
|
|
27
|
+
/** Frame + shape. */
|
|
28
|
+
track?: string;
|
|
29
|
+
frame?: string;
|
|
30
|
+
frameWidth?: string;
|
|
31
|
+
radius?: string;
|
|
32
|
+
height?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
bevel?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Built-in default values for every vitals token — scaffolding/preview only, not a shipped look. */
|
|
37
|
+
export declare const DEFAULT_BAR_TOKENS: Required<BarTokens>;
|
|
38
|
+
/**
|
|
39
|
+
* A style object binding vitals tokens to CSS custom properties — spread onto any HUD ancestor to
|
|
40
|
+
* restyle every atomic bar under it at once. Only the keys you pass are set; the rest fall back to
|
|
41
|
+
* {@link DEFAULT_BAR_TOKENS} inside the bars. This is the single global-theming seam.
|
|
42
|
+
* @capability hud-vitals bind shared vitals CSS tokens onto a HUD subtree
|
|
43
|
+
*/
|
|
44
|
+
export declare function barTokens(tokens: BarTokens): CSSProperties;
|
|
45
|
+
/** Trough shape language — `rect` (default), `pill` (fully round), `skew` (parallelogram, upright
|
|
46
|
+
* label), or `chamfer` (cut corners). Matches a game's skin without hand-rolling a local bar. */
|
|
47
|
+
export type BarShape = "rect" | "pill" | "skew" | "chamfer";
|
|
48
|
+
/** Shared props for every atomic bar — bound to a stat (`statId`/`entityId`) or explicit (`value`). */
|
|
49
|
+
export interface AtomicBarProps {
|
|
50
|
+
/** Trough shape language. Default `rect`. */
|
|
51
|
+
shape?: BarShape;
|
|
52
|
+
/** Explicit fill color, overriding the token (and the low-threshold swap) — for per-instance
|
|
53
|
+
* colors a token can't express (e.g. a class-colored resource bar). Caller overrides win. */
|
|
54
|
+
fill?: string;
|
|
55
|
+
/** Explicit current value; when set the bar is pure and needs no game provider. */
|
|
56
|
+
value?: number;
|
|
57
|
+
/** Explicit max (default 100 when `value` is set without one). */
|
|
58
|
+
max?: number;
|
|
59
|
+
/** Explicit min (default 0). */
|
|
60
|
+
min?: number;
|
|
61
|
+
/** Stat id to bind when `value` is omitted. */
|
|
62
|
+
statId?: string;
|
|
63
|
+
/** Entity to read the stat off; defaults to the local player. */
|
|
64
|
+
entityId?: string;
|
|
65
|
+
/** Uppercase label rendered at the start of the trough. */
|
|
66
|
+
label?: string;
|
|
67
|
+
/** Show the centered `current / max` readout. Default true. */
|
|
68
|
+
showValue?: boolean;
|
|
69
|
+
/** Trough width in px (or any CSS length). Default 200. */
|
|
70
|
+
width?: number | string;
|
|
71
|
+
/** Divide the trough into N segments with tick dividers (hearts/ammo/evo style). */
|
|
72
|
+
segments?: number;
|
|
73
|
+
/** A ReactNode anchored at the start — portrait / orb / icon. Composes, never bundled. */
|
|
74
|
+
endCap?: ReactNode;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
className?: string;
|
|
77
|
+
}
|
|
78
|
+
/** Player/enemy health — danger-flashes to `--jg-health-low` below a quarter full. */
|
|
79
|
+
export declare const HealthBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
80
|
+
/** Absorb/armor shield overlay. */
|
|
81
|
+
export declare const ShieldBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
82
|
+
/** Spell/energy resource pool. */
|
|
83
|
+
export declare const ManaBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
84
|
+
/** Sprint/action stamina pool. */
|
|
85
|
+
export declare const StaminaBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
86
|
+
/** Level progress / experience. */
|
|
87
|
+
export declare const ExperienceBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
88
|
+
/** Resource-charge / soul-box meter — segmented by default. */
|
|
89
|
+
export declare const SoulBar: (props: AtomicBarProps) => import("react").JSX.Element;
|
|
90
|
+
/** A wide encounter/boss health bar with the boss name at the start. */
|
|
91
|
+
export declare function BossBar(props: AtomicBarProps & {
|
|
92
|
+
name?: string;
|
|
93
|
+
}): import("react").JSX.Element;
|
|
94
|
+
/** Loaded-vs-reserve ammunition counter — a segmented magazine plus a reserve tally. */
|
|
95
|
+
export declare function AmmoCounter({ loaded, reserve, magazine, width, style, className, }: {
|
|
96
|
+
loaded: number;
|
|
97
|
+
reserve?: number;
|
|
98
|
+
/** Magazine capacity; drives the segment count and fill fraction. Default = `loaded`. */
|
|
99
|
+
magazine?: number;
|
|
100
|
+
width?: number | string;
|
|
101
|
+
style?: CSSProperties;
|
|
102
|
+
className?: string;
|
|
103
|
+
}): import("react").JSX.Element;
|