@jgengine/core 0.12.0 → 0.14.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 (94) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +45 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +4 -1
  29. package/dist/runtime/context/scene.js +86 -1
  30. package/dist/runtime/gameContext.js +51 -4
  31. package/dist/runtime/gameContextTypes.d.ts +58 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  36. package/dist/scene/builtinSceneKinds.js +10 -3
  37. package/dist/scene/colliders.d.ts +41 -0
  38. package/dist/scene/colliders.js +140 -0
  39. package/dist/scene/collisionMesh.d.ts +8 -0
  40. package/dist/scene/collisionMesh.js +48 -0
  41. package/dist/scene/entityStats.js +9 -1
  42. package/dist/scene/sceneKinds.d.ts +19 -0
  43. package/dist/stats/statModifiers.d.ts +16 -0
  44. package/dist/stats/statModifiers.js +30 -1
  45. package/dist/stats/statPool.js +5 -2
  46. package/dist/ui/hudScale.d.ts +16 -1
  47. package/dist/ui/hudScale.js +22 -1
  48. package/dist/ui/panelModel.d.ts +120 -0
  49. package/dist/ui/panelModel.js +168 -0
  50. package/dist/ui.d.ts +1 -0
  51. package/dist/ui.js +1 -0
  52. package/dist/world/buildingLots.d.ts +84 -0
  53. package/dist/world/buildingLots.js +119 -0
  54. package/dist/world/buildings.d.ts +6 -0
  55. package/dist/world/cityGenerator.d.ts +41 -0
  56. package/dist/world/cityGenerator.js +58 -0
  57. package/dist/world/cityKind.d.ts +2 -2
  58. package/dist/world/cityKind.js +12 -6
  59. package/dist/world/environmentSummary.js +57 -1
  60. package/dist/world/features.d.ts +35 -2
  61. package/dist/world/features.js +5 -2
  62. package/dist/world/grassKind.js +4 -1
  63. package/dist/world/roads.d.ts +19 -2
  64. package/dist/world/roads.js +60 -3
  65. package/dist/world/scatterCoverage.d.ts +98 -0
  66. package/dist/world/scatterCoverage.js +101 -0
  67. package/dist/world/scatterRegion.js +6 -0
  68. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  69. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  70. package/dist/world/streets.d.ts +74 -1
  71. package/dist/world/streets.js +218 -0
  72. package/dist/world/terrain.d.ts +20 -1
  73. package/dist/world/terrain.js +41 -2
  74. package/dist/world.d.ts +1 -0
  75. package/dist/world.js +1 -0
  76. package/package.json +2 -2
  77. package/skills/jgengine/SKILL.md +5 -2
  78. package/skills/jgengine/api.md +13 -5
  79. package/skills/jgengine-combat/SKILL.md +15 -1
  80. package/skills/jgengine-combat/api.md +39 -2
  81. package/skills/jgengine-combat/capabilities.md +17 -1
  82. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  83. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  84. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  85. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  86. package/skills/jgengine-combat/reference.md +22 -3
  87. package/skills/jgengine-gameplay/SKILL.md +8 -0
  88. package/skills/jgengine-gameplay/api.md +35 -5
  89. package/skills/jgengine-gameplay/capabilities.md +18 -0
  90. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  91. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  92. package/skills/jgengine-world/api.md +82 -25
  93. package/skills/jgengine-world/capabilities.md +12 -4
  94. package/skills/jgengine-world/reference.md +1 -1
@@ -500,6 +500,54 @@ function buildBvh(positions, indices) {
500
500
  triOrder,
501
501
  };
502
502
  }
503
+ /**
504
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
505
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
506
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
507
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
508
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
509
+ */
510
+ export function prepareCollisionMeshSource(source) {
511
+ const raw = source.positions;
512
+ const rawIndices = source.indices;
513
+ if (raw.length < 9 || raw.length % 3 !== 0)
514
+ return null;
515
+ if (rawIndices.length < 3 || rawIndices.length % 3 !== 0)
516
+ return null;
517
+ const vertexCount = raw.length / 3;
518
+ const positions = new Float32Array(raw.length);
519
+ const finite = new Uint8Array(vertexCount);
520
+ for (let v = 0; v < vertexCount; v += 1) {
521
+ const x = raw[v * 3];
522
+ const y = raw[v * 3 + 1];
523
+ const z = raw[v * 3 + 2];
524
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
525
+ continue;
526
+ positions[v * 3] = x;
527
+ positions[v * 3 + 1] = y;
528
+ positions[v * 3 + 2] = z;
529
+ finite[v] = 1;
530
+ }
531
+ const triangles = [];
532
+ for (let t = 0; t + 2 < rawIndices.length; t += 3) {
533
+ const a = rawIndices[t];
534
+ const b = rawIndices[t + 1];
535
+ const c = rawIndices[t + 2];
536
+ if (!Number.isInteger(a) || a < 0 || a >= vertexCount || finite[a] === 0)
537
+ continue;
538
+ if (!Number.isInteger(b) || b < 0 || b >= vertexCount || finite[b] === 0)
539
+ continue;
540
+ if (!Number.isInteger(c) || c < 0 || c >= vertexCount || finite[c] === 0)
541
+ continue;
542
+ if (a === b || b === c || a === c)
543
+ continue;
544
+ triangles.push(a, b, c);
545
+ }
546
+ if (triangles.length === 0)
547
+ return null;
548
+ const indices = new Uint32Array(triangles);
549
+ return { positions, indices, ...buildBvh(positions, indices) };
550
+ }
503
551
  const preparedCache = new WeakMap();
504
552
  /**
505
553
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
@@ -19,6 +19,11 @@ export function applyPoolDelta(map, statId, amount) {
19
19
  }
20
20
  const change = changeStatPool(existing, amount);
21
21
  const stat = change.pool;
22
+ // No-op change: `changeStatPool` handed back the same pool reference, so keep
23
+ // the existing map too — callers can guard on reference identity.
24
+ if (stat === existing) {
25
+ return { status: "ok", map, stat, hitMin: change.hitMin, hitMax: change.hitMax };
26
+ }
22
27
  return {
23
28
  status: "ok",
24
29
  map: { ...map, [statId]: stat },
@@ -81,10 +86,13 @@ export function createEntityStatsApi(resolve) {
81
86
  const map = resolve(instanceId);
82
87
  if (map === undefined)
83
88
  return { reason: `unknown instance "${instanceId}"` };
89
+ const existing = map[statId];
84
90
  const result = applyPoolDelta(map, statId, amount);
85
91
  if (result.status === "rejected")
86
92
  return { reason: result.reason };
87
- map[statId] = result.stat;
93
+ // Skip the store write when nothing moved so reference guards stay stable.
94
+ if (result.stat !== existing)
95
+ map[statId] = result.stat;
88
96
  return null;
89
97
  },
90
98
  };
@@ -145,6 +145,19 @@ export interface ParamSchema {
145
145
  export declare function findSchemaPreset(schema: ParamSchema, id: string): ParamPreset | undefined;
146
146
  /** Parsed params after `parseParams`: every schema field present with a validated, defaulted value. */
147
147
  export type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]>;
148
+ /**
149
+ * Marks a kind as *scatterable* — it fills an authored area with instances at one density under a
150
+ * bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider
151
+ * + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget
152
+ * from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into
153
+ * the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted
154
+ * "what fills it" palette summarized in the header.
155
+ */
156
+ export interface SceneKindCoverage {
157
+ spec: import("../world/scatterCoverage.js").ScatterCoverageKind;
158
+ densityKey: string;
159
+ assetsKey?: string;
160
+ }
148
161
  /**
149
162
  * Parse a raw `meta` bag against a schema into typed params — every field present, invalid/missing
150
163
  * values replaced by the field default, numbers clamped to their range. The single parser every
@@ -201,6 +214,12 @@ export interface SceneKindDefinition<TResolved = unknown> {
201
214
  resolve?: (object: SceneKindObject, params: ParsedParams, context: SceneKindResolveContext) => TResolved;
202
215
  /** Optional one-line readout under the inspector fields (estimate, dimensions) — owned by the studio. */
203
216
  note?: (object: SceneKindObject, params: ParsedParams) => string;
217
+ /**
218
+ * Optional scatterable-coverage descriptor. When set, the editor leads this kind's inspector with
219
+ * the shared Area / Assets / Density `CoverageSection` and surfaces the budget clamp-and-warn note,
220
+ * giving every scatterable kind (grass/scatter/city) one authoring pattern.
221
+ */
222
+ coverage?: SceneKindCoverage;
204
223
  }
205
224
  /** The raw document object a resolver receives — shape shared by markers, volumes, and paths. */
206
225
  export interface SceneKindObject {
@@ -6,6 +6,17 @@ export type StatModifierSet<TStat extends string> = Partial<Record<TStat, StatMo
6
6
  export interface CreateStatsOptions {
7
7
  now?: () => number;
8
8
  }
9
+ /** One serializable modifier source in a {@link StatsSnapshot}. */
10
+ interface StatModifierSourceSnapshot<TStat extends string> {
11
+ id: string;
12
+ modifiers: StatModifierSet<TStat>;
13
+ expiresAtMs?: number;
14
+ }
15
+ /** Complete plain-data state for a {@link Stats} runtime. */
16
+ export interface StatsSnapshot<TStat extends string> {
17
+ base: Record<TStat, number>;
18
+ sources: StatModifierSourceSnapshot<TStat>[];
19
+ }
9
20
  export interface Stats<TStat extends string> {
10
21
  setBase(stat: TStat, value: number): void;
11
22
  getBase(stat: TStat): number;
@@ -17,6 +28,10 @@ export interface Stats<TStat extends string> {
17
28
  get(stat: TStat, nowMs?: number): number;
18
29
  pruneExpired(nowMs: number): string[];
19
30
  sources(): string[];
31
+ /** Return a detached JSON-serializable copy of base values and modifier sources. */
32
+ snapshot(): StatsSnapshot<TStat>;
33
+ /** Replace all runtime state from caller-owned decoded snapshot data. */
34
+ restore(snapshot: StatsSnapshot<TStat>): void;
20
35
  }
21
36
  /**
22
37
  * A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
@@ -24,3 +39,4 @@ export interface Stats<TStat extends string> {
24
39
  * @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
25
40
  */
26
41
  export declare function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat>;
42
+ export {};
@@ -1,10 +1,19 @@
1
+ function cloneModifierSet(modifiers) {
2
+ const copy = {};
3
+ for (const stat of Object.keys(modifiers)) {
4
+ const modifier = modifiers[stat];
5
+ if (modifier !== undefined)
6
+ copy[stat] = { ...modifier };
7
+ }
8
+ return copy;
9
+ }
1
10
  /**
2
11
  * A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
3
12
  *
4
13
  * @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
5
14
  */
6
15
  export function createStats(base, options) {
7
- const baseValues = { ...base };
16
+ let baseValues = { ...base };
8
17
  const sourceEntries = new Map();
9
18
  const clock = options?.now;
10
19
  function resolveNow(nowMs) {
@@ -75,5 +84,25 @@ export function createStats(base, options) {
75
84
  sources() {
76
85
  return Array.from(sourceEntries.keys());
77
86
  },
87
+ snapshot() {
88
+ return {
89
+ base: { ...baseValues },
90
+ sources: Array.from(sourceEntries, ([id, entry]) => ({
91
+ id,
92
+ modifiers: cloneModifierSet(entry.modifiers),
93
+ ...(entry.expiresAtMs === undefined ? {} : { expiresAtMs: entry.expiresAtMs }),
94
+ })),
95
+ };
96
+ },
97
+ restore(snapshot) {
98
+ baseValues = { ...snapshot.base };
99
+ sourceEntries.clear();
100
+ for (const source of snapshot.sources) {
101
+ sourceEntries.set(source.id, {
102
+ modifiers: cloneModifierSet(source.modifiers),
103
+ expiresAtMs: source.expiresAtMs,
104
+ });
105
+ }
106
+ },
78
107
  };
79
108
  }
@@ -33,10 +33,13 @@ export function patchStatPool(pool, patch) {
33
33
  */
34
34
  export function changeStatPool(pool, amount) {
35
35
  const next = patchStatPool(pool, { current: pool.current + amount });
36
+ const applied = next.current - pool.current;
36
37
  return {
37
38
  previous: pool,
38
- pool: next,
39
- applied: next.current - pool.current,
39
+ // Preserve the input reference on a no-op change (regen at max, damage at 0,
40
+ // clamp to a bound) so reference-equality guards in HUD hooks don't churn.
41
+ pool: applied === 0 ? pool : next,
42
+ applied,
40
43
  hitMin: next.current === next.min,
41
44
  hitMax: next.current === next.max,
42
45
  };
@@ -37,7 +37,22 @@ export interface HudOverflow {
37
37
  }
38
38
  /** Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside). */
39
39
  export declare function rectOverflow(rect: HudRect, viewport: HudSize, tolerance?: number): Omit<HudOverflow, "id"> | null;
40
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
40
+ /**
41
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
42
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
43
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
44
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
45
+ * Only a surface that is partly visible and partly past an edge is a real defect.
46
+ *
47
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
48
+ */
49
+ export declare function rectIntersectsViewport(rect: HudRect, viewport: HudSize, minVisiblePx?: number): boolean;
50
+ /**
51
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
52
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
53
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
54
+ * unopened menus never raise a false alarm.
55
+ */
41
56
  export declare function overflowingPanels(panels: readonly {
42
57
  id: string;
43
58
  rect: HudRect;
@@ -43,10 +43,31 @@ export function rectOverflow(rect, viewport, tolerance = 1.5) {
43
43
  const round = (n) => Math.round(n * 10) / 10;
44
44
  return { left: round(left), top: round(top), right: round(right), bottom: round(bottom) };
45
45
  }
46
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
46
+ /**
47
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
48
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
49
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
50
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
51
+ * Only a surface that is partly visible and partly past an edge is a real defect.
52
+ *
53
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
54
+ */
55
+ export function rectIntersectsViewport(rect, viewport, minVisiblePx = 1) {
56
+ const visibleWidth = Math.min(rect.x + rect.width, viewport.width) - Math.max(rect.x, 0);
57
+ const visibleHeight = Math.min(rect.y + rect.height, viewport.height) - Math.max(rect.y, 0);
58
+ return visibleWidth >= minVisiblePx && visibleHeight >= minVisiblePx;
59
+ }
60
+ /**
61
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
62
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
63
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
64
+ * unopened menus never raise a false alarm.
65
+ */
47
66
  export function overflowingPanels(panels, viewport, tolerance = 1.5) {
48
67
  const out = [];
49
68
  for (const panel of panels) {
69
+ if (!rectIntersectsViewport(panel.rect, viewport))
70
+ continue;
50
71
  const overflow = rectOverflow(panel.rect, viewport, tolerance);
51
72
  if (overflow !== null)
52
73
  out.push({ id: panel.id, ...overflow });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Headless, serializable model for a set of player-toggled windows/panels — the data layer behind a
3
+ * WoW-style UI where B opens the bag, C the character sheet, and ESC closes the topmost window. It
4
+ * carries *which panels are open*, their focus/z stacking order, per-panel position overrides, and
5
+ * exclusive-group membership, with zero rendering opinion. The React chrome in
6
+ * `@jgengine/react/panels` consumes this; a game keeps layout, skin, keybinds, and window content
7
+ * caller-owned. Pure and immutable: every reducer returns a new state and never mutates its input.
8
+ */
9
+ /** A pixel position override for a panel (top-left, relative to the host surface). */
10
+ export interface PanelPosition {
11
+ x: number;
12
+ y: number;
13
+ }
14
+ /**
15
+ * A caller-authored panel/window declaration — the DATA input to the model. Only `id` is required so
16
+ * a bag, a character sheet, and a one-off dialog all share the shape.
17
+ */
18
+ export interface PanelDef {
19
+ id: string;
20
+ /** Human title shown in the window's title bar; falls back to `id`. */
21
+ title?: string;
22
+ /** Keybind that opens/toggles the panel — a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`), matched case-insensitively by {@link panelByHotkey}. */
23
+ hotkey?: string;
24
+ /** Open on {@link createPanelState} (default false). */
25
+ initial?: boolean;
26
+ /** Exclusive group: opening one panel in a group closes the others in it (e.g. only one center window). */
27
+ group?: string;
28
+ /** Whether the player can close it (title-bar button / ESC). Default true; `false` pins it open. */
29
+ closable?: boolean;
30
+ }
31
+ /** @internal Serializable per-panel metadata lifted from {@link PanelDef} so reducers stay `(state, id)` pure. */
32
+ export interface PanelMeta {
33
+ group?: string;
34
+ closable?: boolean;
35
+ }
36
+ /**
37
+ * Serializable state for a set of panels: which are `open`, their `z` focus stack (higher renders on
38
+ * top), optional per-panel `pos` overrides, and the `meta` registry (group/closable) captured from
39
+ * the defs so the reducers below need no defs argument. Plain JSON — persist and rehydrate it freely.
40
+ */
41
+ export interface PanelState {
42
+ /** Open panels, keyed by id (`true` for open). Closed panels are absent. */
43
+ open: Record<string, true>;
44
+ /** z / focus order per open panel — the panel with the highest value is focused/on top. */
45
+ z: Record<string, number>;
46
+ /** Per-panel position overrides; remembered across close/reopen. */
47
+ pos: Record<string, PanelPosition>;
48
+ /** Group/closable metadata captured from the defs at construction. */
49
+ meta: Record<string, PanelMeta>;
50
+ /** Next z value to assign on open/focus — monotonic so focus order is deterministic. */
51
+ nextZ: number;
52
+ }
53
+ /**
54
+ * Build the initial {@link PanelState} from the panel defs: panels flagged `initial` start open (in
55
+ * declaration order, respecting group exclusivity), and every def's group/closable is captured into
56
+ * the state so later reducers need no defs.
57
+ *
58
+ * @capability panel-state serializable open/z/position state for a set of toggleable game windows
59
+ */
60
+ export declare function createPanelState(defs: readonly PanelDef[]): PanelState;
61
+ /** Whether panel `id` is currently open. */
62
+ export declare function isOpen(state: PanelState, id: string): boolean;
63
+ /**
64
+ * Open panel `id`, raising it to the top of the focus stack. If it belongs to a `group`, the other
65
+ * open panels in that group are closed (exclusive windows). Opening an already-open panel just
66
+ * refocuses it. Position overrides are preserved.
67
+ *
68
+ * @capability panel-open open a window, raise its z, and enforce exclusive-group membership
69
+ */
70
+ export declare function openPanel(state: PanelState, id: string): PanelState;
71
+ /**
72
+ * Close panel `id`. No-op if it is already closed. Its position override is kept so reopening restores
73
+ * the last placement.
74
+ *
75
+ * @capability panel-close close a window while remembering its last position
76
+ */
77
+ export declare function closePanel(state: PanelState, id: string): PanelState;
78
+ /**
79
+ * Toggle panel `id` — open it (with group exclusivity) if closed, close it if open. The one-key
80
+ * behavior behind a keybind like `B` for the bag.
81
+ *
82
+ * @capability panel-toggle toggle a window open/closed from a single keybind
83
+ */
84
+ export declare function togglePanel(state: PanelState, id: string): PanelState;
85
+ /**
86
+ * Raise open panel `id` to the top of the focus stack (assign it the highest z). No-op if it is
87
+ * closed or already on top, so a pointer-down that refocuses the top window returns the same state.
88
+ *
89
+ * @capability panel-focus raise a window to the top of the focus/z stack
90
+ */
91
+ export declare function focusPanel(state: PanelState, id: string): PanelState;
92
+ /**
93
+ * Close the topmost closable open panel — the ESC handler. Skips panels pinned with `closable: false`
94
+ * and returns the same state when nothing closable is open.
95
+ *
96
+ * @capability panel-close-top close the focused (topmost) closable window — the ESC behavior
97
+ */
98
+ export declare function closeTopPanel(state: PanelState): PanelState;
99
+ /**
100
+ * Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be
101
+ * a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so
102
+ * author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
103
+ *
104
+ * @capability panel-hotkey resolve a keybind (code or key) to the panel id it toggles
105
+ */
106
+ export declare function panelByHotkey(defs: readonly PanelDef[], code: string): string | null;
107
+ /**
108
+ * Set panel `id`'s position override — the drag commit. Does not require the panel to be open, so a
109
+ * caller can pre-place a window before it is shown.
110
+ *
111
+ * @capability panel-move override a window's on-surface position (the drag commit)
112
+ */
113
+ export declare function movePanel(state: PanelState, id: string, pos: PanelPosition): PanelState;
114
+ /**
115
+ * The open panels' defs sorted ascending by z — the render order for a window host (map to elements
116
+ * so the last, highest-z window paints on top). Defs unknown to the state are skipped.
117
+ *
118
+ * @capability panel-ordered-open open panels' defs in z/render order for a window host
119
+ */
120
+ export declare function orderedOpen(state: PanelState, defs: readonly PanelDef[]): PanelDef[];
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Headless, serializable model for a set of player-toggled windows/panels — the data layer behind a
3
+ * WoW-style UI where B opens the bag, C the character sheet, and ESC closes the topmost window. It
4
+ * carries *which panels are open*, their focus/z stacking order, per-panel position overrides, and
5
+ * exclusive-group membership, with zero rendering opinion. The React chrome in
6
+ * `@jgengine/react/panels` consumes this; a game keeps layout, skin, keybinds, and window content
7
+ * caller-owned. Pure and immutable: every reducer returns a new state and never mutates its input.
8
+ */
9
+ /**
10
+ * Build the initial {@link PanelState} from the panel defs: panels flagged `initial` start open (in
11
+ * declaration order, respecting group exclusivity), and every def's group/closable is captured into
12
+ * the state so later reducers need no defs.
13
+ *
14
+ * @capability panel-state serializable open/z/position state for a set of toggleable game windows
15
+ */
16
+ export function createPanelState(defs) {
17
+ const meta = {};
18
+ for (const def of defs) {
19
+ const entry = {};
20
+ if (def.group !== undefined)
21
+ entry.group = def.group;
22
+ if (def.closable !== undefined)
23
+ entry.closable = def.closable;
24
+ meta[def.id] = entry;
25
+ }
26
+ let state = { open: {}, z: {}, pos: {}, meta, nextZ: 1 };
27
+ for (const def of defs) {
28
+ if (def.initial === true)
29
+ state = openPanel(state, def.id);
30
+ }
31
+ return state;
32
+ }
33
+ /** Whether panel `id` is currently open. */
34
+ export function isOpen(state, id) {
35
+ return state.open[id] === true;
36
+ }
37
+ /** @internal Whether panel `id` may be closed by the player (default true). */
38
+ function isClosable(state, id) {
39
+ return state.meta[id]?.closable !== false;
40
+ }
41
+ /**
42
+ * Open panel `id`, raising it to the top of the focus stack. If it belongs to a `group`, the other
43
+ * open panels in that group are closed (exclusive windows). Opening an already-open panel just
44
+ * refocuses it. Position overrides are preserved.
45
+ *
46
+ * @capability panel-open open a window, raise its z, and enforce exclusive-group membership
47
+ */
48
+ export function openPanel(state, id) {
49
+ const group = state.meta[id]?.group;
50
+ const open = { ...state.open };
51
+ const z = { ...state.z };
52
+ let nextZ = state.nextZ;
53
+ if (group !== undefined) {
54
+ for (const otherId of Object.keys(open)) {
55
+ if (otherId !== id && state.meta[otherId]?.group === group) {
56
+ delete open[otherId];
57
+ delete z[otherId];
58
+ }
59
+ }
60
+ }
61
+ open[id] = true;
62
+ z[id] = nextZ++;
63
+ return { ...state, open, z, nextZ };
64
+ }
65
+ /**
66
+ * Close panel `id`. No-op if it is already closed. Its position override is kept so reopening restores
67
+ * the last placement.
68
+ *
69
+ * @capability panel-close close a window while remembering its last position
70
+ */
71
+ export function closePanel(state, id) {
72
+ if (state.open[id] !== true)
73
+ return state;
74
+ const open = { ...state.open };
75
+ const z = { ...state.z };
76
+ delete open[id];
77
+ delete z[id];
78
+ return { ...state, open, z };
79
+ }
80
+ /**
81
+ * Toggle panel `id` — open it (with group exclusivity) if closed, close it if open. The one-key
82
+ * behavior behind a keybind like `B` for the bag.
83
+ *
84
+ * @capability panel-toggle toggle a window open/closed from a single keybind
85
+ */
86
+ export function togglePanel(state, id) {
87
+ return isOpen(state, id) ? closePanel(state, id) : openPanel(state, id);
88
+ }
89
+ /**
90
+ * Raise open panel `id` to the top of the focus stack (assign it the highest z). No-op if it is
91
+ * closed or already on top, so a pointer-down that refocuses the top window returns the same state.
92
+ *
93
+ * @capability panel-focus raise a window to the top of the focus/z stack
94
+ */
95
+ export function focusPanel(state, id) {
96
+ if (state.open[id] !== true)
97
+ return state;
98
+ if (topPanel(state) === id)
99
+ return state;
100
+ return { ...state, z: { ...state.z, [id]: state.nextZ }, nextZ: state.nextZ + 1 };
101
+ }
102
+ /** @internal The id of the top (highest-z) open panel, or null when none are open. */
103
+ function topPanel(state) {
104
+ let top = null;
105
+ let topZ = -Infinity;
106
+ for (const id of Object.keys(state.open)) {
107
+ const value = state.z[id] ?? 0;
108
+ if (value > topZ) {
109
+ topZ = value;
110
+ top = id;
111
+ }
112
+ }
113
+ return top;
114
+ }
115
+ /**
116
+ * Close the topmost closable open panel — the ESC handler. Skips panels pinned with `closable: false`
117
+ * and returns the same state when nothing closable is open.
118
+ *
119
+ * @capability panel-close-top close the focused (topmost) closable window — the ESC behavior
120
+ */
121
+ export function closeTopPanel(state) {
122
+ const ordered = orderedOpenIds(state);
123
+ for (let i = ordered.length - 1; i >= 0; i--) {
124
+ const id = ordered[i];
125
+ if (isClosable(state, id))
126
+ return closePanel(state, id);
127
+ }
128
+ return state;
129
+ }
130
+ /** @internal Open panel ids sorted ascending by z (render order — last is on top). */
131
+ function orderedOpenIds(state) {
132
+ return Object.keys(state.open).sort((a, b) => (state.z[a] ?? 0) - (state.z[b] ?? 0));
133
+ }
134
+ /**
135
+ * Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be
136
+ * a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so
137
+ * author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
138
+ *
139
+ * @capability panel-hotkey resolve a keybind (code or key) to the panel id it toggles
140
+ */
141
+ export function panelByHotkey(defs, code) {
142
+ const needle = code.toLowerCase();
143
+ for (const def of defs) {
144
+ if (def.hotkey !== undefined && def.hotkey.toLowerCase() === needle)
145
+ return def.id;
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Set panel `id`'s position override — the drag commit. Does not require the panel to be open, so a
151
+ * caller can pre-place a window before it is shown.
152
+ *
153
+ * @capability panel-move override a window's on-surface position (the drag commit)
154
+ */
155
+ export function movePanel(state, id, pos) {
156
+ return { ...state, pos: { ...state.pos, [id]: { x: pos.x, y: pos.y } } };
157
+ }
158
+ /**
159
+ * The open panels' defs sorted ascending by z — the render order for a window host (map to elements
160
+ * so the last, highest-z window paints on top). Defs unknown to the state are skipped.
161
+ *
162
+ * @capability panel-ordered-open open panels' defs in z/render order for a window host
163
+ */
164
+ export function orderedOpen(state, defs) {
165
+ return defs
166
+ .filter((def) => state.open[def.id] === true)
167
+ .sort((a, b) => (state.z[a.id] ?? 0) - (state.z[b.id] ?? 0));
168
+ }
package/dist/ui.d.ts CHANGED
@@ -10,6 +10,7 @@ export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPan
10
10
  export { HUD_ANCHOR_FRACTIONS, type HudAnchor, type HudLayoutStore, type HudPlacement, type HudSize, } from "./ui/hudLayout.js";
11
11
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, type HudPlatform, type HudViewportConfig, } from "./ui/hudScale.js";
12
12
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, type LayoutOrientation, } from "./ui/orientation.js";
13
+ export { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen, movePanel, openPanel, orderedOpen, panelByHotkey, togglePanel, type PanelDef, type PanelPosition, type PanelState, } from "./ui/panelModel.js";
13
14
  export { moveSelectionFocus, selectionWindow, summarizeSelection, type EntitySummaryDef, type EntityVital, type SelectionFocusDirection, type SelectionGroup, type SelectionView, type SelectionWindow, type SummarizeSelectionOptions, } from "./ui/selectionModel.js";
14
15
  export { swingTimerState, type SwingTargetInput } from "./ui/swingTimer.js";
15
16
  export { actionTooltip, placePopover, type PopoverOptions, type PopoverPlacement, type PopoverSide, type TooltipContent, type UiRect, type UiSize, } from "./ui/tooltipModel.js";
package/dist/ui.js CHANGED
@@ -10,6 +10,7 @@ export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPan
10
10
  export { HUD_ANCHOR_FRACTIONS, } from "./ui/hudLayout.js";
11
11
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, } from "./ui/hudScale.js";
12
12
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "./ui/orientation.js";
13
+ export { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen, movePanel, openPanel, orderedOpen, panelByHotkey, togglePanel, } from "./ui/panelModel.js";
13
14
  export { moveSelectionFocus, selectionWindow, summarizeSelection, } from "./ui/selectionModel.js";
14
15
  export { swingTimerState } from "./ui/swingTimer.js";
15
16
  export { actionTooltip, placePopover, } from "./ui/tooltipModel.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Street-aware building placement: derive building LOTS along ROAD FRONTAGE. Given road segments
3
+ * (centerline + width) and a district area, walk each road by arc length and emit lots on both
4
+ * sides at a consistent setback — a curb + sidewalk strip — with each lot carrying the yaw that
5
+ * makes the building FRONT face its road. Unlike `cityBlocks` (which needs a CLOSED street network
6
+ * to extract planar block faces), this works on a sparse set of segments — the common authoring
7
+ * case — so buildings line the streets instead of scattering on plinths around a center point.
8
+ *
9
+ * Pure seeded math over `cityGeometry` `Vec2` primitives: no rendering, deterministic from the
10
+ * inputs, bounded by `maxLots`.
11
+ *
12
+ * @capability world-environment lots aligned to and facing road frontage, with a setback sidewalk strip
13
+ */
14
+ import type { Vec2 } from "./cityGeometry.js";
15
+ import type { WorldBounds } from "./features.js";
16
+ /** One road the frontage placer lines with buildings: a centerline polyline and its full width. */
17
+ export interface RoadFrontage {
18
+ /** Road centerline vertices in world XZ; at least two points. */
19
+ path: readonly Vec2[];
20
+ /** Road full width in world units; lots set back from `width / 2`. */
21
+ width: number;
22
+ }
23
+ /** Rectangular clip region lots must fall within (world center + half-extents). */
24
+ export interface LotArea {
25
+ /** World-space center of the region; default `[0, 0]`. */
26
+ center?: Vec2;
27
+ /** Half-extents along world x and z. */
28
+ halfExtents: Vec2;
29
+ }
30
+ /** Options for {@link deriveBuildingLots}. */
31
+ export interface BuildingLotOptions {
32
+ /** Roads whose frontage is lined with lots. */
33
+ roads: readonly RoadFrontage[];
34
+ /** Lot footprint: `w` is frontage width (along the road), `d` is depth (into the block). Default `{ w: 12, d: 10 }`. */
35
+ footprint?: WorldBounds;
36
+ /** Gap between neighbouring lots along a frontage, world units. Default 2. */
37
+ spacing?: number;
38
+ /** Sidewalk strip between the curb (`width / 2`) and the building front, world units. Default 3. */
39
+ setback?: number;
40
+ /** Place lots on both sides of each road. Default true. */
41
+ bothSides?: boolean;
42
+ /** Clip region; lots whose center falls outside are dropped. Omit to keep every lot. */
43
+ area?: LotArea;
44
+ /**
45
+ * Seed forwarded by callers for parity with other feature configs. Lot GEOMETRY is fully
46
+ * determined by the roads and dials (so a layout is identical without it); per-building variation
47
+ * (floors, massing) is seeded downstream where the lots become buildings.
48
+ */
49
+ seed?: string;
50
+ /** Hard cap on emitted lots so a long network stays bounded. Default 400. */
51
+ maxLots?: number;
52
+ }
53
+ /** One placed building lot: where a building stands and how it is turned to face its road. */
54
+ export interface PlacedBuildingLot {
55
+ /** Lot (building footprint) center in world XZ. */
56
+ center: Vec2;
57
+ /** Building yaw (radians) so its FRONT (+z local face) points at the road. */
58
+ rotationY: number;
59
+ /** Footprint of the lot: `w` along the frontage, `d` into the block. */
60
+ footprint: WorldBounds;
61
+ /** Index into the input `roads`. */
62
+ road: number;
63
+ /** Which side of the road: +1 = left normal, -1 = right normal. */
64
+ side: 1 | -1;
65
+ /** Distance from the lot's front face to the road centerline (= `width / 2 + setback`). */
66
+ frontDistance: number;
67
+ }
68
+ /**
69
+ * Yaw that turns the building FRONT (local +z) to face the road. The lot center sits at
70
+ * `roadPoint + outwardNormal * d`, so the road lies along `-outwardNormal` from the center. In the
71
+ * engine's Y-rotation convention a local +z of `(0, 1)` maps to world `(sin y, cos y)`; setting
72
+ * that equal to `-outwardNormal` gives `y = atan2(-nx, -nz)`.
73
+ * @internal
74
+ */
75
+ export declare function facingRotation(outwardNormal: Vec2): number;
76
+ /**
77
+ * Derive street-aligned building lots from road frontage. Lots are stepped along each road at
78
+ * `footprint.w + spacing`, offset to each side by `width / 2 + setback + footprint.d / 2`, and
79
+ * turned to face the road. Cross-road overlaps are rejected by a bounded nearest-neighbour test, so
80
+ * two roads meeting at a corner don't stack buildings. Deterministic: identical inputs (including
81
+ * seed) always yield the identical list.
82
+ * @capability building-lots derive street-facing building lots from road frontage
83
+ */
84
+ export declare function deriveBuildingLots(options: BuildingLotOptions): PlacedBuildingLot[];