@jgengine/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -1,27 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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";
|
|
1
|
+
import { extractGraphBlocks, carveCorridors, conformPolygonToRing, cutParcel, isSliverBlock, RingWalker, } from "./cityBlocks.js";
|
|
2
|
+
import { buildLandmarkPieces, buildLotPieces, classWidthRange, landmarkFloors, pickClass, rollClassPlacement, zoneBand, zoneMetric, CITY_LANDMARK_CLASSES, } from "./cityContent.js";
|
|
3
|
+
import { fitRectInPolygon, polygonArea, polygonsOverlap, rayDistanceToRing } from "./cityGeometry.js";
|
|
4
|
+
import { isOnRoad, nearestOnPath } from "./roads.js";
|
|
5
|
+
import { seededStreams } from "../random/rng.js";
|
|
18
6
|
import { generateStreets, } from "./streetGenerator.js";
|
|
19
7
|
/** City-shaped defaults for every street dial except the seed. */
|
|
20
8
|
const CITY_STREET_DEFAULTS = {
|
|
21
9
|
gridness: 0.85,
|
|
22
10
|
loopiness: 0.35,
|
|
23
11
|
connectivity: 0.6,
|
|
24
|
-
branching: 0.
|
|
12
|
+
branching: 0.4,
|
|
25
13
|
deadEnds: 0.15,
|
|
26
14
|
segmentLength: 90,
|
|
27
15
|
aspect: 1.4,
|
|
@@ -31,28 +19,650 @@ const CITY_STREET_DEFAULTS = {
|
|
|
31
19
|
maxTurnAngle: 110,
|
|
32
20
|
width: 9,
|
|
33
21
|
boulevards: 0.2,
|
|
22
|
+
// Seed-unique silhouette by default: cities grow lobes and bays instead of filling the square.
|
|
23
|
+
outline: 0.4,
|
|
24
|
+
// Suburb life: most spurs grow as winding cul-de-sac side streets lined with houses.
|
|
25
|
+
residentialBranches: 0.6,
|
|
34
26
|
};
|
|
27
|
+
/** All plot tiers, for schema hints and validation. */
|
|
28
|
+
export const CITY_PLOT_TIERS = ["small", "medium", "large", "grand"];
|
|
35
29
|
/**
|
|
36
|
-
* Grow a street network inside the `hx`/`hz` half-extents
|
|
37
|
-
* Deterministic: identical options ⇒ identical city.
|
|
30
|
+
* Grow a street network inside the `hx`/`hz` half-extents, extract its blocks, and subdivide their
|
|
31
|
+
* frontage into plots. Deterministic: identical options ⇒ identical city.
|
|
38
32
|
*/
|
|
39
33
|
export function generateCity(options, hx, hz) {
|
|
40
34
|
const { seed, streets: streetOverrides, lots: lotOptions, context } = options;
|
|
41
35
|
const rules = { seed, ...CITY_STREET_DEFAULTS, ...streetOverrides };
|
|
42
36
|
const network = generateStreets(rules, hx, hz, context ?? {});
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const candidates = deriveBuildingLots({
|
|
48
|
-
...lotRest,
|
|
49
|
-
roads: frontage,
|
|
37
|
+
const contentEnabled = options.content !== undefined && options.content !== false;
|
|
38
|
+
const overrides = typeof options.content === "object" ? options.content : {};
|
|
39
|
+
const footprint = lotOptions?.footprint ?? { w: 12, d: 10 };
|
|
40
|
+
const { plots, parks } = deriveCityPlots(network, {
|
|
50
41
|
seed,
|
|
51
|
-
|
|
42
|
+
halfExtents: [hx, hz],
|
|
43
|
+
laneFrontage: lotOptions?.laneFrontage ?? false,
|
|
44
|
+
setback: lotOptions?.setback,
|
|
45
|
+
spacing: lotOptions?.spacing,
|
|
46
|
+
variety: lotOptions?.variety,
|
|
47
|
+
blockFill: overrides.blockFill ?? lotOptions?.blockFill,
|
|
48
|
+
landmarks: overrides.landmarks,
|
|
49
|
+
plotScale: [footprint.w / 12, footprint.d / 10],
|
|
50
|
+
maxPlots: lotOptions?.maxLots,
|
|
51
|
+
profile: overrides.profile,
|
|
52
|
+
coreExtent: overrides.coreExtent,
|
|
53
|
+
midExtent: overrides.midExtent,
|
|
54
|
+
});
|
|
55
|
+
const lots = plots.map((plot) => toPlacedLot(plot, network));
|
|
56
|
+
if (!contentEnabled)
|
|
57
|
+
return { network, lots, plots, parks };
|
|
58
|
+
const lotContent = resolveCityLotContent({ network, lots, plots, parks }, { seed, halfExtents: [hx, hz], laneFrontage: lotOptions?.laneFrontage ?? false, ...overrides });
|
|
59
|
+
return { network, lots, plots, parks, lotContent };
|
|
60
|
+
}
|
|
61
|
+
/** Rect-lot compat view of a plot: center/yaw/footprint plus street reference fields. */
|
|
62
|
+
function toPlacedLot(plot, network) {
|
|
63
|
+
const street = network.streets[plot.frontage.street];
|
|
64
|
+
let side = 1;
|
|
65
|
+
let frontDistance = plot.depth / 2;
|
|
66
|
+
if (street !== undefined) {
|
|
67
|
+
const near = nearestOnPath(street.points, plot.center[0], plot.center[1]);
|
|
68
|
+
if (near !== null) {
|
|
69
|
+
const offX = plot.center[0] - near.point[0];
|
|
70
|
+
const offZ = plot.center[1] - near.point[1];
|
|
71
|
+
// Left normal of the tangent (CCW): (-tz, tx).
|
|
72
|
+
side = offX * -near.tangent[1] + offZ * near.tangent[0] >= 0 ? 1 : -1;
|
|
73
|
+
frontDistance = Math.max(0, near.distance - plot.depth / 2);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
center: [plot.center[0], plot.center[1]],
|
|
78
|
+
rotationY: plot.rotationY,
|
|
79
|
+
footprint: { w: plot.width, d: plot.depth },
|
|
80
|
+
road: plot.frontage.street,
|
|
81
|
+
side,
|
|
82
|
+
frontDistance,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Plot derivation — block frontage subdivision into size-tiered polygonal plots.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/** Default `blockFill`: gently gapped frontage — plots with breathing room, parks reserved. */
|
|
89
|
+
export const DEFAULT_BLOCK_FILL = 0.45;
|
|
90
|
+
/** Default grand-plot share dial — a handful of block-scale parcels per default city. */
|
|
91
|
+
export const DEFAULT_LANDMARK_SHARE = 0.04;
|
|
92
|
+
/** Hard cap on grand plots emitted regardless of dial/city size. */
|
|
93
|
+
export const LANDMARK_HARD_CAP = 12;
|
|
94
|
+
/** Frontage/depth ranges (meters, before `plotScale`) per tier. */
|
|
95
|
+
const PLOT_TIER_SIZES = {
|
|
96
|
+
small: { frontage: [6.5, 11], depth: [9, 16] },
|
|
97
|
+
medium: { frontage: [11, 18], depth: [12, 22] },
|
|
98
|
+
large: { frontage: [18, 30], depth: [16, 30] },
|
|
99
|
+
grand: { frontage: [34, 60], depth: [30, 58] },
|
|
100
|
+
};
|
|
101
|
+
/** Non-grand tier weights per zone band — many different plot sizes, denser splits downtown. */
|
|
102
|
+
const PLOT_TIER_WEIGHTS = {
|
|
103
|
+
core: { small: 1, medium: 3, large: 2.6 },
|
|
104
|
+
mid: { small: 2.4, medium: 3, large: 1.2 },
|
|
105
|
+
edge: { small: 3.4, medium: 2, large: 0.5 },
|
|
106
|
+
};
|
|
107
|
+
/** Street-level multipliers on tier weights: boulevards pull big plots, lanes pull small ones. */
|
|
108
|
+
const PLOT_LEVEL_TIER_BIAS = {
|
|
109
|
+
boulevard: { large: 1.7, medium: 1.1, small: 0.6 },
|
|
110
|
+
avenue: { large: 1.3, medium: 1.1 },
|
|
111
|
+
street: {},
|
|
112
|
+
lane: { small: 1.8, large: 0.35 },
|
|
113
|
+
};
|
|
114
|
+
/** Grand-share multiplier per zone band: civic-scale parcels concentrate toward the core. */
|
|
115
|
+
const GRAND_ZONE_FACTOR = { core: 1.6, mid: 1, edge: 0.45 };
|
|
116
|
+
/** Weighted tier pick from zone weights × street-level bias. */
|
|
117
|
+
function pickTier(zone, level, roll, variety) {
|
|
118
|
+
const weights = PLOT_TIER_WEIGHTS[zone];
|
|
119
|
+
const bias = PLOT_LEVEL_TIER_BIAS[level];
|
|
120
|
+
const entries = Object.keys(weights).map((tier) => ({
|
|
121
|
+
tier,
|
|
122
|
+
weight: (weights[tier] * (bias[tier] ?? 1)) * variety + (tier === "medium" ? 1 - variety : 0),
|
|
123
|
+
}));
|
|
124
|
+
let total = 0;
|
|
125
|
+
for (const e of entries)
|
|
126
|
+
total += e.weight;
|
|
127
|
+
let cursor = roll * total;
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
cursor -= e.weight;
|
|
130
|
+
if (cursor <= 0)
|
|
131
|
+
return e.tier;
|
|
132
|
+
}
|
|
133
|
+
return "medium";
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Subdivide every buildable block's street frontage into size-tiered polygonal plots. Blocks come
|
|
137
|
+
* from the street network's planar faces ({@link extractBlocks}), so every plot fronts a real
|
|
138
|
+
* street; block interiors stay open (courtyards) and a seeded fraction of whole blocks becomes
|
|
139
|
+
* parks. Grand plots — the old "landmarks" — are just the biggest tier of the same pass.
|
|
140
|
+
* Deterministic per seed, bounded by `maxPlots` and per-block caps.
|
|
141
|
+
*
|
|
142
|
+
* @capability city-generator subdivide street blocks into size-tiered street-fronting plots
|
|
143
|
+
*/
|
|
144
|
+
export function deriveCityPlots(network, options) {
|
|
145
|
+
const [hx, hz] = options.halfExtents;
|
|
146
|
+
const laneFrontage = options.laneFrontage ?? false;
|
|
147
|
+
const setback = Math.max(0, options.setback ?? 3);
|
|
148
|
+
const spacing = Math.max(0, options.spacing ?? 2);
|
|
149
|
+
const variety = Math.max(0, Math.min(1, options.variety ?? 1));
|
|
150
|
+
const fill = Math.max(0, Math.min(1, options.blockFill ?? DEFAULT_BLOCK_FILL));
|
|
151
|
+
const grandShare = Math.max(0, Math.min(1, options.landmarks ?? DEFAULT_LANDMARK_SHARE));
|
|
152
|
+
const [wScale, dScale] = options.plotScale ?? [1, 1];
|
|
153
|
+
const maxPlots = Math.max(0, Math.floor(options.maxPlots ?? 600));
|
|
154
|
+
const profile = options.profile ?? "core-out";
|
|
155
|
+
const coreExtent = options.coreExtent ?? 0.35;
|
|
156
|
+
const midExtent = options.midExtent ?? 0.7;
|
|
157
|
+
const streets = network.streets;
|
|
158
|
+
const streams = seededStreams(`${options.seed}:plots`);
|
|
159
|
+
// Node-pair → owning street index, so pruned dead-end corridors keep their street identity and
|
|
160
|
+
// cul-de-sacs can be lined with plots that reference a real street.
|
|
161
|
+
const edgeStreet = new Map();
|
|
162
|
+
streets.forEach((s, si) => {
|
|
163
|
+
for (let i = 0; i + 1 < s.nodes.length; i += 1) {
|
|
164
|
+
const a = s.nodes[i];
|
|
165
|
+
const b = s.nodes[i + 1];
|
|
166
|
+
edgeStreet.set(a < b ? `${a}:${b}` : `${b}:${a}`, si);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// Blocks from the network's EXACT graph (nodes/edges), not from re-welded polylines — wandered,
|
|
170
|
+
// arc-filleted centerlines defeat proximity welding, and the graph is already the truth.
|
|
171
|
+
const fabric = extractGraphBlocks(network.nodes, network.edges.map((e) => ({
|
|
172
|
+
a: e.a,
|
|
173
|
+
b: e.b,
|
|
174
|
+
points: e.points,
|
|
175
|
+
width: e.width,
|
|
176
|
+
level: e.level,
|
|
177
|
+
street: edgeStreet.get(e.a < e.b ? `${e.a}:${e.b}` : `${e.b}:${e.a}`),
|
|
178
|
+
})), { sidewalkBase: 2, curbMargin: 0.35 });
|
|
179
|
+
// Coverage: how much of a frontage run becomes plots (vs left as gaps); rises with the dial.
|
|
180
|
+
const coverage = Math.min(1, 0.62 + fill * 0.42);
|
|
181
|
+
// Gap between neighbouring plots: collapses toward a streetwall as the dial rises.
|
|
182
|
+
const gap = Math.max(0.15, spacing * (1.55 - fill * 1.4));
|
|
183
|
+
// Whole-block park share: a dense city keeps a floor of green, a loose one breathes more.
|
|
184
|
+
const parkFraction = Math.min(0.25, 0.05 + (1 - fill) * 0.16);
|
|
185
|
+
const nearestStreet = (x, z) => {
|
|
186
|
+
let bestStreet = -1;
|
|
187
|
+
let bestDistance = Infinity;
|
|
188
|
+
for (let s = 0; s < streets.length; s += 1) {
|
|
189
|
+
const near = nearestOnPath(streets[s].points, x, z);
|
|
190
|
+
if (near !== null && near.distance < bestDistance) {
|
|
191
|
+
bestDistance = near.distance;
|
|
192
|
+
bestStreet = s;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return bestStreet < 0 ? null : { street: bestStreet, distance: bestDistance };
|
|
196
|
+
};
|
|
197
|
+
const plots = [];
|
|
198
|
+
const parks = [];
|
|
199
|
+
let grandCount = 0;
|
|
200
|
+
// Park budget scales with how many blocks exist, so a small city never loses half its blocks.
|
|
201
|
+
let parkBudget = Math.max(1, Math.round(fabric.blocks.length * parkFraction));
|
|
202
|
+
for (let bi = 0; bi < fabric.blocks.length && plots.length < maxPlots; bi += 1) {
|
|
203
|
+
const land = fabric.blocks[bi].land;
|
|
204
|
+
if (land.length < 3)
|
|
205
|
+
continue;
|
|
206
|
+
const rng = streams(`block:${bi}`);
|
|
207
|
+
const parkRoll = rng();
|
|
208
|
+
if (isSliverBlock(land, 130, 6.5)) {
|
|
209
|
+
// Too thin/small to build on — leftover green.
|
|
210
|
+
if (polygonArea(land) > 60)
|
|
211
|
+
parks.push(land);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// Only modest blocks become whole-block parks — a giant central block stays buildable.
|
|
215
|
+
if (parkBudget > 0 && parkRoll < parkFraction * 1.6 && polygonArea(land) < 11000) {
|
|
216
|
+
parks.push(land);
|
|
217
|
+
parkBudget -= 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const walker = new RingWalker(land);
|
|
221
|
+
const blockPolys = [];
|
|
222
|
+
let blockHasGrand = false;
|
|
223
|
+
let cursor = rng() * 5;
|
|
224
|
+
const endStation = walker.total - 2;
|
|
225
|
+
let guard = 0;
|
|
226
|
+
while (cursor < endStation && plots.length < maxPlots && blockPolys.length < 48 && guard < 200) {
|
|
227
|
+
guard += 1;
|
|
228
|
+
const grandRoll = rng();
|
|
229
|
+
const tierRoll = rng();
|
|
230
|
+
const bandRoll = rng();
|
|
231
|
+
const sizeRoll = rng();
|
|
232
|
+
const depthRoll = rng();
|
|
233
|
+
const coverRoll = rng();
|
|
234
|
+
const mid0 = walker.at(cursor);
|
|
235
|
+
// Only true street frontage grows plots: probe just outside the ring toward the curb.
|
|
236
|
+
const probe = nearestStreet(mid0.p[0] - mid0.normal[0] * 2, mid0.p[1] - mid0.normal[1] * 2);
|
|
237
|
+
const fronted = probe !== null && probe.distance <= (streets[probe.street]?.width ?? 9) / 2 + 6.5;
|
|
238
|
+
const level = fronted ? streets[probe.street].level : "street";
|
|
239
|
+
if (!fronted || (level === "lane" && !laneFrontage)) {
|
|
240
|
+
cursor += 6;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const zone = zoneBand(zoneMetric(mid0.p[0], mid0.p[1], hx, hz), profile, coreExtent, midExtent, bandRoll);
|
|
244
|
+
// Tier: grand first (dial-gated, capped, needs a deep block), else the zone/level mix.
|
|
245
|
+
const across = rayDistanceToRing(land, [mid0.p[0] + mid0.normal[0] * 0.2, mid0.p[1] + mid0.normal[1] * 0.2], mid0.normal);
|
|
246
|
+
const depthCap = Number.isFinite(across) ? Math.max(5, across * 0.5 - 0.4) : 14;
|
|
247
|
+
const wantGrand = grandShare > 0 &&
|
|
248
|
+
!blockHasGrand &&
|
|
249
|
+
grandCount < LANDMARK_HARD_CAP &&
|
|
250
|
+
depthCap >= 24 * dScale &&
|
|
251
|
+
grandRoll < grandShare * 0.5 * GRAND_ZONE_FACTOR[zone];
|
|
252
|
+
const tier = wantGrand ? "grand" : pickTier(zone, level, tierRoll, variety);
|
|
253
|
+
const sizes = PLOT_TIER_SIZES[tier];
|
|
254
|
+
const frontW = (sizes.frontage[0] + sizeRoll * (sizes.frontage[1] - sizes.frontage[0])) * wScale;
|
|
255
|
+
if (cursor + frontW + gap > walker.total - 0.5)
|
|
256
|
+
break;
|
|
257
|
+
// Coverage gaps: skip a sub-plot-sized arc so low fill reads as loose frontage.
|
|
258
|
+
if (tier !== "grand" && coverRoll > coverage) {
|
|
259
|
+
cursor += Math.max(4, frontW * 0.5);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const depthWant = (sizes.depth[0] + depthRoll * (sizes.depth[1] - sizes.depth[0])) * dScale * (0.85 + fill * 0.4);
|
|
263
|
+
const depth = tier === "grand"
|
|
264
|
+
? Math.min(Number.isFinite(across) ? across - 4 : depthCap, Math.max(depthWant, frontW * 0.7))
|
|
265
|
+
: Math.min(depthCap, depthWant);
|
|
266
|
+
const s0 = cursor;
|
|
267
|
+
const s1 = cursor + frontW;
|
|
268
|
+
let poly = cutParcel(walker, land, { s0, s1, depth });
|
|
269
|
+
const midS = walker.at((s0 + s1) / 2);
|
|
270
|
+
// A frontage window can cross a block corner after its first probe. Resolve ownership at the
|
|
271
|
+
// final parcel midpoint so the plot never retains the previous edge's street id.
|
|
272
|
+
const frontageProbe = nearestStreet(midS.p[0] - midS.normal[0] * 2, midS.p[1] - midS.normal[1] * 2);
|
|
273
|
+
const frontageLevel = frontageProbe === null ? undefined : streets[frontageProbe.street]?.level;
|
|
274
|
+
if (frontageProbe === null ||
|
|
275
|
+
frontageProbe.distance > (streets[frontageProbe.street]?.width ?? 9) / 2 + 6.5 ||
|
|
276
|
+
(frontageLevel === "lane" && !laneFrontage)) {
|
|
277
|
+
cursor += frontW * 0.6;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const frontageStreet = streets[frontageProbe.street];
|
|
281
|
+
const frontageLimit = frontageStreet.width / 2 + 6.5;
|
|
282
|
+
const frontageA = walker.at(s0).p;
|
|
283
|
+
const frontageB = walker.at(s1).p;
|
|
284
|
+
if ((nearestOnPath(frontageStreet.points, frontageA[0], frontageA[1])?.distance ?? Infinity) > frontageLimit ||
|
|
285
|
+
(nearestOnPath(frontageStreet.points, frontageB[0], frontageB[1])?.distance ?? Infinity) > frontageLimit) {
|
|
286
|
+
cursor += frontW * 0.6;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (poly !== null && fabric.deadEnds.length > 0) {
|
|
290
|
+
// Cul-de-sac lanes were pruned from the face graph; carve their corridors out of plots.
|
|
291
|
+
const keep = [midS.p[0] + midS.normal[0] * 1.5, midS.p[1] + midS.normal[1] * 1.5];
|
|
292
|
+
let carved = carveCorridors(poly, keep, fabric.deadEnds.map((d) => ({ pts: d.pts, width: d.width + 1 })), 0.8);
|
|
293
|
+
if (carved.length >= 3 && carved.length !== poly.length)
|
|
294
|
+
carved = conformPolygonToRing(carved, land);
|
|
295
|
+
poly = carved.length >= 3 ? carved : null;
|
|
296
|
+
}
|
|
297
|
+
if (poly !== null) {
|
|
298
|
+
// Never overlap a neighbour plot (opposite frontage of a shallow block): retry shallower once.
|
|
299
|
+
let clash = blockPolys.some((other) => polygonsOverlap(poly, other, 0.18));
|
|
300
|
+
if (clash) {
|
|
301
|
+
const shallow = cutParcel(walker, land, { s0, s1, depth: depth * 0.55 });
|
|
302
|
+
if (shallow !== null && !blockPolys.some((other) => polygonsOverlap(shallow, other, 0.18))) {
|
|
303
|
+
poly = shallow;
|
|
304
|
+
clash = false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (clash)
|
|
308
|
+
poly = null;
|
|
309
|
+
}
|
|
310
|
+
if (poly === null) {
|
|
311
|
+
cursor += frontW * 0.6;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Building rect inside the plot, set back from the frontage, facing the street.
|
|
315
|
+
const rotationY = Math.atan2(-midS.normal[0], -midS.normal[1]);
|
|
316
|
+
const maxW = Math.max(3, frontW - gap);
|
|
317
|
+
const maxD = Math.max(3, depth - setback - 1.2);
|
|
318
|
+
const cx0 = midS.p[0] + midS.normal[0] * (setback + maxD / 2);
|
|
319
|
+
const cz0 = midS.p[1] + midS.normal[1] * (setback + maxD / 2);
|
|
320
|
+
const fit = fitRectInPolygon(poly, cx0, cz0, maxW, maxD, rotationY, 0.4);
|
|
321
|
+
if (fit === null || fit.w < 3 || fit.d < 2.6) {
|
|
322
|
+
cursor += frontW * 0.6;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Rendered streets are arc-filleted and junctions grow welded aprons past the ribbons, so
|
|
326
|
+
// both can cut inside a block's corner past the graph face the parcel was cut from — reject
|
|
327
|
+
// any building rect that would sit on pavement or inside a junction apron.
|
|
328
|
+
const rc = Math.cos(rotationY);
|
|
329
|
+
const rs = Math.sin(rotationY);
|
|
330
|
+
const onPavement = [
|
|
331
|
+
[0, 0],
|
|
332
|
+
[fit.w / 2, fit.d / 2],
|
|
333
|
+
[fit.w / 2, -fit.d / 2],
|
|
334
|
+
[-fit.w / 2, fit.d / 2],
|
|
335
|
+
[-fit.w / 2, -fit.d / 2],
|
|
336
|
+
].some(([lx, lz]) => {
|
|
337
|
+
const px = fit.cx + lx * rc + lz * rs;
|
|
338
|
+
const pz = fit.cz - lx * rs + lz * rc;
|
|
339
|
+
return (streets.some((s) => isOnRoad(s.points, s.width + 3.5, px, pz)) ||
|
|
340
|
+
network.junctions.some((j) => Math.hypot(j.x - px, j.z - pz) < j.radius + 4));
|
|
341
|
+
});
|
|
342
|
+
if (onPavement) {
|
|
343
|
+
cursor += frontW * 0.6;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// A grand arc squeezed by the block shape (fit rect came out ordinary-sized) is not a
|
|
347
|
+
// landmark parcel — demote it so class and massing match what actually fits.
|
|
348
|
+
const realizedTier = tier === "grand" && fit.w < 24 * wScale ? "large" : tier;
|
|
349
|
+
let corner = false;
|
|
350
|
+
for (const vi of walker.verticesBetween(s0, s1)) {
|
|
351
|
+
const angle = walker.interiorAngle(vi);
|
|
352
|
+
if (angle < 2.53 || angle > Math.PI * 2 - 2.53) {
|
|
353
|
+
corner = true;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
plots.push({
|
|
358
|
+
polygon: poly,
|
|
359
|
+
center: [fit.cx, fit.cz],
|
|
360
|
+
rotationY,
|
|
361
|
+
width: fit.w,
|
|
362
|
+
depth: fit.d,
|
|
363
|
+
frontage: { street: frontageProbe.street, a: frontageA, b: frontageB },
|
|
364
|
+
streetLevel: frontageLevel ?? level,
|
|
365
|
+
block: bi,
|
|
366
|
+
tier: realizedTier,
|
|
367
|
+
corner,
|
|
368
|
+
});
|
|
369
|
+
blockPolys.push(poly);
|
|
370
|
+
if (realizedTier === "grand") {
|
|
371
|
+
grandCount += 1;
|
|
372
|
+
blockHasGrand = true;
|
|
373
|
+
}
|
|
374
|
+
cursor += frontW + gap;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// --- cul-de-sac / branch-street frontage --------------------------------------------------------
|
|
378
|
+
// Dead-end corridors are pruned from the face graph (they border no closed block), but a
|
|
379
|
+
// residential branch is exactly the street that wants a row of small house plots down both sides
|
|
380
|
+
// — the "one street off the main with just houses on it" look. Step small/medium plots along each
|
|
381
|
+
// non-lane corridor, both sides, rejecting anything on pavement, outside the volume, or
|
|
382
|
+
// overlapping an existing plot. Bounded per corridor and by `maxPlots`.
|
|
383
|
+
const rectPoly = (cx, cz, hw, hd, yaw) => {
|
|
384
|
+
const c = Math.cos(yaw);
|
|
385
|
+
const s = Math.sin(yaw);
|
|
386
|
+
return [
|
|
387
|
+
[-hw, -hd],
|
|
388
|
+
[hw, -hd],
|
|
389
|
+
[hw, hd],
|
|
390
|
+
[-hw, hd],
|
|
391
|
+
].map(([lx, lz]) => [cx + lx * c + lz * s, cz - lx * s + lz * c]);
|
|
392
|
+
};
|
|
393
|
+
for (let di = 0; di < fabric.deadEnds.length && plots.length < maxPlots; di += 1) {
|
|
394
|
+
const corridor = fabric.deadEnds[di];
|
|
395
|
+
if (corridor.level === "lane" && !laneFrontage)
|
|
396
|
+
continue;
|
|
397
|
+
if (corridor.street === undefined)
|
|
398
|
+
continue;
|
|
399
|
+
const pts = corridor.pts;
|
|
400
|
+
let total = 0;
|
|
401
|
+
for (let i = 0; i + 1 < pts.length; i += 1)
|
|
402
|
+
total += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
|
|
403
|
+
if (total < 18)
|
|
404
|
+
continue;
|
|
405
|
+
const rng = streams(`culdesac:${di}`);
|
|
406
|
+
const stationAt = (s) => {
|
|
407
|
+
let acc = 0;
|
|
408
|
+
for (let i = 0; i + 1 < pts.length; i += 1) {
|
|
409
|
+
const a = pts[i];
|
|
410
|
+
const b = pts[i + 1];
|
|
411
|
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
412
|
+
if (len < 1e-9)
|
|
413
|
+
continue;
|
|
414
|
+
if (acc + len >= s || i + 2 === pts.length) {
|
|
415
|
+
const t = Math.max(0, Math.min(1, (s - acc) / len));
|
|
416
|
+
return { p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], tangent: [(b[0] - a[0]) / len, (b[1] - a[1]) / len] };
|
|
417
|
+
}
|
|
418
|
+
acc += len;
|
|
419
|
+
}
|
|
420
|
+
return { p: [pts[0][0], pts[0][1]], tangent: [1, 0] };
|
|
421
|
+
};
|
|
422
|
+
// Keep the corridor ends clear: the junction mouth at one end, the turning bulb at the other.
|
|
423
|
+
const endMargin = corridor.width * 1.4 + 3;
|
|
424
|
+
let placedHere = 0;
|
|
425
|
+
let s = endMargin + rng() * 4;
|
|
426
|
+
while (s < total - endMargin && placedHere < 14 && plots.length < maxPlots) {
|
|
427
|
+
const tier = rng() < 0.72 ? "small" : "medium";
|
|
428
|
+
const sizes = PLOT_TIER_SIZES[tier];
|
|
429
|
+
const frontW = (sizes.frontage[0] + rng() * (sizes.frontage[1] - sizes.frontage[0])) * wScale;
|
|
430
|
+
const depth = (sizes.depth[0] + rng() * (sizes.depth[1] - sizes.depth[0])) * dScale * 0.8;
|
|
431
|
+
const st = stationAt(s + frontW / 2);
|
|
432
|
+
for (const side of [1, -1]) {
|
|
433
|
+
if (plots.length >= maxPlots)
|
|
434
|
+
break;
|
|
435
|
+
if (rng() > coverage)
|
|
436
|
+
continue;
|
|
437
|
+
// Normal into the plot (away from the corridor centerline).
|
|
438
|
+
const nx = -st.tangent[1] * side;
|
|
439
|
+
const nz = st.tangent[0] * side;
|
|
440
|
+
const cx = st.p[0] + nx * (corridor.width / 2 + setback + depth / 2);
|
|
441
|
+
const cz = st.p[1] + nz * (corridor.width / 2 + setback + depth / 2);
|
|
442
|
+
if (Math.abs(cx) > hx || Math.abs(cz) > hz)
|
|
443
|
+
continue;
|
|
444
|
+
const yaw = Math.atan2(-nx, -nz);
|
|
445
|
+
const w = Math.max(3, frontW - gap);
|
|
446
|
+
const corners = [
|
|
447
|
+
[0, 0],
|
|
448
|
+
[w / 2, depth / 2],
|
|
449
|
+
[w / 2, -depth / 2],
|
|
450
|
+
[-w / 2, depth / 2],
|
|
451
|
+
[-w / 2, -depth / 2],
|
|
452
|
+
];
|
|
453
|
+
const yc = Math.cos(yaw);
|
|
454
|
+
const ys = Math.sin(yaw);
|
|
455
|
+
const onPavement = corners.some(([lx, lz]) => {
|
|
456
|
+
const px = cx + lx * yc + lz * ys;
|
|
457
|
+
const pz = cz - lx * ys + lz * yc;
|
|
458
|
+
return (streets.some((street) => isOnRoad(street.points, street.width + 3.5, px, pz)) ||
|
|
459
|
+
network.junctions.some((j) => Math.hypot(j.x - px, j.z - pz) < j.radius + 4));
|
|
460
|
+
});
|
|
461
|
+
if (onPavement)
|
|
462
|
+
continue;
|
|
463
|
+
const poly = rectPoly(cx, cz, w / 2, depth / 2, yaw);
|
|
464
|
+
if (plots.some((other) => polygonsOverlap(poly, other.polygon, 0.18)))
|
|
465
|
+
continue;
|
|
466
|
+
plots.push({
|
|
467
|
+
polygon: poly,
|
|
468
|
+
center: [cx, cz],
|
|
469
|
+
rotationY: yaw,
|
|
470
|
+
width: w,
|
|
471
|
+
depth,
|
|
472
|
+
frontage: { street: corridor.street, a: [st.p[0], st.p[1]], b: [st.p[0] + st.tangent[0] * frontW, st.p[1] + st.tangent[1] * frontW] },
|
|
473
|
+
streetLevel: corridor.level,
|
|
474
|
+
block: -1,
|
|
475
|
+
tier,
|
|
476
|
+
corner: false,
|
|
477
|
+
});
|
|
478
|
+
placedHere += 1;
|
|
479
|
+
}
|
|
480
|
+
s += frontW + gap;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { plots, parks };
|
|
484
|
+
}
|
|
485
|
+
/** Default zoned-metropolis mixes: towers/slabs downtown, slabs+rowhouses mid, houses at the edge. */
|
|
486
|
+
export const DEFAULT_CITY_ZONE_MIXES = {
|
|
487
|
+
core: [
|
|
488
|
+
{ item: "tower", weight: 3 },
|
|
489
|
+
{ item: "slab", weight: 1 },
|
|
490
|
+
{ item: "shop", weight: 1 },
|
|
491
|
+
],
|
|
492
|
+
mid: [
|
|
493
|
+
{ item: "slab", weight: 2 },
|
|
494
|
+
{ item: "rowhouse", weight: 2 },
|
|
495
|
+
{ item: "shop", weight: 1 },
|
|
496
|
+
],
|
|
497
|
+
edge: [
|
|
498
|
+
{ item: "house", weight: 3 },
|
|
499
|
+
{ item: "rowhouse", weight: 1 },
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
/** Default street-level bias — boulevards favor big massing, lanes favor small/rural massing. */
|
|
503
|
+
export const DEFAULT_CITY_LEVEL_BIAS = {
|
|
504
|
+
boulevard: { tower: 2, slab: 1.6, shop: 1.3, mansion: 1.2 },
|
|
505
|
+
avenue: { tower: 1.5, slab: 1.4, shop: 1.2 },
|
|
506
|
+
street: { shop: 1.1, rowhouse: 1.2, house: 1.2 },
|
|
507
|
+
lane: { house: 1.6, rowhouse: 1.4, mansion: 1.3, farmhouse: 1.5, barn: 1.4, silo: 1.3 },
|
|
508
|
+
};
|
|
509
|
+
/** Multiply each band-mix weight by its per-level bias factor (missing class ⇒ ×1). */
|
|
510
|
+
function biasMix(mix, levelBias) {
|
|
511
|
+
return mix.map((entry) => {
|
|
512
|
+
const factor = levelBias[entry.item];
|
|
513
|
+
return factor === undefined ? entry : { item: entry.item, weight: entry.weight * factor };
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Bias a class mix by how well each class's natural frontage width matches the plot width, so the
|
|
518
|
+
* class follows the plot: wide plots pull towers/slabs, narrow plots pull rowhouses/houses.
|
|
519
|
+
*/
|
|
520
|
+
function sizeBiasMix(mix, plotWidth) {
|
|
521
|
+
return mix.map((entry) => {
|
|
522
|
+
const range = classWidthRange(entry.item);
|
|
523
|
+
if (range === undefined)
|
|
524
|
+
return entry;
|
|
525
|
+
const mid = (range[0] + range[1]) / 2;
|
|
526
|
+
const spread = (range[1] - range[0]) / 2 + 5;
|
|
527
|
+
const t = (plotWidth - mid) / spread;
|
|
528
|
+
const factor = 1 / (1 + t * t);
|
|
529
|
+
return { item: entry.item, weight: entry.weight * factor };
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/** Local axis-aligned bounding box of a lot's massing pieces, accounting for each piece's local yaw. */
|
|
533
|
+
function massingFootprint(pieces) {
|
|
534
|
+
let minX = Infinity;
|
|
535
|
+
let maxX = -Infinity;
|
|
536
|
+
let minZ = Infinity;
|
|
537
|
+
let maxZ = -Infinity;
|
|
538
|
+
for (const piece of pieces) {
|
|
539
|
+
const c = Math.abs(Math.cos(piece.rotationY));
|
|
540
|
+
const s = Math.abs(Math.sin(piece.rotationY));
|
|
541
|
+
const hx = (piece.size[0] / 2) * c + (piece.size[2] / 2) * s;
|
|
542
|
+
const hz = (piece.size[0] / 2) * s + (piece.size[2] / 2) * c;
|
|
543
|
+
minX = Math.min(minX, piece.offset[0] - hx);
|
|
544
|
+
maxX = Math.max(maxX, piece.offset[0] + hx);
|
|
545
|
+
minZ = Math.min(minZ, piece.offset[2] - hz);
|
|
546
|
+
maxZ = Math.max(maxZ, piece.offset[2] + hz);
|
|
547
|
+
}
|
|
548
|
+
if (!Number.isFinite(minX))
|
|
549
|
+
return { w: 0, d: 0, bounds: { minX: 0, maxX: 0, minZ: 0, maxZ: 0 } };
|
|
550
|
+
return { w: maxX - minX, d: maxZ - minZ, bounds: { minX, maxX, minZ, maxZ } };
|
|
551
|
+
}
|
|
552
|
+
/** Which landmark classes each zone band favours (weighted). Data-only, mirrors the zone-mix pattern. */
|
|
553
|
+
const LANDMARK_ZONE_MIX = {
|
|
554
|
+
core: [
|
|
555
|
+
{ cls: "hall", weight: 3 },
|
|
556
|
+
{ cls: "arena", weight: 1 },
|
|
557
|
+
{ cls: "market", weight: 1 },
|
|
558
|
+
],
|
|
559
|
+
mid: [
|
|
560
|
+
{ cls: "arena", weight: 2 },
|
|
561
|
+
{ cls: "campus", weight: 2 },
|
|
562
|
+
{ cls: "market", weight: 2 },
|
|
563
|
+
],
|
|
564
|
+
edge: [
|
|
565
|
+
{ cls: "campus", weight: 2 },
|
|
566
|
+
{ cls: "arena", weight: 1 },
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
/** Weighted pick of a landmark class from a zone entry list, restricted to `allowed`. */
|
|
570
|
+
function pickLandmarkClass(entries, allowed, roll) {
|
|
571
|
+
let total = 0;
|
|
572
|
+
for (const e of entries)
|
|
573
|
+
if (allowed.has(e.cls) && e.weight > 0)
|
|
574
|
+
total += e.weight;
|
|
575
|
+
if (total <= 0) {
|
|
576
|
+
// Zone table has nothing allowed here — fall back to any allowed class deterministically.
|
|
577
|
+
const pool = CITY_LANDMARK_CLASSES.filter((c) => allowed.has(c));
|
|
578
|
+
return pool[Math.min(pool.length - 1, Math.floor(roll * pool.length))] ?? "campus";
|
|
579
|
+
}
|
|
580
|
+
let cursor = roll * total;
|
|
581
|
+
for (const e of entries) {
|
|
582
|
+
if (!allowed.has(e.cls) || e.weight <= 0)
|
|
583
|
+
continue;
|
|
584
|
+
cursor -= e.weight;
|
|
585
|
+
if (cursor <= 0)
|
|
586
|
+
return e.cls;
|
|
587
|
+
}
|
|
588
|
+
return entries[0].cls;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Enrich a generated city's plots into classed, massed buildings: each plot gets its zone band
|
|
592
|
+
* (radial position under a {@link CityZoneProfile}), a building class rolled from the band's
|
|
593
|
+
* weighted mix — biased by street level AND plot size, with grand plots rolling a landmark class —
|
|
594
|
+
* floors, and the deterministic massing pieces the class composes, sized to the plot's building
|
|
595
|
+
* rect. Deterministic per `seed` via {@link seededStreams}, bounded (one pass over `city.lots`),
|
|
596
|
+
* and pure. Same seed + city ⇒ identical resolved lots.
|
|
597
|
+
*
|
|
598
|
+
* A city without `plots` (a hand-built `{network, lots}` pair) still resolves: every lot is treated
|
|
599
|
+
* as an ordinary plot of its footprint size.
|
|
600
|
+
*
|
|
601
|
+
* @capability city-generator resolve city plots into zoned, classed, massed buildings
|
|
602
|
+
*/
|
|
603
|
+
export function resolveCityLotContent(city, options) {
|
|
604
|
+
const [hx, hz] = options.halfExtents;
|
|
605
|
+
const profile = options.profile ?? "core-out";
|
|
606
|
+
const coreExtent = options.coreExtent ?? 0.35;
|
|
607
|
+
const midExtent = options.midExtent ?? 0.7;
|
|
608
|
+
const lotScale = options.lotScale ?? 1;
|
|
609
|
+
const floorsMin = options.floorsMin ?? 1;
|
|
610
|
+
const floorsMax = options.floorsMax ?? 30;
|
|
611
|
+
const floorHeight = options.floorHeight ?? 3;
|
|
612
|
+
const setback = options.setback ?? 2.5;
|
|
613
|
+
const spacing = options.spacing ?? 1.4;
|
|
614
|
+
const mixes = {
|
|
615
|
+
core: options.mixes?.core ?? DEFAULT_CITY_ZONE_MIXES.core,
|
|
616
|
+
mid: options.mixes?.mid ?? DEFAULT_CITY_ZONE_MIXES.mid,
|
|
617
|
+
edge: options.mixes?.edge ?? DEFAULT_CITY_ZONE_MIXES.edge,
|
|
618
|
+
};
|
|
619
|
+
const biasSetting = options.streetLevelBias ?? true;
|
|
620
|
+
const biasTable = biasSetting === false ? null : biasSetting === true ? DEFAULT_CITY_LEVEL_BIAS : biasSetting;
|
|
621
|
+
const allowed = new Set(options.landmarkClasses ?? CITY_LANDMARK_CLASSES);
|
|
622
|
+
const streams = seededStreams(options.seed);
|
|
623
|
+
return city.lots.map((lot, i) => {
|
|
624
|
+
const plot = city.plots?.[i];
|
|
625
|
+
const streetLevel = plot?.streetLevel ?? city.network.streets[lot.road]?.level ?? "street";
|
|
626
|
+
const rng = streams(`lot:${i}`);
|
|
627
|
+
const bandRoll = rng();
|
|
628
|
+
const classRoll = rng();
|
|
629
|
+
const zone = zoneBand(zoneMetric(lot.center[0], lot.center[1], hx, hz), profile, coreExtent, midExtent, bandRoll);
|
|
630
|
+
if (plot?.tier === "grand") {
|
|
631
|
+
const cls = pickLandmarkClass(LANDMARK_ZONE_MIX[zone], allowed, classRoll);
|
|
632
|
+
const floors = landmarkFloors(cls, rng);
|
|
633
|
+
const pieces = buildLandmarkPieces(cls, lot.footprint.w, lot.footprint.d, floors, floorHeight, rng);
|
|
634
|
+
return {
|
|
635
|
+
lot,
|
|
636
|
+
zone,
|
|
637
|
+
class: cls,
|
|
638
|
+
landmark: cls,
|
|
639
|
+
streetLevel,
|
|
640
|
+
floors,
|
|
641
|
+
pieces,
|
|
642
|
+
center: lot.center,
|
|
643
|
+
rotationY: lot.rotationY,
|
|
644
|
+
footprint: massingFootprint(pieces),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const leveled = biasTable === null ? mixes[zone] : biasMix(mixes[zone], biasTable[streetLevel]);
|
|
648
|
+
const mix = sizeBiasMix(leveled, lot.footprint.w);
|
|
649
|
+
const cls = pickClass(mix, classRoll);
|
|
650
|
+
const placement = rollClassPlacement(cls, rng, lotScale, floorsMin, floorsMax, setback, spacing);
|
|
651
|
+
// Massing follows the plot: fill its building rect, clamped so a small class on a big plot
|
|
652
|
+
// doesn't balloon far past its natural size.
|
|
653
|
+
const width = Math.min(lot.footprint.w, placement.width * 1.35);
|
|
654
|
+
const depth = Math.min(lot.footprint.d, placement.depth * 1.35);
|
|
655
|
+
const pieces = buildLotPieces(cls, width, depth, placement.floors, floorHeight, rng);
|
|
656
|
+
return {
|
|
657
|
+
lot,
|
|
658
|
+
zone,
|
|
659
|
+
class: cls,
|
|
660
|
+
streetLevel,
|
|
661
|
+
floors: placement.floors,
|
|
662
|
+
pieces,
|
|
663
|
+
center: lot.center,
|
|
664
|
+
rotationY: lot.rotationY,
|
|
665
|
+
footprint: massingFootprint(pieces),
|
|
666
|
+
};
|
|
52
667
|
});
|
|
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
668
|
}
|