@jgengine/core 0.12.0 → 0.13.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  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 +25 -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 +2 -1
  29. package/dist/runtime/context/scene.js +39 -1
  30. package/dist/runtime/gameContext.js +50 -4
  31. package/dist/runtime/gameContextTypes.d.ts +37 -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/colliders.d.ts +21 -0
  36. package/dist/scene/colliders.js +62 -0
  37. package/dist/scene/entityStats.js +9 -1
  38. package/dist/stats/statModifiers.d.ts +16 -0
  39. package/dist/stats/statModifiers.js +30 -1
  40. package/dist/stats/statPool.js +5 -2
  41. package/dist/ui/panelModel.d.ts +120 -0
  42. package/dist/ui/panelModel.js +168 -0
  43. package/dist/ui.d.ts +1 -0
  44. package/dist/ui.js +1 -0
  45. package/dist/world/buildingLots.d.ts +84 -0
  46. package/dist/world/buildingLots.js +119 -0
  47. package/dist/world/buildings.d.ts +6 -0
  48. package/dist/world/cityGenerator.d.ts +41 -0
  49. package/dist/world/cityGenerator.js +58 -0
  50. package/dist/world/cityKind.d.ts +2 -2
  51. package/dist/world/cityKind.js +4 -4
  52. package/dist/world/environmentSummary.js +57 -1
  53. package/dist/world/features.d.ts +35 -2
  54. package/dist/world/features.js +5 -2
  55. package/dist/world/roads.d.ts +19 -2
  56. package/dist/world/roads.js +60 -3
  57. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  58. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  59. package/dist/world/streets.d.ts +74 -1
  60. package/dist/world/streets.js +218 -0
  61. package/dist/world/terrain.d.ts +20 -1
  62. package/dist/world/terrain.js +41 -2
  63. package/package.json +1 -1
  64. package/skills/jgengine/SKILL.md +1 -0
  65. package/skills/jgengine/api.md +13 -5
  66. package/skills/jgengine-combat/SKILL.md +15 -1
  67. package/skills/jgengine-combat/api.md +39 -2
  68. package/skills/jgengine-combat/capabilities.md +17 -1
  69. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  70. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  71. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  72. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  73. package/skills/jgengine-combat/reference.md +22 -3
  74. package/skills/jgengine-gameplay/SKILL.md +8 -0
  75. package/skills/jgengine-gameplay/api.md +35 -5
  76. package/skills/jgengine-gameplay/capabilities.md +18 -0
  77. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  78. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  79. package/skills/jgengine-world/api.md +52 -24
  80. package/skills/jgengine-world/capabilities.md +8 -4
  81. package/skills/jgengine-world/reference.md +1 -1
@@ -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[];
@@ -0,0 +1,119 @@
1
+ const DEFAULT_FOOTPRINT = { w: 12, d: 10 };
2
+ /** Cumulative arc length of a polyline. */
3
+ function polylineLength(path) {
4
+ let sum = 0;
5
+ for (let i = 0; i + 1 < path.length; i += 1) {
6
+ sum += Math.hypot(path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1]);
7
+ }
8
+ return sum;
9
+ }
10
+ /** Point + unit tangent at arc length `s` along a polyline (clamped to its ends). */
11
+ function stationAt(path, s) {
12
+ let acc = 0;
13
+ for (let i = 0; i + 1 < path.length; i += 1) {
14
+ const a = path[i];
15
+ const b = path[i + 1];
16
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
17
+ if (len < 1e-9)
18
+ continue;
19
+ if (acc + len >= s || i + 2 === path.length) {
20
+ const t = Math.max(0, Math.min(1, (s - acc) / len));
21
+ return {
22
+ p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
23
+ tangent: [(b[0] - a[0]) / len, (b[1] - a[1]) / len],
24
+ };
25
+ }
26
+ acc += len;
27
+ }
28
+ const a = path[0];
29
+ return { p: [a[0], a[1]], tangent: [1, 0] };
30
+ }
31
+ function inArea(area, x, z) {
32
+ if (area === undefined)
33
+ return true;
34
+ const [cx, cz] = area.center ?? [0, 0];
35
+ return Math.abs(x - cx) <= area.halfExtents[0] && Math.abs(z - cz) <= area.halfExtents[1];
36
+ }
37
+ /**
38
+ * Yaw that turns the building FRONT (local +z) to face the road. The lot center sits at
39
+ * `roadPoint + outwardNormal * d`, so the road lies along `-outwardNormal` from the center. In the
40
+ * engine's Y-rotation convention a local +z of `(0, 1)` maps to world `(sin y, cos y)`; setting
41
+ * that equal to `-outwardNormal` gives `y = atan2(-nx, -nz)`.
42
+ * @internal
43
+ */
44
+ export function facingRotation(outwardNormal) {
45
+ return Math.atan2(-outwardNormal[0], -outwardNormal[1]);
46
+ }
47
+ /**
48
+ * Derive street-aligned building lots from road frontage. Lots are stepped along each road at
49
+ * `footprint.w + spacing`, offset to each side by `width / 2 + setback + footprint.d / 2`, and
50
+ * turned to face the road. Cross-road overlaps are rejected by a bounded nearest-neighbour test, so
51
+ * two roads meeting at a corner don't stack buildings. Deterministic: identical inputs (including
52
+ * seed) always yield the identical list.
53
+ * @capability building-lots derive street-facing building lots from road frontage
54
+ */
55
+ export function deriveBuildingLots(options) {
56
+ const footprint = options.footprint ?? DEFAULT_FOOTPRINT;
57
+ const spacing = Math.max(0, options.spacing ?? 2);
58
+ const setback = Math.max(0, options.setback ?? 3);
59
+ const bothSides = options.bothSides ?? true;
60
+ const maxLots = Math.max(0, Math.floor(options.maxLots ?? 400));
61
+ const step = Math.max(1, footprint.w + spacing);
62
+ const sides = bothSides ? [1, -1] : [1];
63
+ // Minimum center separation for cross-road de-dup: half the footprint diagonal, a touch under
64
+ // the along-road step so same-road neighbours are never rejected.
65
+ const minSep = Math.min(step * 0.95, Math.hypot(footprint.w, footprint.d) * 0.5);
66
+ const minSep2 = minSep * minSep;
67
+ const lots = [];
68
+ const accepted = [];
69
+ for (let r = 0; r < options.roads.length && lots.length < maxLots; r += 1) {
70
+ const road = options.roads[r];
71
+ if (road.path.length < 2)
72
+ continue;
73
+ const total = polylineLength(road.path);
74
+ if (total < 1e-6)
75
+ continue;
76
+ const front = road.width / 2 + setback;
77
+ const offset = front + footprint.d / 2;
78
+ // Center the run so lots sit symmetrically and endpoints keep a half-step margin.
79
+ const count = Math.max(1, Math.floor(total / step));
80
+ const startS = (total - (count - 1) * step) / 2;
81
+ for (let i = 0; i < count && lots.length < maxLots; i += 1) {
82
+ const s = startS + i * step;
83
+ const station = stationAt(road.path, s);
84
+ const [tx, tz] = station.tangent;
85
+ // Left normal of the tangent (CCW): (-tz, tx). Right side negates it.
86
+ for (const side of sides) {
87
+ if (lots.length >= maxLots)
88
+ break;
89
+ const nx = -tz * side;
90
+ const nz = tx * side;
91
+ const cx = station.p[0] + nx * offset;
92
+ const cz = station.p[1] + nz * offset;
93
+ if (!inArea(options.area, cx, cz))
94
+ continue;
95
+ let clash = false;
96
+ for (const [ax, az] of accepted) {
97
+ const dx = ax - cx;
98
+ const dz = az - cz;
99
+ if (dx * dx + dz * dz < minSep2) {
100
+ clash = true;
101
+ break;
102
+ }
103
+ }
104
+ if (clash)
105
+ continue;
106
+ lots.push({
107
+ center: [cx, cz],
108
+ rotationY: facingRotation([nx, nz]),
109
+ footprint: { w: footprint.w, d: footprint.d },
110
+ road: r,
111
+ side,
112
+ frontDistance: front,
113
+ });
114
+ accepted.push([cx, cz]);
115
+ }
116
+ }
117
+ }
118
+ return lots;
119
+ }
@@ -72,6 +72,12 @@ export interface GeneratedBuilding {
72
72
  floorHeight: number;
73
73
  bounds: Aabb;
74
74
  parts: readonly BuildingPartPlacement[];
75
+ /**
76
+ * Optional building yaw (radians) about `center`, applied by the renderer to the whole massing —
77
+ * used by street-aware placement to turn a building's front to face its road. Absent/0 keeps the
78
+ * axis-aligned grid orientation.
79
+ */
80
+ rotationY?: number;
75
81
  }
76
82
  export interface BuildingLot {
77
83
  id: string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The seed-driven procedural CITY GENERATOR — the composition seam over the two fabric engines:
3
+ * {@link generateStreets} grows the street network, and {@link deriveBuildingLots} lines its
4
+ * frontage with street-facing building lots. One call, one seed, one deterministic city: same rules
5
+ * ⇒ identical streets and identical lots, with both engines' bounded-work caps intact. Local
6
+ * (volume-centered) coords, same as the street generator; the caller translates into world space.
7
+ *
8
+ * Street dials default to a dense, gently-varied city net (high gridness/connectivity, light
9
+ * winding); pass any subset of {@link StreetNetworkRules} to override, and lot options pass through
10
+ * to the frontage engine untouched. Lanes are skipped as lot frontage by default — buildings line
11
+ * boulevards, avenues, and streets — so alleys stay service alleys; opt lanes in via
12
+ * `lots.laneFrontage`.
13
+ *
14
+ * @capability city-generator compose a deterministic city — street network plus street-facing building lots — from one seed
15
+ */
16
+ import { type BuildingLotOptions, type PlacedBuildingLot } from "./buildingLots.js";
17
+ import { type StreetNetwork, type StreetNetworkContext, type StreetNetworkRules } from "./streetGenerator.js";
18
+ /** Options for {@link generateCity}: a seed, street-dial overrides, and lot pass-through options. */
19
+ export interface CityGeneratorOptions {
20
+ /** Drives both the street network and downstream lot/floor variation. */
21
+ seed: string;
22
+ /** Street-dial overrides; anything omitted takes the city defaults above. */
23
+ streets?: Partial<Omit<StreetNetworkRules, "seed">>;
24
+ /** Frontage-lot options, minus `roads` (the generated streets are the roads). */
25
+ lots?: Omit<BuildingLotOptions, "roads" | "seed"> & {
26
+ /** Also line `lane`-level streets (alleys) with lots. Default false. */
27
+ laneFrontage?: boolean;
28
+ };
29
+ /** Ground sampler + bridge/tunnel toggles forwarded to the street generator. */
30
+ context?: StreetNetworkContext;
31
+ }
32
+ /** A generated city: the street network and the building lots lining its frontage. */
33
+ export interface GeneratedCity {
34
+ network: StreetNetwork;
35
+ lots: PlacedBuildingLot[];
36
+ }
37
+ /**
38
+ * Grow a street network inside the `hx`/`hz` half-extents and line its frontage with building lots.
39
+ * Deterministic: identical options ⇒ identical city.
40
+ */
41
+ export declare function generateCity(options: CityGeneratorOptions, hx: number, hz: number): GeneratedCity;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The seed-driven procedural CITY GENERATOR — the composition seam over the two fabric engines:
3
+ * {@link generateStreets} grows the street network, and {@link deriveBuildingLots} lines its
4
+ * frontage with street-facing building lots. One call, one seed, one deterministic city: same rules
5
+ * ⇒ identical streets and identical lots, with both engines' bounded-work caps intact. Local
6
+ * (volume-centered) coords, same as the street generator; the caller translates into world space.
7
+ *
8
+ * Street dials default to a dense, gently-varied city net (high gridness/connectivity, light
9
+ * winding); pass any subset of {@link StreetNetworkRules} to override, and lot options pass through
10
+ * to the frontage engine untouched. Lanes are skipped as lot frontage by default — buildings line
11
+ * boulevards, avenues, and streets — so alleys stay service alleys; opt lanes in via
12
+ * `lots.laneFrontage`.
13
+ *
14
+ * @capability city-generator compose a deterministic city — street network plus street-facing building lots — from one seed
15
+ */
16
+ import { deriveBuildingLots } from "./buildingLots.js";
17
+ import { isOnRoad } from "./roads.js";
18
+ import { generateStreets, } from "./streetGenerator.js";
19
+ /** City-shaped defaults for every street dial except the seed. */
20
+ const CITY_STREET_DEFAULTS = {
21
+ gridness: 0.85,
22
+ loopiness: 0.35,
23
+ connectivity: 0.6,
24
+ branching: 0.25,
25
+ deadEnds: 0.15,
26
+ segmentLength: 90,
27
+ aspect: 1.4,
28
+ winding: 0.15,
29
+ minCurveRadius: 18,
30
+ minTurnAngle: 12,
31
+ maxTurnAngle: 110,
32
+ width: 9,
33
+ boulevards: 0.2,
34
+ };
35
+ /**
36
+ * Grow a street network inside the `hx`/`hz` half-extents and line its frontage with building lots.
37
+ * Deterministic: identical options ⇒ identical city.
38
+ */
39
+ export function generateCity(options, hx, hz) {
40
+ const { seed, streets: streetOverrides, lots: lotOptions, context } = options;
41
+ const rules = { seed, ...CITY_STREET_DEFAULTS, ...streetOverrides };
42
+ const network = generateStreets(rules, hx, hz, context ?? {});
43
+ const { laneFrontage = false, ...lotRest } = lotOptions ?? {};
44
+ const frontage = network.streets
45
+ .filter((street) => laneFrontage || street.level !== "lane")
46
+ .map((street) => ({ path: street.points, width: street.width }));
47
+ const candidates = deriveBuildingLots({
48
+ ...lotRest,
49
+ roads: frontage,
50
+ seed,
51
+ area: lotRest.area ?? { center: [0, 0], halfExtents: [hx, hz] },
52
+ });
53
+ // The frontage engine only avoids the roads it lines; streets excluded from frontage (lanes by
54
+ // default) are still pavement, so drop any lot whose footprint would straddle one.
55
+ const clearance = Math.min(lotRest.footprint?.w ?? 12, lotRest.footprint?.d ?? 10);
56
+ const lots = candidates.filter((lot) => network.streets.every((street) => !isOnRoad(street.points, street.width + clearance, lot.center[0], lot.center[1])));
57
+ return { network, lots };
58
+ }
@@ -2,7 +2,7 @@ import { type ParamSchema, type SceneKindObject, type SceneKindResolveContext, t
2
2
  import { type BuildingStyle } from "./buildings.js";
3
3
  import { type CityLotClass, type CityLotPiece, type CityTreeSpecies, type CityZoneBand, type CityZoneProfile } from "./cityContent.js";
4
4
  import type { RoadPoint } from "./roads.js";
5
- import { type PathFeatureKind } from "./pathNetwork.js";
5
+ import { type StreetFeatureKind } from "./streetGenerator.js";
6
6
  import { type CityBlockKind } from "./cityBlocks.js";
7
7
  /** The editor volume kind marking a box as a procedural city district. */
8
8
  export declare const CITY_KIND = "city";
@@ -130,7 +130,7 @@ export interface CityStreet {
130
130
  bulb?: RoadPoint;
131
131
  /** Bridge/tunnel spans along this street as `[from, to]` index windows into `points`. */
132
132
  features?: readonly {
133
- kind: PathFeatureKind;
133
+ kind: StreetFeatureKind;
134
134
  from: number;
135
135
  to: number;
136
136
  bankHeight: number;
@@ -3,7 +3,7 @@
3
3
  * the whole synthesis, from a rigid Manhattan grid (`gridness` 1, `curviness` 0) to winding
4
4
  * hillside estates, a two-road crossroads farm town, or — at the loopiness-high / branching-and-
5
5
  * connectivity-low corner — a closed RACE CIRCUIT. Streets come from the unified seed-driven
6
- * {@link buildPathNetwork} engine (city and track are the same generator at opposite slider
6
+ * {@link generateStreets} engine (city and track are the same generator at opposite slider
7
7
  * extremes), carry a visible hierarchy (boulevards with medians, avenues, streets, lanes —
8
8
  * optionally gravel), and connect or deliberately dead-end into cul-de-sac bulbs; water gaps become
9
9
  * styled bridge decks and ridges become tunnel bores, both path features on continuous streets so a
@@ -32,7 +32,7 @@ import { parseParams, registerSceneKind, } from "../scene/sceneKinds.js";
32
32
  import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_STYLE } from "./buildings.js";
33
33
  import { buildLotPieces, pickClass, pickSpecies, rollClassPlacement, zoneBand, zoneMetric, } from "./cityContent.js";
34
34
  import { furnitureSpots } from "./streets.js";
35
- import { buildPathNetwork, pathNetworkMode, } from "./pathNetwork.js";
35
+ import { generateStreets, streetNetworkMode, } from "./streetGenerator.js";
36
36
  import { buildablePolygon, carveCorridors, conformPolygonToRing, cutParcel, extractBlocks, isSliverBlock, pointsInPolygon, RingWalker, } from "./cityBlocks.js";
37
37
  import { fitRectInPolygon, rayDistanceToRing, pointInPolygon, polygonArea, polygonCentroid, polygonMeanWidth, polygonsOverlap, insetRingUniform, ringBounds, } from "./cityGeometry.js";
38
38
  /** The editor volume kind marking a box as a procedural city district. */
@@ -1215,7 +1215,7 @@ export function resolveCityObject(object, context) {
1215
1215
  width: rules.streetWidth,
1216
1216
  boulevards: rules.boulevards,
1217
1217
  };
1218
- const network = buildPathNetwork(netRules, hx, hz, {
1218
+ const network = generateStreets(netRules, hx, hz, {
1219
1219
  heightAt: heightAt ?? undefined,
1220
1220
  minElevation: rules.minElevation,
1221
1221
  bridges: rules.bridges,
@@ -1537,7 +1537,7 @@ export function registerCityKind() {
1537
1537
  return "Give the volume a box footprint.";
1538
1538
  if (resolved.streets.length === 0)
1539
1539
  return `Footprint too small — needs at least ${Math.ceil(resolved.rules.blockSize * 1.5)} m across.`;
1540
- const mode = pathNetworkMode({
1540
+ const mode = streetNetworkMode({
1541
1541
  seed: resolved.rules.seed,
1542
1542
  gridness: resolved.rules.gridness,
1543
1543
  loopiness: resolved.rules.loopiness,
@@ -1,7 +1,11 @@
1
- import { generateBuildingDistrict, resolveBuildingPalette, } from "./buildings.js";
1
+ import { DEFAULT_BUILDING_CONFIG, generateBuilding, generateBuildingDistrict, resolveBuildingPalette, } from "./buildings.js";
2
+ import { deriveBuildingLots } from "./buildingLots.js";
2
3
  import { pathLength } from "./roads.js";
3
4
  import { resolveTerrainField, resolveTerrainPalette } from "./terrain.js";
4
5
  export function resolveStructureBuildings(descriptor) {
6
+ if (descriptor.along !== undefined) {
7
+ return resolveFrontageBuildings(descriptor, descriptor.along);
8
+ }
5
9
  const columns = Math.max(1, Math.ceil(Math.sqrt(descriptor.count)));
6
10
  const rows = Math.max(1, Math.ceil(descriptor.count / columns));
7
11
  const spacing = descriptor.spacing;
@@ -21,6 +25,58 @@ export function resolveStructureBuildings(descriptor) {
21
25
  ...(descriptor.seed === undefined ? {} : { seed: descriptor.seed }),
22
26
  }).slice(0, descriptor.count);
23
27
  }
28
+ function hashU32(input) {
29
+ let hash = 2166136261;
30
+ for (let i = 0; i < input.length; i += 1) {
31
+ hash ^= input.charCodeAt(i);
32
+ hash = Math.imul(hash, 16777619);
33
+ }
34
+ hash ^= hash >>> 16;
35
+ hash = Math.imul(hash, 2246822507);
36
+ hash ^= hash >>> 13;
37
+ hash = Math.imul(hash, 3266489909);
38
+ hash ^= hash >>> 16;
39
+ return hash >>> 0;
40
+ }
41
+ /**
42
+ * Build one street-aware building per road-frontage lot: massed at the lot center, its `rotationY`
43
+ * set so the front faces the road (the renderer turns the whole building about `center`), floors
44
+ * rolled deterministically from the descriptor's `stories` and the lot's road/side/index.
45
+ */
46
+ function resolveFrontageBuildings(descriptor, along) {
47
+ const seed = descriptor.seed ?? "buildings";
48
+ const lots = deriveBuildingLots({
49
+ roads: along.roads,
50
+ footprint: descriptor.footprint,
51
+ spacing: descriptor.spacing,
52
+ maxLots: descriptor.count,
53
+ seed: `${seed}:lots`,
54
+ ...(along.setback === undefined ? {} : { setback: along.setback }),
55
+ ...(along.bothSides === undefined ? {} : { bothSides: along.bothSides }),
56
+ ...(along.area === undefined ? {} : { area: along.area }),
57
+ });
58
+ const [floorsMin, floorsMax] = descriptor.stories;
59
+ const bayWidth = DEFAULT_BUILDING_CONFIG.bayWidth;
60
+ return lots.map((lot, index) => buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, descriptor.storyHeight));
61
+ }
62
+ function buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, storyHeight) {
63
+ const lo = Math.min(floorsMin, floorsMax);
64
+ const hi = Math.max(floorsMin, floorsMax);
65
+ const floors = lo + Math.floor((hashU32(`${seed}:floors:${index}`) / 4294967296) * (hi - lo + 1));
66
+ const baysWide = Math.max(1, Math.floor(lot.footprint.w / bayWidth));
67
+ const baysDeep = Math.max(1, Math.floor(lot.footprint.d / bayWidth));
68
+ const building = generateBuilding({
69
+ id: `frontage-${index}`,
70
+ seed: `${seed}:${index}`,
71
+ center: lot.center,
72
+ floors,
73
+ baysWide,
74
+ baysDeep,
75
+ bayWidth,
76
+ floorHeight: storyHeight,
77
+ });
78
+ return { ...building, rotationY: lot.rotationY };
79
+ }
24
80
  const TERRAIN_SAMPLE_RESOLUTION = 16;
25
81
  function sampleTerrainHeights(descriptor) {
26
82
  const field = resolveTerrainField(descriptor);