Compare commits

...

6 commits

Author SHA1 Message Date
15c1031d72 not bad city system 2026-05-24 21:21:17 +09:00
f5e7a1df1d human tweaks 2026-05-24 19:33:09 +09:00
1ea8ba1701 town tweak 2026-05-24 17:38:51 +09:00
84ad22f7af smaller teerrain system 2026-05-23 20:06:29 +09:00
8e1ec7b9ac terrain tweak? 2026-05-23 18:06:01 +09:00
1be3d86da0 terrain update. broken but ok 2026-05-22 19:28:39 +09:00
19 changed files with 9888 additions and 1672 deletions

View file

@ -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) {
@ -558,20 +564,22 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
if (!bothUrban && !bothLivingCorridor) return false;
}
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68;
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge;
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50;
return barrier < threshold && (!majorRiverEdge || urbanEdge);
}
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0;
let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0;
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
sx += x; sy += y; pop += populationDensity[i];
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
ridgeExposure += ridgeField[i];
coastalExposure += coastalLowland[i];
@ -584,6 +592,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
unit.area = area;
unit.x = sx / Math.max(1, area);
unit.y = sy / Math.max(1, area);
unit.minX = area ? minX : 0;
unit.minY = area ? minY : 0;
unit.maxX = area ? maxX : 0;
unit.maxY = area ? maxY : 0;
unit.width = area ? maxX - minX + 1 : 0;
unit.height = area ? maxY - minY + 1 : 0;
unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height));
unit.population = pop;
unit.urbanWeight = urbanWeight / Math.max(1, area);
unit.ridgeExposure = ridgeExposure / Math.max(1, area);
@ -596,13 +611,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
}
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
let first = -1, second = -1, bestA = -INF, bestB = -INF;
if (!unit || unit.area < 24) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20)));
let first = -1;
let second = -1;
let bestA = -INF;
let bestB = -INF;
const width = unit.width || (unit.maxX - unit.minX + 1) || 1;
const height = unit.height || (unit.maxY - unit.minY + 1) || 1;
const horizontal = width >= height;
const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04;
const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0);
const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0;
const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
@ -610,36 +637,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0;
const d = Math.hypot(x - fx, y - fy);
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const localOwner = new Map([[first, 0], [second, 1]]);
const queue = [first, second];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const owner = localOwner.get(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const owner = new Int8Array(SIZE);
owner.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
owner[source] = sourceOwner;
dist[source] = 0;
heap.push({ i: source, f: 0, owner: sourceOwner });
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
localOwner.set(ni, owner);
queue.push(ni);
if (!cellSet.has(ni)) continue;
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32;
const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25;
const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12));
const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step;
const nd = cur.f + stepCost;
if (nd < dist[ni]) {
dist[ni] = nd;
owner[ni] = cur.owner;
heap.push({ i: ni, f: nd, owner: cur.owner });
}
}
}
for (const ci of unit.cells) if (!localOwner.has(ci)) {
const [x, y] = xyOf(ci);
const d0 = Math.hypot(x - fx, y - fy);
const [sx, sy] = xyOf(second);
const d1 = Math.hypot(x - sx, y - sy);
localOwner.set(ci, d0 <= d1 ? 0 : 1);
const aCells = [];
const bCells = [];
for (const ci of unit.cells) {
if (owner[ci] === 1) bCells.push(ci);
else aCells.push(ci);
}
const aCells = [], bCells = [];
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
if (aCells.length < 10 || bCells.length < 10) return null;
if (aCells.length < minPart || bCells.length < minPart) return null;
unit.cells = aCells;
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
for (const ci of bCells) compartmentId[ci] = newId;
@ -663,7 +708,374 @@ function naturalGroupKey(unit) {
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
}
function collectLandComponents(prefectureMask, sea) {
const seen = new Uint8Array(SIZE);
const components = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
const cells = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
components.push(cells);
}
return components;
}
function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) {
const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8;
const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0));
const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0));
const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08);
const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22);
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
}
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
const seeds = [];
const seedComponentId = [];
const minCellsPerUnit = 9;
let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit)));
const sortedComponents = landComponents
.map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length }))
.sort((a, b) => b.area - a.area);
for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) {
const { cells, componentIndex, area } = sortedComponents[componentOrder];
if (area <= 0) continue;
const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea));
let localTarget = Math.max(1, proportional);
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget));
remainingTarget -= localTarget;
const candidates = cells
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
.sort((a, b) => b.score - a.score);
const localSeeds = [];
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
for (const factor of spacingPasses) {
const minDist = Math.max(2.2, idealSpacing * factor);
for (const candidate of candidates) {
if (localSeeds.length >= localTarget) break;
const [x, y] = xyOf(candidate.i);
let ok = true;
for (const existing of localSeeds) {
const [ex, ey] = xyOf(existing);
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
}
if (ok) localSeeds.push(candidate.i);
}
if (localSeeds.length >= localTarget) break;
}
for (const i of localSeeds) {
seeds.push(i);
seedComponentId.push(componentIndex);
}
}
if (seeds.length === 0 && landComponents[0]?.length) {
seeds.push(landComponents[0][0]);
seedComponentId.push(0);
}
return { seeds, seedComponentId };
}
function naturalStepCost(a, b, cellClass, fields) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields;
const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5;
const ridge = Math.max(ridgeField[a], ridgeField[b]);
const riverEdge = Math.max(river[a], river[b]);
const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0);
const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55;
const elevationBreak = Math.abs(elevation[a] - elevation[b]);
const slopeBreak = Math.max(slope[a], slope[b]);
const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08;
const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30);
const lowlandContinuity = Math.min(
(plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20,
(plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20
);
const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45);
const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0));
const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0;
return Math.max(0.16,
0.72 +
barrier * 5.1 +
ridge * 0.82 +
elevationBreak * 3.0 +
slopeBreak * 0.56 +
riverPenalty +
classBreak -
corridorBonus
);
}
function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) {
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields;
let maxId = -1;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i];
const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 }));
for (let i = 0; i < SIZE; i++) {
const id = compartmentId[i];
if (id >= 0 && units[id]) units[id].cells.push(i);
}
for (const unit of units) {
if (!unit.cells.length) { unit.area = 0; continue; }
const counts = new Map();
for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1);
let klass = -1, best = -1;
for (const [k, count] of counts) if (count > best) { best = count; klass = k; }
unit.classId = klass;
unit.dominantLandscapeClass = klass;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
let riverExposure = 0;
for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45;
unit.riverExposure = riverExposure / Math.max(1, unit.area);
}
return units;
}
function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) {
const queue = [];
for (const unit of [...compartments]) {
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
const unitCellSet = new Set(unit.cells);
const seen = new Set();
const components = [];
for (const start of unit.cells) {
if (seen.has(start) || compartmentId[start] !== unit.id) continue;
const cells = [];
queue.length = 0;
queue.push(start);
seen.add(start);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue;
seen.add(ni);
queue.push(ni);
}
}
components.push(cells);
}
if (components.length <= 1) continue;
components.sort((a, b) => b.length - a.length);
unit.cells = components[0];
for (const extra of components.slice(1)) {
const newId = compartments.length;
for (const ci of extra) compartmentId[ci] = newId;
compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass });
}
}
}
function renumberCompartments(compartmentId, compartments, prefectureMask, sea) {
const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length);
const idMap = new Map();
active.forEach((unit, newId) => idMap.set(unit.id, newId));
for (let i = 0; i < SIZE; i++) {
const id = compartmentId[i];
if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1;
else if (idMap.has(id)) compartmentId[i] = idMap.get(id);
}
active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; });
return active;
}
function refreshAllCompartmentStats(compartments, fields) {
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
let riverExposure = 0;
const counts = new Map();
for (const ci of unit.cells) {
riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45;
if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1);
}
unit.riverExposure = riverExposure / Math.max(1, unit.area);
}
}
function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 20) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields;
const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18)));
let cx = unit.x || 0, cy = unit.y || 0;
let first = -1, second = -1, bestA = -INF, bestB = -INF;
const elongated = (unit.elongation || 1) > 2.3;
const horizontal = (unit.width || 0) >= (unit.height || 0);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const centerDist = Math.hypot(x - cx, y - cy);
const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0;
const interior = 1 - (naturalBarrierScore[i] || 0);
const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
const [fx, fy] = xyOf(first);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const d = Math.hypot(x - fx, y - fy);
const interior = 1 - (naturalBarrierScore[i] || 0);
const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const owner = new Int8Array(SIZE);
owner.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
owner[source] = sourceOwner;
dist[source] = 0;
heap.push({ i: source, f: 0, owner: sourceOwner });
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!cellSet.has(ni)) continue;
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
if (nd < dist[ni]) {
dist[ni] = nd;
owner[ni] = cur.owner;
heap.push({ i: ni, f: nd, owner: cur.owner });
}
}
}
const aCells = [], bCells = [];
for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci);
if (aCells.length < minPart || bCells.length < minPart) return null;
unit.cells = aCells;
for (const ci of bCells) compartmentId[ci] = newId;
const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass };
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
return newUnit;
}
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);
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass };
const landComponents = collectLandComponents(prefectureMask, sea);
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
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);
dist.fill(INF);
const heap = new MinHeap();
seeds.forEach((i, id) => {
compartmentId[i] = id;
dist[i] = 0;
heap.push({ i, f: 0, id });
});
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
if (nd < dist[ni]) {
dist[ni] = nd;
compartmentId[ni] = cur.id;
heap.push({ i: ni, f: nd, id: cur.id });
}
}
}
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(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 && (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;
return sb - sa;
})[0];
if (!worst) break;
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97);
if (!newUnit) {
worst._splitRejected = (worst._splitRejected || 0) + 1;
if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1);
if (!needMore) break;
continue;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) {
const stillBad = compartments.some((unit) => unit && unit.area >= 20 && (
unit.area > maxNaturalCompartmentArea * 1.35 ||
((unit.elongation || 1) > 4.2 && unit.area > 28)
));
if (!stillBad) break;
}
}
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
return { compartmentId, compartments, naturalBarrierScore };
}
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
const compartmentId = new Int32Array(SIZE);
compartmentId.fill(-1);
@ -679,6 +1091,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
const startClass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
queue.length = 0;
queue.push(i);
compartmentId[i] = id;
@ -687,6 +1100,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
const [x, y] = xyOf(cur);
cells.push(cur);
sx += x; sy += y; pop += populationDensity[cur];
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
@ -711,6 +1125,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
y: sy / area,
classId: startClass,
dominantLandscapeClass: startClass,
minX,
minY,
maxX,
maxY,
width: maxX - minX + 1,
height: maxY - minY + 1,
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
population: pop,
urbanWeight: urbanWeight / area,
ridgeExposure: ridgeExposure / area,
@ -730,22 +1151,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
const targetCount = options.targetCompartmentCount || 0;
if (targetCount > 0) {
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore };
let guard = targetCount * 3;
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
const splitScore = (unit) => {
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
};
let guard = Math.max(targetCount * 4, 80);
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
const candidates = compartments
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
.sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
.sort((a, b) => splitScore(b) - splitScore(a));
const target = candidates[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
if (!newUnit) {
target.lowlandFitness = 0;
target._splitRejected = (target._splitRejected || 0) + 1;
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
if (target._splitRejected > 2) target.area = target.cells.length;
continue;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
guard = Math.max(targetCount * 2, 60);
while (guard-- > 0) {
const target = compartments
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
.sort((a, b) => splitScore(b) - splitScore(a))[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
if (!newUnit) {
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
break;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
return { compartmentId, compartments, naturalBarrierScore };
@ -1055,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;
@ -1070,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 {
@ -1081,6 +1531,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
...relationMetrics,
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0,
maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1,
worstNaturalCompartments: activeCompartments
.map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) }))
.sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area))))
.slice(0, 8),
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),

1776
adminRegions.notrace.js Normal file

File diff suppressed because it is too large Load diff

91
app.js
View file

@ -1,5 +1,6 @@
import { generateMap } from "./mapGenerator.js";
import { drawMap } from "./renderer.js";
import { landuseLabel } from "./landuseCodes.js";
const modes = [
["all", "All"],
@ -7,11 +8,9 @@ const modes = [
["suitability", "Suitability"],
["history", "Premodern"],
["modern", "Modern"],
["roads", "Roads"],
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["terrain-debug", "Terrain Debug"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
];
@ -33,6 +32,9 @@ const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const idsEl = document.getElementById("nameIds");
const tooltipEl = document.getElementById("mapTooltip");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
@ -55,14 +57,63 @@ function countText(items) {
return `${insideCount(items)} / outside ${outsideCount(items)}`;
}
function formatMs(ms) {
if (!Number.isFinite(ms)) return "-";
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
}
function renderTimingRows(timings = []) {
if (!progressTimingsEl) return;
progressTimingsEl.innerHTML = "";
for (const row of timings) {
const item = document.createElement("div");
item.className = "progress-timing-row";
const label = document.createElement("span");
label.textContent = row.label;
const value = document.createElement("strong");
value.textContent = formatMs(row.ms);
item.append(label, value);
progressTimingsEl.append(item);
}
}
function updateGenerationProgress(event) {
if (!progressEl) return;
progressEl.classList.remove("hidden");
if (progressStageEl) {
progressStageEl.textContent = event?.status === "done"
? `Completed: ${event.label} / ${formatMs(event.ms)}`
: `Running: ${event?.label || "Preparing"}`;
}
renderTimingRows(event?.timings || []);
}
function setProgressVisible(visible, message = "Preparing") {
if (!progressEl) return;
progressEl.classList.toggle("hidden", !visible);
if (progressStageEl) progressStageEl.textContent = message;
if (visible) renderTimingRows([]);
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function getStats(map) {
return [
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
["Generation Time", map.generationTotalMs ? `${formatMs(map.generationTotalMs)} / slowest ${(map.generationTimings || []).slice().sort((a, b) => b.ms - a.ms)[0]?.label || "-"}` : "-"],
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
["Premodern Roads", map.premodernRoads.length],
["Minor Roads", map.minorRoads.length],
["Prefecture", map.prefectureName || "-"],
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"],
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
["Modern Cities", countText(map.modernCities)],
["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`],
["Satellite Cities", countText(map.satelliteCities || [])],
@ -72,7 +123,8 @@ function getStats(map) {
["Harbor Works", (map.harborWorks || []).length],
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
["Industrial Zones", countText(map.industrialZones)],
["National Roads", map.nationalRoads.length + (map.ringRoads || []).length],
["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`],
["General Ring Roads", (map.ringRoads || []).length],
["Expressways", map.expressways.length + map.externalExpressways.length],
["External Gateways", map.externalGateways.length],
["Interchanges", countText(map.interchanges)],
@ -144,18 +196,7 @@ function nearestEntity(map, x, y, maxDistance = 5) {
}
function landuseName(value) {
return {
0: "Agriculture",
1: "Plain",
2: "Old urban area",
3: "CBD / DID core",
4: "Suburban urban area",
5: "Industrial zone",
6: "Logistics area",
7: "New town",
8: "Roadside development",
9: "Forest / rural land",
}[value] || "Land";
return landuseLabel(value);
}
function adminName(map, adminId) {
@ -205,12 +246,22 @@ function renderModeButtons() { modeGrid.innerHTML = "";
}
}
function regenerate() {
async function regenerate() {
state.seedText = seedInput.value;
state.map = generateMap(parseSeed(state.seedText));
renderStats(state.map);
renderNameIds(state.map);
redraw();
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
renderStats(state.map);
renderNameIds(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
renderTimingRows(state.map.generationTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
throw error;
}
}
function redraw() {

View file

@ -22,6 +22,11 @@
<div class="canvas-shell">
<canvas id="mapCanvas" class="map-canvas"></canvas>
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
<div class="progress-title">Generating map...</div>
<div id="generationProgressStage" class="progress-stage">Preparing</div>
<div id="generationProgressTimings" class="progress-timings"></div>
</div>
<div id="mapTooltip" class="map-tooltip" role="status" aria-live="polite"></div>
</div>
</section>

37
landuseCodes.js Normal file
View file

@ -0,0 +1,37 @@
export const LANDUSE = Object.freeze({
RURAL: 0,
FARMLAND: 1,
OLD_URBAN: 2,
CBD: 3,
SUBURB: 4,
INDUSTRIAL: 5,
LOGISTICS: 6,
NEW_TOWN: 7,
ROADSIDE: 8,
FOREST: 9,
});
export const LANDUSE_LABELS = Object.freeze({
[LANDUSE.RURAL]: "Rural / natural land",
[LANDUSE.FARMLAND]: "Farmland",
[LANDUSE.OLD_URBAN]: "Old urban area",
[LANDUSE.CBD]: "CBD / DID core",
[LANDUSE.SUBURB]: "Suburban urban area",
[LANDUSE.INDUSTRIAL]: "Industrial zone",
[LANDUSE.LOGISTICS]: "Logistics area",
[LANDUSE.NEW_TOWN]: "New town",
[LANDUSE.ROADSIDE]: "Roadside development",
[LANDUSE.FOREST]: "Forest / mountain land",
});
export function landuseLabel(value) {
return LANDUSE_LABELS[value] || "Land";
}
export function isBuiltLanduse(value) {
return value >= LANDUSE.OLD_URBAN && value <= LANDUSE.ROADSIDE;
}
export function isUrbanResidentialLanduse(value) {
return value === LANDUSE.OLD_URBAN || value === LANDUSE.CBD || value === LANDUSE.SUBURB || value === LANDUSE.NEW_TOWN || value === LANDUSE.ROADSIDE;
}

View file

@ -11,6 +11,15 @@ import {
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
const OUTER_ANCHOR_REGION_ID = -2;
function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) {
if (prefectureMask[i]) return 0;
const regionalId = prefectureRegionId?.[i] ?? -1;
if (regionalId === 0) return OUTER_ANCHOR_REGION_ID;
return regionalId;
}
function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
@ -277,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart
return { changedCells, restoredSeeds };
}
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
function municipalityCountBoundsForRegion(landCells, meta = {}) {
const focused = meta.isFocusedRegion !== false;
if (focused) return { min: 20, max: 50 };
// Neighbor prefectures are often visible only as clipped map-edge slivers.
// Avoid giving every tiny visible fragment the full 20-municipality floor.
let min = 1;
if (landCells >= 500) min = 2;
if (landCells >= 950) min = 3;
if (landCells >= 1700) min = 5;
if (landCells >= 2800) min = 7;
if (landCells >= 4300) min = 10;
const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
return { min, max };
}
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
let landCells = 0;
let habitableCells = 0;
let lowlandCells = 0;
@ -305,8 +329,9 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
return clamp(target, 20, 50);
const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
return clamp(rawTarget, min, max);
}
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
@ -580,7 +605,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c
return changed;
}
export function generateAdminLayout({
function generateAdminLayoutForMask({
seed,
prefectureMask,
sea,
@ -611,14 +636,24 @@ export function generateAdminLayout({
stations,
industrialZones,
logisticsParks,
adminRegionMeta = {},
adminProgress = null,
}) {
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5);
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
: 120;
const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
: 360;
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
@ -643,11 +678,14 @@ export function generateAdminLayout({
newTowns,
stations,
});
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
});
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
@ -700,6 +738,7 @@ export function generateAdminLayout({
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
@ -725,6 +764,7 @@ export function generateAdminLayout({
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
previousSnapshot = new Int16Array(adminId);
}
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
@ -787,19 +827,34 @@ export function generateAdminLayout({
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
markChanged("changedAfterInitialMerge");
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
// The initial compartment graph assignment is now the primary natural partition.
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
markChanged("changedAfterLandscapePartition");
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
// The older oversized-lowland pass rebuilds natural compartments a second time.
// The current pipeline already performs pending-seed lowland splitting on the active
// compartment graph above, so keep the full admin layout while avoiding the duplicate
// high-cost recomputation.
const oversizedSplitDebug = {
changedCells: 0,
splitMunicipalities: 0,
rejectedMunicipalities: 0,
skippedDuplicateCompartmentRebuild: true,
skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
};
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
previousSnapshot = new Int16Array(adminId);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
@ -871,8 +926,293 @@ export function generateAdminLayout({
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
return { adminCentersRaw, adminId, adminBorders, adminDebug };
}
function filterPointsForMask(points = [], mask, sea) {
return (points || [])
.filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
.map((p) => ({ ...p }));
}
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
const mask = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0;
}
return mask;
}
function maskLandArea(mask, sea) {
let area = 0;
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
return area;
}
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);
const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
const newAdminId = new Int16Array(SIZE);
newAdminId.fill(-1);
const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
for (let i = 0; i < SIZE; i++) {
if (!humanMask[i] || sea[i]) continue;
const newId = idMap.get(adminId[i]);
if (newId === undefined) continue;
newAdminId[i] = newId;
cellsByNewId[newId].push(i);
}
const chooseOffice = (newId, oldId) => {
const cells = cellsByNewId[newId] || [];
const current = centers[oldId];
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 };
}
}
let sx = 0, sy = 0;
for (const i of cells) {
const [x, y] = xyOf(i);
sx += x;
sy += y;
}
const cx = cells.length ? sx / cells.length : current?.x || 0;
const cy = cells.length ? sy / cells.length : current?.y || 0;
let bestI = cells[0] ?? -1;
let bestScore = -INF;
for (const i of cells) {
const [x, y] = xyOf(i);
const land = fields.landuse?.[i] ?? 0;
const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
const density = fields.populationDensity?.[i] || 0;
const settlement = fields.settlementScore?.[i] || 0;
const score =
density * 2.25 +
settlement * 0.75 +
urbanBonus +
(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.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; }
}
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
return {
...(current || {}),
x: bx,
y: by,
score: bestScore > -INF ? bestScore : 0,
seedKind: current?.seedKind || "generatedMunicipalOffice",
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
localAdminId: newId,
oldAdminId: oldId,
municipalityOffice: true,
generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
};
};
const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
return {
adminId: newAdminId,
adminCenters,
activeMunicipalityCount: activeIds.length,
removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
};
}
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id);
}
return [...ids].sort((a, b) => a - b);
}
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 === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
let combinedAdminId = new Int16Array(SIZE);
combinedAdminId.fill(-1);
const combinedHumanMask = new Uint8Array(SIZE);
let combinedCenters = [];
const combinedCompartmentBorders = [];
const perRegion = [];
let idOffset = 0;
for (const regionId of regionIds) {
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
const regionArea = maskLandArea(regionMask, sea);
if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
const localContext = {
...context,
seed: (context.seed + regionId * 10007) >>> 0,
prefectureMask: regionMask,
modernCities: filterPointsForMask(context.modernCities, regionMask, sea),
satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea),
newTowns: filterPointsForMask(context.newTowns, regionMask, sea),
markets: filterPointsForMask(context.markets, regionMask, sea),
villages: filterPointsForMask(context.villages, regionMask, sea),
ports: filterPointsForMask(context.ports, regionMask, sea),
stations: filterPointsForMask(context.stations, regionMask, sea),
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
adminRegionMeta: {
regionId,
landArea: regionArea,
isFocusedRegion: regionId === 0,
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
},
adminProgress,
};
adminProgress?.({ status: "region-start", regionId, area: regionArea });
const local = generateAdminLayoutForMask(localContext);
adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
let localMaxAdminId = -1;
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1);
const localCenters = [];
for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) {
let center = local.adminCentersRaw?.[localAdminId];
if (!center) {
let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF;
for (let i = 0; i < SIZE; i++) {
if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue;
const [x, y] = xyOf(i);
sx += x;
sy += y;
count++;
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
if (score > bestScore) { bestScore = score; bestI = i; }
}
if (count && bestI >= 0) {
const [bx, by] = xyOf(bestI);
center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true };
}
}
if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true };
localCenters.push({
...center,
regionId,
localAdminId,
adminIdOffset: idOffset,
});
}
combinedCenters.push(...localCenters);
for (let i = 0; i < SIZE; i++) {
if (!regionMask[i] || sea[i]) continue;
combinedHumanMask[i] = 1;
const localId = local.adminId?.[i] ?? -1;
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
}
perRegion.push({
regionId,
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,
naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0,
targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0,
averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0,
maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0,
maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1,
worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [],
singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0,
});
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);
}
for (const [regionId, cells] of leftoverByRegion) {
let sx = 0, sy = 0, 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;
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 });
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 });
}
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
populationDensity,
plain,
slope,
settlementScore: context.settlementScore,
landuse: context.landuse,
basinField: context.basinField,
coastalLowland: context.coastalLowland,
roadInfluence: context.roadInfluence,
stationInfluence: context.stationInfluence,
});
combinedAdminId = compactedAdmin.adminId;
combinedCenters = compactedAdmin.adminCenters;
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0);
const adminDebug = {
multiRegionAdmin: true,
adminRegionCount: perRegion.length,
minFullAdminRegionArea,
perRegion,
finalMunicipalityCount: totalMunicipalityCount,
actualMunicipalityCount: totalMunicipalityCount,
candidateSeedCount: combinedCenters.length,
municipalOfficePointCount: combinedCenters.length,
generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
naturalCompartmentCount: totalNaturalCompartmentCount,
compartmentCount: totalNaturalCompartmentCount,
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0,
maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)),
maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)),
averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0,
singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0,
compartmentBorders: combinedCompartmentBorders,
};
return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug };
}

1064
mapAdminStage.nolog.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

955
mapFeaturesV2.js Normal file
View file

@ -0,0 +1,955 @@
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 (regionId === 0) return 1.15;
if (!st || st.area <= 0) return 0;
// Small map-edge slivers should not get the same municipal/human density
// as full neighboring prefectures. This keeps external regions legible.
return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92);
}
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 = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0;
const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3;
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 = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0;
const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1;
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 capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 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 = regionId === 0
? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9)
: clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1);
const selected = pickEntities(list, {
max: maxCities,
minDistance: regionId === 0 ? 16 : 18,
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);
}
}
if (!modernCities.some((p) => inFocusedPrefecture(p))) {
const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p));
let fallback = focusCandidates.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]);
})[0];
if (!fallback) {
let best = null;
let bestScore = -INF;
for (let y = 2; y < MAP_H - 2; y += 2) {
for (let x = 2; x < MAP_W - 2; x += 2) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04;
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; }
}
}
fallback = best;
}
if (fallback) modernCities.push({
...fallback,
candidateKind: fallback.candidateKind || "fallback",
score: fallback.score || 0.5,
capacity: estimateUrbanCapacity(fallback, 30, 1.15),
regionId: 0,
});
}
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
for (const [rank, city] of modernCities.entries()) {
const isFocused = inFocusedPrefecture(city);
const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital);
const rawPop = isPrefecturalCapital
? 450000 + rand(seed, 12200) * 1150000
: isRegionalCapital
? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
city.population = Math.max(isPrefecturalCapital ? 260000 : 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, isPrefecturalCapital ? 40 : isRegionalCapital ? 32 : 24);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5);
city.sprawlRadius = clamp(city.urbanRadius * (isPrefecturalCapital ? 1.65 : isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isPrefecturalCapital ? 56 : isRegionalCapital ? 42 : 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?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
const bias = city?.isPrefecturalCapital ? 1.25 : 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, regionId === 0 ? 18 : 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 = regionId === 0 ? Math.min(14, 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, regionId === 0 ? 8 : 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, regionId === 0 ? 7 : 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: regionId === 0 ? 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);
}
}
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5);
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.isPrefecturalCapital ? 0.46 : 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(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.12 + 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 +
transport * 0.18 +
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 + transport * 0.12);
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 + roadInfluence[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 (transport > 0.18 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.04 || cityInfluence[i] > 0.09)) {
landuse[i] = transport > 0.28 && stationInfluence[i] > 0.10 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
} 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(roadInfluence[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] = urbanNeighbors >= 4 || transport > 0.28 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
}
}
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadInfluence[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 (landuse[i] === LANDUSE.SUBURB && urbanNeighbors <= 1) {
const keep = clamp(cityInfluence[i] * 0.52 + transport * 0.32 + stationInfluence[i] * 0.18 + 0.08);
if (hash2(x, y, seed + 15051) > keep) {
landuse[i] = agriculture[i] > 0.22 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
}
}
}
}
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,
};
}

View file

@ -475,6 +475,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
const displayRegionId = new Int16Array(beforeRegionId);
for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200);
let changed = 0;
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
const afterBorderCount = countRegionBorderEdges(regionId, sea);
@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
return {
regionId,
displayRegionId,
centers: seeded.centers,
naturalBarrierScore,
debug: {
@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
changedAfterCompartmentAssignment: changed,
@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities,
}
}
}
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
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 * 650;
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
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);
}
}

View file

@ -2,88 +2,146 @@ import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
const density = fields.populationDensity?.[i] || 0;
const land = fields.landuse?.[i] ?? 0;
const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite;
const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46;
if (urban) return "市";
if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村";
return "町";
}
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
let value = String(root || center?.name || "").trim();
if (!value) value = `自治${ordinal + 1}`;
value = value.replace(/[駅港城跡宿]$/u, "");
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
}
export function finishMapOutput({
seed,
options,
terrainTemplate,
seaLevel,
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
villages,
ports,
crossings,
passes,
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,
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
transportDebug,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
terrain,
features,
admin,
}) {
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
} = terrain;
const {
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
settlementCluster,
villages: inputVillages,
ports: inputPorts,
crossings: inputCrossings,
passes: inputPasses,
markets: inputMarkets,
castles: inputCastles,
castleTowns: inputCastleTowns,
premodernRoads,
minorRoads,
modernCities: inputModernCities,
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations: inputStations,
industrialZones: inputIndustrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges: inputInterchanges,
logisticsParks: inputLogisticsParks,
satelliteCities: inputSatelliteCities,
newTowns: inputNewTowns,
landuse,
externalGateways: inputExternalGateways,
transportDebug,
} = features;
const {
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
} = admin;
let villages = inputVillages;
let ports = inputPorts;
let crossings = inputCrossings;
let passes = inputPasses;
let markets = inputMarkets;
let castles = inputCastles;
let castleTowns = inputCastleTowns;
let modernCities = inputModernCities;
let stations = inputStations;
let industrialZones = inputIndustrialZones;
let interchanges = inputInterchanges;
let logisticsParks = inputLogisticsParks;
let satelliteCities = inputSatelliteCities;
let newTowns = inputNewTowns;
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.
// This keeps population figures proportional to the actually rendered urbanized area.
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
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;
}
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2);
for (const city of modernCities) {
if (city.isPrefecturalCapital) continue;
const cap = cityPopulationCap(city);
@ -95,6 +153,8 @@ export function finishMapOutput({
}
}
outputProgress("harbor works");
function makeHarborWorks(ports) {
const out = [];
for (const port of ports) {
@ -115,18 +175,13 @@ export function finishMapOutput({
return out;
}
// Bridge and tunnel icon systems were removed from the visual model.
// Arrays remain empty for backward-compatible tests and downstream code.
const bridges = [];
const tunnels = [];
const harborWorks = makeHarborWorks(ports);
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set();
const nameDebug = createNameDebug();
outputProgress("feature naming");
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
@ -143,13 +198,14 @@ export function finishMapOutput({
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
outputProgress("municipality naming");
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
].filter((p) => p.insidePrefecture && p.name);
].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)]));
for (const center of adminCenters) {
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
let best = null;
@ -167,25 +223,37 @@ export function finishMapOutput({
}
if (best) break;
}
center.generatedMunicipalityName = center.generatedMunicipalityName || center.name;
if (best) {
center.representativeFeatureId = best.id;
center.representativeFeatureName = best.name;
center.generatedMunicipalityName = center.name;
center.name = best.name;
center.municipalityRootName = best.name;
} else {
center.municipalityRootName = center.generatedMunicipalityName;
}
}
const usedAdminNames = new Set();
for (const center of adminCenters) {
let candidate = center.name;
const generated = String(center.generatedMunicipalityName || "");
for (const [index, center] of adminCenters.entries()) {
center.adminNumericId = index;
center.municipalityId = index;
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
if (usedAdminNames.has(candidate)) {
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
candidate = `${base}${index + 1}${suffix}`;
}
center.name = candidate;
center.labelName = candidate;
center.municipalityName = candidate;
usedAdminNames.add(center.name);
}
nameDebug.maxDerivedPerBase = 0;
outputProgress("final package");
const entitiesForNames = [
...modernCities,
...ports,
@ -210,6 +278,7 @@ export function finishMapOutput({
terrainTemplate,
seaLevel,
prefectureMask,
humanRegionMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
@ -228,6 +297,8 @@ export function finishMapOutput({
settlementCluster,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
@ -269,17 +340,13 @@ export function finishMapOutput({
logisticsParks,
satelliteCities,
newTowns,
bridges,
tunnels,
harborWorks,
landuse,
adminCenters,
adminId,
adminBorders,
adminDebug,
abandonedRailways,
castleRuins,
preservedOldRoads,
riverPaths,
mainRivers,
tributaryRivers,

View file

@ -1,26 +1,39 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { generateMapFeatures } from "./mapFeaturesV2.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
function nowMs() {
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
}
function timedStage(timings, options, key, label, fn) {
options?.onProgress?.({ status: "start", key, label, timings: timings.slice() });
const t0 = nowMs();
const value = fn();
const ms = Math.round((nowMs() - t0) * 10) / 10;
const entry = { key, label, ms };
timings.push(entry);
options?.onProgress?.({ status: "done", key, label, ms, timings: timings.slice() });
return value;
}
export function generateMap(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const terrain = generateTerrainAndRivers(seed);
const generationTimings = [];
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
ridgeField,
@ -28,49 +41,55 @@ export function generateMap(seedInput = 114514, options = {}) {
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
portSuitability,
crossingSuitability,
passSuitability,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
adminPrefectureRegionId,
} = terrain;
const features = generateMapFeatures(seed, terrain);
const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
const {
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, 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,
settlementScore,
villages,
markets,
modernCities,
populationDensity,
stations,
industrialZones,
logisticsParks,
satelliteCities,
newTowns,
ports,
landuse,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
} = features;
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
});
adminProgress: (event) => options?.onProgress?.({
...event,
key: "admin",
label: event.status === "region-done"
? `Admin region ${event.regionId} done`
: event.status === "admin-step"
? `Admin region ${event.regionId}: ${event.step}`
: `Admin region ${event.regionId}`,
timings: generationTimings.slice(),
}),
}));
return finishMapOutput({
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
villages, ports, crossings, passes, 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, adminCentersRaw, adminId, adminBorders, adminDebug,
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
regionalDebug, terrainDebug, transportDebug,
});
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed,
options,
terrain,
features,
admin,
}));
output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
return output;
}

File diff suppressed because it is too large Load diff

1804
mapTerrain.v4.bak.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
export const MAP_W = 172;
export const MAP_H = 122;
export const CELL_SIZE = 6;
export const MAP_W = 258;
export const MAP_H = 183;
export const CELL_SIZE = 4;
export const SIZE = MAP_W * MAP_H;
export const INF = 1e9;

View file

@ -30,11 +30,11 @@ export const NAME_KANJI_POOLS = {
"池", "沼", "泉", "井",
"滝", "梅", "沢", "澤", "谷", "津",
"水", "清", "渡", "橋", "堀",
"溝", "浦", "洲"
"溝", "浦"
],
coastalTerrain: [
"津", "浦", "ヶ浦", "津", "崎",
"津", "浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
"泊", "江", "浦", "灘", "入",

View file

@ -1,4 +1,4 @@
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
@ -287,6 +287,23 @@ function fieldSample(field, fx, fy) {
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
}
function interpolateColorStops(value, stops) {
if (value <= stops[0][0]) return stops[0][1].slice();
for (let i = 1; i < stops.length; i++) {
const [v, c] = stops[i];
const [pv, pc] = stops[i - 1];
if (value <= v) {
const t = clamp((value - pv) / Math.max(0.0001, v - pv));
return [
Math.round(pc[0] + (c[0] - pc[0]) * t),
Math.round(pc[1] + (c[1] - pc[1]) * t),
Math.round(pc[2] + (c[2] - pc[2]) * t),
];
}
}
return stops[stops.length - 1][1].slice();
}
function terrainColorContinuous(map, fx, fy, mode) {
const i = sampleCellIndex(fx, fy);
const isInside = Boolean(map.prefectureMask[i]);
@ -316,37 +333,90 @@ function terrainColorContinuous(map, fx, fy, mode) {
Math.round(230 + density * 10),
];
} else {
// 地形色を少し濃く(暗く)調整
// 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
const e = fieldSample(map.elevation, fx, fy);
if (e > 0.82) color = [210, 205, 195];
else if (e > 0.68) color = [218, 215, 205];
else if (e > 0.52) color = [220, 225, 210];
else if (e > 0.34) color = [225, 230, 215];
else if (e > 0.24) color = [230, 235, 220];
else color = [238, 242, 228];
color = interpolateColorStops(clamp(e), [
[0.20, [231, 236, 223]],
[0.30, [223, 231, 214]],
[0.40, [213, 223, 201]],
[0.50, [204, 215, 188]],
[0.58, [195, 207, 173]],
[0.65, [185, 196, 158]],
[0.71, [177, 181, 141]],
[0.76, [169, 164, 125]],
[0.81, [157, 145, 105]],
[0.86, [144, 128, 89]],
[0.91, [130, 111, 79]],
[0.95, [118, 103, 89]],
[0.985, [146, 141, 133]],
[1.00, [183, 179, 171]],
]);
}
return blendOutside(color, isInside);
}
function terrainShadeContinuous(map, fx, fy) {
const step = 0.50;
const eC = fieldSample(map.elevation, fx, fy);
const eL = fieldSample(map.elevation, fx - step, fy);
const eR = fieldSample(map.elevation, fx + step, fy);
const eU = fieldSample(map.elevation, fx, fy - step);
const eD = fieldSample(map.elevation, fx, fy + step);
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
const dzdx = (eR - eL) / (step * 2);
const dzdy = (eD - eU) / (step * 2);
const nx = -dzdx * 4.4;
const ny = -dzdy * 4.4;
const nz = 1.0;
const nLen = Math.hypot(nx, ny, nz) || 1;
const lx = -0.5;
const ly = -0.5;
const lz = 0.7071067811865476;
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6);
let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030;
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26);
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12);
return clamp(shade, 0.54, 1.26);
}
function discreteColor(map, x, y, mode) {
const i = indexOf(x, y);
let color;
if (map.sea[i]) {
color = [170, 218, 255];
color = [160, 205, 239];
} else if (mode === "landuse") {
const colors = {
0: [242, 248, 238],
1: [248, 250, 245],
2: [240, 238, 232],
3: [245, 230, 220],
4: [250, 248, 245],
5: [235, 235, 240],
6: [240, 245, 240],
7: [245, 248, 252],
8: [250, 248, 240],
9: [240, 245, 238],
0: [244, 247, 240], // rural / natural land
1: [222, 236, 188], // farmland
2: [232, 222, 214], // old urban
3: [221, 188, 184], // CBD / DID core
4: [235, 225, 236], // suburb
5: [223, 224, 232], // industrial
6: [232, 237, 232], // logistics
7: [231, 236, 246], // new town
8: [243, 233, 210], // roadside
9: [221, 236, 216], // forest / mountain land
};
color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") {
@ -374,12 +444,7 @@ function drawBase(ctx, map, mode, continuousTerrain) {
for (let px = 0; px < width; px++) {
const fx = px / CELL_SIZE;
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
const eL = fieldSample(map.elevation, fx - 0.6, fy);
const eR = fieldSample(map.elevation, fx + 0.6, fy);
const eU = fieldSample(map.elevation, fx, fy - 0.6);
const eD = fieldSample(map.elevation, fx, fy + 0.6);
const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08);
const shade = terrainShadeContinuous(map, fx, fy);
const ii = (py * width + px) * 4;
img.data[ii] = Math.round(r * shade);
@ -407,6 +472,28 @@ function drawBase(ctx, map, mode, continuousTerrain) {
ctx.putImageData(img, 0, 0);
}
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
if (!path || path.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (let k = 0; k < path.length - 1; k++) {
const [x1, y1] = path[k];
const [x2, y2] = path[k + 1];
const i1 = indexOf(x1, y1);
const i2 = indexOf(x2, y2);
const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95);
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
ctx.lineWidth = widthFn(strength, k / Math.max(1, path.length - 1));
ctx.beginPath();
ctx.moveTo(x1 * CELL_SIZE + CELL_SIZE / 2, y1 * CELL_SIZE + CELL_SIZE / 2);
ctx.lineTo(x2 * CELL_SIZE + CELL_SIZE / 2, y2 * CELL_SIZE + CELL_SIZE / 2);
ctx.stroke();
}
ctx.restore();
}
function drawPath(ctx, path, color, width, dashed = false) {
const points = vectorPath(path);
if (points.length < 2) return;
@ -486,24 +573,25 @@ function drawSegments(ctx, segments, color, width, dashed = false) {
}
function drawUrbanAreas(ctx, map, mode) {
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
if (!visibleModes.includes(mode)) return;
const colors = {
2: "rgba(225, 222, 215, 0.6)",
3: "rgba(240, 220, 205, 0.85)",
4: "rgba(242, 240, 235, 0.5)",
5: "rgba(220, 220, 225, 0.6)",
6: "rgba(225, 230, 225, 0.5)",
7: "rgba(235, 240, 245, 0.6)",
8: "rgba(245, 242, 235, 0.5)",
2: "rgba(223, 214, 206, 0.72)",
3: "rgba(215, 175, 172, 0.88)",
4: "rgba(231, 219, 231, 0.68)",
5: "rgba(218, 218, 226, 0.64)",
6: "rgba(225, 230, 225, 0.56)",
7: "rgba(229, 234, 242, 0.64)",
8: "rgba(244, 230, 205, 0.62)",
};
ctx.save();
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i]) continue;
const areaMask = map.humanRegionMask || map.prefectureMask;
if (areaMask && !areaMask[i]) continue;
const lu = map.landuse[i];
if (!colors[lu]) continue;
@ -522,7 +610,8 @@ function drawDebugCells(ctx, map, field, color) {
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i]) continue;
const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const v = clamp(field[i] || 0, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
@ -590,6 +679,42 @@ function drawLabels(ctx, points, limit = Infinity) {
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
}
function drawScaleBar(ctx) {
const kmPerCell = 2;
const targetKm = 20;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE;
const margin = 14;
const x = margin;
const y = margin + 18;
ctx.save();
ctx.lineCap = "butt";
ctx.strokeStyle = "rgba(0,0,0,0.78)";
ctx.lineWidth = 2.2;
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.fillRect(x - 8, y - 18, lengthPx + 16, 30);
ctx.strokeStyle = "rgba(80,80,80,0.22)";
ctx.strokeRect(x - 8, y - 18, lengthPx + 16, 30);
ctx.strokeStyle = "rgba(30,30,30,0.82)";
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + lengthPx, y);
ctx.stroke();
for (let t = 0; t <= 2; t++) {
const tx = x + (lengthPx * t) / 2;
ctx.beginPath();
ctx.moveTo(tx, y - 5);
ctx.lineTo(tx, y + 5);
ctx.stroke();
}
ctx.fillStyle = "rgba(20,20,20,0.88)";
ctx.font = "600 11px ui-sans-serif, system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${targetKm} km`, x + lengthPx / 2, y - 7);
ctx.restore();
}
export function drawMap(canvas, map, options) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
@ -612,13 +737,58 @@ export function drawMap(canvas, map, options) {
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5);
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5);
const mediumBlue = "rgba(160, 205, 240, 0.88)";
const riverStrengthForPath = (path) => {
if (!path || path.length === 0) return 0;
let peak = 0;
let tail = 0;
const tailStart = Math.max(0, path.length - Math.min(path.length, 8));
let tailCount = 0;
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
peak = Math.max(peak, strength);
if (k >= tailStart) {
tail += strength;
tailCount++;
}
}
return Math.max(peak, tail / Math.max(1, tailCount));
};
// Draw a dendritic river network. Width is intentionally separated by
// river order: small streams are hairline/low-alpha, tributaries are thin,
// and only trunk rivers get a modestly wider stroke.
for (const path of map.smallStreams || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 5 || strength < 0.045) continue;
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
}
for (const path of map.tributaryRivers || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 9 || strength < 0.45) continue;
drawRiverPath(ctx, map, path, mediumBlue, (s, t) => {
const downstreamBoost = 0.92 + t * 0.18;
if (s > 1.65) return 1.35 * downstreamBoost;
if (s > 0.95) return 1.12 * downstreamBoost;
return 0.94 * downstreamBoost;
}, 0.92);
}
for (const path of map.mainRivers || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 9) continue;
drawRiverPath(ctx, map, path, waterBlue, (s, t) => {
const downstreamBoost = 0.96 + t * 0.24;
if (s > 2.35) return 2.15 * downstreamBoost;
if (s > 1.45) return 1.86 * downstreamBoost;
return 1.55 * downstreamBoost;
}, 1.0);
}
const showHistory = ["history", "all", "terrain"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["roads", "all", "development"].includes(mode);
const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["all", "development"].includes(mode);
const showMinorRoads = ["all", "modern", "development"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
// 3. Borders
@ -627,12 +797,19 @@ export function drawMap(canvas, map, options) {
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
// artificial horizontal hatching, so only strong terrain dividers are shown.
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
if (showPrefectureRegions && map.regionalPrefectureBorders) {
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
@ -683,12 +860,21 @@ export function drawMap(canvas, map, options) {
}
// 6. Icons & Labels
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
}
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
const allLayerTowns = mode === "all"
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
: [];
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
for (const p of map.modernCities) {
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.4, "transparent", "rgba(200,80,80,0.9)");
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)");
}
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
if (mode === "admin-debug" || mode === "borders-debug") {
@ -700,17 +886,24 @@ export function drawMap(canvas, map, options) {
if (showLabels) {
if (mode === "admin") {
drawLabels(ctx, map.adminCenters || [], Infinity);
drawScaleBar(ctx);
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
drawLabels(ctx, map.adminCenters || [], Infinity);
drawScaleBar(ctx);
return;
}
const allLayerTowns = mode === "all"
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 }))
: [];
const important = [
...map.modernCities,
...map.ports,
...(map.satelliteCities || []),
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
drawLabels(ctx, important, 60);
...allLayerTowns,
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 25000);
drawLabels(ctx, important, mode === "all" ? 85 : 60);
}
drawScaleBar(ctx);
}

View file

@ -65,4 +65,11 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0}
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
.map-tooltip.visible{opacity:1;transform:translateY(0)}
.map-tooltip.visible{opacity:1;transform:translateY(0)}
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
.generation-progress.hidden{display:none}
.progress-title{font-weight:700;margin-bottom:4px}
.progress-stage{color:#5f6368;margin-bottom:10px}
.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043}
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}

View file

@ -531,7 +531,7 @@ try {
assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked");
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
assert(Array.isArray(map.harborWorks), "harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");