tweak
This commit is contained in:
parent
d762a88b22
commit
5c82bfcab7
13 changed files with 1028 additions and 8801 deletions
1792
adminRegions.js
1792
adminRegions.js
File diff suppressed because it is too large
Load diff
|
|
@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po
|
|||
if (same4 === 1) energy += 1.7;
|
||||
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
|
||||
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
|
||||
if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) {
|
||||
const drift = centerDist[candidateId][i] - centerDist[oldId][i];
|
||||
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
||||
if (candidateId !== oldId) {
|
||||
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
|
||||
const oldDistance = centerDistanceAt(centerDist, oldId, i);
|
||||
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
|
||||
const drift = candidateDistance - oldDistance;
|
||||
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
||||
}
|
||||
}
|
||||
return energy;
|
||||
}
|
||||
|
||||
function centerDistanceAt(centerDist, id, i) {
|
||||
const field = centerDist?.fields?.[id] || centerDist?.[id];
|
||||
if (field) return field[i];
|
||||
const center = centerDist?.centers?.[id];
|
||||
if (!center || !inside(center.x, center.y)) return 24;
|
||||
const [x, y] = xyOf(i);
|
||||
return Math.hypot(x - center.x, y - center.y);
|
||||
}
|
||||
|
||||
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
|
||||
const fields = [];
|
||||
for (const id of adminIds) {
|
||||
const center = adminCenters[id];
|
||||
const field = new Float32Array(SIZE);
|
||||
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24);
|
||||
else {
|
||||
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y);
|
||||
}
|
||||
fields[id] = field;
|
||||
}
|
||||
return fields;
|
||||
// Older versions materialized one full SIZE Float32Array per municipality.
|
||||
// In multi-prefecture generation this can create heavy transient memory use.
|
||||
// Keep the same interface conceptually, but compute distances on demand.
|
||||
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
|
||||
}
|
||||
|
||||
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
|
||||
|
|
@ -981,6 +987,7 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed
|
|||
}
|
||||
|
||||
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
cellClass.fill(-1);
|
||||
|
|
@ -991,6 +998,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
|||
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
||||
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
||||
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
||||
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
|
|
@ -1017,22 +1025,25 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
|||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
|
||||
progress?.("natural seeded growth complete");
|
||||
|
||||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
||||
let guard = Math.max(80, targetCount * 3);
|
||||
let guard = Math.max(60, targetCount * 2);
|
||||
while (guard-- > 0) {
|
||||
let active = compartments.filter((unit) => unit && unit.area > 0);
|
||||
const needMore = active.length < targetCount;
|
||||
const worst = active
|
||||
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
||||
.filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
||||
.sort((a, b) => {
|
||||
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
|
||||
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
|
||||
|
|
@ -1490,10 +1501,13 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
|
|||
}
|
||||
|
||||
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||
progress?.("natural compartments built");
|
||||
const adminId = new Int16Array(SIZE);
|
||||
adminId.fill(-1);
|
||||
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
||||
progress?.("natural compartments assigned");
|
||||
for (const unit of compartments) {
|
||||
const assigned = owner[unit.id];
|
||||
if (assigned < 0) continue;
|
||||
|
|
@ -1505,6 +1519,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
|||
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||
progress?.("natural topology repaired");
|
||||
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
||||
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
|
||||
return {
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
<section class="card legend">
|
||||
<div class="card-title">Notes</div>
|
||||
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
|
||||
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
|
|
|||
190
mapAdminStage.js
190
mapAdminStage.js
|
|
@ -950,6 +950,104 @@ function maskLandArea(mask, sea) {
|
|||
return area;
|
||||
}
|
||||
|
||||
function connectedMaskComponents(mask, sea, minArea = 1) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || sea[i] || seen[i]) continue;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
seen[i] = 1;
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!mask[ni] || sea[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
if (cells.length >= minArea) {
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
for (const cell of cells) {
|
||||
const [x, y] = xyOf(cell);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
components.push({ cells, area: cells.length, minX, minY, maxX, maxY });
|
||||
}
|
||||
}
|
||||
return components.sort((a, b) => b.area - a.area);
|
||||
}
|
||||
|
||||
function maskFromCells(cells) {
|
||||
const mask = new Uint8Array(SIZE);
|
||||
for (const i of cells || []) mask[i] = 1;
|
||||
return mask;
|
||||
}
|
||||
|
||||
|
||||
function splitDisconnectedAdminComponents(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||
let nextId = Math.max(-1, ...adminId) + 1;
|
||||
let splitCount = 0;
|
||||
const ids = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))];
|
||||
for (const id of ids) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (adminId[i] !== id || !humanMask[i] || sea[i] || seen[i]) continue;
|
||||
const cells = [];
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (adminId[ni] !== id || !humanMask[ni] || sea[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => b.length - a.length);
|
||||
for (let c = 1; c < components.length; c++) {
|
||||
const newId = nextId++;
|
||||
let bestI = components[c][0];
|
||||
let bestScore = -INF;
|
||||
for (const i of components[c]) {
|
||||
const score =
|
||||
(fields.populationDensity?.[i] || 0) * 4.0 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.8 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.45 +
|
||||
(fields.settlementScore?.[i] || 0) * 0.6 +
|
||||
(fields.plain?.[i] || 0) * 0.2 -
|
||||
(fields.slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [x, y] = xyOf(bestI);
|
||||
for (const i of components[c]) adminId[i] = newId;
|
||||
centers[newId] = { ...(centers[id] || {}), x, y, score: bestScore, seedKind: "splitDisconnectedMunicipality", generatedOfficePoint: true, municipalityOffice: true };
|
||||
splitCount++;
|
||||
}
|
||||
}
|
||||
return { adminId, centers, splitDisconnectedMunicipalityCount: splitCount };
|
||||
}
|
||||
|
||||
function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
|
||||
|
|
@ -968,11 +1066,12 @@ function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields
|
|||
const chooseOffice = (newId, oldId) => {
|
||||
const cells = cellsByNewId[newId] || [];
|
||||
const current = centers[oldId];
|
||||
let currentValid = false;
|
||||
let currentScore = -INF;
|
||||
if (current && inside(current.x, current.y)) {
|
||||
const ci = indexOf(current.x, current.y);
|
||||
if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
|
||||
return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
|
||||
}
|
||||
currentValid = newAdminId[ci] === newId && humanMask[ci] && !sea[ci];
|
||||
if (currentValid) currentScore = (fields.populationDensity?.[ci] || 0) + (fields.stationInfluence?.[ci] || 0) * 0.32 + (fields.roadInfluence?.[ci] || 0) * 0.20;
|
||||
}
|
||||
let sx = 0, sy = 0;
|
||||
for (const i of cells) {
|
||||
|
|
@ -991,19 +1090,20 @@ function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields
|
|||
const density = fields.populationDensity?.[i] || 0;
|
||||
const settlement = fields.settlementScore?.[i] || 0;
|
||||
const score =
|
||||
density * 2.25 +
|
||||
settlement * 0.75 +
|
||||
urbanBonus +
|
||||
density * 4.20 +
|
||||
settlement * 0.72 +
|
||||
urbanBonus * 1.15 +
|
||||
(fields.plain?.[i] || 0) * 0.32 +
|
||||
(fields.basinField?.[i] || 0) * 0.24 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.18 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.34 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.45 -
|
||||
(fields.roadInfluence?.[i] || 0) * 0.48 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.86 -
|
||||
(fields.slope?.[i] || 0) * 0.52 -
|
||||
Math.hypot(x - cx, y - cy) * 0.018 +
|
||||
hash2(x, y, 91337 + newId) * 0.012;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
if (currentValid && currentScore >= bestScore * 0.92 && (fields.populationDensity?.[indexOf(current.x, current.y)] || 0) >= 0.16) bestI = indexOf(current.x, current.y);
|
||||
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
|
||||
return {
|
||||
...(current || {}),
|
||||
|
|
@ -1041,11 +1141,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
|||
|
||||
export function generateAdminLayout(context) {
|
||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
|
||||
const minFullAdminRegionArea = 1500;
|
||||
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
||||
.filter((regionId) => regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea);
|
||||
const minFullAdminRegionArea = 650;
|
||||
const minComponentArea = 18;
|
||||
const regionComponents = [];
|
||||
for (const regionId of discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)) {
|
||||
const baseMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const components = connectedMaskComponents(baseMask, sea, minComponentArea);
|
||||
components.forEach((component, componentIndex) => regionComponents.push({ regionId, componentIndex, component, area: component.area }));
|
||||
}
|
||||
const fullComponents = regionComponents.filter((row) => row.area >= minFullAdminRegionArea);
|
||||
|
||||
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
||||
if (!prefectureRegionId || fullComponents.length <= 1) return generateAdminLayoutForMask(context);
|
||||
|
||||
let combinedAdminId = new Int16Array(SIZE);
|
||||
combinedAdminId.fill(-1);
|
||||
|
|
@ -1055,14 +1161,15 @@ export function generateAdminLayout(context) {
|
|||
const perRegion = [];
|
||||
let idOffset = 0;
|
||||
|
||||
for (const regionId of regionIds) {
|
||||
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const regionArea = maskLandArea(regionMask, sea);
|
||||
if (regionArea < minFullAdminRegionArea) continue;
|
||||
const processedCells = new Uint8Array(SIZE);
|
||||
for (const row of fullComponents) {
|
||||
const { regionId, componentIndex, component } = row;
|
||||
const regionMask = maskFromCells(component.cells);
|
||||
const regionArea = component.area;
|
||||
|
||||
const localContext = {
|
||||
...context,
|
||||
seed: (context.seed + regionId * 10007) >>> 0,
|
||||
seed: (context.seed + regionId * 10007 + componentIndex * 9973) >>> 0,
|
||||
prefectureMask: regionMask,
|
||||
modernCities: filterPointsForMask(context.modernCities, regionMask, sea),
|
||||
satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea),
|
||||
|
|
@ -1074,9 +1181,11 @@ export function generateAdminLayout(context) {
|
|||
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
||||
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
||||
adminRegionMeta: {
|
||||
regionId,
|
||||
regionId: `${regionId}:${componentIndex}`,
|
||||
sourceRegionId: regionId,
|
||||
componentIndex,
|
||||
landArea: regionArea,
|
||||
isFocusedRegion: true,
|
||||
isFocusedRegion: regionId === 0,
|
||||
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
|
||||
},
|
||||
adminProgress,
|
||||
|
|
@ -1121,12 +1230,14 @@ export function generateAdminLayout(context) {
|
|||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!regionMask[i] || sea[i]) continue;
|
||||
combinedHumanMask[i] = 1;
|
||||
processedCells[i] = 1;
|
||||
const localId = local.adminId?.[i] ?? -1;
|
||||
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
|
||||
}
|
||||
|
||||
perRegion.push({
|
||||
regionId,
|
||||
componentIndex,
|
||||
area: regionArea,
|
||||
centerCount: localCenters.length,
|
||||
municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size,
|
||||
|
|
@ -1141,34 +1252,29 @@ export function generateAdminLayout(context) {
|
|||
idOffset += localSlotCount;
|
||||
}
|
||||
|
||||
const leftoverByRegion = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
|
||||
if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue;
|
||||
if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []);
|
||||
leftoverByRegion.get(regionId).push(i);
|
||||
const leftoverRows = [];
|
||||
for (const row of regionComponents) {
|
||||
const cells = row.component.cells.filter((i) => !sea[i] && combinedAdminId[i] < 0);
|
||||
if (cells.length) leftoverRows.push({ ...row, cells });
|
||||
}
|
||||
for (const [regionId, cells] of leftoverByRegion) {
|
||||
let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF;
|
||||
for (const row of leftoverRows) {
|
||||
const { regionId, componentIndex, cells } = row;
|
||||
let bestI = cells[0], bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
|
||||
const score = (populationDensity?.[i] || 0) * 2.6 + (plain?.[i] || 0) * 0.22 - (slope?.[i] || 0) * 0.20;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [cx, cy] = xyOf(bestI);
|
||||
const id = combinedCenters.length;
|
||||
combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
|
||||
combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, componentIndex, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
|
||||
for (const i of cells) {
|
||||
combinedHumanMask[i] = 1;
|
||||
combinedAdminId[i] = id;
|
||||
}
|
||||
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
perRegion.push({ regionId, componentIndex, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
}
|
||||
|
||||
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
|
||||
const compactFields = {
|
||||
populationDensity,
|
||||
plain,
|
||||
slope,
|
||||
|
|
@ -1178,7 +1284,14 @@ export function generateAdminLayout(context) {
|
|||
coastalLowland: context.coastalLowland,
|
||||
roadInfluence: context.roadInfluence,
|
||||
stationInfluence: context.stationInfluence,
|
||||
});
|
||||
};
|
||||
let compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
combinedAdminId = compactedAdmin.adminId;
|
||||
combinedCenters = compactedAdmin.adminCenters;
|
||||
const splitDisconnected = splitDisconnectedAdminComponents(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
combinedAdminId = splitDisconnected.adminId;
|
||||
combinedCenters = splitDisconnected.centers;
|
||||
compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
combinedAdminId = compactedAdmin.adminId;
|
||||
combinedCenters = compactedAdmin.adminCenters;
|
||||
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
||||
|
|
@ -1191,6 +1304,11 @@ export function generateAdminLayout(context) {
|
|||
multiRegionAdmin: true,
|
||||
adminRegionCount: perRegion.length,
|
||||
minFullAdminRegionArea,
|
||||
minComponentArea,
|
||||
connectedComponentAdmin: true,
|
||||
fullComponentCount: fullComponents.length,
|
||||
leftoverComponentCount: leftoverRows.length,
|
||||
splitDisconnectedMunicipalityCount: splitDisconnected.splitDisconnectedMunicipalityCount,
|
||||
perRegion,
|
||||
finalMunicipalityCount: totalMunicipalityCount,
|
||||
actualMunicipalityCount: totalMunicipalityCount,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
3722
mapFeatures.js
3722
mapFeatures.js
File diff suppressed because it is too large
Load diff
930
mapFeaturesV2.js
930
mapFeaturesV2.js
|
|
@ -1,930 +0,0 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
|
||||
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
|
||||
// Lightweight Human Geography V2
|
||||
// --------------------------------
|
||||
// This replaces the heavy iterative human stage with a sparse skeleton + raster
|
||||
// synthesis model:
|
||||
// 1. build terrain-derived human context once
|
||||
// 2. place villages/towns/cities by region quotas
|
||||
// 3. make sparse approximate transport paths without full-resolution A*
|
||||
// 4. synthesize population and land-use fields in one raster pass
|
||||
|
||||
export function generateMapFeatures(seed, terrain) {
|
||||
const {
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureRegionId,
|
||||
naturalBarrierScore,
|
||||
} = terrain;
|
||||
|
||||
function regionIdAt(x, y) {
|
||||
if (!inside(x, y)) return -1;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) return -1;
|
||||
if (prefectureMask?.[i]) return 0;
|
||||
const id = prefectureRegionId?.[i];
|
||||
return id !== undefined && id >= 0 ? id : -1;
|
||||
}
|
||||
|
||||
function inFocusedPrefecture(p) {
|
||||
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||
}
|
||||
|
||||
function localConfluenceScore(x, y) {
|
||||
let arms = 0;
|
||||
let strong = 0;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const rv = river[indexOf(nx, ny)];
|
||||
if (rv > 0.18) arms++;
|
||||
if (rv > 0.34) strong++;
|
||||
}
|
||||
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
|
||||
}
|
||||
|
||||
// --- 1. Human context: one full raster pass -----------------------------
|
||||
const developable = new Float32Array(SIZE);
|
||||
const ruralSuitability = new Float32Array(SIZE);
|
||||
const townSuitability = new Float32Array(SIZE);
|
||||
const valleySettlement = new Float32Array(SIZE);
|
||||
const coastalSettlement = new Float32Array(SIZE);
|
||||
const confluenceField = new Float32Array(SIZE);
|
||||
const barrierCost = new Float32Array(SIZE);
|
||||
const corridorCost = new Float32Array(SIZE);
|
||||
const settlementCluster = new Float32Array(SIZE);
|
||||
const settlementScore = new Float32Array(SIZE);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) {
|
||||
barrierCost[i] = INF;
|
||||
corridorCost[i] = INF;
|
||||
continue;
|
||||
}
|
||||
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
|
||||
const highPenalty = Math.max(0, elevation[i] - 0.56);
|
||||
const lowSlope = clamp(1 - slope[i] * 2.3);
|
||||
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
|
||||
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||
confluenceField[i] = confluence;
|
||||
|
||||
developable[i] = clamp(
|
||||
plain[i] * 0.34 +
|
||||
agriculture[i] * 0.24 +
|
||||
basinField[i] * 0.24 +
|
||||
valleyField[i] * 0.24 +
|
||||
coastalLowland[i] * 0.18 +
|
||||
depositional * 0.22 +
|
||||
lowSlope * 0.10 -
|
||||
slope[i] * 0.82 -
|
||||
ridgeField[i] * 0.52 -
|
||||
spine * 0.24 -
|
||||
highPenalty * 1.14 -
|
||||
floodplain[i] * 0.03
|
||||
);
|
||||
valleySettlement[i] = clamp(
|
||||
valleyField[i] * 0.52 +
|
||||
river[i] * 0.08 +
|
||||
confluence * 0.38 +
|
||||
depositional * 0.20 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.08 +
|
||||
lowSlope * 0.12 -
|
||||
slope[i] * 0.54 -
|
||||
ridgeField[i] * 0.30 -
|
||||
spine * 0.16 -
|
||||
highPenalty * 0.70 -
|
||||
floodplain[i] * 0.10
|
||||
);
|
||||
coastalSettlement[i] = clamp(
|
||||
coastalLowland[i] * 0.50 +
|
||||
(portSuitability?.[i] || 0) * 0.30 +
|
||||
(deltaField?.[i] || 0) * 0.20 +
|
||||
plain[i] * 0.10 -
|
||||
slope[i] * 0.52 -
|
||||
ridgeField[i] * 0.24 -
|
||||
spine * 0.12
|
||||
);
|
||||
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
|
||||
settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise);
|
||||
ruralSuitability[i] = clamp(
|
||||
agriculture[i] * 0.42 +
|
||||
developable[i] * 0.28 +
|
||||
valleySettlement[i] * 0.24 +
|
||||
coastalSettlement[i] * 0.15 +
|
||||
settlementCluster[i] * 0.24 -
|
||||
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||
);
|
||||
townSuitability[i] = clamp(
|
||||
developable[i] * 0.40 +
|
||||
valleySettlement[i] * 0.26 +
|
||||
coastalSettlement[i] * 0.20 +
|
||||
confluence * 0.34 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.12 +
|
||||
settlementCluster[i] * 0.16 -
|
||||
slope[i] * 0.34 -
|
||||
ridgeField[i] * 0.17 -
|
||||
spine * 0.10
|
||||
);
|
||||
settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10);
|
||||
const naturalBarrier = naturalBarrierScore?.[i] || 0;
|
||||
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
|
||||
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// --- region statistics ---------------------------------------------------
|
||||
const regionStats = new Map();
|
||||
function ensureRegion(regionId) {
|
||||
let st = regionStats.get(regionId);
|
||||
if (!st) {
|
||||
st = {
|
||||
id: regionId,
|
||||
area: 0,
|
||||
developableCells: 0,
|
||||
developableSum: 0,
|
||||
valleyCells: 0,
|
||||
coastCells: 0,
|
||||
townCells: 0,
|
||||
plainCells: 0,
|
||||
minX: MAP_W,
|
||||
minY: MAP_H,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
};
|
||||
regionStats.set(regionId, st);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const st = ensureRegion(regionId);
|
||||
st.area++;
|
||||
st.developableSum += developable[i];
|
||||
if (developable[i] > 0.16) st.developableCells++;
|
||||
if (valleySettlement[i] > 0.24) st.valleyCells++;
|
||||
if (coastalSettlement[i] > 0.25) st.coastCells++;
|
||||
if (townSuitability[i] > 0.28) st.townCells++;
|
||||
if (plain[i] > 0.24) st.plainCells++;
|
||||
st.minX = Math.min(st.minX, x);
|
||||
st.minY = Math.min(st.minY, y);
|
||||
st.maxX = Math.max(st.maxX, x);
|
||||
st.maxY = Math.max(st.maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityFactor(regionId, st) {
|
||||
if (!st || st.area <= 0) return 0;
|
||||
// Treat the focused prefecture and neighboring prefectures with the same
|
||||
// density curve. Only genuinely clipped map-edge slivers are downscaled.
|
||||
return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05);
|
||||
}
|
||||
|
||||
function pickRegionalPoints(scoreArray, {
|
||||
stride = 1,
|
||||
threshold = 0.25,
|
||||
minDistance = 6,
|
||||
totalMax = 100,
|
||||
seedOffset = 0,
|
||||
quotaForRegion,
|
||||
predicate = () => true,
|
||||
kind = "Point",
|
||||
extraScore = () => 0,
|
||||
}) {
|
||||
const byRegion = new Map();
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
|
||||
if (score < threshold) continue;
|
||||
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
|
||||
byRegion.get(regionId).push({ x, y, score, kind, regionId });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const st = regionStats.get(regionId);
|
||||
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
|
||||
if (quota <= 0) continue;
|
||||
out.push(...pickEntities(candidates, {
|
||||
max: quota,
|
||||
minDistance,
|
||||
threshold,
|
||||
seed: seed + seedOffset + regionId * 1009,
|
||||
jitter: 0.04,
|
||||
}));
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
|
||||
}
|
||||
|
||||
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
|
||||
const candidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
|
||||
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
||||
}
|
||||
|
||||
// --- 2. Sparse points ----------------------------------------------------
|
||||
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
|
||||
threshold: 0.30 + rand(seed, 1001) * 0.08,
|
||||
max: 10,
|
||||
minDistance: 13,
|
||||
seedOffset: 1000,
|
||||
predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25,
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18;
|
||||
const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake";
|
||||
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
|
||||
return { ...p, harborPotential, portClass, kind, score: harborPotential };
|
||||
}).sort((a, b) => b.harborPotential - a.harborPotential);
|
||||
if (ports.length && !ports.some((p) => p.portClass === "major")) {
|
||||
ports[0].portClass = "major";
|
||||
ports[0].kind = "Major Port";
|
||||
}
|
||||
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
|
||||
|
||||
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
|
||||
threshold: 0.30 + rand(seed, 1011) * 0.06,
|
||||
max: 18,
|
||||
minDistance: 9,
|
||||
seedOffset: 1010,
|
||||
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
|
||||
}).map((p) => ({ ...p, kind: "River Crossing" }));
|
||||
|
||||
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
|
||||
threshold: 0.18 + rand(seed, 1021) * 0.06,
|
||||
max: 12,
|
||||
minDistance: 11,
|
||||
seedOffset: 1020,
|
||||
predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i],
|
||||
}).map((p) => ({ ...p, kind: "Pass" }));
|
||||
|
||||
const villageScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08);
|
||||
}
|
||||
const villages = pickRegionalPoints(villageScore, {
|
||||
stride: 2,
|
||||
threshold: 0.25 + rand(seed, 1031) * 0.04,
|
||||
totalMax: 140,
|
||||
minDistance: 5,
|
||||
seedOffset: 1030,
|
||||
kind: "Village",
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.developableCells < 10) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf;
|
||||
const min = st.area > 2600 ? 7 : st.area > 1400 ? 4 : st.area > 520 ? 2 : st.area > 220 ? 1 : 0;
|
||||
const max = st.area > 3600 ? 24 : st.area > 2200 ? 17 : st.area > 900 ? 9 : 4;
|
||||
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
|
||||
},
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
|
||||
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
||||
|
||||
const marketScore = new Float32Array(SIZE);
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const featurePull = Math.max(
|
||||
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
|
||||
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
|
||||
confluenceField[i] * 0.16
|
||||
);
|
||||
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
|
||||
marketScore[i] = clamp(
|
||||
townSuitability[i] * 0.62 +
|
||||
villageInfluence[i] * 0.38 +
|
||||
featurePull +
|
||||
valleyMouth +
|
||||
basinField[i] * 0.12 +
|
||||
plain[i] * 0.14 +
|
||||
coastalLowland[i] * 0.08 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.08
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const markets = pickRegionalPoints(marketScore, {
|
||||
stride: 2,
|
||||
threshold: 0.31 + rand(seed, 1041) * 0.045,
|
||||
totalMax: 52,
|
||||
minDistance: 9,
|
||||
seedOffset: 1040,
|
||||
kind: "Market Town",
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.townCells < 8) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf;
|
||||
const min = st.area > 2600 ? 3 : st.area > 1200 ? 2 : st.area > 520 ? 1 : 0;
|
||||
const max = st.area > 3600 ? 9 : st.area > 2200 ? 7 : st.area > 800 ? 4 : 2;
|
||||
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
|
||||
},
|
||||
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
|
||||
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
const defenseScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
defenseScore[i] = clamp(
|
||||
confluenceField[i] * 0.38 +
|
||||
townSuitability[i] * 0.16 +
|
||||
ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 +
|
||||
plain[i] * 0.08 -
|
||||
floodplain[i] * 0.36 -
|
||||
coastalLowland[i] * 0.08
|
||||
);
|
||||
}
|
||||
const castles = pickGlobalPoints(defenseScore, {
|
||||
threshold: 0.34 + rand(seed, 1051) * 0.06,
|
||||
max: 5,
|
||||
minDistance: 16,
|
||||
seedOffset: 1050,
|
||||
}).map((p) => ({
|
||||
...p,
|
||||
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
|
||||
}));
|
||||
|
||||
const castleTowns = castles.map((c, n) => {
|
||||
const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0];
|
||||
const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x;
|
||||
const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y;
|
||||
return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) };
|
||||
});
|
||||
|
||||
// --- 3. Cities by region, without detailed urban flood-fill --------------
|
||||
function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) {
|
||||
if (!p || !inside(p.x, p.y)) return 0;
|
||||
const centerRegion = regionIdAt(p.x, p.y);
|
||||
let capacity = 0;
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const dev = developable[i];
|
||||
if (dev < 0.04) continue;
|
||||
const radial = clamp(1 - d / Math.max(1, radius));
|
||||
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
|
||||
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
|
||||
}
|
||||
}
|
||||
return Math.max(26000, Math.round(capacity / 1000) * 1000);
|
||||
}
|
||||
|
||||
const urbanCandidates = [
|
||||
...markets.map((p) => ({ ...p, candidateKind: "town" })),
|
||||
...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })),
|
||||
...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })),
|
||||
...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })),
|
||||
];
|
||||
|
||||
const cityCandidateByRegion = new Map();
|
||||
for (const p of urbanCandidates) {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const regionId = regionIdAt(p.x, p.y);
|
||||
if (regionId < 0) continue;
|
||||
const st = regionStats.get(regionId);
|
||||
const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20;
|
||||
const capacity = estimateUrbanCapacity(p, cityRadius, 1.0);
|
||||
const score =
|
||||
Math.log10(capacity + 1) * 0.72 +
|
||||
townSuitability[i] * 1.40 +
|
||||
developable[i] * 1.05 +
|
||||
confluenceField[i] * 0.22 +
|
||||
(p.candidateKind === "port" ? 0.48 : 0) +
|
||||
(p.candidateKind === "castleTown" ? 0.22 : 0) +
|
||||
hash2(p.x, p.y, seed + 12000) * 0.16;
|
||||
if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []);
|
||||
cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId });
|
||||
}
|
||||
|
||||
const modernCities = [];
|
||||
const usedCitySites = [];
|
||||
for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const st = regionStats.get(regionId);
|
||||
if (!st || st.developableCells < 30) continue;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const maxCities = clamp(
|
||||
Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2),
|
||||
st.area > 1600 ? 1 : 0,
|
||||
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
|
||||
);
|
||||
const selected = pickEntities(list, {
|
||||
max: maxCities,
|
||||
minDistance: 17,
|
||||
threshold: 0,
|
||||
seed: seed + 12110 + regionId * 313,
|
||||
jitter: 0.02,
|
||||
});
|
||||
for (const p of selected) {
|
||||
if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue;
|
||||
usedCitySites.push(p);
|
||||
modernCities.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// No focused-prefecture fallback: all prefecture regions use the same city
|
||||
// selection rules, so the highlighted region is not overwritten after the
|
||||
// regional pass.
|
||||
|
||||
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
|
||||
for (const [rank, city] of modernCities.entries()) {
|
||||
const isFirstInRegion = !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId);
|
||||
const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
|
||||
const isRegionalCapital = isFirstInRegion;
|
||||
const rawPop = isRegionalCapital
|
||||
? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000
|
||||
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
|
||||
const capMultiplier = isRegionalCapital ? 1.10 : 1.0;
|
||||
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
|
||||
city.population = Math.max(isRegionalCapital ? 90000 : 24000, population);
|
||||
city.isPrefecturalCapital = isPrefecturalCapital;
|
||||
city.isRegionalCapital = isRegionalCapital;
|
||||
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||
city.kind = city.rank;
|
||||
city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isRegionalCapital ? 34 : 24);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isRegionalCapital ? 6.5 : 5.6);
|
||||
city.sprawlRadius = clamp(city.urbanRadius * (isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isRegionalCapital ? 44 : 30);
|
||||
city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5);
|
||||
}
|
||||
|
||||
function cityPopulationCap(city) {
|
||||
const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
|
||||
const bias = city?.isRegionalCapital ? 1.12 : 1.0;
|
||||
return estimateUrbanCapacity(city, radius, bias);
|
||||
}
|
||||
|
||||
// --- 4. Lightweight corridors -------------------------------------------
|
||||
function routeLight(a, b, snapRadius = 3) {
|
||||
if (!a || !b) return [];
|
||||
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
|
||||
const out = [];
|
||||
let lastKey = "";
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const fx = a.x + (b.x - a.x) * t;
|
||||
const fy = a.y + (b.y - a.y) * t;
|
||||
let best = null;
|
||||
let bestCost = INF;
|
||||
const radius = snapRadius + (s > 0 && s < steps ? 1 : 0);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = Math.round(fx + dx);
|
||||
const y = Math.round(fy + dy);
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const lineDist = Math.hypot(x - fx, y - fy);
|
||||
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
best = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) best = [Math.round(fx), Math.round(fy)];
|
||||
const key = `${best[0]},${best[1]}`;
|
||||
if (key !== lastKey) {
|
||||
out.push(best);
|
||||
lastKey = key;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function importantNodesForRegion(regionId) {
|
||||
const inRegion = (p) => regionIdAt(p.x, p.y) === regionId;
|
||||
return [
|
||||
...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })),
|
||||
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
|
||||
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
|
||||
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
|
||||
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10);
|
||||
}
|
||||
|
||||
const premodernRoads = [];
|
||||
const nationalRoads = [];
|
||||
const minorRoads = [];
|
||||
const railways = [];
|
||||
const branchRailways = [];
|
||||
const externalRoads = [];
|
||||
const externalRailways = [];
|
||||
const expressways = [];
|
||||
const ringRoads = [];
|
||||
const ringRailways = [];
|
||||
const ringExpressways = [];
|
||||
const externalExpressways = [];
|
||||
const icAccessRoads = [];
|
||||
const externalGateways = [];
|
||||
|
||||
// Premodern roads connect castles/markets/ports sparsely.
|
||||
for (const c of castles) {
|
||||
const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
|
||||
for (const n of near) {
|
||||
const path = routeLight(c, n, 2);
|
||||
if (path.length > 2) premodernRoads.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
const nodes = importantNodesForRegion(regionId);
|
||||
if (nodes.length < 2) continue;
|
||||
const connected = [nodes[0]];
|
||||
const remaining = nodes.slice(1);
|
||||
const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1);
|
||||
while (remaining.length && nationalRoads.length < 48) {
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
for (const a of connected) {
|
||||
for (const b of remaining) {
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
|
||||
if (score < bestScore) { bestScore = score; best = { a, b }; }
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
const path = routeLight(best.a, best.b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
connected.push(best.b);
|
||||
remaining.splice(remaining.indexOf(best.b), 1);
|
||||
if (connected.length - 1 >= maxEdges) break;
|
||||
}
|
||||
|
||||
// A few k-nearest shortcuts for urbanized regions.
|
||||
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4);
|
||||
for (let i = 0; i < urbanNodes.length; i++) {
|
||||
const a = urbanNodes[i];
|
||||
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
|
||||
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
|
||||
const path = routeLight(a, b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
}
|
||||
|
||||
// Railways: only high-order cities/ports, as a lightweight placeholder.
|
||||
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4);
|
||||
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
for (let i = 1; i < railNodes.length; i++) {
|
||||
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
|
||||
if (path.length > 4) railways.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// External gateways at land edges; used by naming/UI and later transport work.
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
const st = regionStats.get(regionId);
|
||||
if (!st || st.area < 140) continue;
|
||||
const edgeCandidates = [];
|
||||
for (let y = st.minY; y <= st.maxY; y += 3) {
|
||||
for (const x of [st.minX, st.maxX]) {
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||
}
|
||||
}
|
||||
for (let x = st.minX; x <= st.maxX; x += 3) {
|
||||
for (const y of [st.minY, st.maxY]) {
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||
}
|
||||
}
|
||||
const gateway = pickEntities(edgeCandidates, { max: (regionStats.get(regionId)?.area || 0) > 2200 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0];
|
||||
if (gateway) {
|
||||
gateway.kind = "External Gateway";
|
||||
gateway.regionId = regionId;
|
||||
externalGateways.push(gateway);
|
||||
const target = importantNodesForRegion(regionId)[0];
|
||||
if (target) {
|
||||
const path = routeLight(gateway, target, 3);
|
||||
if (path.length > 2) externalRoads.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate expressways as a very small subset of top inter-city links.
|
||||
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
|
||||
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
|
||||
const a = topCities[i - 1];
|
||||
const b = topCities[i];
|
||||
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
|
||||
const path = routeLight(a, b, 5);
|
||||
if (path.length > 5) expressways.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Land-use road influence intentionally excludes expressways. Expressways
|
||||
// are through-corridors here, not automatic suburbanization generators.
|
||||
// A narrow field controls land-use attachment, while a broader field raises
|
||||
// population density around trunk roads without painting a wide suburb band.
|
||||
const roadLanduseInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25);
|
||||
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0);
|
||||
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4);
|
||||
|
||||
const stations = [];
|
||||
const usedStationKeys = new Set();
|
||||
function addStation(x, y, kind = "Station", score = 1) {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
if (!inside(x, y) || sea[indexOf(x, y)]) return;
|
||||
const key = `${x},${y}`;
|
||||
if (usedStationKeys.has(key)) return;
|
||||
usedStationKeys.add(key);
|
||||
stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5);
|
||||
for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8);
|
||||
const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85);
|
||||
|
||||
// --- 5. Approximate city/town influence and land-use ---------------------
|
||||
const cityInfluence = new Float32Array(SIZE);
|
||||
const coreInfluence = new Float32Array(SIZE);
|
||||
const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9);
|
||||
const populationDensity = new Float32Array(SIZE);
|
||||
|
||||
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1;
|
||||
const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
|
||||
if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v);
|
||||
else if (v > grid[i]) grid[i] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const city of modernCities) {
|
||||
addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add");
|
||||
addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add");
|
||||
addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max");
|
||||
}
|
||||
const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25));
|
||||
|
||||
// Industrial/logistics/new town placeholders remain lightweight. They are
|
||||
// routed by land-use proximity rather than expensive search passes.
|
||||
const industrialZones = [];
|
||||
for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) {
|
||||
const candidates = [];
|
||||
for (let dy = -10; dy <= 10; dy++) {
|
||||
for (let dx = -10; dx <= 10; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < 3 || d > 10) continue;
|
||||
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06;
|
||||
if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0];
|
||||
if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z);
|
||||
if (industrialZones.length >= 8) break;
|
||||
}
|
||||
const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0);
|
||||
|
||||
const satelliteCities = [];
|
||||
const newTowns = [];
|
||||
const logisticsParks = [];
|
||||
const interchanges = [];
|
||||
var landuse = new Uint8Array(SIZE);
|
||||
|
||||
// Re-run land-use classification after landuse allocation. The loop above is
|
||||
// intentionally inside a helper to keep all thresholds in one place.
|
||||
function classifyLanduse() {
|
||||
landuse.fill(LANDUSE.RURAL);
|
||||
let maxDensity = 0;
|
||||
const baseNoiseSeed = seed + 15000;
|
||||
const urbanCapacity = new Float32Array(SIZE);
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10;
|
||||
const core = coreInfluence[i];
|
||||
const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38;
|
||||
const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30;
|
||||
const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10);
|
||||
urbanCapacity[i] = clamp(
|
||||
developable[i] * 0.66 +
|
||||
plain[i] * 0.16 +
|
||||
basinField[i] * 0.16 +
|
||||
valleyField[i] * 0.16 +
|
||||
coastalLowland[i] * 0.12 +
|
||||
roadInfluence[i] * 0.16 + transport * 0.10 +
|
||||
riverUrban * 0.14 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.12 -
|
||||
floodplain[i] * 0.08
|
||||
);
|
||||
populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + roadInfluence[i] * 0.18 + transport * 0.08);
|
||||
maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
|
||||
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) {
|
||||
landuse[i] = LANDUSE.FOREST;
|
||||
continue;
|
||||
}
|
||||
if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.INDUSTRIAL;
|
||||
continue;
|
||||
}
|
||||
if (core > 0.38 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if (oldTown > 0.18 && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = LANDUSE.OLD_URBAN;
|
||||
continue;
|
||||
}
|
||||
|
||||
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
|
||||
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28);
|
||||
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
|
||||
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
|
||||
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
} else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
} else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) {
|
||||
landuse[i] = LANDUSE.FARMLAND;
|
||||
} else {
|
||||
landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseLanduse = landuse.slice();
|
||||
const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE;
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue;
|
||||
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
let urbanNeighbors = 0;
|
||||
let cbdNeighbors = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const lu = baseLanduse[indexOf(x + dx, y + dy)];
|
||||
if (isBuilt(lu)) urbanNeighbors++;
|
||||
if (lu === LANDUSE.CBD) cbdNeighbors++;
|
||||
}
|
||||
}
|
||||
if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
|
||||
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
|
||||
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
|
||||
if (fringeChance > 0.34 + noise) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
}
|
||||
}
|
||||
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||
}
|
||||
classifyLanduse();
|
||||
|
||||
for (const city of modernCities) {
|
||||
let urbanFootprintCells = 0;
|
||||
let coreFootprintCells = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
if (Math.hypot(dx, dy) > r) continue;
|
||||
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
|
||||
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
|
||||
}
|
||||
}
|
||||
city.urbanFootprintCells = urbanFootprintCells;
|
||||
city.coreFootprintCells = coreFootprintCells;
|
||||
}
|
||||
|
||||
const transportDebug = {
|
||||
humanStageVersion: "v2-sparse-raster",
|
||||
aStarRoutes: 0,
|
||||
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
|
||||
nationalRoadPopulationCoverage: 0,
|
||||
nationalRoadUncoveredPopulation: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
ports,
|
||||
crossings,
|
||||
passes,
|
||||
settlementCluster,
|
||||
settlementScore,
|
||||
villages,
|
||||
markets,
|
||||
castles,
|
||||
castleTowns,
|
||||
premodernRoads,
|
||||
minorRoads,
|
||||
modernCities,
|
||||
populationDensity,
|
||||
railways,
|
||||
branchRailways,
|
||||
ringRailways,
|
||||
externalRailways,
|
||||
stations,
|
||||
industrialZones,
|
||||
nationalRoads,
|
||||
ringRoads,
|
||||
expressways,
|
||||
ringExpressways,
|
||||
icAccessRoads,
|
||||
externalRoads,
|
||||
externalExpressways,
|
||||
interchanges,
|
||||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
landuse,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
villageInfluence,
|
||||
externalGateways,
|
||||
cityPopulationCap,
|
||||
transportDebug,
|
||||
};
|
||||
}
|
||||
|
|
@ -915,69 +915,3 @@ export function applyOutputOptions(map, options = {}) {
|
|||
delete slim.naturalBarrierScore;
|
||||
return slim;
|
||||
}
|
||||
|
||||
export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
|
||||
populationDensity.fill(0);
|
||||
const allCities = [...modernCities, ...satelliteCities];
|
||||
for (const city of allCities) {
|
||||
const urbanR = Math.max(4, city.urbanRadius || 8);
|
||||
const coreR = Math.max(2, city.coreRadius || 3);
|
||||
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
|
||||
const r = Math.ceil(urbanR * 2.2);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !prefectureMask[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const lu = landuse[i];
|
||||
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
|
||||
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
|
||||
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
|
||||
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
|
||||
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
let maxDensity = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||
|
||||
for (const city of allCities) {
|
||||
let urbanCells = 0;
|
||||
let coreCells = 0;
|
||||
let densitySum = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > r) continue;
|
||||
const lu = landuse[i];
|
||||
if (lu >= 2 && lu <= 8) {
|
||||
urbanCells++;
|
||||
densitySum += populationDensity[i];
|
||||
if (lu === 3) coreCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const capitalLike = city.isPrefecturalCapital || city.isRegionalCapital;
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const coreComponent = coreCells * 3200;
|
||||
const densityComponent = densitySum * 360;
|
||||
const computedPopulation = base + urbanComponent + coreComponent + densityComponent;
|
||||
const footprintCells = city.urbanFootprintCells || urbanCells;
|
||||
const footprintCoreCells = city.coreFootprintCells || coreCells;
|
||||
const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000);
|
||||
city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
mapOutput.js
63
mapOutput.js
|
|
@ -1,7 +1,6 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, hash2, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
||||
|
|
@ -25,48 +24,6 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
|||
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
|
||||
}
|
||||
|
||||
function ensureMunicipalOfficeSettlementFootprints(adminCentersRaw, { sea, elevation, slope, ridgeField, plain, basinField, coastalLowland, populationDensity, landuse, humanRegionMask, roadInfluence, stationInfluence }, seed) {
|
||||
let changedCells = 0;
|
||||
const isBuildable = (i) => !sea[i] && (!humanRegionMask || humanRegionMask[i]) && elevation[i] < 0.78 && slope[i] < 0.62 && ridgeField[i] < 0.76;
|
||||
for (const [n, center] of (adminCentersRaw || []).entries()) {
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const ci = indexOf(center.x, center.y);
|
||||
if (!isBuildable(ci)) continue;
|
||||
const existingUrban = landuse[ci] >= LANDUSE.OLD_URBAN && landuse[ci] <= LANDUSE.NEW_TOWN;
|
||||
const baseRadius = existingUrban ? 1.2 : (center.population || 0) >= 60000 ? 2.6 : 2.0;
|
||||
const scoreBoost = clamp((populationDensity[ci] || 0) * 0.45 + (roadInfluence?.[ci] || 0) * 0.22 + (stationInfluence?.[ci] || 0) * 0.26 + 0.24);
|
||||
const r = Math.ceil(baseRadius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = center.x + dx;
|
||||
const y = center.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!isBuildable(i)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > baseRadius) continue;
|
||||
const lowland = clamp(plain[i] * 0.28 + basinField[i] * 0.24 + coastalLowland[i] * 0.20 + (roadInfluence?.[i] || 0) * 0.14 - slope[i] * 0.28 - ridgeField[i] * 0.16 + 0.30);
|
||||
if (lowland <= 0.12) continue;
|
||||
if (d <= 0.85) {
|
||||
if (landuse[i] < LANDUSE.OLD_URBAN || landuse[i] === LANDUSE.FARMLAND || landuse[i] === LANDUSE.RURAL) {
|
||||
landuse[i] = LANDUSE.OLD_URBAN;
|
||||
changedCells++;
|
||||
}
|
||||
populationDensity[i] = Math.max(populationDensity[i] || 0, 0.34 + scoreBoost * 0.35);
|
||||
} else if (d <= baseRadius && landuse[i] <= LANDUSE.FARMLAND) {
|
||||
const keep = lowland * (1 - d / (baseRadius + 0.1)) + hash2(x, y, seed + 18800 + n) * 0.08;
|
||||
if (keep > 0.16) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
changedCells++;
|
||||
populationDensity[i] = Math.max(populationDensity[i] || 0, 0.20 + scoreBoost * 0.20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return changedCells;
|
||||
}
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
|
|
@ -177,17 +134,17 @@ export function finishMapOutput({
|
|||
let externalGateways = inputExternalGateways;
|
||||
|
||||
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
|
||||
outputProgress("population recalculation");
|
||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
||||
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
||||
outputProgress("final packaging");
|
||||
// Use all generated prefecture regions for human-geography masks, not only
|
||||
// the focused prefecture. Population density itself is already generated in
|
||||
// the human stage and is not rebuilt here.
|
||||
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
|
||||
for (let i = 0; i < humanRegionMask.length; i++) {
|
||||
humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0;
|
||||
}
|
||||
const municipalOfficeUrbanizedCells = ensureMunicipalOfficeSettlementFootprints(adminCentersRaw, {
|
||||
sea, elevation, slope, ridgeField, plain, basinField, coastalLowland, populationDensity, landuse, humanRegionMask, roadInfluence, stationInfluence,
|
||||
}, seed);
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
// Population density is generated directly in the human stage. Do not rebuild
|
||||
// it here from land-use or municipal offices; output should only package and
|
||||
// name features.
|
||||
for (const city of modernCities) {
|
||||
const cap = cityPopulationCap(city);
|
||||
if (cap < INF && (city.population || 0) > cap) {
|
||||
|
|
@ -366,7 +323,7 @@ export function finishMapOutput({
|
|||
adminCenters,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug: adminDebug ? { ...adminDebug, municipalOfficeUrbanizedCells } : { municipalOfficeUrbanizedCells },
|
||||
adminDebug,
|
||||
castleRuins,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||
import { generateMapFeatures } from "./mapFeaturesV2.js";
|
||||
import { generateMapFeatures } from "./mapFeatures.js";
|
||||
import { finishMapOutput } from "./mapOutput.js";
|
||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||
|
||||
|
|
|
|||
1804
mapTerrain.v4.bak.js
1804
mapTerrain.v4.bak.js
File diff suppressed because it is too large
Load diff
110
names.js
110
names.js
|
|
@ -4,24 +4,26 @@ export const NAME_KANJI_POOLS = {
|
|||
modifiers: [
|
||||
"大", "小", "上", "下", "中", "奥", "脇",
|
||||
"東", "西", "南", "北",
|
||||
"新", "古", "本", "元",
|
||||
"新", "古", "本",
|
||||
"高", "長", "広", "深", "浅",
|
||||
"白", "黒", "青", "赤",
|
||||
"白", "黒", "青", "赤", "藍",
|
||||
"奥", "前", "後", "内", "外",
|
||||
"美", "吉", "福", "幸", "徳",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
|
||||
"霧", "霞", "朝", "日", "天", "雨", "晴",
|
||||
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
|
||||
"霞", "朝", "日", "天", "晴",
|
||||
"土", "砂", "石", "岩",
|
||||
"丑", "卯", "辰", "巳", "酉",
|
||||
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌",
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "野", "荒", "野", "沢",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
|
||||
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
|
||||
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
||||
"聡", "郷", "里",
|
||||
"馬", "鹿", "亀", "鷲", "鷹",
|
||||
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "竜", "龍", "牛", "鳥",
|
||||
"湯",
|
||||
],
|
||||
|
||||
|
|
@ -36,10 +38,10 @@ export const NAME_KANJI_POOLS = {
|
|||
coastalTerrain: [
|
||||
"津", "浦", "津", "崎",
|
||||
"島", "磯", "潟", "湊", "津",
|
||||
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
|
||||
"州", "洲", "瀬", "砂", "潮", "塩", "浜",
|
||||
"泊", "江", "浦", "灘", "入",
|
||||
"戸", "門",
|
||||
"鯵", "鰐", "漁", "魚"
|
||||
"鯵", "鰐", "漁", "魚", "鮫", "鮎",
|
||||
],
|
||||
|
||||
plants: [
|
||||
|
|
@ -48,7 +50,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"菅", "榎", "椿", "桐", "柳",
|
||||
"橘", "柏", "槙", "柿", "桃",
|
||||
"梨", "桑", "麻", "芦", "茅",
|
||||
"粟", "稲", "麦", "稗", "米", "飯", "糠",
|
||||
"粟", "稲", "稗", "米", "飯", "糠",
|
||||
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
|
||||
],
|
||||
|
||||
|
|
@ -95,7 +97,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"陀", "芸", "雲",
|
||||
"幡", "耆", "摩",
|
||||
"張", "江", "河", "斐", "濃",
|
||||
"岐", "防", "門", "隅", "向",
|
||||
"岐", "門", "隅", "向",
|
||||
"居", "前", "中", "後", "波",
|
||||
"勢", "渡", "城", "紫", "野", "度",
|
||||
"津", "島", "信", "登", "賀", "志",
|
||||
|
|
@ -103,17 +105,16 @@ export const NAME_KANJI_POOLS = {
|
|||
],
|
||||
|
||||
settlementWords: [
|
||||
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
|
||||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒",
|
||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"城", "館", "屋", "家", "所",
|
||||
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
|
||||
"市", "場", "関", "地蔵", "辻", "角", "堰",
|
||||
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
|
||||
]
|
||||
};
|
||||
|
||||
export const NAME_PROBABILITIES = {
|
||||
customName: 0.20,
|
||||
forcedName: 1.0,
|
||||
customNameList: 0.25,
|
||||
retryCount: 24,
|
||||
|
||||
categoryFallback: {
|
||||
|
|
@ -281,8 +282,9 @@ export const NAME_TEMPLATE_WEIGHTS = {
|
|||
},
|
||||
};
|
||||
|
||||
export const CUSTOM_NAMES = {};
|
||||
export const FORCED_NAMES = {};
|
||||
// Add preferred reusable place names here. Each generated entity has a
|
||||
// deterministic chance to use one before falling back to template kanji.
|
||||
export const CUSTOM_NAME_LIST = [];
|
||||
|
||||
// Legacy export kept only so older imports do not fail.
|
||||
export const NAME_PARTS = {};
|
||||
|
|
@ -316,6 +318,20 @@ function countChars(value) {
|
|||
return Array.from(String(value || "")).length;
|
||||
}
|
||||
|
||||
function isKanjiChar(ch) {
|
||||
return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(ch);
|
||||
}
|
||||
|
||||
function hasRepeatedKanji(value) {
|
||||
const seen = new Set();
|
||||
for (const ch of Array.from(String(value || ""))) {
|
||||
if (!isKanjiChar(ch)) continue;
|
||||
if (seen.has(ch)) return true;
|
||||
seen.add(ch);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function incrementCounter(counter, key, amount = 1) {
|
||||
counter[key] = (counter[key] || 0) + amount;
|
||||
}
|
||||
|
|
@ -397,15 +413,15 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME
|
|||
const emptyPools = POOL_KEYS.filter((key) => !pools[key]?.length);
|
||||
const poolsPresent = Object.fromEntries(POOL_KEYS.map((key) => [key, Boolean(pools[key]?.length)]));
|
||||
return {
|
||||
effectiveCustomNameProbability: probabilities.customName,
|
||||
effectiveCustomNameListProbability: probabilities.customNameList,
|
||||
poolsPresent,
|
||||
emptyPools,
|
||||
selectedTemplateCounts: {},
|
||||
selectedContextCounts: {},
|
||||
customNamesUsed: 0,
|
||||
forcedNamesUsed: 0,
|
||||
generatedNamesUsed: 0,
|
||||
customNameListUsed: 0,
|
||||
invalidNamesRejected: 0,
|
||||
repeatedKanjiNamesRejected: 0,
|
||||
oneCharacterNamesPrevented: 0,
|
||||
rejectedOneCharacterNames: 0,
|
||||
duplicateRetries: 0,
|
||||
|
|
@ -459,6 +475,7 @@ export function validateGeneratedName(name, options = {}) {
|
|||
}
|
||||
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
|
||||
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
|
||||
if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" };
|
||||
return { valid: true, reason: "valid" };
|
||||
}
|
||||
|
||||
|
|
@ -512,35 +529,39 @@ function uniqueDiagnosticName(seed, id, usedNames, debug, startAttempt = 0) {
|
|||
return `${ASCII_DIAGNOSTIC_PREFIX}${stableHash(`${seed}:${id}`).toString(36).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function tryCustomName(seed, id, usedNames, debug) {
|
||||
const customName = CUSTOM_NAMES[id];
|
||||
if (!customName) return null;
|
||||
if (roll(seed, id, 0, 3501) >= NAME_PROBABILITIES.customName) return null;
|
||||
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
|
||||
if (!validation.valid) {
|
||||
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
|
||||
if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++;
|
||||
else debug.invalidNamesRejected++;
|
||||
return null;
|
||||
function tryCustomNameList(seed, id, usedNames, debug) {
|
||||
if (!CUSTOM_NAME_LIST.length) return null;
|
||||
if (roll(seed, id, 0, 3527) >= (NAME_PROBABILITIES.customNameList ?? 0)) return null;
|
||||
|
||||
const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length;
|
||||
for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) {
|
||||
const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length];
|
||||
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
|
||||
if (!validation.valid) {
|
||||
if (validation.reason === "oneCharacter") {
|
||||
debug.oneCharacterNamesPrevented++;
|
||||
debug.rejectedOneCharacterNames++;
|
||||
} else if (validation.reason === "repeatedKanji") {
|
||||
debug.repeatedKanjiNamesRejected++;
|
||||
} else {
|
||||
debug.invalidNamesRejected++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (usedNames?.has(customName)) {
|
||||
debug.duplicateRetries++;
|
||||
continue;
|
||||
}
|
||||
debug.customNameListUsed++;
|
||||
return customName;
|
||||
}
|
||||
if (usedNames?.has(customName)) {
|
||||
debug.duplicateRetries++;
|
||||
return null;
|
||||
}
|
||||
debug.customNamesUsed++;
|
||||
return customName;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) {
|
||||
debug ||= createNameDebug();
|
||||
const forcedName = FORCED_NAMES[id];
|
||||
if (forcedName) {
|
||||
debug.forcedNamesUsed++;
|
||||
return forcedName;
|
||||
}
|
||||
|
||||
const customName = tryCustomName(seed, id, usedNames, debug);
|
||||
if (customName) return customName;
|
||||
const listedCustomName = tryCustomNameList(seed, id, usedNames, debug);
|
||||
if (listedCustomName) return listedCustomName;
|
||||
|
||||
const retryCount = Math.max(1, NAME_PROBABILITIES.retryCount || 1);
|
||||
for (let attempt = 0; attempt < retryCount; attempt++) {
|
||||
|
|
@ -556,6 +577,7 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
|
|||
debug.oneCharacterNamesPrevented++;
|
||||
debug.rejectedOneCharacterNames++;
|
||||
}
|
||||
else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++;
|
||||
else debug.invalidNamesRejected++;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
36
test.js
36
test.js
|
|
@ -1,7 +1,6 @@
|
|||
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
|
||||
import {
|
||||
CUSTOM_NAMES,
|
||||
FORCED_NAMES,
|
||||
CUSTOM_NAME_LIST,
|
||||
NAME_KANJI_POOLS,
|
||||
NAME_PARTS,
|
||||
NAME_PROBABILITIES,
|
||||
|
|
@ -9,6 +8,7 @@ import {
|
|||
NAME_TEMPLATE_WEIGHTS,
|
||||
generateEntityName,
|
||||
generateTemplateName,
|
||||
validateGeneratedName,
|
||||
} from "./names.js";
|
||||
|
||||
const result = document.getElementById("result");
|
||||
|
|
@ -654,6 +654,7 @@ try {
|
|||
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
|
||||
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
|
||||
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
|
||||
assert(map.entitiesForNames.every((item) => validateGeneratedName(item.name, { allowAsciiDiagnostic: true }).valid), "generated names pass place-name validation");
|
||||
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
|
||||
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
|
||||
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
|
||||
|
|
@ -664,14 +665,12 @@ try {
|
|||
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
|
||||
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
|
||||
assert(
|
||||
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
|
||||
map.nameDebug.generatedNamesUsed + map.nameDebug.customNameListUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
|
||||
"nameDebug accounting covers named entities"
|
||||
);
|
||||
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
|
||||
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
|
||||
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
|
||||
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
|
||||
|
||||
assert(
|
||||
map.adminCenters.length !== other.adminCenters.length ||
|
||||
map.villages.length !== other.villages.length ||
|
||||
|
|
@ -751,28 +750,13 @@ try {
|
|||
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
|
||||
.sort((a, b) => a.deposition - b.deposition);
|
||||
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
|
||||
CUSTOM_NAME_LIST.push("L1", "L2");
|
||||
const listedCustomNames = Array.from({ length: 50 }, (_, n) => generateEntityName(9200 + n, `list-probe-${n}`, { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
|
||||
const listedCustomHits = listedCustomNames.filter((name) => name === "L1" || name === "L2").length;
|
||||
assert(NAME_PROBABILITIES.customNameList > 0 && listedCustomHits > 0 && listedCustomHits < listedCustomNames.length, "CUSTOM_NAME_LIST supplies probabilistic selected place names");
|
||||
CUSTOM_NAME_LIST.length = 0;
|
||||
|
||||
CUSTOM_NAMES["city-0"] = "C1";
|
||||
const customSameA = generateMap(321);
|
||||
const customSameB = generateMap(321);
|
||||
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
|
||||
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
|
||||
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
|
||||
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
|
||||
const customHits = customTargets.filter((item) => item.name === "C1").length;
|
||||
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
|
||||
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
|
||||
delete CUSTOM_NAMES["city-0"];
|
||||
|
||||
CUSTOM_NAMES["custom-probe"] = "C1";
|
||||
const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
|
||||
const directCustomHits = directCustomNames.filter((name) => name === "C1").length;
|
||||
assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions");
|
||||
delete CUSTOM_NAMES["custom-probe"];
|
||||
|
||||
FORCED_NAMES["forced-probe"] = "F1";
|
||||
assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply");
|
||||
delete FORCED_NAMES["forced-probe"];
|
||||
assert(!validateGeneratedName("青青").valid && validateGeneratedName("青青").reason === "repeatedKanji", "place names reject repeated kanji");
|
||||
|
||||
for (const seed of [101, 2026, 54321]) {
|
||||
const seeded = generateMap(seed);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue