@jgengine/react 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/accessibility.d.ts +39 -0
  3. package/dist/accessibility.js +61 -0
  4. package/dist/achievements.d.ts +50 -0
  5. package/dist/achievements.js +94 -0
  6. package/dist/cameraShake.d.ts +52 -0
  7. package/dist/cameraShake.js +53 -0
  8. package/dist/coachMarks.d.ts +72 -0
  9. package/dist/coachMarks.js +136 -0
  10. package/dist/codex.d.ts +25 -0
  11. package/dist/codex.js +62 -0
  12. package/dist/comboMeter.d.ts +63 -0
  13. package/dist/comboMeter.js +93 -0
  14. package/dist/components.js +4 -3
  15. package/dist/creditsScreen.d.ts +19 -0
  16. package/dist/creditsScreen.js +31 -0
  17. package/dist/cutscene.d.ts +66 -0
  18. package/dist/cutscene.js +132 -0
  19. package/dist/damageDirection.d.ts +51 -0
  20. package/dist/damageDirection.js +103 -0
  21. package/dist/dialogueView.d.ts +69 -0
  22. package/dist/dialogueView.js +111 -0
  23. package/dist/entityFrames.d.ts +114 -0
  24. package/dist/entityFrames.js +55 -0
  25. package/dist/fastTravel.d.ts +36 -0
  26. package/dist/fastTravel.js +103 -0
  27. package/dist/floatingText.d.ts +84 -0
  28. package/dist/floatingText.js +61 -0
  29. package/dist/gameViewport.js +8 -12
  30. package/dist/hooks.d.ts +42 -0
  31. package/dist/hooks.js +85 -0
  32. package/dist/hudTheme.d.ts +104 -7
  33. package/dist/hudTheme.js +300 -2
  34. package/dist/i18n.d.ts +29 -0
  35. package/dist/i18n.js +44 -0
  36. package/dist/index.d.ts +34 -0
  37. package/dist/index.js +34 -0
  38. package/dist/interactionPrompt.d.ts +80 -0
  39. package/dist/interactionPrompt.js +112 -0
  40. package/dist/keybindingMenu.d.ts +59 -0
  41. package/dist/keybindingMenu.js +146 -0
  42. package/dist/killFeed.d.ts +63 -0
  43. package/dist/killFeed.js +88 -0
  44. package/dist/map.d.ts +123 -2
  45. package/dist/map.js +239 -36
  46. package/dist/menuRouter.d.ts +51 -0
  47. package/dist/menuRouter.js +49 -0
  48. package/dist/modals.d.ts +131 -0
  49. package/dist/modals.js +168 -0
  50. package/dist/notifications.d.ts +39 -0
  51. package/dist/notifications.js +111 -0
  52. package/dist/objectiveBanner.d.ts +62 -0
  53. package/dist/objectiveBanner.js +129 -0
  54. package/dist/photoMode.d.ts +21 -0
  55. package/dist/photoMode.js +39 -0
  56. package/dist/previewFixtures.d.ts +30 -0
  57. package/dist/previewFixtures.js +46 -0
  58. package/dist/questTracker.d.ts +24 -0
  59. package/dist/questTracker.js +31 -0
  60. package/dist/quickMenu.d.ts +39 -0
  61. package/dist/quickMenu.js +79 -0
  62. package/dist/radialMenu.d.ts +50 -0
  63. package/dist/radialMenu.js +69 -0
  64. package/dist/saveSlots.d.ts +81 -0
  65. package/dist/saveSlots.js +138 -0
  66. package/dist/scoreboard.d.ts +67 -0
  67. package/dist/scoreboard.js +71 -0
  68. package/dist/settings.d.ts +9 -3
  69. package/dist/settings.js +10 -4
  70. package/dist/shopGrid.d.ts +70 -0
  71. package/dist/shopGrid.js +129 -0
  72. package/dist/statusEffectBar.d.ts +52 -0
  73. package/dist/statusEffectBar.js +57 -0
  74. package/dist/streetGeometryPreview.d.ts +7 -0
  75. package/dist/streetGeometryPreview.js +119 -0
  76. package/dist/talentTree.d.ts +60 -0
  77. package/dist/talentTree.js +217 -0
  78. package/dist/timerReadout.d.ts +90 -0
  79. package/dist/timerReadout.js +111 -0
  80. package/dist/useDebouncedCommit.d.ts +54 -0
  81. package/dist/useDebouncedCommit.js +81 -0
  82. package/dist/waveHud.d.ts +49 -0
  83. package/dist/waveHud.js +53 -0
  84. package/dist/waypointMarkers.d.ts +80 -0
  85. package/dist/waypointMarkers.js +116 -0
  86. package/package.json +3 -3
  87. package/skills/ce-handoff/SKILL.md +149 -0
  88. package/skills/jgengine/SKILL.md +3 -1
  89. package/skills/jgengine/api.md +17 -7
  90. package/skills/jgengine/capabilities.md +12 -0
  91. package/skills/jgengine/recipes/minimal-game.md +7 -4
  92. package/skills/jgengine-ui/SKILL.md +5 -3
  93. package/skills/jgengine-ui/api.md +708 -19
  94. package/skills/jgengine-ui/capabilities.md +362 -1
  95. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  96. package/skills/jgengine-ui/reference.md +7 -4
@@ -0,0 +1,69 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { DialogueGraphChoice, DialogueGraphView, DialogueRun } from "@jgengine/core/game/dialogueGraph";
3
+ /**
4
+ * Subscribe to a {@link DialogueRun} and re-render on every advance / jump / reset,
5
+ * returning the current node view (or `null` if the run's node id is unknown).
6
+ *
7
+ * @capability use-dialogue-run React hook binding a branching dialogue run to a component — re-renders as the player advances the conversation
8
+ */
9
+ export declare function useDialogueRun(run: DialogueRun): DialogueGraphView | null;
10
+ /** Reskin tokens for {@link DialogueView}. Each defaults to a HudTheme `--jg-*` token. */
11
+ export interface DialogueViewTheme {
12
+ /** Panel background. Default reads `--jg-frame-bg`. */
13
+ background?: string;
14
+ /** Panel border. Default reads `--jg-frame-border`. */
15
+ border?: string;
16
+ /** Panel corner radius (any CSS length). Default reads `--jg-frame-radius`. */
17
+ radius?: string;
18
+ /** Panel outer glow / drop shadow. Default reads `--jg-frame-glow`. */
19
+ glow?: string;
20
+ /** Body/line text color. Default reads `--jg-bar-text`. */
21
+ text?: string;
22
+ /** Accent for the speaker name and choice hover/focus. Default reads `--jg-accent`. */
23
+ accent?: string;
24
+ /** Choice button background. Default reads `--jg-slot-bg`. */
25
+ choiceBackground?: string;
26
+ /** Choice button border. Default reads `--jg-slot-border`. */
27
+ choiceBorder?: string;
28
+ /** Choice button corner radius. Default reads `--jg-slot-radius`. */
29
+ choiceRadius?: string;
30
+ }
31
+ /** Props for {@link DialogueView}. */
32
+ export interface DialogueViewProps {
33
+ /** The live branching-conversation run to render and advance. */
34
+ run: DialogueRun;
35
+ /**
36
+ * Render a portrait for the current node. Given the node's opaque `portrait` key/URL and
37
+ * its `speakerKind`; return `null` to omit the portrait slot. Omit the prop for no portrait.
38
+ */
39
+ renderPortrait?: (portrait: string | undefined, speakerKind: string | undefined) => ReactNode;
40
+ /** Called when the player picks the choice at `index` (after the run advances). */
41
+ onChoose?: (choice: DialogueGraphChoice, index: number) => void;
42
+ /**
43
+ * Called when the player advances a terminal node — a choice with no destination, or the
44
+ * Close control on a node with no choices. Wire this to `ctx.game.dialogue.close()`.
45
+ */
46
+ onClose?: () => void;
47
+ /** Label for the Close control shown on a terminal node with no choices. Default `"Close"`. */
48
+ closeLabel?: string;
49
+ theme?: DialogueViewTheme;
50
+ className?: string;
51
+ style?: CSSProperties;
52
+ speakerClassName?: string;
53
+ textClassName?: string;
54
+ choicesClassName?: string;
55
+ choiceClassName?: string;
56
+ }
57
+ /**
58
+ * A drop-in branching-conversation view over a {@link DialogueRun}: renders the current
59
+ * node's speaker name (+ an optional portrait slot), the line, and a list of clickable
60
+ * response choices that advance the conversation — the game passes its dialogue graph and
61
+ * gets working node traversal and choice state with no hand-rolled walk. A choice with a
62
+ * destination advances the run; a terminal choice (or the Close control on a choiceless
63
+ * node) fires `onClose`. Speaker/choice `kind` are free strings surfaced as `data-*`
64
+ * attributes for the game to style; the view never interprets them. HudTheme-skinned via
65
+ * `--jg-*` tokens; reskin further with {@link DialogueViewTheme}.
66
+ *
67
+ * @capability dialogue-view drop-in branching conversation UI — speaker + portrait slot, current line, clickable response choices that advance a DialogueRun
68
+ */
69
+ export declare function DialogueView({ run, renderPortrait, onChoose, onClose, closeLabel, theme, className, style, speakerClassName, textClassName, choicesClassName, choiceClassName, }: DialogueViewProps): ReactNode;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer } from "react";
3
+ /**
4
+ * Subscribe to a {@link DialogueRun} and re-render on every advance / jump / reset,
5
+ * returning the current node view (or `null` if the run's node id is unknown).
6
+ *
7
+ * @capability use-dialogue-run React hook binding a branching dialogue run to a component — re-renders as the player advances the conversation
8
+ */
9
+ export function useDialogueRun(run) {
10
+ const [, bump] = useReducer((n) => n + 1, 0);
11
+ useEffect(() => run.subscribe(bump), [run]);
12
+ return run.current();
13
+ }
14
+ function resolveTheme(theme) {
15
+ return {
16
+ background: theme?.background ?? "var(--jg-frame-bg, linear-gradient(180deg, rgba(20,24,32,0.92), rgba(10,12,16,0.94)))",
17
+ border: theme?.border ?? "var(--jg-frame-border, 1px solid rgba(255,255,255,0.12))",
18
+ radius: theme?.radius ?? "var(--jg-frame-radius, 12px)",
19
+ glow: theme?.glow ?? "var(--jg-frame-glow, 0 18px 48px rgba(0,0,0,0.5))",
20
+ text: theme?.text ?? "var(--jg-bar-text, #e5e9f0)",
21
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
22
+ choiceBackground: theme?.choiceBackground ?? "var(--jg-slot-bg, rgba(12,14,20,0.72))",
23
+ choiceBorder: theme?.choiceBorder ?? "var(--jg-slot-border, 1px solid rgba(148,163,184,0.28))",
24
+ choiceRadius: theme?.choiceRadius ?? "var(--jg-slot-radius, 8px)",
25
+ };
26
+ }
27
+ /**
28
+ * A drop-in branching-conversation view over a {@link DialogueRun}: renders the current
29
+ * node's speaker name (+ an optional portrait slot), the line, and a list of clickable
30
+ * response choices that advance the conversation — the game passes its dialogue graph and
31
+ * gets working node traversal and choice state with no hand-rolled walk. A choice with a
32
+ * destination advances the run; a terminal choice (or the Close control on a choiceless
33
+ * node) fires `onClose`. Speaker/choice `kind` are free strings surfaced as `data-*`
34
+ * attributes for the game to style; the view never interprets them. HudTheme-skinned via
35
+ * `--jg-*` tokens; reskin further with {@link DialogueViewTheme}.
36
+ *
37
+ * @capability dialogue-view drop-in branching conversation UI — speaker + portrait slot, current line, clickable response choices that advance a DialogueRun
38
+ */
39
+ export function DialogueView({ run, renderPortrait, onChoose, onClose, closeLabel = "Close", theme, className, style, speakerClassName, textClassName, choicesClassName, choiceClassName, }) {
40
+ const view = useDialogueRun(run);
41
+ const t = resolveTheme(theme);
42
+ if (view === null)
43
+ return null;
44
+ const portrait = renderPortrait?.(view.portrait, view.speakerKind) ?? null;
45
+ const handleChoose = (choice, index) => {
46
+ const terminal = choice.to === undefined || choice.to === null;
47
+ run.choose(index);
48
+ onChoose?.(choice, index);
49
+ if (terminal)
50
+ onClose?.();
51
+ };
52
+ return (_jsxs("div", { className: className, "data-dialogue-node": view.nodeId, "data-speaker-kind": view.speakerKind, style: {
53
+ boxSizing: "border-box",
54
+ maxWidth: 560,
55
+ width: "100%",
56
+ padding: 18,
57
+ background: t.background,
58
+ border: t.border,
59
+ borderRadius: t.radius,
60
+ boxShadow: t.glow,
61
+ color: t.text,
62
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
63
+ display: "flex",
64
+ gap: 16,
65
+ alignItems: "flex-start",
66
+ pointerEvents: "auto",
67
+ ...style,
68
+ }, children: [portrait !== null ? (_jsx("div", { "data-dialogue-portrait": true, style: {
69
+ flex: "0 0 auto",
70
+ width: 72,
71
+ height: 72,
72
+ borderRadius: 10,
73
+ overflow: "hidden",
74
+ border: t.choiceBorder,
75
+ display: "grid",
76
+ placeItems: "center",
77
+ background: t.choiceBackground,
78
+ }, children: portrait })) : null, _jsxs("div", { style: { flex: "1 1 auto", minWidth: 0 }, children: [view.speaker !== undefined ? (_jsx("div", { className: speakerClassName, "data-dialogue-speaker": true, style: {
79
+ fontSize: 12,
80
+ fontWeight: 800,
81
+ letterSpacing: 0.6,
82
+ textTransform: "uppercase",
83
+ color: t.accent,
84
+ }, children: view.speaker })) : null, _jsx("p", { className: textClassName, "data-dialogue-text": true, style: { margin: view.speaker !== undefined ? "6px 0 0" : 0, fontSize: 15, lineHeight: 1.55 }, children: view.text }), _jsx("div", { className: choicesClassName, "data-dialogue-choices": true, style: { marginTop: 14, display: "flex", flexDirection: "column", gap: 8 }, children: view.choices.length > 0
85
+ ? view.choices.map((choice, index) => (_jsx("button", { type: "button", className: choiceClassName, "data-dialogue-choice": index, "data-choice-kind": choice.kind, onClick: () => handleChoose(choice, index), style: {
86
+ appearance: "none",
87
+ textAlign: "left",
88
+ cursor: "pointer",
89
+ padding: "9px 13px",
90
+ fontSize: 13.5,
91
+ fontWeight: 600,
92
+ color: t.text,
93
+ background: t.choiceBackground,
94
+ border: t.choiceBorder,
95
+ borderRadius: t.choiceRadius,
96
+ }, children: choice.text }, index)))
97
+ : onClose !== undefined
98
+ ? (_jsx("button", { type: "button", className: choiceClassName, "data-dialogue-close": true, onClick: onClose, style: {
99
+ appearance: "none",
100
+ alignSelf: "flex-start",
101
+ cursor: "pointer",
102
+ padding: "9px 16px",
103
+ fontSize: 13.5,
104
+ fontWeight: 700,
105
+ color: "#06121b",
106
+ background: t.accent,
107
+ border: "1px solid transparent",
108
+ borderRadius: t.choiceRadius,
109
+ }, children: closeLabel }))
110
+ : null })] })] }));
111
+ }
@@ -0,0 +1,114 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /**
3
+ * World-anchored entity frames — a data-first seam for floating overhead UI
4
+ * (enemy nameplates + health bars, interaction prompts, objective pips) that
5
+ * tracks entities in the 3D world. The game keeps owning its entities: it passes
6
+ * a plain array of `{ id, worldPosition }` entries and a `project` function that
7
+ * turns a world position into a screen point, and composes each frame's markup
8
+ * itself (typically the shipped {@link ./bars!HealthBar} plus a name) reskinned
9
+ * through `HudTheme`/`barTokens`. This primitive owns only the reusable behavior:
10
+ * projecting, offsetting, culling entries that are off-screen or behind the
11
+ * camera, and stacking nearer entities above farther ones.
12
+ *
13
+ * No `GameProvider`, entity store, Three.js, or React Three Fiber is required or
14
+ * imported here — the same way {@link ./map!Minimap} takes display markers. An
15
+ * R3F game can get `project` for free from `useWorldProjection`/`WorldEntityFrames`
16
+ * in `@jgengine/shell/world/WorldEntityFrames`, which samples the live camera and
17
+ * composes this component.
18
+ *
19
+ * @capability entity-frames world-anchored overhead entity frames (nameplate/healthbar), data-first + caller-composed
20
+ */
21
+ /** A world position projected to screen space by the caller's camera. */
22
+ export interface EntityScreenProjection {
23
+ /** CSS px from the viewport's left edge. */
24
+ x: number;
25
+ /** CSS px from the viewport's top edge. */
26
+ y: number;
27
+ /**
28
+ * Stacking / draw-order key — larger means farther from the camera, so nearer
29
+ * frames paint on top. Optional; defaults to 0 when the projection omits it.
30
+ */
31
+ depth?: number;
32
+ /** `true` when the point is behind the camera; such entries are culled. */
33
+ behind?: boolean;
34
+ }
35
+ /**
36
+ * Projects a world position to a screen point, or returns `null` to cull the
37
+ * entry entirely (e.g. outside the frustum). Caller-owned so this seam never
38
+ * depends on a specific camera or renderer.
39
+ */
40
+ export type ProjectEntity = (worldPosition: readonly [number, number, number]) => EntityScreenProjection | null;
41
+ /** The minimum an entry must carry; games extend it with their own display data. */
42
+ export interface EntityFrameEntry {
43
+ /** Stable key across frames — used for React keys and deterministic stacking. */
44
+ id: string;
45
+ /** World-space anchor, typically the entity's head (`[x, y + height, z]`). */
46
+ worldPosition: readonly [number, number, number];
47
+ }
48
+ /** One entry resolved to a screen placement, ready to render. */
49
+ export interface EntityFramePlacement<E extends EntityFrameEntry> {
50
+ entry: E;
51
+ /** Final screen x after `offsetX`. */
52
+ x: number;
53
+ /** Final screen y after `offsetY`. */
54
+ y: number;
55
+ /** Resolved depth (0 when the projection omitted one). */
56
+ depth: number;
57
+ }
58
+ /** Anchoring, culling, and stacking options shared by the layout core and component. */
59
+ export interface EntityFrameLayoutOptions {
60
+ /** Screen px added to every projected x. Default 0. */
61
+ offsetX?: number;
62
+ /** Screen px added to every projected y — negative lifts frames above the anchor. Default 0. */
63
+ offsetY?: number;
64
+ /**
65
+ * Viewport size used to cull frames that project past the edges. Omit to keep
66
+ * every in-front entry (the caller's `project` may already frustum-cull).
67
+ */
68
+ viewport?: {
69
+ width: number;
70
+ height: number;
71
+ };
72
+ /** Extra px beyond each viewport edge still treated as on-screen. Default 64. */
73
+ margin?: number;
74
+ /** Cap the number of rendered frames, keeping the nearest. Omit for no cap. */
75
+ maxCount?: number;
76
+ }
77
+ /**
78
+ * Pure layout core: projects each entry, drops the ones behind the camera,
79
+ * off-screen (when `viewport` is set), or beyond `maxCount`, and returns the
80
+ * survivors sorted farthest-first so nearer frames render last (on top). Stable
81
+ * for equal depths via `id`. No React, no DOM — unit-testable in isolation.
82
+ */
83
+ export declare function layoutEntityFrames<E extends EntityFrameEntry>(entries: readonly E[], project: ProjectEntity, options?: EntityFrameLayoutOptions): EntityFramePlacement<E>[];
84
+ /** Props for {@link EntityFrames}. */
85
+ export interface EntityFramesProps<E extends EntityFrameEntry> extends EntityFrameLayoutOptions {
86
+ /** Caller-owned entities to anchor. Only `id` + `worldPosition` are read here. */
87
+ entries: readonly E[];
88
+ /** World→screen projector; return `null` to cull. */
89
+ project: ProjectEntity;
90
+ /**
91
+ * Renders one entity's frame — compose the shipped bars (`HealthBar value/max`)
92
+ * and a name here, reskinned via `barTokens`/`HudTheme`. The wrapper handles
93
+ * positioning; render your content anchored at bottom-center of the point.
94
+ */
95
+ renderFrame: (entry: E, placement: EntityFramePlacement<E>) => ReactNode;
96
+ /** Class on the fullscreen overlay layer. */
97
+ className?: string;
98
+ /** Style on the fullscreen overlay layer (merged over the absolute-fill default). */
99
+ style?: CSSProperties;
100
+ /**
101
+ * CSS transform on each frame wrapper, controlling how content sits relative
102
+ * to the anchor point. Default `translate(-50%, -100%)` (centered, above).
103
+ */
104
+ anchorTransform?: string;
105
+ }
106
+ /**
107
+ * Absolutely-positioned overlay that renders a caller-composed frame over every
108
+ * on-screen entry. Drop it inside `HudCanvas` (or any positioned ancestor) and
109
+ * feed it fresh `entries`/`project` each frame. Every part carries `data-*`
110
+ * hooks (`data-entity-frames`, `data-entity-frame="<id>"`) for tests and skins.
111
+ *
112
+ * @capability entity-frames overlay that renders caller-composed frames over on-screen entities
113
+ */
114
+ export declare function EntityFrames<E extends EntityFrameEntry>({ entries, project, renderFrame, className, style, anchorTransform, ...layout }: EntityFramesProps<E>): import("react").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ /**
4
+ * Pure layout core: projects each entry, drops the ones behind the camera,
5
+ * off-screen (when `viewport` is set), or beyond `maxCount`, and returns the
6
+ * survivors sorted farthest-first so nearer frames render last (on top). Stable
7
+ * for equal depths via `id`. No React, no DOM — unit-testable in isolation.
8
+ */
9
+ export function layoutEntityFrames(entries, project, options = {}) {
10
+ const { offsetX = 0, offsetY = 0, viewport, margin = 64, maxCount } = options;
11
+ const placed = [];
12
+ for (const entry of entries) {
13
+ const projection = project(entry.worldPosition);
14
+ if (projection === null || projection.behind === true)
15
+ continue;
16
+ const x = projection.x + offsetX;
17
+ const y = projection.y + offsetY;
18
+ if (viewport !== undefined) {
19
+ if (x < -margin ||
20
+ x > viewport.width + margin ||
21
+ y < -margin ||
22
+ y > viewport.height + margin) {
23
+ continue;
24
+ }
25
+ }
26
+ placed.push({ entry, x, y, depth: projection.depth ?? 0 });
27
+ }
28
+ // Farthest first; ties resolved by id so the DOM/stacking order is deterministic.
29
+ placed.sort((a, b) => (b.depth - a.depth) || (a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0));
30
+ if (maxCount !== undefined && placed.length > maxCount) {
31
+ // Keep the nearest `maxCount` (the tail of a farthest-first list).
32
+ return placed.slice(placed.length - maxCount);
33
+ }
34
+ return placed;
35
+ }
36
+ /**
37
+ * Absolutely-positioned overlay that renders a caller-composed frame over every
38
+ * on-screen entry. Drop it inside `HudCanvas` (or any positioned ancestor) and
39
+ * feed it fresh `entries`/`project` each frame. Every part carries `data-*`
40
+ * hooks (`data-entity-frames`, `data-entity-frame="<id>"`) for tests and skins.
41
+ *
42
+ * @capability entity-frames overlay that renders caller-composed frames over on-screen entities
43
+ */
44
+ export function EntityFrames({ entries, project, renderFrame, className, style, anchorTransform = "translate(-50%, -100%)", ...layout }) {
45
+ const placements = layoutEntityFrames(entries, project, layout);
46
+ return (_jsx("div", { className: className, "data-entity-frames": "", style: { position: "absolute", inset: 0, pointerEvents: "none", ...style }, children: placements.map((placement, index) => (_jsx("div", { "data-entity-frame": placement.entry.id, style: {
47
+ position: "absolute",
48
+ left: placement.x,
49
+ top: placement.y,
50
+ transform: anchorTransform,
51
+ // Nearer frames come last in a farthest-first list; index gives them
52
+ // a higher z so overlapping plates stack front-over-back.
53
+ zIndex: index,
54
+ }, children: renderFrame(placement.entry, placement) }, placement.entry.id))) }));
55
+ }
@@ -0,0 +1,36 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { FastTravelNetwork, TravelPointView } from "@jgengine/core/world/fastTravel";
3
+ import type { WorldXZ } from "@jgengine/core/world/minimap";
4
+ /**
5
+ * Subscribe a component to a fast-travel network's discovery changes. Returns the
6
+ * network unchanged; the hook only forces a re-render when a point is discovered
7
+ * or the network is restored, so callers can read `destinations`/`list` in render.
8
+ *
9
+ * @capability use-fast-travel re-render a component when a fast-travel network's discovery state changes
10
+ */
11
+ export declare function useFastTravel<TMeta = unknown>(network: FastTravelNetwork<TMeta>): FastTravelNetwork<TMeta>;
12
+ /** Props for {@link FastTravelMenu}. */
13
+ export interface FastTravelMenuProps<TMeta = unknown> {
14
+ network: FastTravelNetwork<TMeta>;
15
+ /** Origin for distance sorting/labels — usually the player's world-XZ. */
16
+ from?: WorldXZ;
17
+ /** Fires with the chosen destination id; the game applies the teleport. */
18
+ onTravel?: (id: string, point: TravelPointView<TMeta>) => void;
19
+ /** The id the player is standing on — shown as "You are here", not travelable. */
20
+ currentId?: string;
21
+ title?: string;
22
+ emptyLabel?: string;
23
+ onClose?: () => void;
24
+ className?: string;
25
+ style?: CSSProperties;
26
+ }
27
+ /**
28
+ * Fast-travel menu — the discovered-destinations list players pick from: points
29
+ * grouped by region, distance-sorted from `from`, each showing icon, name, and
30
+ * distance, with a header counting discovered vs. total. Selecting a row calls
31
+ * `onTravel`; the current location is flagged and not travelable. Bind it to a
32
+ * `createFastTravelNetwork`.
33
+ *
34
+ * @capability fast-travel-menu discovered-destinations picker grouped by region with distances, a discovery counter, and a travel callback
35
+ */
36
+ export declare function FastTravelMenu<TMeta = unknown>({ network, from, onTravel, currentId, title, emptyLabel, onClose, className, style, }: FastTravelMenuProps<TMeta>): ReactNode;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer } from "react";
3
+ /**
4
+ * Subscribe a component to a fast-travel network's discovery changes. Returns the
5
+ * network unchanged; the hook only forces a re-render when a point is discovered
6
+ * or the network is restored, so callers can read `destinations`/`list` in render.
7
+ *
8
+ * @capability use-fast-travel re-render a component when a fast-travel network's discovery state changes
9
+ */
10
+ export function useFastTravel(network) {
11
+ const [, bump] = useReducer((n) => n + 1, 0);
12
+ useEffect(() => network.subscribe(bump), [network]);
13
+ return network;
14
+ }
15
+ function formatDistance(distance) {
16
+ if (distance === undefined)
17
+ return null;
18
+ if (distance < 1000)
19
+ return `${Math.round(distance)}m`;
20
+ return `${(distance / 1000).toFixed(1)}km`;
21
+ }
22
+ function groupByRegion(views) {
23
+ const groups = [];
24
+ const index = new Map();
25
+ for (const view of views) {
26
+ const key = view.region ?? null;
27
+ let bucket = index.get(key);
28
+ if (bucket === undefined) {
29
+ bucket = [];
30
+ index.set(key, bucket);
31
+ groups.push({ region: key, points: bucket });
32
+ }
33
+ bucket.push(view);
34
+ }
35
+ return groups;
36
+ }
37
+ /**
38
+ * Fast-travel menu — the discovered-destinations list players pick from: points
39
+ * grouped by region, distance-sorted from `from`, each showing icon, name, and
40
+ * distance, with a header counting discovered vs. total. Selecting a row calls
41
+ * `onTravel`; the current location is flagged and not travelable. Bind it to a
42
+ * `createFastTravelNetwork`.
43
+ *
44
+ * @capability fast-travel-menu discovered-destinations picker grouped by region with distances, a discovery counter, and a travel callback
45
+ */
46
+ export function FastTravelMenu({ network, from, onTravel, currentId, title = "Fast Travel", emptyLabel = "No destinations discovered yet.", onClose, className, style, }) {
47
+ useFastTravel(network);
48
+ const destinations = network.destinations(from);
49
+ const groups = groupByRegion(destinations);
50
+ const discovered = network.discoveredCount();
51
+ const total = network.total();
52
+ return (_jsxs("div", { className: className, "data-fast-travel": true, style: {
53
+ width: 320,
54
+ maxHeight: 460,
55
+ display: "flex",
56
+ flexDirection: "column",
57
+ borderRadius: 14,
58
+ background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(11,14,19,0.97))",
59
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.32))",
60
+ color: "#e2e8f0",
61
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
62
+ boxShadow: "0 18px 44px rgba(0,0,0,0.5)",
63
+ ...style,
64
+ }, children: [_jsxs("div", { style: {
65
+ display: "flex",
66
+ alignItems: "center",
67
+ justifyContent: "space-between",
68
+ gap: 8,
69
+ padding: "10px 12px",
70
+ borderBottom: "1px solid rgba(148,163,184,0.2)",
71
+ }, children: [_jsxs("span", { style: { fontSize: 12, fontWeight: 700, letterSpacing: 1.2, textTransform: "uppercase" }, children: [title, _jsxs("span", { style: { marginLeft: 6, color: "rgba(148,163,184,0.7)", fontWeight: 500 }, children: [discovered, "/", total] })] }), onClose !== undefined ? (_jsx("button", { type: "button", onClick: onClose, style: {
72
+ border: "1px solid rgba(148,163,184,0.4)",
73
+ borderRadius: 6,
74
+ background: "transparent",
75
+ color: "rgba(226,232,240,0.85)",
76
+ fontSize: 11,
77
+ padding: "3px 8px",
78
+ cursor: "pointer",
79
+ }, children: "\u2715" })) : null] }), destinations.length === 0 ? (_jsx("p", { style: { margin: 0, padding: "16px 12px", fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("div", { style: { overflowY: "auto", padding: 6, display: "flex", flexDirection: "column", gap: 8 }, children: groups.map((group) => (_jsxs("div", { "data-region": group.region ?? "", children: [group.region !== null ? (_jsx("div", { style: {
80
+ fontSize: 10,
81
+ fontWeight: 700,
82
+ letterSpacing: 1,
83
+ textTransform: "uppercase",
84
+ color: "rgba(148,163,184,0.65)",
85
+ padding: "2px 6px",
86
+ }, children: group.region })) : null, _jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }, children: group.points.map((point) => {
87
+ const isCurrent = point.id === currentId;
88
+ const distance = formatDistance(point.distance);
89
+ return (_jsx("li", { children: _jsxs("button", { type: "button", "data-travel-point": point.id, "data-current": isCurrent, disabled: isCurrent, onClick: () => onTravel?.(point.id, point), style: {
90
+ width: "100%",
91
+ display: "flex",
92
+ alignItems: "center",
93
+ gap: 10,
94
+ padding: "9px 10px",
95
+ borderRadius: 9,
96
+ border: "1px solid transparent",
97
+ background: isCurrent ? "rgba(56,189,248,0.12)" : "rgba(148,163,184,0.06)",
98
+ color: "#e2e8f0",
99
+ textAlign: "left",
100
+ cursor: isCurrent ? "default" : "pointer",
101
+ }, children: [_jsx("span", { style: { fontSize: 18, width: 22, textAlign: "center", flexShrink: 0 }, children: point.icon ?? "📍" }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 600 }, children: point.name }), isCurrent ? (_jsx("span", { style: { fontSize: 10, color: "rgba(56,189,248,0.85)" }, children: "You are here" })) : null] }), distance !== null && !isCurrent ? (_jsx("span", { style: { fontSize: 11, color: "rgba(148,163,184,0.75)", whiteSpace: "nowrap" }, children: distance })) : null] }) }, point.id));
102
+ }) })] }, group.region ?? "__none__"))) }))] }));
103
+ }
@@ -0,0 +1,84 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { FloatingTextView } from "@jgengine/core/ui/floatingText";
3
+ import type { ProjectEntity } from "./entityFrames.js";
4
+ /**
5
+ * World-anchored floating text — the presentation half of the data-first
6
+ * `@jgengine/core/ui/floatingText` seam. The game keeps owning the simulation: it
7
+ * passes the field's `active()` views plus a `project` function (world → screen),
8
+ * exactly the transport {@link ./entityFrames!EntityFrames} uses, so an R3F game
9
+ * gets it for free from `useWorldProjection`. This component owns only the
10
+ * reusable presentation: projecting each entry, culling ones behind the camera or
11
+ * off-screen, and rendering absolutely-positioned text with the entry's color,
12
+ * current alpha, and pop-in scale — reskinnable per `kind` through `styleFor`.
13
+ *
14
+ * No `GameProvider`, Three.js, or React Three Fiber is imported here — the same
15
+ * way `EntityFrames` stays renderer-agnostic. An R3F game gets the one-line drop-in
16
+ * from `@jgengine/shell/vfx/WorldFloatingText`, which binds the live camera and
17
+ * advances the field.
18
+ *
19
+ * @capability floating-text world-anchored floating combat text / damage numbers overlay, data-first + caller-projected + per-kind skinnable
20
+ */
21
+ /** One projected, on-screen floating-text entry ready to render. */
22
+ export interface FloatingTextPlacement {
23
+ view: FloatingTextView;
24
+ /** Final screen x in CSS px after `offsetX`. */
25
+ x: number;
26
+ /** Final screen y in CSS px after `offsetY`. */
27
+ y: number;
28
+ /** Projected depth (0 when the projection omitted one); larger = farther. */
29
+ depth: number;
30
+ }
31
+ /** Anchoring and culling options shared by the layout core and the component. */
32
+ export interface FloatingTextLayoutOptions {
33
+ /** Screen px added to every projected x. Default 0. */
34
+ offsetX?: number;
35
+ /** Screen px added to every projected y — negative lifts text above the anchor. Default 0. */
36
+ offsetY?: number;
37
+ /** Viewport size used to cull entries past the edges. Omit to keep every in-front entry. */
38
+ viewport?: {
39
+ width: number;
40
+ height: number;
41
+ };
42
+ /** Extra px beyond each viewport edge still treated as on-screen. Default 96. */
43
+ margin?: number;
44
+ }
45
+ /**
46
+ * Pure layout core: projects each view, drops the ones behind the camera or
47
+ * (when `viewport` is set) off-screen, and returns the survivors sorted
48
+ * farthest-first so nearer text paints on top. No React, no DOM — unit-testable.
49
+ *
50
+ * @capability floating-text pure world→screen layout for floating-text views (project, cull, depth-sort)
51
+ */
52
+ export declare function layoutFloatingText(views: readonly FloatingTextView[], project: ProjectEntity, options?: FloatingTextLayoutOptions): FloatingTextPlacement[];
53
+ /** Props for {@link FloatingText}. */
54
+ export interface FloatingTextProps extends FloatingTextLayoutOptions {
55
+ /** The field's live views (`field.active()`), refreshed each frame. */
56
+ entries: readonly FloatingTextView[];
57
+ /** World→screen projector; return `null` to cull. Same shape `EntityFrames` takes. */
58
+ project: ProjectEntity;
59
+ /**
60
+ * Per-`kind` art direction: return extra CSS (color, font, weight, shadow) for a
61
+ * view. Merged over the defaults, so returning `{}` keeps them. The default look
62
+ * uses `view.color` (or white), a bold sans stack, and a readable shadow.
63
+ */
64
+ styleFor?: (view: FloatingTextView) => CSSProperties;
65
+ /** Base font size in px at `size === 1`; scaled by each view's `size`. Default 20. */
66
+ fontSizePx?: number;
67
+ /** Class on the fullscreen overlay layer. */
68
+ className?: string;
69
+ /** Style on the fullscreen overlay layer (merged over the absolute-fill default). */
70
+ style?: CSSProperties;
71
+ /** Per-entry render override; return your own node instead of the default text chip. */
72
+ renderEntry?: (placement: FloatingTextPlacement) => ReactNode;
73
+ }
74
+ /**
75
+ * Absolutely-positioned overlay that renders every on-screen floating-text entry
76
+ * as text with its color, current alpha, and pop-in scale. Drop it inside a
77
+ * positioned ancestor (e.g. an `<Html fullscreen>` overlay or `HudCanvas`) and feed
78
+ * it fresh `entries`/`project` each frame. Every part carries `data-*` hooks
79
+ * (`data-floating-text`, `data-floating-text-entry="<id>"`, `data-kind="<kind>"`)
80
+ * for tests and skins.
81
+ *
82
+ * @capability floating-text overlay that renders on-screen floating combat text with per-kind color, alpha, and pop-in scale
83
+ */
84
+ export declare function FloatingText({ entries, project, styleFor, fontSizePx, className, style, renderEntry, ...layout }: FloatingTextProps): import("react").JSX.Element;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ /**
4
+ * Pure layout core: projects each view, drops the ones behind the camera or
5
+ * (when `viewport` is set) off-screen, and returns the survivors sorted
6
+ * farthest-first so nearer text paints on top. No React, no DOM — unit-testable.
7
+ *
8
+ * @capability floating-text pure world→screen layout for floating-text views (project, cull, depth-sort)
9
+ */
10
+ export function layoutFloatingText(views, project, options = {}) {
11
+ const { offsetX = 0, offsetY = 0, viewport, margin = 96 } = options;
12
+ const placed = [];
13
+ for (const view of views) {
14
+ const projection = project(view.position);
15
+ if (projection === null || projection.behind === true)
16
+ continue;
17
+ const x = projection.x + offsetX;
18
+ const y = projection.y + offsetY;
19
+ if (viewport !== undefined &&
20
+ (x < -margin || x > viewport.width + margin || y < -margin || y > viewport.height + margin)) {
21
+ continue;
22
+ }
23
+ placed.push({ view, x, y, depth: projection.depth ?? 0 });
24
+ }
25
+ placed.sort((a, b) => b.depth - a.depth || a.view.id - b.view.id);
26
+ return placed;
27
+ }
28
+ /**
29
+ * Absolutely-positioned overlay that renders every on-screen floating-text entry
30
+ * as text with its color, current alpha, and pop-in scale. Drop it inside a
31
+ * positioned ancestor (e.g. an `<Html fullscreen>` overlay or `HudCanvas`) and feed
32
+ * it fresh `entries`/`project` each frame. Every part carries `data-*` hooks
33
+ * (`data-floating-text`, `data-floating-text-entry="<id>"`, `data-kind="<kind>"`)
34
+ * for tests and skins.
35
+ *
36
+ * @capability floating-text overlay that renders on-screen floating combat text with per-kind color, alpha, and pop-in scale
37
+ */
38
+ export function FloatingText({ entries, project, styleFor, fontSizePx = 20, className, style, renderEntry, ...layout }) {
39
+ const placements = layoutFloatingText(entries, project, layout);
40
+ return (_jsx("div", { className: className, "data-floating-text": "", style: { position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden", ...style }, children: placements.map((placement, index) => {
41
+ const { view } = placement;
42
+ const custom = styleFor?.(view);
43
+ return (_jsx("div", { "data-floating-text-entry": view.id, "data-kind": view.kind, style: {
44
+ position: "absolute",
45
+ left: placement.x,
46
+ top: placement.y,
47
+ transform: `translate(-50%, -50%) scale(${view.size})`,
48
+ transformOrigin: "center",
49
+ opacity: view.alpha,
50
+ zIndex: index,
51
+ whiteSpace: "nowrap",
52
+ fontFamily: "system-ui, sans-serif",
53
+ fontWeight: 800,
54
+ fontSize: fontSizePx,
55
+ lineHeight: 1,
56
+ color: view.color ?? "#ffffff",
57
+ textShadow: "0 2px 4px rgba(0,0,0,0.9), 0 0 2px rgba(0,0,0,0.9)",
58
+ ...custom,
59
+ }, children: renderEntry !== undefined ? renderEntry(placement) : view.text }, view.id));
60
+ }) }));
61
+ }