@jgengine/react 0.28.0 → 0.30.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 +31 -0
- package/dist/accessibility.d.ts +40 -7
- package/dist/accessibility.js +40 -12
- package/dist/menuNav.d.ts +7 -1
- package/dist/menuNav.js +42 -12
- package/dist/settingsBlocks.d.ts +34 -8
- package/dist/settingsBlocks.js +109 -29
- package/package.json +2 -2
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/api.md +22 -14
- package/skills/jgengine-ui/capabilities.md +18 -2
- package/skills/jgengine-ui/reference.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,37 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.30.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.30.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.
|
|
38
|
+
- `SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.
|
|
39
|
+
- `createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.
|
|
40
|
+
- `createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.
|
|
41
|
+
- `createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.
|
|
42
|
+
- `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- `AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.
|
|
47
|
+
- World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.
|
|
48
|
+
- A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.
|
|
49
|
+
|
|
50
|
+
## 0.29.0
|
|
51
|
+
|
|
52
|
+
### Migrate
|
|
53
|
+
|
|
54
|
+
- **Bump lockstep SDK packages to `^0.29.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.26.0`; `@jgengine/github` is `0.16.0`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- `MissionView.remaining` (`@jgengine/core/gameplay`) now counts down from a `survive` objective's own `duration`, not only from a `timeout`. A defend or hold objective reports the seconds it still has to run, which is the one number its HUD needs; a completed run still reports null.
|
|
59
|
+
|
|
29
60
|
## 0.28.0
|
|
30
61
|
|
|
31
62
|
### Migrate
|
package/dist/accessibility.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import { type AccessibilityState, type AccessibilityStore } from "@jgengine/core/ui/accessibility";
|
|
2
|
+
import { type AccessibilityState, type AccessibilityStore, type ColorblindMode } from "@jgengine/core/ui/accessibility";
|
|
3
|
+
import type { SettingsStore } from "@jgengine/core/settings/settingsModel";
|
|
3
4
|
interface AccessibilityContextValue {
|
|
4
5
|
state: AccessibilityState;
|
|
5
6
|
store: AccessibilityStore;
|
|
@@ -8,21 +9,53 @@ interface AccessibilityContextValue {
|
|
|
8
9
|
export declare function useAccessibility(): AccessibilityContextValue;
|
|
9
10
|
/**
|
|
10
11
|
* Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
12
|
+
* `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
|
|
13
|
+
* export it standalone for a root that applies the filter itself.
|
|
13
14
|
*
|
|
14
15
|
* @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
|
|
15
16
|
*/
|
|
16
17
|
export declare function ColorblindFilters(): ReactNode;
|
|
18
|
+
/** Props for {@link ColorblindFrame}. */
|
|
19
|
+
export interface ColorblindFrameProps {
|
|
20
|
+
/** The player's colour-vision mode. `none` (default) renders the frame untouched. */
|
|
21
|
+
mode?: ColorblindMode;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Apply the player's colour-vision mode to one rendered frame: a box carrying
|
|
28
|
+
* `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
|
|
29
|
+
* game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
|
|
30
|
+
* caption text outside it keep the contrast and hues the rest of the UI assumes.
|
|
31
|
+
*
|
|
32
|
+
* The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
|
|
33
|
+
* as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
|
|
34
|
+
* unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
|
|
35
|
+
*
|
|
36
|
+
* @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
|
|
37
|
+
*/
|
|
38
|
+
export declare function ColorblindFrame({ mode, children, className, style }: ColorblindFrameProps): ReactNode;
|
|
39
|
+
/**
|
|
40
|
+
* The live colour-vision mode from a settings store — the same store the settings rows write, so the
|
|
41
|
+
* frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
|
|
42
|
+
* `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
|
|
43
|
+
*
|
|
44
|
+
* @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
|
|
45
|
+
*/
|
|
46
|
+
export declare function useColorblindMode(store: SettingsStore): ColorblindMode;
|
|
17
47
|
/**
|
|
18
48
|
* Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
|
|
19
|
-
* text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
|
|
20
|
-
* `data-colorblind` / `data-captions` for CSS to respond to
|
|
21
|
-
* in the selected colorblind `feColorMatrix` filter. Reads a
|
|
49
|
+
* text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
|
|
50
|
+
* `data-colorblind` / `data-captions` for CSS to respond to. Reads a
|
|
22
51
|
* `createAccessibilityStore` and re-renders on change; provides the state to
|
|
23
52
|
* `useAccessibility`.
|
|
24
53
|
*
|
|
25
|
-
*
|
|
54
|
+
* It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
|
|
55
|
+
* over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
|
|
56
|
+
* {@link ColorblindFrame} instead.
|
|
57
|
+
*
|
|
58
|
+
* @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
|
|
26
59
|
*/
|
|
27
60
|
export declare function AccessibilityProvider({ store, children, className, style, }: {
|
|
28
61
|
store: AccessibilityStore;
|
package/dist/accessibility.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useSyncExternalStore } from "react";
|
|
3
|
-
import { COLORBLIND_MATRICES, } from "@jgengine/core/ui/accessibility";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useSyncExternalStore, } from "react";
|
|
3
|
+
import { COLORBLIND_MATRICES, colorblindFrameFilter, } from "@jgengine/core/ui/accessibility";
|
|
4
|
+
import { readColorblind } from "@jgengine/core/settings/accessibilitySettings";
|
|
4
5
|
const AccessibilityContext = createContext(null);
|
|
5
6
|
const FILTER_MODES = [
|
|
6
7
|
"protanopia",
|
|
@@ -17,31 +18,58 @@ export function useAccessibility() {
|
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
20
|
* Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
21
|
+
* `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
|
|
22
|
+
* export it standalone for a root that applies the filter itself.
|
|
22
23
|
*
|
|
23
24
|
* @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
|
|
24
25
|
*/
|
|
25
26
|
export function ColorblindFilters() {
|
|
26
27
|
return (_jsx("svg", { "aria-hidden": true, width: 0, height: 0, style: { position: "absolute" }, "data-colorblind-filters": true, children: _jsx("defs", { children: FILTER_MODES.map((mode) => (_jsx("filter", { id: `jg-cb-${mode}`, colorInterpolationFilters: "sRGB", children: _jsx("feColorMatrix", { type: "matrix", values: COLORBLIND_MATRICES[mode] ?? "" }) }, mode))) }) }));
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Apply the player's colour-vision mode to one rendered frame: a box carrying
|
|
31
|
+
* `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
|
|
32
|
+
* game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
|
|
33
|
+
* caption text outside it keep the contrast and hues the rest of the UI assumes.
|
|
34
|
+
*
|
|
35
|
+
* The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
|
|
36
|
+
* as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
|
|
37
|
+
* unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
|
|
38
|
+
*
|
|
39
|
+
* @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
|
|
40
|
+
*/
|
|
41
|
+
export function ColorblindFrame({ mode = "none", children, className, style }) {
|
|
42
|
+
const filter = colorblindFrameFilter(mode);
|
|
43
|
+
return (_jsxs(_Fragment, { children: [_jsx(ColorblindFilters, {}), _jsx("div", { className: className, "data-colorblind-frame": mode, style: filter === null ? style : { ...style, filter }, children: children })] }));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The live colour-vision mode from a settings store — the same store the settings rows write, so the
|
|
47
|
+
* frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
|
|
48
|
+
* `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
|
|
49
|
+
*
|
|
50
|
+
* @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
|
|
51
|
+
*/
|
|
52
|
+
export function useColorblindMode(store) {
|
|
53
|
+
return useSyncExternalStore(store.subscribe, () => readColorblind(store), () => readColorblind(store));
|
|
54
|
+
}
|
|
28
55
|
/**
|
|
29
56
|
* Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
|
|
30
|
-
* text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
|
|
31
|
-
* `data-colorblind` / `data-captions` for CSS to respond to
|
|
32
|
-
* in the selected colorblind `feColorMatrix` filter. Reads a
|
|
57
|
+
* text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
|
|
58
|
+
* `data-colorblind` / `data-captions` for CSS to respond to. Reads a
|
|
33
59
|
* `createAccessibilityStore` and re-renders on change; provides the state to
|
|
34
60
|
* `useAccessibility`.
|
|
35
61
|
*
|
|
36
|
-
*
|
|
62
|
+
* It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
|
|
63
|
+
* over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
|
|
64
|
+
* {@link ColorblindFrame} instead.
|
|
65
|
+
*
|
|
66
|
+
* @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
|
|
37
67
|
*/
|
|
38
68
|
export function AccessibilityProvider({ store, children, className, style, }) {
|
|
39
69
|
const state = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
40
70
|
const rootStyle = { ...style };
|
|
41
71
|
rootStyle["--jg-text-scale"] = String(state.textScale);
|
|
42
|
-
|
|
43
|
-
rootStyle.filter = `url(#jg-cb-${state.colorblind})`;
|
|
44
|
-
return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsxs("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: [_jsx(ColorblindFilters, {}), children] }) }));
|
|
72
|
+
return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsx("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: children }) }));
|
|
45
73
|
}
|
|
46
74
|
/**
|
|
47
75
|
* OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live
|
package/dist/menuNav.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface UseMenuNavOptions {
|
|
|
43
43
|
enabled?: boolean;
|
|
44
44
|
/** Confirm (Enter/Space/A). Called with the focused id. */
|
|
45
45
|
onConfirm?: (id: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Left/right (arrow keys, d-pad, left stick) change the focused item's value by one step instead of
|
|
48
|
+
* moving focus. Give it to a menu whose items are settings rows; hold-to-repeat applies, so a held
|
|
49
|
+
* direction steps repeatedly. Up/down and confirm/back are unchanged.
|
|
50
|
+
*/
|
|
51
|
+
onAdjust?: (id: string, delta: -1 | 1) => void;
|
|
46
52
|
/** Back (Escape/Backspace/B/Start). */
|
|
47
53
|
onBack?: () => void;
|
|
48
54
|
/** Poll a connected gamepad for menu intents. Default true. */
|
|
@@ -58,6 +64,6 @@ export interface UseMenuNavOptions {
|
|
|
58
64
|
*
|
|
59
65
|
* @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
|
|
60
66
|
*/
|
|
61
|
-
export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
|
|
67
|
+
export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, onAdjust, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
|
|
62
68
|
/** Render prop for a menu whose items come from {@link useMenuNav}. */
|
|
63
69
|
export type MenuNavRender = (nav: MenuNavController) => ReactNode;
|
package/dist/menuNav.js
CHANGED
|
@@ -14,7 +14,7 @@ function isTextEntry(target) {
|
|
|
14
14
|
*
|
|
15
15
|
* @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
|
|
16
16
|
*/
|
|
17
|
-
export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }) {
|
|
17
|
+
export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }) {
|
|
18
18
|
const disabledIndices = useMemo(() => {
|
|
19
19
|
if (disabled === undefined || disabled.length === 0)
|
|
20
20
|
return undefined;
|
|
@@ -36,16 +36,28 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
36
36
|
idsRef.current = ids;
|
|
37
37
|
const activeIndexRef = useRef(activeIndex);
|
|
38
38
|
activeIndexRef.current = activeIndex;
|
|
39
|
-
const callbacks = useRef({ onConfirm, onBack });
|
|
40
|
-
callbacks.current = { onConfirm, onBack };
|
|
39
|
+
const callbacks = useRef({ onConfirm, onBack, onAdjust });
|
|
40
|
+
callbacks.current = { onConfirm, onBack, onAdjust };
|
|
41
41
|
const elements = useRef(new Map());
|
|
42
42
|
const pendingFocus = useRef(true);
|
|
43
|
+
// The first pass claims focus only when nothing else owns it; later moves focus the new item.
|
|
44
|
+
const claimed = useRef(false);
|
|
45
|
+
const firstEnablePass = useRef(true);
|
|
43
46
|
const focusActive = useCallback(() => {
|
|
44
47
|
const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
|
|
45
48
|
if (id === null)
|
|
46
49
|
return;
|
|
47
50
|
elements.current.get(id)?.focus({ preventScroll: true });
|
|
48
51
|
}, []);
|
|
52
|
+
// A control or dialog that already owns focus keeps it; the first move brings focus into the menu. Without
|
|
53
|
+
// this a menu mounted inside a dialog takes focus from the dialog's own initial control, and the dialog's
|
|
54
|
+
// opener is never restored on close.
|
|
55
|
+
const claimFocus = useCallback(() => {
|
|
56
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
57
|
+
if (active !== null && active !== document.body && active !== document.documentElement)
|
|
58
|
+
return;
|
|
59
|
+
focusActive();
|
|
60
|
+
}, [focusActive]);
|
|
49
61
|
const move = useCallback((direction) => {
|
|
50
62
|
const layout = {
|
|
51
63
|
count: idsRef.current.length,
|
|
@@ -75,11 +87,19 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
75
87
|
activate(menuNavIdAt(idsRef.current, activeIndexRef.current));
|
|
76
88
|
else if (intent === "back")
|
|
77
89
|
callbacks.current.onBack?.();
|
|
90
|
+
else if (intent === "left" || intent === "right") {
|
|
91
|
+
const adjust = callbacks.current.onAdjust;
|
|
92
|
+
if (adjust === undefined) {
|
|
93
|
+
move(intent);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
|
|
97
|
+
if (id !== null)
|
|
98
|
+
adjust(id, intent === "right" ? 1 : -1);
|
|
99
|
+
}
|
|
78
100
|
else
|
|
79
101
|
move(intent);
|
|
80
102
|
}, [activate, move]);
|
|
81
|
-
const moveRef = useRef(move);
|
|
82
|
-
moveRef.current = move;
|
|
83
103
|
const runIntentRef = useRef(runIntent);
|
|
84
104
|
runIntentRef.current = runIntent;
|
|
85
105
|
const repeatRef = useRef(repeat);
|
|
@@ -88,14 +108,24 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
88
108
|
if (!pendingFocus.current)
|
|
89
109
|
return;
|
|
90
110
|
pendingFocus.current = false;
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
if (claimed.current) {
|
|
112
|
+
focusActive();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
claimed.current = true;
|
|
116
|
+
claimFocus();
|
|
117
|
+
}, [activeIndex, claimFocus, focusActive]);
|
|
93
118
|
useEffect(() => {
|
|
94
119
|
if (!enabled)
|
|
95
120
|
return;
|
|
96
121
|
pendingFocus.current = true;
|
|
122
|
+
if (firstEnablePass.current) {
|
|
123
|
+
firstEnablePass.current = false;
|
|
124
|
+
claimFocus();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
97
127
|
focusActive();
|
|
98
|
-
}, [enabled, focusActive]);
|
|
128
|
+
}, [enabled, claimFocus, focusActive]);
|
|
99
129
|
useEffect(() => {
|
|
100
130
|
const index = ids.indexOf(activeId ?? "");
|
|
101
131
|
if (activeId !== null && index >= 0)
|
|
@@ -201,12 +231,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
201
231
|
}
|
|
202
232
|
if (isNew) {
|
|
203
233
|
held.set(button, { pressedAt: now, lastFiredAt: now });
|
|
204
|
-
|
|
234
|
+
runIntentRef.current(intent);
|
|
205
235
|
continue;
|
|
206
236
|
}
|
|
207
237
|
if (record !== undefined && shouldRepeatMenuNav(now, record, repeatRef.current)) {
|
|
208
238
|
record.lastFiredAt = now;
|
|
209
|
-
|
|
239
|
+
runIntentRef.current(intent);
|
|
210
240
|
}
|
|
211
241
|
}
|
|
212
242
|
for (const button of [...held.keys()])
|
|
@@ -222,12 +252,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
222
252
|
}
|
|
223
253
|
if (stick === null || stick.intent !== axisIntent) {
|
|
224
254
|
stick = { intent: axisIntent, pressedAt: now, lastFiredAt: now };
|
|
225
|
-
|
|
255
|
+
runIntentRef.current(axisIntent);
|
|
226
256
|
return;
|
|
227
257
|
}
|
|
228
258
|
if (shouldRepeatMenuNav(now, stick, repeatRef.current)) {
|
|
229
259
|
stick.lastFiredAt = now;
|
|
230
|
-
|
|
260
|
+
runIntentRef.current(axisIntent);
|
|
231
261
|
}
|
|
232
262
|
};
|
|
233
263
|
frame = requestAnimationFrame(poll);
|
package/dist/settingsBlocks.d.ts
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { SettingValue } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
import { type MenuNavItemProps } from "./menuNav.js";
|
|
2
4
|
import type { SettingsCategoryView, SettingsKeybindRow, SettingsRow } from "./settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a
|
|
7
|
+
* select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the
|
|
8
|
+
* kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
|
|
9
|
+
*
|
|
10
|
+
* @capability adjust-setting-row step a settings row value once, for keyboard and gamepad alike
|
|
11
|
+
*/
|
|
12
|
+
export declare function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined;
|
|
3
13
|
/**
|
|
4
14
|
* One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
|
|
5
15
|
* drag never storms the settings store or the graphics rebuild it drives.
|
|
6
16
|
*
|
|
7
17
|
* @capability settings-slider a settings slider row that mirrors locally and persists on release
|
|
8
18
|
*/
|
|
9
|
-
export declare function SettingsSlider({ row }: {
|
|
19
|
+
export declare function SettingsSlider({ row, itemProps }: {
|
|
10
20
|
row: SettingsRow;
|
|
21
|
+
itemProps?: MenuNavItemProps;
|
|
11
22
|
}): ReactNode;
|
|
12
23
|
/** One on/off row from a {@link SettingsRow}. */
|
|
13
|
-
export declare function SettingsToggle({ row }: {
|
|
24
|
+
export declare function SettingsToggle({ row, itemProps }: {
|
|
14
25
|
row: SettingsRow;
|
|
26
|
+
itemProps?: MenuNavItemProps;
|
|
15
27
|
}): ReactNode;
|
|
16
28
|
/** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
|
|
17
|
-
export declare function SettingsSelect({ row }: {
|
|
29
|
+
export declare function SettingsSelect({ row, itemProps }: {
|
|
18
30
|
row: SettingsRow;
|
|
31
|
+
itemProps?: MenuNavItemProps;
|
|
19
32
|
}): ReactNode;
|
|
20
|
-
/** Render the control a {@link SettingsRow} kind asks for. */
|
|
21
|
-
export declare function SettingsRowControl({ row }: {
|
|
33
|
+
/** Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}. */
|
|
34
|
+
export declare function SettingsRowControl({ row, itemProps }: {
|
|
22
35
|
row: SettingsRow;
|
|
36
|
+
itemProps?: MenuNavItemProps;
|
|
23
37
|
}): ReactNode;
|
|
24
38
|
/** One rebindable action row: current key glyph, armed-capture state and a reset button. */
|
|
25
39
|
export interface SettingsKeybindControlProps {
|
|
@@ -30,6 +44,8 @@ export interface SettingsKeybindControlProps {
|
|
|
30
44
|
onArm?: () => void;
|
|
31
45
|
/** Props the caller can spread onto the binding button for focus tracking. */
|
|
32
46
|
buttonProps?: Record<string, unknown>;
|
|
47
|
+
/** Props for the row element (a `useMenuNav` item). */
|
|
48
|
+
rowProps?: MenuNavItemProps;
|
|
33
49
|
}
|
|
34
50
|
/**
|
|
35
51
|
* A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
|
|
@@ -37,7 +53,7 @@ export interface SettingsKeybindControlProps {
|
|
|
37
53
|
*
|
|
38
54
|
* @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
|
|
39
55
|
*/
|
|
40
|
-
export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode;
|
|
56
|
+
export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode;
|
|
41
57
|
/** How a {@link SettingsSection} handles a rebind capture. */
|
|
42
58
|
export interface SettingsSectionProps {
|
|
43
59
|
/** The category to render: its rows and its rebindable actions. */
|
|
@@ -46,6 +62,10 @@ export interface SettingsSectionProps {
|
|
|
46
62
|
heading?: boolean;
|
|
47
63
|
/** Allow click-to-rebind on the controls rows. Default true. */
|
|
48
64
|
capture?: boolean;
|
|
65
|
+
/** Take arrow keys, the d-pad and the left stick for row navigation and value edits. Default true. */
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
/** Row focused on mount. Default the first row. */
|
|
68
|
+
initialId?: string;
|
|
49
69
|
className?: string;
|
|
50
70
|
style?: CSSProperties;
|
|
51
71
|
/** Called with the armed action id (or `null`) so a game can suspend its own keys. */
|
|
@@ -56,9 +76,15 @@ export interface SettingsSectionProps {
|
|
|
56
76
|
* game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
|
|
57
77
|
* settings controller hands it, and writes every change straight back to that controller's store.
|
|
58
78
|
*
|
|
59
|
-
*
|
|
79
|
+
* The rows are one `useMenuNav` menu: up/down (or the d-pad/stick) move between them, left/right step
|
|
80
|
+
* the focused value — a slider by its step, a toggle flipped, a select to the next/previous option —
|
|
81
|
+
* and Enter/Space/A confirms, which flips a toggle, steps a select forward or arms a keybind.
|
|
82
|
+
* Keyboard and gamepad share that one mapping, so a held direction repeats at the same rate for both,
|
|
83
|
+
* and no row carries its own key handling.
|
|
84
|
+
*
|
|
85
|
+
* @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
|
|
60
86
|
*/
|
|
61
|
-
export declare function SettingsSection({ category, heading, capture, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
|
|
87
|
+
export declare function SettingsSection({ category, heading, capture, enabled, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
|
|
62
88
|
/** The live category tabs for a settings screen: a scrollable rail with pressed state. */
|
|
63
89
|
export interface SettingsTabsProps {
|
|
64
90
|
/** The live categories from the settings controller. */
|
package/dist/settingsBlocks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { hudSpace, hudType, hudTransition } from "./hudTokens.js";
|
|
4
|
+
import { useMenuNav } from "./menuNav.js";
|
|
4
5
|
import { ScrollRail } from "./scrollRail.js";
|
|
5
6
|
import { useDebouncedCommit } from "./useDebouncedCommit.js";
|
|
6
7
|
const rowStyle = {
|
|
@@ -24,8 +25,35 @@ const valueStyle = {
|
|
|
24
25
|
minWidth: 48,
|
|
25
26
|
textAlign: "right",
|
|
26
27
|
};
|
|
27
|
-
function RowShell({ label, children }) {
|
|
28
|
-
return (_jsxs("div", { "data-settings-row": "", style: rowStyle, children: [_jsx("span", { style: labelStyle, children: label }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("sm"), minWidth: 0 }, children: children })] }));
|
|
28
|
+
function RowShell({ label, children, itemProps }) {
|
|
29
|
+
return (_jsxs("div", { "data-settings-row": "", style: rowStyle, ...itemProps, children: [_jsx("span", { style: labelStyle, children: label }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("sm"), minWidth: 0 }, children: children })] }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a
|
|
33
|
+
* select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the
|
|
34
|
+
* kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
|
|
35
|
+
*
|
|
36
|
+
* @capability adjust-setting-row step a settings row value once, for keyboard and gamepad alike
|
|
37
|
+
*/
|
|
38
|
+
export function adjustSettingRow(row, delta) {
|
|
39
|
+
if (row.kind === "toggle")
|
|
40
|
+
return !Boolean(row.value);
|
|
41
|
+
if (row.kind === "select") {
|
|
42
|
+
const options = row.options ?? [];
|
|
43
|
+
if (options.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
const current = options.findIndex((option) => option.value === String(row.value));
|
|
46
|
+
const base = current < 0 ? (delta > 0 ? -1 : 0) : current;
|
|
47
|
+
return options[(base + delta + options.length) % options.length]?.value;
|
|
48
|
+
}
|
|
49
|
+
if (row.kind !== "slider")
|
|
50
|
+
return undefined;
|
|
51
|
+
const step = row.step ?? 1;
|
|
52
|
+
const min = row.min ?? Number.NEGATIVE_INFINITY;
|
|
53
|
+
const max = row.max ?? Number.POSITIVE_INFINITY;
|
|
54
|
+
const next = Math.min(max, Math.max(min, Number(row.value) + step * delta));
|
|
55
|
+
// Settings persist as plain numbers, so keep float noise out of values like 0.30000000000000004.
|
|
56
|
+
return Math.round(next * 1e6) / 1e6;
|
|
29
57
|
}
|
|
30
58
|
/**
|
|
31
59
|
* One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
|
|
@@ -33,14 +61,14 @@ function RowShell({ label, children }) {
|
|
|
33
61
|
*
|
|
34
62
|
* @capability settings-slider a settings slider row that mirrors locally and persists on release
|
|
35
63
|
*/
|
|
36
|
-
export function SettingsSlider({ row }) {
|
|
64
|
+
export function SettingsSlider({ row, itemProps }) {
|
|
37
65
|
const { value, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
|
|
38
|
-
return (_jsxs(RowShell, { label: row.label, children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, style: { flex: 1, minWidth: 120, accentColor: "var(--jg-accent, #6ee7a8)" } }), _jsx("span", { style: valueStyle, children: row.format?.(value) ?? String(value) })] }));
|
|
66
|
+
return (_jsxs(RowShell, { label: row.label, itemProps: itemProps, children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, style: { flex: 1, minWidth: 120, accentColor: "var(--jg-accent, #6ee7a8)" } }), _jsx("span", { style: valueStyle, children: row.format?.(value) ?? String(value) })] }));
|
|
39
67
|
}
|
|
40
68
|
/** One on/off row from a {@link SettingsRow}. */
|
|
41
|
-
export function SettingsToggle({ row }) {
|
|
69
|
+
export function SettingsToggle({ row, itemProps }) {
|
|
42
70
|
const on = Boolean(row.value);
|
|
43
|
-
return (_jsx(RowShell, { label: row.label, children: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), style: {
|
|
71
|
+
return (_jsx(RowShell, { label: row.label, itemProps: itemProps, children: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), style: {
|
|
44
72
|
position: "relative",
|
|
45
73
|
width: 48,
|
|
46
74
|
height: 26,
|
|
@@ -62,9 +90,9 @@ export function SettingsToggle({ row }) {
|
|
|
62
90
|
} }) }) }));
|
|
63
91
|
}
|
|
64
92
|
/** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
|
|
65
|
-
export function SettingsSelect({ row }) {
|
|
93
|
+
export function SettingsSelect({ row, itemProps }) {
|
|
66
94
|
const current = String(row.value);
|
|
67
|
-
return (_jsx(RowShell, { label: row.label, children: _jsx("div", { role: "group", "aria-label": row.label, style: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 4 }, children: (row.options ?? []).map((option) => {
|
|
95
|
+
return (_jsx(RowShell, { label: row.label, itemProps: itemProps, children: _jsx("div", { role: "group", "aria-label": row.label, style: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 4 }, children: (row.options ?? []).map((option) => {
|
|
68
96
|
const selected = option.value === current;
|
|
69
97
|
return (_jsx("button", { type: "button", "aria-pressed": selected, onClick: () => row.set(option.value), style: {
|
|
70
98
|
minHeight: 30,
|
|
@@ -80,13 +108,13 @@ export function SettingsSelect({ row }) {
|
|
|
80
108
|
}, children: option.label }, option.value));
|
|
81
109
|
}) }) }));
|
|
82
110
|
}
|
|
83
|
-
/** Render the control a {@link SettingsRow} kind asks for. */
|
|
84
|
-
export function SettingsRowControl({ row }) {
|
|
111
|
+
/** Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}. */
|
|
112
|
+
export function SettingsRowControl({ row, itemProps }) {
|
|
85
113
|
if (row.kind === "slider")
|
|
86
|
-
return _jsx(SettingsSlider, { row: row });
|
|
114
|
+
return _jsx(SettingsSlider, { row: row, itemProps: itemProps });
|
|
87
115
|
if (row.kind === "toggle")
|
|
88
|
-
return _jsx(SettingsToggle, { row: row });
|
|
89
|
-
return _jsx(SettingsSelect, { row: row });
|
|
116
|
+
return _jsx(SettingsToggle, { row: row, itemProps: itemProps });
|
|
117
|
+
return _jsx(SettingsSelect, { row: row, itemProps: itemProps });
|
|
90
118
|
}
|
|
91
119
|
/**
|
|
92
120
|
* A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
|
|
@@ -94,8 +122,8 @@ export function SettingsRowControl({ row }) {
|
|
|
94
122
|
*
|
|
95
123
|
* @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
|
|
96
124
|
*/
|
|
97
|
-
export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }) {
|
|
98
|
-
return (_jsxs("div", { "data-settings-keybind": row.action, "data-armed": armed ? "" : undefined, style: rowStyle, children: [_jsx("span", { style: labelStyle, children: row.label }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("xs") }, children: [_jsx("button", { type: "button", "data-settings-capture": "", ...buttonProps, onClick: onArm, disabled: armed, title: "Click, then press a key \u2014 Escape cancels", style: {
|
|
125
|
+
export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps, rowProps }) {
|
|
126
|
+
return (_jsxs("div", { "data-settings-keybind": row.action, "data-armed": armed ? "" : undefined, style: rowStyle, ...rowProps, children: [_jsx("span", { style: labelStyle, children: row.label }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("xs") }, children: [_jsx("button", { type: "button", "data-settings-capture": "", ...buttonProps, onClick: onArm, disabled: armed, title: "Click, then press a key \u2014 Escape cancels", style: {
|
|
99
127
|
minWidth: 84,
|
|
100
128
|
minHeight: 30,
|
|
101
129
|
padding: `0 ${hudSpace("sm")}`,
|
|
@@ -117,43 +145,95 @@ export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps
|
|
|
117
145
|
cursor: "pointer",
|
|
118
146
|
}, children: "\u21BA" })] })] }));
|
|
119
147
|
}
|
|
148
|
+
const ROW_ITEM_PREFIX = "row:";
|
|
149
|
+
const KEY_ITEM_PREFIX = "key:";
|
|
120
150
|
/**
|
|
121
151
|
* One settings category as a block: its rows, then its rebindable actions with click-to-capture. The
|
|
122
152
|
* game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
|
|
123
153
|
* settings controller hands it, and writes every change straight back to that controller's store.
|
|
124
154
|
*
|
|
125
|
-
*
|
|
155
|
+
* The rows are one `useMenuNav` menu: up/down (or the d-pad/stick) move between them, left/right step
|
|
156
|
+
* the focused value — a slider by its step, a toggle flipped, a select to the next/previous option —
|
|
157
|
+
* and Enter/Space/A confirms, which flips a toggle, steps a select forward or arms a keybind.
|
|
158
|
+
* Keyboard and gamepad share that one mapping, so a held direction repeats at the same rate for both,
|
|
159
|
+
* and no row carries its own key handling.
|
|
160
|
+
*
|
|
161
|
+
* @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
|
|
126
162
|
*/
|
|
127
|
-
export function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }) {
|
|
163
|
+
export function SettingsSection({ category, heading = true, capture = true, enabled = true, initialId, className, style, onCaptureChange, }) {
|
|
128
164
|
const [armed, setArmed] = useState(null);
|
|
165
|
+
// Mirrors `armed` synchronously. Two key events in one tick — Escape then the key after it — would both
|
|
166
|
+
// see the pre-render state without it, and the second would rebind what Escape just cancelled.
|
|
167
|
+
const armedRef = useRef(null);
|
|
168
|
+
const arm = useCallback((action) => {
|
|
169
|
+
armedRef.current = action;
|
|
170
|
+
setArmed(action);
|
|
171
|
+
}, []);
|
|
129
172
|
const callbacks = useRef({ onCaptureChange });
|
|
130
173
|
callbacks.current = { onCaptureChange };
|
|
131
174
|
useEffect(() => {
|
|
132
175
|
callbacks.current.onCaptureChange?.(armed);
|
|
133
176
|
}, [armed]);
|
|
177
|
+
const rows = category.rows;
|
|
178
|
+
const keybinds = category.keybinds;
|
|
179
|
+
const ids = useMemo(() => [...rows.map((row) => `${ROW_ITEM_PREFIX}${row.id}`), ...keybinds.map((row) => `${KEY_ITEM_PREFIX}${row.action}`)], [rows, keybinds]);
|
|
180
|
+
const adjust = useCallback((id, delta) => {
|
|
181
|
+
// A keybind row has no value to step; confirming it is what arms the capture.
|
|
182
|
+
if (id.startsWith(KEY_ITEM_PREFIX))
|
|
183
|
+
return;
|
|
184
|
+
const row = rows.find((candidate) => `${ROW_ITEM_PREFIX}${candidate.id}` === id);
|
|
185
|
+
if (row === undefined)
|
|
186
|
+
return;
|
|
187
|
+
const next = adjustSettingRow(row, delta);
|
|
188
|
+
if (next !== undefined)
|
|
189
|
+
row.set(next);
|
|
190
|
+
}, [rows]);
|
|
191
|
+
const confirm = useCallback((id) => {
|
|
192
|
+
if (id.startsWith(ROW_ITEM_PREFIX)) {
|
|
193
|
+
const row = rows.find((candidate) => `${ROW_ITEM_PREFIX}${candidate.id}` === id);
|
|
194
|
+
// A slider's value belongs to left/right; confirming it would jump the thumb for a keyboard user.
|
|
195
|
+
if (row === undefined || row.kind === "slider")
|
|
196
|
+
return;
|
|
197
|
+
const next = adjustSettingRow(row, 1);
|
|
198
|
+
if (next !== undefined)
|
|
199
|
+
row.set(next);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!capture)
|
|
203
|
+
return;
|
|
204
|
+
arm(id.slice(KEY_ITEM_PREFIX.length));
|
|
205
|
+
}, [arm, capture, rows]);
|
|
206
|
+
const nav = useMenuNav({
|
|
207
|
+
ids,
|
|
208
|
+
...(initialId === undefined ? {} : { initialId: `${ROW_ITEM_PREFIX}${initialId}` }),
|
|
209
|
+
enabled: enabled && armed === null,
|
|
210
|
+
onAdjust: adjust,
|
|
211
|
+
onConfirm: confirm,
|
|
212
|
+
});
|
|
134
213
|
useEffect(() => {
|
|
135
214
|
if (armed === null)
|
|
136
215
|
return;
|
|
137
|
-
const row =
|
|
216
|
+
const row = keybinds.find((candidate) => candidate.action === armed);
|
|
138
217
|
if (row === undefined) {
|
|
139
|
-
|
|
218
|
+
arm(null);
|
|
140
219
|
return;
|
|
141
220
|
}
|
|
142
221
|
const onKey = (event) => {
|
|
143
222
|
event.preventDefault();
|
|
144
223
|
event.stopPropagation();
|
|
145
|
-
if (
|
|
146
|
-
setArmed(null);
|
|
224
|
+
if (armedRef.current !== row.action)
|
|
147
225
|
return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
226
|
+
arm(null);
|
|
227
|
+
if (event.code !== "Escape")
|
|
228
|
+
row.rebind(event.code);
|
|
151
229
|
};
|
|
152
230
|
const onPointer = (event) => {
|
|
153
231
|
event.preventDefault();
|
|
154
232
|
event.stopPropagation();
|
|
233
|
+
if (armedRef.current !== row.action)
|
|
234
|
+
return;
|
|
235
|
+
arm(null);
|
|
155
236
|
row.rebind(`mouse${event.button}`);
|
|
156
|
-
setArmed(null);
|
|
157
237
|
};
|
|
158
238
|
const onContext = (event) => event.preventDefault();
|
|
159
239
|
window.addEventListener("keydown", onKey, true);
|
|
@@ -164,8 +244,8 @@ export function SettingsSection({ category, heading = true, capture = true, clas
|
|
|
164
244
|
window.removeEventListener("pointerdown", onPointer, true);
|
|
165
245
|
window.removeEventListener("contextmenu", onContext, true);
|
|
166
246
|
};
|
|
167
|
-
}, [armed,
|
|
168
|
-
return (_jsxs("section", { "data-settings-section": category.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), ...style }, children: [heading ? (_jsx("h3", { style: {
|
|
247
|
+
}, [armed, arm, keybinds]);
|
|
248
|
+
return (_jsxs("section", { ...nav.containerProps, "data-settings-section": category.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), ...style }, children: [heading ? (_jsx("h3", { style: {
|
|
169
249
|
margin: 0,
|
|
170
250
|
marginBottom: hudSpace("xs"),
|
|
171
251
|
fontFamily: "var(--jg-font-display, inherit)",
|
|
@@ -174,7 +254,7 @@ export function SettingsSection({ category, heading = true, capture = true, clas
|
|
|
174
254
|
letterSpacing: "0.18em",
|
|
175
255
|
textTransform: "uppercase",
|
|
176
256
|
color: "var(--jg-accent, #6ee7a8)",
|
|
177
|
-
}, children: category.label })) : null,
|
|
257
|
+
}, children: category.label })) : null, rows.map((row) => (_jsx(SettingsRowControl, { row: row, itemProps: nav.itemProps(`${ROW_ITEM_PREFIX}${row.id}`) }, row.id))), keybinds.map((row) => (_jsx(SettingsKeybindControl, { row: row, armed: armed === row.action, onArm: capture ? () => arm(row.action) : undefined, rowProps: nav.itemProps(`${KEY_ITEM_PREFIX}${row.action}`) }, row.action)))] }));
|
|
178
258
|
}
|
|
179
259
|
/**
|
|
180
260
|
* The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"styles": "bun scripts/styles.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@jgengine/core": "^0.
|
|
38
|
+
"@jgengine/core": "^0.30.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^19.0.0",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
287
287
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
288
|
-
- `VERSION` (const): const VERSION: "0.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -407,6 +407,7 @@
|
|
|
407
407
|
- `TEXT_SCALE_MAX` (const): const TEXT_SCALE_MAX: 2 — Maximum UI text scale.
|
|
408
408
|
- `TEXT_SCALE_MIN` (const): const TEXT_SCALE_MIN: 0.75 — Minimum UI text scale.
|
|
409
409
|
- `clampTextScale` (function): function clampTextScale(scale: number): number — Clamp a text-scale multiplier into the supported `[TEXT_SCALE_MIN, TEXT_SCALE_MAX]` range.
|
|
410
|
+
- `colorblindFrameFilter` (function): function colorblindFrameFilter(mode: ColorblindMode): string | null — The CSS filter that applies `mode` to one rendered frame — a `url(#jg-cb-<mode>)` reference to the `feColorMatrix` defs {@link COLORBLIND_MATRICES} drives. `"none"` maps to `null` (no filter), so a caller can spread it onto the canvas alone and leave HUD and menu text at its own contrast.
|
|
410
411
|
- `createAccessibilityStore` (function): function createAccessibilityStore(initial: Partial<AccessibilityState> = {}): AccessibilityStore — Serializable, observable store of accessibility preferences (reduced motion, high contrast, text scale, colorblind mode, captions). Genre-agnostic plumbing like input rebinding — a game binds these to its settings UI and adapts its presentation; `snapshot`/`restore` round-trip through a save or settings blob.
|
|
411
412
|
- `reducedMotionDuration` (function): function reducedMotionDuration(state: AccessibilityState, ms: number, reduced = 0): number — Return `ms` when motion is allowed, or `reduced` (default 0) when `reducedMotion` is set — the animation-duration gate.
|
|
412
413
|
|
|
@@ -652,7 +653,7 @@
|
|
|
652
653
|
## @jgengine/react
|
|
653
654
|
|
|
654
655
|
- `AbilitySlotBindingOptions` (interface): interface AbilitySlotBindingOptions { intervalMs?: number } — ⚠ undocumented · used by `useEventMeter`: Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render half of the ult/adrenaline…
|
|
655
|
-
- `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to
|
|
656
|
+
- `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
|
|
656
657
|
- `AchievementGallery` (function): function AchievementGallery({ achievements, title = "Achievements", maskSecrets = true, renderIcon, columns = 2, emptyLabel = "No achievements yet.", className, style, }: AchievementGalleryProps): ReactNode — Achievement/trophy gallery — a responsive grid of cards showing unlocked vs. locked state, a progress bar for counter achievements, and a header summary of completion and score. Secret+locked entries mask their name/description. Feed it `useAchievements(tracker)`.
|
|
657
658
|
- `AchievementGalleryProps` (interface): interface AchievementGalleryProps — Props for {@link AchievementGallery}.
|
|
658
659
|
- `AchievementToast` (function): function AchievementToast({ name, description, icon, points, heading = "Achievement Unlocked", className, style, }: AchievementToastProps): ReactNode — The unlock-moment banner — icon, "Achievement Unlocked" heading, name, and optional points. Purely presentational; pair the game's `onUnlock` seam with a toast queue and render one of these per entry.
|
|
@@ -703,7 +704,9 @@
|
|
|
703
704
|
- `Codex` (function): function Codex({ entries, title = "Codex", columns = 3, maskSecrets = true, renderIcon, onSelect, emptyLabel = "Nothing discovered yet.", className, style, }: CodexProps): ReactNode — Codex / bestiary gallery — category tabs, a responsive grid of entry cards (discovered vs. locked, secret entries masked until found), and a header summarizing completion. Feed it `codex.list()`; wire `onSelect` to a detail pane.
|
|
704
705
|
- `CodexProps` (interface): interface CodexProps — Props for {@link Codex}.
|
|
705
706
|
- `Coins` (function): function Coins({ currencyId, icon = "🪙", style, className, }: { currencyId: string; icon?: ReactNode; style?: CSSProperties; className?: string; }): React.JSX.Element — A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
|
|
706
|
-
- `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `
|
|
707
|
+
- `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
|
|
708
|
+
- `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
|
|
709
|
+
- `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
|
|
707
710
|
- `ComboMeterHud` (function): function ComboMeterHud({ meter, tierColors, theme, animate = true, hideWhenEmpty = true, barWidth = 220, className, style, }: ComboMeterHudProps): ReactNode — A drop-in HUD for a core {@link ComboMeter}: the big live combo count ("×12"), the current free-string tier label, the derived score multiplier, and a draining window bar (reusing the shared atomic bar) that empties as the decay window runs out and re-fills on every hit. The active tier's color — supplied by the game via `tierColors`, keyed on the free-string tier id the model never interprets — drives the count glow, the label, and the bar fill, so a game reskins the escalation ("good" → "great" → "savage") without the engine hardcoding any tier meaning. Presentation only: all counting, timing, tiers, and the multiplier live in the core model.
|
|
708
711
|
- `ComboMeterHudProps` (interface): interface ComboMeterHudProps — Props for {@link ComboMeterHud}.
|
|
709
712
|
- `ComboMeterTheme` (interface): interface ComboMeterTheme — Reskin tokens for {@link ComboMeterHud}.
|
|
@@ -1067,6 +1070,7 @@
|
|
|
1067
1070
|
- `useChat` (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[] — ⚠ undocumented
|
|
1068
1071
|
- `useChatBubbles` (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[] — ⚠ undocumented
|
|
1069
1072
|
- `useCoachMarks` (function): function useCoachMarks(sequence: CoachMarkSequence): CoachMarkView | null — Subscribe to a coach-mark sequence and re-render on every change, returning the current step view (or `null` when the tour is complete or waiting on a gate).
|
|
1073
|
+
- `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
|
|
1070
1074
|
- `useComboMeter` (function): function useComboMeter(meter: ComboMeter, animate = true): ComboMeterView — Subscribe to a combo meter and re-render it each animation frame so the decay window bar drains live, returning the pooled view to draw right now. Reading the view each frame advances the meter's clock and fires window expiry, so a combo that times out visibly drops even with no further hits. The returned object is the model's pooled view — read its fields immediately, do not retain it.
|
|
1071
1075
|
- `useCurrency` (function): function useCurrency(currencyId: string): number — ⚠ undocumented
|
|
1072
1076
|
- `useDamageDirection` (function): function useDamageDirection(tracker: DamageDirectionTracker): readonly DamageIndicator[] — Subscribe to a damage-direction tracker and animate. Because indicators fade continuously on a clock (not just on discrete events), this drives a `requestAnimationFrame` loop while any indicator is live and stops when the screen is clear, then wakes again on the next hit. Returns the tracker's current `active()` array (reused between frames — copy if you retain it).
|
|
@@ -1110,7 +1114,7 @@
|
|
|
1110
1114
|
- `useLocalPlayerDead` (function): function useLocalPlayerDead(healthStatId = "health"): boolean — ⚠ undocumented
|
|
1111
1115
|
- `useLocale` (function): function useLocale(): Locale — Subscribe to the active locale — re-renders on `setLocale`.
|
|
1112
1116
|
- `useMarkers` (function): function useMarkers<TMeta = unknown>(markers: MarkerSet<TMeta>): readonly MapMarker<TMeta>[] — Subscribe to a native marker set or external marker source, or read a static marker array.
|
|
1113
|
-
- `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
|
|
1117
|
+
- `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
|
|
1114
1118
|
- `useModalStack` (function): function useModalStack(stack: ModalStack): ModalStackView — Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
|
|
1115
1119
|
- `useNearestWorldItem` (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
|
|
1116
1120
|
- `useNotifications` (function): function useNotifications<TMeta = unknown>(store: NotificationStore<TMeta>): readonly NotificationEntry<TMeta>[] — Subscribe to a notification store's live list.
|
|
@@ -1161,9 +1165,12 @@
|
|
|
1161
1165
|
|
|
1162
1166
|
## @jgengine/react/accessibility
|
|
1163
1167
|
|
|
1164
|
-
- `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to
|
|
1165
|
-
- `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `
|
|
1168
|
+
- `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
|
|
1169
|
+
- `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
|
|
1170
|
+
- `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
|
|
1171
|
+
- `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
|
|
1166
1172
|
- `useAccessibility` (function): function useAccessibility(): AccessibilityContextValue — Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above.
|
|
1173
|
+
- `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
|
|
1167
1174
|
- `usePrefersReducedMotion` (function): function usePrefersReducedMotion(): boolean — OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live boolean — SSR-safe (defaults false). Use it to seed an accessibility store's `reducedMotion` default.
|
|
1168
1175
|
- `useReducedMotion` (function): function useReducedMotion(): boolean — True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
|
|
1169
1176
|
|
|
@@ -1746,7 +1753,7 @@
|
|
|
1746
1753
|
- `MenuNavItemProps` (interface): interface MenuNavItemProps — Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus.
|
|
1747
1754
|
- `MenuNavRender` (type): type MenuNavRender = (nav: MenuNavController) => ReactNode — Render prop for a menu whose items come from {@link useMenuNav}.
|
|
1748
1755
|
- `UseMenuNavOptions` (interface): interface UseMenuNavOptions — How {@link useMenuNav} is wired.
|
|
1749
|
-
- `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
|
|
1756
|
+
- `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
|
|
1750
1757
|
|
|
1751
1758
|
## @jgengine/react/menuRouter
|
|
1752
1759
|
|
|
@@ -1921,16 +1928,17 @@
|
|
|
1921
1928
|
|
|
1922
1929
|
## @jgengine/react/settingsBlocks
|
|
1923
1930
|
|
|
1924
|
-
- `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
|
|
1931
|
+
- `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
|
|
1925
1932
|
- `SettingsKeybindControlProps` (interface): interface SettingsKeybindControlProps — One rebindable action row: current key glyph, armed-capture state and a reset button.
|
|
1926
|
-
- `SettingsRowControl` (function): function SettingsRowControl({ row }: { row: SettingsRow }): ReactNode — Render the control a {@link SettingsRow} kind asks for.
|
|
1927
|
-
- `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
|
|
1933
|
+
- `SettingsRowControl` (function): function SettingsRowControl({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}.
|
|
1934
|
+
- `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, enabled = true, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
|
|
1928
1935
|
- `SettingsSectionProps` (interface): interface SettingsSectionProps — How a {@link SettingsSection} handles a rebind capture.
|
|
1929
|
-
- `SettingsSelect` (function): function SettingsSelect({ row }: { row: SettingsRow }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
|
|
1930
|
-
- `SettingsSlider` (function): function SettingsSlider({ row }: { row: SettingsRow }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
|
|
1936
|
+
- `SettingsSelect` (function): function SettingsSelect({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
|
|
1937
|
+
- `SettingsSlider` (function): function SettingsSlider({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
|
|
1931
1938
|
- `SettingsTabs` (function): function SettingsTabs({ categories, activeId, onSelect, leading, tabProps, className, style }: SettingsTabsProps): ReactNode — The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The game owns the surrounding layout; this block keeps every category reachable on narrow screens.
|
|
1932
1939
|
- `SettingsTabsProps` (interface): interface SettingsTabsProps — The live category tabs for a settings screen: a scrollable rail with pressed state.
|
|
1933
|
-
- `SettingsToggle` (function): function SettingsToggle({ row }: { row: SettingsRow }): ReactNode — One on/off row from a {@link SettingsRow}.
|
|
1940
|
+
- `SettingsToggle` (function): function SettingsToggle({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One on/off row from a {@link SettingsRow}.
|
|
1941
|
+
- `adjustSettingRow` (function): function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined — One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
|
|
1934
1942
|
|
|
1935
1943
|
## @jgengine/react/shopGrid
|
|
1936
1944
|
|
|
@@ -3654,9 +3662,9 @@
|
|
|
3654
3662
|
- `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented
|
|
3655
3663
|
- `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented
|
|
3656
3664
|
- `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number; pxPerMeter?: number } — ⚠ undocumented
|
|
3657
|
-
- `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance
|
|
3665
|
+
- `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond this … — ⚠ undocumented
|
|
3658
3666
|
- `WorldFloatText` (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element — ⚠ undocumented
|
|
3659
|
-
- `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance
|
|
3667
|
+
- `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React.JSX.… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
|
|
3660
3668
|
- `WorldNameplatesProps` (interface): interface WorldNameplatesProps — Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks.
|
|
3661
3669
|
- `WorldObjectHighlights` (function): function WorldObjectHighlights({ color = "#facc15", radius, y = 0.05 }: WorldObjectHighlightsProps): React.JSX.Element — Ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`. Mount it once in the game's scene (headless: no defaults are imposed beyond a visible ring) instead of hand-rolling a selection highlight through `WorldOverlay` against external state.
|
|
3662
3670
|
- `WorldObjectHighlightsProps` (interface): interface WorldObjectHighlightsProps — Props for {@link WorldObjectHighlights}.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*.
|
|
6
6
|
|
|
7
|
-
## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var
|
|
7
|
+
## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
|
|
8
8
|
|
|
9
9
|
- `AccessibilityProvider` (function) · `import { AccessibilityProvider } from "@jgengine/react"`
|
|
10
10
|
|
|
@@ -52,6 +52,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
52
52
|
|
|
53
53
|
- `ActionTooltip` (function) · `import { ActionTooltip } from "@jgengine/react"`
|
|
54
54
|
|
|
55
|
+
## adjust-setting-row — step a settings row value once, for keyboard and gamepad alike
|
|
56
|
+
|
|
57
|
+
- `adjustSettingRow` (function) · `import { adjustSettingRow } from "@jgengine/react/settingsBlocks"`
|
|
58
|
+
|
|
55
59
|
## animation — turn a killed rigged character into a physics ragdoll on death
|
|
56
60
|
|
|
57
61
|
- `useRagdoll` (function) · `import { useRagdoll } from "@jgengine/shell/render/useRagdoll"`
|
|
@@ -159,6 +163,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
159
163
|
|
|
160
164
|
- `ColorblindFilters` (function) · `import { ColorblindFilters } from "@jgengine/react"`
|
|
161
165
|
|
|
166
|
+
## colorblind-frame — apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
|
|
167
|
+
|
|
168
|
+
- `ColorblindFrame` (function) · `import { ColorblindFrame } from "@jgengine/react"`
|
|
169
|
+
|
|
170
|
+
## colorblind-frame-filter — resolve a colorblind mode to the CSS filter for the 3D frame alone
|
|
171
|
+
|
|
172
|
+
- `colorblindFrameFilter` (function) · `import { colorblindFrameFilter } from "@jgengine/core/ui/accessibility"`
|
|
173
|
+
|
|
174
|
+
## colorblind-frame-mode — read the live colour-vision mode from the settings store that the settings rows write
|
|
175
|
+
|
|
176
|
+
- `useColorblindMode` (function) · `import { useColorblindMode } from "@jgengine/react"`
|
|
177
|
+
|
|
162
178
|
## 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
|
|
163
179
|
|
|
164
180
|
- `ComboMeterHud` (function) · `import { ComboMeterHud } from "@jgengine/react"`
|
|
@@ -758,7 +774,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
758
774
|
|
|
759
775
|
- `SettingsKeybindControl` (function) · `import { SettingsKeybindControl } from "@jgengine/react/settingsBlocks"`
|
|
760
776
|
|
|
761
|
-
## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
|
|
777
|
+
## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
|
|
762
778
|
|
|
763
779
|
- `SettingsSection` (function) · `import { SettingsSection } from "@jgengine/react/settingsBlocks"`
|
|
764
780
|
|
|
@@ -279,9 +279,9 @@ HUD-only games (`presentation: "hud"` or a camera with rig `"none"`) use the sam
|
|
|
279
279
|
|
|
280
280
|
**Present it any way you want.** `useSettings()` (`@jgengine/react`) returns the live controller — `{ categories, actions, variant, surface, isOpen, open, close, setOpen }` — so a game can drive its own pause-menu button, or render `categories`/`actions` (rows carry `value`/`set`/bounds, keybinds carry `rebind`/`reset`) entirely inside its own HUD. `useHasSettings()` gates a custom entry; `useSetting(id, fallback)` reads/writes one value. Set a slider's `min`/`max` explicitly — an omitted range collapses the thumb to 0/1.
|
|
281
281
|
|
|
282
|
-
**Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category
|
|
282
|
+
**Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category - its rows, then its click-to-capture keybind rows - as one `useMenuNav` menu: the d-pad and left stick move between rows, left/right step the focused value (slider by its step, toggle flipped, select to the next option), and confirm arms a keybind. Arrow keys take the same `onAdjust` path, so a keyboard and a pad cannot diverge. `SettingsTabs` renders the category rail (pass `tabProps` from `useMenuNav` so a gamepad can switch categories). `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect` and `SettingsKeybindControl` are the individual rows if the layout is not a list; pass the `itemProps` they accept so focus tracking still works. All of them read `--jg-*` tokens and write through the controller, so the game's rebinds land in the same keybinding store as the stock menu. The stock menu uses these same blocks, so a game-composed screen and the engine dialog cannot drift.
|
|
283
283
|
|
|
284
|
-
**Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout.
|
|
284
|
+
**Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout. The 3D shell applies the colourblind matrix to its canvas alone with `ColorblindFrame` (`@jgengine/react/accessibility`), so HUD and menu text keep the contrast the rest of the UI assumes. A custom presentation wraps its own canvas in `ColorblindFrame` with `useColorblindMode(store)`; wrapping the HUD or menu root instead recolours the text, which is why `AccessibilityProvider` sets `data-colorblind` and leaves the filtering to the frame.
|
|
285
285
|
|
|
286
286
|
## 8. Motion and game feel
|
|
287
287
|
|