@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.
- package/CHANGELOG.md +26 -0
- package/dist/combat/effects.d.ts +11 -4
- package/dist/combat/effects.js +24 -17
- package/dist/combat/magazine.d.ts +13 -0
- package/dist/combat/magazine.js +27 -0
- package/dist/combat/vfxPresets.d.ts +176 -0
- package/dist/combat/vfxPresets.js +74 -0
- package/dist/combat/weaponFire.d.ts +114 -0
- package/dist/combat/weaponFire.js +96 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +3 -1
- package/dist/devtools/urlFlags.d.ts +22 -0
- package/dist/devtools/urlFlags.js +62 -0
- package/dist/game/clipRoles.d.ts +51 -0
- package/dist/game/clipRoles.js +142 -0
- package/dist/game/defineGame.d.ts +2 -2
- package/dist/game/partAnimation.d.ts +78 -0
- package/dist/game/partAnimation.js +147 -0
- package/dist/game/playableGame.d.ts +38 -5
- package/dist/game/progression.d.ts +2 -2
- package/dist/game/progression.js +4 -2
- package/dist/inventory/inventoryModel.d.ts +24 -8
- package/dist/inventory/inventoryModel.js +47 -8
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +25 -1
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/combatFx.js +15 -5
- package/dist/runtime/context/scene.d.ts +2 -1
- package/dist/runtime/context/scene.js +39 -1
- package/dist/runtime/gameContext.js +50 -4
- package/dist/runtime/gameContextTypes.d.ts +37 -4
- package/dist/runtime/headlessRunner.d.ts +13 -0
- package/dist/runtime/headlessRunner.js +14 -0
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/colliders.d.ts +21 -0
- package/dist/scene/colliders.js +62 -0
- package/dist/scene/entityStats.js +9 -1
- package/dist/stats/statModifiers.d.ts +16 -0
- package/dist/stats/statModifiers.js +30 -1
- package/dist/stats/statPool.js +5 -2
- package/dist/ui/panelModel.d.ts +120 -0
- package/dist/ui/panelModel.js +168 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/dist/world/buildingLots.d.ts +84 -0
- package/dist/world/buildingLots.js +119 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/cityGenerator.d.ts +41 -0
- package/dist/world/cityGenerator.js +58 -0
- package/dist/world/cityKind.d.ts +2 -2
- package/dist/world/cityKind.js +4 -4
- package/dist/world/environmentSummary.js +57 -1
- package/dist/world/features.d.ts +35 -2
- package/dist/world/features.js +5 -2
- package/dist/world/roads.d.ts +19 -2
- package/dist/world/roads.js +60 -3
- package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
- package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
- package/dist/world/streets.d.ts +74 -1
- package/dist/world/streets.js +218 -0
- package/dist/world/terrain.d.ts +20 -1
- package/dist/world/terrain.js +41 -2
- package/package.json +1 -1
- package/skills/jgengine/SKILL.md +1 -0
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-combat/SKILL.md +15 -1
- package/skills/jgengine-combat/api.md +39 -2
- package/skills/jgengine-combat/capabilities.md +17 -1
- package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
- package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
- package/skills/jgengine-combat/reference.md +22 -3
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +35 -5
- package/skills/jgengine-gameplay/capabilities.md +18 -0
- package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
- package/skills/jgengine-gameplay/reference-systems.md +9 -0
- package/skills/jgengine-world/api.md +52 -24
- package/skills/jgengine-world/capabilities.md +8 -4
- 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
|
+
}
|
package/dist/world/cityKind.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
133
|
+
kind: StreetFeatureKind;
|
|
134
134
|
from: number;
|
|
135
135
|
to: number;
|
|
136
136
|
bankHeight: number;
|
package/dist/world/cityKind.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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);
|