town tweak

This commit is contained in:
33333-33333 2026-05-24 17:38:51 +09:00
commit 1ea8ba1701
12 changed files with 5005 additions and 379 deletions

View file

@ -558,20 +558,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 +586,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 +605,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 +631,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 +702,369 @@ 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 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);
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;
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
let guard = Math.max(80, targetCount * 3);
while (guard-- > 0) {
let active = compartments.filter((unit) => unit && unit.area > 0);
const needMore = active.length < targetCount;
const worst = active
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
.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 +1080,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 +1089,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 +1114,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 +1140,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 };
@ -1081,6 +1516,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

17
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"],
@ -57,6 +58,8 @@ function countText(items) {
function getStats(map) {
return [
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
@ -66,6 +69,7 @@ function getStats(map) {
["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 || [])],
@ -148,18 +152,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) {

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++;
@ -580,7 +589,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c
return changed;
}
export function generateAdminLayout({
function generateAdminLayoutForMask({
seed,
prefectureMask,
sea,
@ -617,8 +626,8 @@ export function generateAdminLayout({
: ridgeField;
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 compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
@ -648,6 +657,7 @@ export function generateAdminLayout({
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
});
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
@ -876,3 +886,179 @@ export function generateAdminLayout({
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)]);
}
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 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 } = context;
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
.filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120);
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
const combinedAdminId = new Int16Array(SIZE);
combinedAdminId.fill(-1);
const combinedHumanMask = new Uint8Array(SIZE);
const 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 (regionArea < 120) 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),
};
const local = generateAdminLayoutForMask(localContext);
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 adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size;
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,
perRegion,
finalMunicipalityCount: totalMunicipalityCount,
actualMunicipalityCount: totalMunicipalityCount,
candidateSeedCount: combinedCenters.length,
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

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,6 +2,28 @@ 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,
@ -84,8 +106,12 @@ export function finishMapOutput({
regionalPrefectureBorders,
}) {
// 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);
@ -145,13 +171,13 @@ 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);
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;
@ -169,21 +195,30 @@ 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()) {
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;
@ -212,6 +247,7 @@ export function finishMapOutput({
terrainTemplate,
seaLevel,
prefectureMask,
humanRegionMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,

View file

@ -44,6 +44,7 @@ export function generateMap(seedInput = 114514, options = {}) {
prefectureMask,
prefectureBorder,
prefectureRegionId,
adminPrefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
@ -61,7 +62,7 @@ export function generateMap(seedInput = 114514, options = {}) {
} = features;
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
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,
});

View file

@ -38,6 +38,15 @@ function quantile(values, q) {
return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f);
}
function softCapElevation(e, start = 0.91, cap = 1.08) {
if (e <= start) return e;
const over = e - start;
const span = Math.max(0.001, cap - start);
// Hard clipping made high mountains become flat mesas. This keeps peaks high,
// but compresses only the excess so local relief survives near the top.
return start + span * (1 - Math.exp(-over / span));
}
function forDisk(cx, cy, radius, fn) {
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
@ -131,7 +140,12 @@ function ellipticalMask(px, py, system) {
const dy = py - system.y;
const { u, v } = rotate(dx, dy, system.angle);
const a = Math.max(0.01, system.length * 0.5);
const b = Math.max(0.01, system.width * 0.5);
const along = clamp((u / a + 1) * 0.5);
const widthWave = 1
+ (system.widthVariance ?? 0.28) * Math.sin((along + (system.phase ?? 0)) * Math.PI * 2.0)
+ (system.widthVariance ?? 0.28) * 0.50 * Math.sin((along * 2.7 + (system.phase ?? 0) * 1.7) * Math.PI * 2.0);
const endTaper = lerp(0.60, 1.0, Math.sin(along * Math.PI));
const b = Math.max(0.012, system.width * 0.5 * clamp(widthWave, 0.62, 1.60) * endTaper);
const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2);
return clamp(1 - smoothstep((r - 0.55) / 0.65));
}
@ -142,35 +156,203 @@ function sampleInsideUnitDisk(seed, n) {
return { x: Math.cos(a) * r, y: Math.sin(a) * r, r };
}
const TERRAIN_TYPES = [
{
id: "tohoku_spine",
label: "東北型・長大脊梁",
weight: 0.24,
coastStyle: "parallel_spine",
mountainMode: "range",
massifnessRange: [0.06, 0.26],
seaRatioRange: [0.13, 0.23],
twoSidedChance: 0.96,
mountainOffsetRange: [0.47, 0.53],
baseHeightRange: [0.74, 1.10],
primaryLengthRange: [0.76, 0.96],
primaryWidthRange: [0.17, 0.30],
systemCountRange: [12, 16],
beltCountRange: [3, 4],
angleSpread: 0.14,
crossSpread: 0.54,
lengthScale: 1.22,
widthScale: 1.16,
heightScale: 1.24,
coastStrength: 0.90,
plainBiasRange: [0.16, 0.34],
riverRichnessRange: [0.70, 1.18],
bigRiverChanceRange: [0.22, 0.46],
},
{
id: "chubu_mountain",
label: "中部型・交差高山地",
weight: 0.24,
coastStyle: "outer_coast",
mountainMode: "massif",
massifnessRange: [0.42, 0.74],
seaRatioRange: [0.10, 0.20],
twoSidedChance: 0.20,
mountainOffsetRange: [0.16, 0.36],
baseHeightRange: [0.68, 1.04],
primaryLengthRange: [0.62, 0.92],
primaryWidthRange: [0.30, 0.58],
systemCountRange: [16, 20],
beltCountRange: [3, 5],
angleSpread: 0.92,
crossSpread: 0.82,
lengthScale: 1.34,
widthScale: 1.30,
heightScale: 1.12,
coastStrength: 0.74,
plainBiasRange: [0.08, 0.24],
riverRichnessRange: [0.72, 1.12],
bigRiverChanceRange: [0.28, 0.58],
},
{
id: "setouchi_inland_sea",
label: "瀬戸内型・内海多島",
weight: 0.16,
coastStyle: "inland_sea",
mountainMode: "mixed",
massifnessRange: [0.24, 0.48],
seaRatioRange: [0.20, 0.33],
twoSidedChance: 0.92,
mountainOffsetRange: [0.22, 0.34],
baseHeightRange: [0.56, 1.00],
primaryLengthRange: [0.52, 0.76],
primaryWidthRange: [0.18, 0.34],
systemCountRange: [12, 16],
beltCountRange: [2, 3],
angleSpread: 0.24,
crossSpread: 0.70,
lengthScale: 0.98,
widthScale: 1.08,
heightScale: 1.08,
coastStrength: 1.10,
plainBiasRange: [0.26, 0.50],
riverRichnessRange: [0.58, 0.96],
bigRiverChanceRange: [0.18, 0.42],
},
{
id: "kanto_alluvial",
label: "関東・濃尾型・大河川平野",
weight: 0.16,
coastStyle: "open_bay",
mountainMode: "range",
massifnessRange: [0.18, 0.44],
seaRatioRange: [0.15, 0.26],
twoSidedChance: 0.18,
mountainOffsetRange: [0.28, 0.46],
baseHeightRange: [0.62, 1.04],
primaryLengthRange: [0.42, 0.70],
primaryWidthRange: [0.20, 0.36],
systemCountRange: [10, 14],
beltCountRange: [2, 3],
angleSpread: 0.34,
crossSpread: 0.62,
lengthScale: 0.92,
widthScale: 1.10,
heightScale: 1.10,
coastStrength: 0.92,
plainBiasRange: [0.56, 0.86],
riverRichnessRange: [0.98, 1.38],
bigRiverChanceRange: [0.62, 0.90],
},
{
id: "mixed_archipelago",
label: "混合型・列島変化",
weight: 0.20,
coastStyle: "mixed_archipelago",
mountainMode: "mixed",
massifnessRange: [0.16, 0.72],
seaRatioRange: [0.13, 0.29],
twoSidedChance: 0.42,
mountainOffsetRange: [0.18, 0.40],
baseHeightRange: [0.68, 1.18],
primaryLengthRange: [0.46, 0.82],
primaryWidthRange: [0.20, 0.48],
systemCountRange: [14, 17],
beltCountRange: [3, 4],
angleSpread: 0.50,
crossSpread: 0.74,
lengthScale: 1.00,
widthScale: 1.18,
heightScale: 1.25,
coastStrength: 0.96,
plainBiasRange: [0.22, 0.52],
riverRichnessRange: [0.72, 1.26],
bigRiverChanceRange: [0.34, 0.68],
},
];
function pickTerrainType(seed) {
const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0);
let r = rand(seed, 10001) * total;
for (const type of TERRAIN_TYPES) {
r -= type.weight;
if (r <= 0) return type;
}
return TERRAIN_TYPES[TERRAIN_TYPES.length - 1];
}
function rangeValue(seed, salt, [lo, hi]) {
return lo + rand(seed, salt) * (hi - lo);
}
function rangeInt(seed, salt, [lo, hi]) {
return Math.round(lo + rand(seed, salt) * (hi - lo));
}
export function buildTerrainTemplate(seed) {
const mountainModeRoll = rand(seed, 12);
const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif";
const mountainMassifness = mountainMode === "massif" ? 0.72 + rand(seed, 13) * 0.24 : mountainMode === "mixed" ? 0.34 + rand(seed, 14) * 0.36 : rand(seed, 15) * 0.24;
const coastAngle = rand(seed, 21) * Math.PI * 2;
const twoSidedCoast = rand(seed, 22) < 0.36;
const seaRatio = 0.14 + rand(seed, 23) * 0.16;
const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48);
const baseHeight = 0.52 + rand(seed, 25) * 0.46;
const primaryLength = lerp(0.70 + rand(seed, 26) * 0.22, 0.38 + rand(seed, 27) * 0.20, mountainMassifness);
const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness);
const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness));
// 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、
// 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。
const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16
const terrainType = pickTerrainType(seed);
const mountainMode = terrainType.mountainMode === "mixed"
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
: terrainType.mountainMode;
let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange);
if (mountainMode === "range") mountainMassifness *= 0.70;
if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12);
let coastAngle = rand(seed, 21) * Math.PI * 2;
let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance;
const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange);
let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange));
if (terrainType.id === "tohoku_spine") {
// 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。
// coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。
coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10;
twoSidedCoast = true;
mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16;
}
const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange);
const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange);
const primaryWidth = rangeValue(seed, 28, terrainType.primaryWidthRange);
const scratchCount = Math.round(lerp(24 + rand(seed, 30) * 20, 16 + rand(seed, 31) * 18, mountainMassifness));
const mountainSystemCount = rangeInt(seed, 32, terrainType.systemCountRange);
const mountainBeltCount = rangeInt(seed, 46, terrainType.beltCountRange);
return {
seed,
terrainType: terrainType.id,
terrainTypeLabel: terrainType.label,
coastStyle: terrainType.coastStyle,
seaRatio,
coastAngle,
twoSidedCoast,
coastNoise: 0.045 + rand(seed, 33) * 0.045,
coastNoise: 0.040 + rand(seed, 33) * 0.056,
coastStrength: terrainType.coastStrength,
mountainMode,
mountainMassifness,
mountainAngle,
mountainAngleSpread: terrainType.angleSpread,
mountainCrossSpread: terrainType.crossSpread,
mountainLengthScale: terrainType.lengthScale,
mountainWidthScale: terrainType.widthScale,
mountainHeightScale: terrainType.heightScale,
mountainBeltCount,
mountainBaseHeight: baseHeight,
mountainDensity: 0.62 + rand(seed, 34) * 0.35,
mountainDensity: 0.58 + rand(seed, 34) * 0.39,
primaryMountain: {
x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.28, 0.22, 0.78),
y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.28, 0.22, 0.78),
x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.36, 0.18, 0.82),
y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82),
angle: mountainAngle,
length: primaryLength,
width: primaryWidth,
@ -186,9 +368,9 @@ export function buildTerrainTemplate(seed) {
roughness: 0.40 + rand(seed, 40) * 0.50,
erosion: 0.34 + rand(seed, 41) * 0.48,
deposition: 0.28 + rand(seed, 42) * 0.56,
riverRichness: 0.72 + rand(seed, 43) * 0.60,
bigRiverChance: 0.34 + rand(seed, 44) * 0.34,
plainBias: 0.32 + rand(seed, 45) * 0.46,
riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange),
bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange),
plainBias: rangeValue(seed, 45, terrainType.plainBiasRange),
};
}
@ -196,85 +378,146 @@ function buildMountainSystems(template, seed) {
const systems = [];
const targetCount = Math.max(8, template.mountainSystemCount ?? 15);
const baseAngle = template.mountainAngle;
const angleSpread = template.mountainAngleSpread ?? 0.42;
const crossSpread = template.mountainCrossSpread ?? 0.70;
const lengthScale = template.mountainLengthScale ?? 1;
const widthScale = template.mountainWidthScale ?? 1;
const heightScale = template.mountainHeightScale ?? 1;
const isChubu = template.terrainType === "chubu_mountain";
const isTohoku = template.terrainType === "tohoku_spine";
// 複数の脊梁山脈システムを、画面中央ではなくマップ全域に分散配置する。
// 5x3 / 4x4 に近い粗い格子へ jitter を入れ、さらに farthest-candidate で
// 既存システムから離れた候補を選ぶ。これにより「中央に単一山塊」化しにくくする。
const cols = targetCount >= 14 ? 5 : 4;
const rows = Math.ceil(targetCount / cols);
const cellOrder = Array.from({ length: cols * rows }, (_, i) => i)
.map((v) => ({ v, key: rand(seed, 1000 + v * 17) }))
.sort((a, b) => a.key - b.key)
.map((o) => o.v);
// 山脈システムは完全ランダムではなく、複数の広い造山帯に沿って配置する。
// これにより「方向性はそこそこ揃う」が、「中央一点に集まらない」分布になる。
const beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3));
const belts = [];
for (let b = 0; b < beltCount; b++) {
const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5);
const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread;
const axisX = Math.cos(angle);
const axisY = Math.sin(angle);
const crossX = Math.cos(angle + Math.PI / 2);
const crossY = Math.sin(angle + Math.PI / 2);
const crossOffset = t * crossSpread + (rand(seed, 1010 + b) - 0.5) * (0.10 + crossSpread * 0.08);
const alongShift = (rand(seed, 1020 + b) - 0.5) * 0.22;
belts.push({
angle,
x: clamp(0.50 + axisX * alongShift / ASPECT + crossX * crossOffset / ASPECT, 0.08, 0.92),
y: clamp(0.50 + axisY * alongShift + crossY * crossOffset, 0.08, 0.92),
lengthBias: 0.82 + rand(seed, 1030 + b) * 0.32,
heightBias: 0.82 + rand(seed, 1040 + b) * 0.42,
});
}
function gridCandidate(k, attempt) {
const cell = cellOrder[(k + attempt * 7) % cellOrder.length];
const cx = cell % cols;
const cy = Math.floor(cell / cols);
const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62;
const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62;
const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945);
const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945);
const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92;
const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16;
function beltCandidate(k, attempt) {
const beltIndex = (k + Math.floor(k / beltCount)) % beltCount;
const belt = belts[beltIndex];
const perBelt = Math.ceil(targetCount / beltCount);
const ordinal = Math.floor(k / beltCount);
const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5;
const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28);
const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40);
const along = (baseT + alongJitter) * 1.03 * belt.lengthBias;
const cross = crossJitter;
const axisX = Math.cos(belt.angle);
const axisY = Math.sin(belt.angle);
const crossX = Math.cos(belt.angle + Math.PI / 2);
const crossY = Math.sin(belt.angle + Math.PI / 2);
return {
x,
y,
angle: baseAngle + localTurn + diagonalBias,
x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955),
y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955),
angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82,
beltIndex,
};
}
function randomCandidate(k, attempt) {
return {
x: clamp(0.055 + rand(seed, 2000 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945),
y: clamp(0.055 + rand(seed, 2100 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945),
angle: baseAngle + (rand(seed, 2200 + k * 137 + attempt) - 0.5) * Math.PI * 1.05,
};
function edgeAwareScore(c) {
const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y);
const centerD = distNorm(c.x, c.y, 0.5, 0.5);
return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08;
}
function candidateAt(k, attempt) {
return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt);
if (isTohoku) {
const centralAngle = baseAngle + (rand(seed, 3330) - 0.5) * 0.06;
const centralAlong = (rand(seed, 3331) - 0.5) * 0.10;
const centralCross = (rand(seed, 3332) - 0.5) * 0.045;
systems.push({
x: clamp(0.50 + Math.cos(centralAngle) * centralAlong / ASPECT + Math.cos(centralAngle + Math.PI / 2) * centralCross / ASPECT, 0.12, 0.88),
y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88),
angle: centralAngle,
length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale,
width: (0.17 + rand(seed, 3334) * 0.11) * widthScale,
height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18),
scratchCount: Math.round(20 + rand(seed, 3336) * 10),
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55),
role: "central-primary",
beltIndex: 0,
widthVariance: 0.30 + rand(seed, 3337) * 0.46,
phase: rand(seed, 3338),
});
if (rand(seed, 3339) < 0.72) {
const side = rand(seed, 3340) < 0.5 ? -1 : 1;
systems.push({
x: clamp(0.50 + Math.cos(centralAngle) * (centralAlong + side * 0.18) / ASPECT + Math.cos(centralAngle + Math.PI / 2) * (centralCross + side * 0.028) / ASPECT, 0.10, 0.90),
y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90),
angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08,
length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale,
width: (0.11 + rand(seed, 3343) * 0.08) * widthScale,
height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16),
scratchCount: Math.round(12 + rand(seed, 3345) * 8),
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65),
role: "central-secondary",
beltIndex: 0,
widthVariance: 0.24 + rand(seed, 3346) * 0.36,
phase: rand(seed, 3347),
});
}
}
for (let k = 0; k < targetCount; k++) {
let best = candidateAt(k, 0);
for (let k = systems.length; k < targetCount; k++) {
let best = beltCandidate(k, 0);
let bestScore = -INF;
for (let attempt = 0; attempt < 18; attempt++) {
const c = candidateAt(k, attempt);
for (let attempt = 0; attempt < 12; attempt++) {
const c = beltCandidate(k, attempt);
let minD = 999;
for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y));
// 中央集中を避けるため、中心距離を少し加点する。ただし端に張り付きすぎないよう edge も見る。
const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y);
const centerD = distNorm(c.x, c.y, 0.5, 0.5);
const score =
minD * 1.25 +
centerD * 0.18 +
Math.min(edgeD, 0.16) * 0.22 +
rand(seed, 2300 + k * 101 + attempt) * 0.04;
const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035;
if (score > bestScore) { bestScore = score; best = c; }
}
const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.50);
const belt = belts[best.beltIndex];
const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.38);
const isMassif = m > 0.58;
const major = k < 4 || rand(seed, 2500 + k) > 0.68;
const major = k < beltCount || rand(seed, 2500 + k) > 0.72;
const lengthBaseRange = isChubu
? (major ? [0.48, 0.78] : [0.34, 0.58])
: isTohoku
? (major ? [0.44, 0.72] : [0.28, 0.48])
: (major ? [0.32, 0.52] : [0.21, 0.36]);
const lengthMassifRange = isChubu
? (major ? [0.38, 0.58] : [0.28, 0.44])
: (major ? [0.23, 0.35] : [0.17, 0.27]);
const length = lerp(
major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16,
major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12,
lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]),
lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]),
m
);
) * belt.lengthBias * lengthScale;
const widthRangeA = major ? [0.082, 0.170] : [0.060, 0.120];
const widthRangeB = major ? [0.150, 0.260] : [0.110, 0.200];
const width = lerp(
major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045,
major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070,
widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]),
widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]),
m
);
const height = template.mountainBaseHeight * (
) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46);
const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * (
major
? 0.34 + rand(seed, 2800 + k) * 0.24
: 0.20 + rand(seed, 2810 + k) * 0.18
? 0.44 + rand(seed, 2800 + k) * 0.28
: 0.26 + rand(seed, 2810 + k) * 0.20
);
const height = isChubu ? heightBase * 0.82 : heightBase;
const scratchCount = Math.round(lerp(
major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7,
isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7,
major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6,
isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6,
m
));
@ -287,7 +530,10 @@ function buildMountainSystems(template, seed) {
height,
scratchCount,
massifness: m,
role: major ? (k < 4 ? "primary" : "major") : "minor",
role: major ? (k < beltCount ? "primary" : "major") : "minor",
beltIndex: best.beltIndex,
widthVariance: 0.18 + rand(seed, 3100 + k) * 0.46,
phase: rand(seed, 3200 + k),
});
}
@ -308,8 +554,8 @@ function buildScratchRidges(system, seed, systemId) {
const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97);
const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97);
const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness);
const width = lerp(0.010 + rand(seed, 2300 + i) * 0.012, 0.018 + rand(seed, 2300 + i) * 0.020, system.massifness) * (0.80 + density * 0.60);
const height = system.height * (0.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035);
const width = lerp(0.014 + rand(seed, 2300 + i) * 0.018, 0.024 + rand(seed, 2300 + i) * 0.026, system.massifness) * (0.85 + density * 0.70);
const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050);
ridges.push({
x, y,
angle: localAngle,
@ -327,13 +573,45 @@ function buildScratchRidges(system, seed, systemId) {
}
function computeCoastLower(px, py, template, seed) {
const axis = (px - 0.5) * Math.cos(template.coastAngle) * ASPECT + (py - 0.5) * Math.sin(template.coastAngle);
const angle = template.coastAngle;
const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle);
const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle);
const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise;
const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055;
const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26);
const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0;
const pressure = Math.max(sideA, sideB);
return { pressure, signedAxis: axis };
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
let pressure = 0;
if (template.coastStyle === "inland_sea") {
// 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。
const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26);
const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27);
const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82;
pressure = Math.max(sideA, sideB, channel);
} else if (template.coastStyle === "parallel_spine") {
// 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。
// 内海的な中央水路は作らない。
const edgeA = smoothstep((-axis - 0.26 + wave * 0.42 + bay * 0.35) / 0.20);
const edgeB = template.twoSidedCoast ? smoothstep((axis - 0.26 - wave * 0.42 + bay * 0.25) / 0.22) * 0.86 : 0;
pressure = Math.max(edgeA, edgeB);
} else if (template.coastStyle === "outer_coast") {
// 中部型: 外縁海を中心にし、内陸へ海が入り込みすぎないようにする。
const radial = distNorm(px, py, 0.5, 0.5);
const outer = smoothstep((radial - 0.44 + wave * 0.8 + bay * 0.5) / 0.24);
const side = smoothstep((-axis + 0.30 + wave) / 0.30) * 0.45;
pressure = Math.max(outer, side);
} else if (template.coastStyle === "open_bay") {
// 関東・濃尾型: 一方向に開いた湾と、その背後の沖積平野を作りやすくする。
const openSide = smoothstep((-axis + 0.29 + wave + bay) / 0.25);
const bayMouth = smoothstep((0.22 - Math.abs(cross + wave * 0.8)) / 0.25) * smoothstep((-axis + 0.16 + bay) / 0.22) * 0.68;
pressure = Math.max(openSide, bayMouth);
} else {
const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26);
const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0;
const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25;
pressure = Math.max(sideA, sideB, outerBite);
}
return { pressure: clamp(pressure), signedAxis: axis };
}
function recomputeSlope(elevation, sea, slope) {
@ -593,10 +871,10 @@ function deriveFields(seed, template, fields, seaLevel) {
const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34);
ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge));
basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32);
const low = clamp((0.58 - e) * 1.55);
const low = clamp((0.52 - e) * 1.70);
const lowSlope = clamp((0.34 - slope[i]) * 2.8);
const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40);
plain[i] = clamp(low * lowSlope * (0.18 + template.plainBias * 0.42 + riverGate * 0.92));
plain[i] = clamp(low * lowSlope * (0.12 + template.plainBias * 0.34 + riverGate * 0.84));
floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52));
deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72);
alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4));
@ -675,13 +953,13 @@ export function generateTerrainAndRivers(seed) {
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23;
const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (0.22 + terrainTemplate.deposition * 0.040);
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
let mountainMaskMax = 0;
for (let s = 0; s < systems.length; s++) {
const system = systems[s];
const mask = ellipticalMask(px, py, system);
mountainMaskMax = Math.max(mountainMaskMax, mask);
const broad = Math.pow(mask, lerp(2.0, 1.35, system.massifness)) * system.height * lerp(0.13, 0.25, system.massifness);
const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness);
e += broad;
arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72));
}
@ -698,7 +976,7 @@ export function generateTerrainAndRivers(seed) {
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
e += global * 0.020;
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
elevation[i] = clamp(e, 0.025, 1.08);
elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08);
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
@ -707,7 +985,7 @@ export function generateTerrainAndRivers(seed) {
}
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
seaLevel = clamp(seaLevel, 0.20, 0.47);
seaLevel = clamp(seaLevel, 0.14, 0.47);
classifyWater(elevation, seaLevel, sea, ocean, lake);
recomputeSlope(elevation, sea, slope);
@ -721,8 +999,9 @@ export function generateTerrainAndRivers(seed) {
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
const prefectureRegionId = regional.regionId;
const adminPrefectureRegionId = regional.displayRegionId || regional.regionId;
const regionalDebug = regional.debug;
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea);
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
let landCount = 0;
@ -734,12 +1013,15 @@ export function generateTerrainAndRivers(seed) {
for (let i = 0; i < SIZE; i++) {
if (sea[i]) { waterCount++; continue; }
landCount++;
if (elevation[i] > 0.60 || ridgeField[i] > 0.58) mountainCount++;
if (elevation[i] > 0.56 || ridgeField[i] > 0.52) mountainCount++;
if (plain[i] > 0.36) plainCount++;
if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; }
}
primarySpineStrength /= Math.max(1, spineSamples);
const terrainDebug = {
terrainType: terrainTemplate.terrainType,
terrainTypeLabel: terrainTemplate.terrainTypeLabel,
coastStyle: terrainTemplate.coastStyle,
primarySpineStrength,
riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0,
smallIslandCount: 0,
@ -790,6 +1072,7 @@ export function generateTerrainAndRivers(seed) {
prefectureMask,
prefectureBorder,
prefectureRegionId,
adminPrefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,

View file

@ -358,42 +358,45 @@ function terrainColorContinuous(map, fx, fy, mode) {
}
function terrainShadeContinuous(map, fx, fy) {
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 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)。
// 光源は北西上空(日本の地形表現で一般的な見え方)
const dzdx = (eR - eL) / 1.2;
const dzdy = (eD - eU) / 1.2;
const nx = -dzdx * 2.5;
const ny = -dzdy * 2.5;
// 描画では地形生成上の差分をやや誇張し、粗い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.5 + 0.5);
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.75, fy) : 0;
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0;
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0;
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0;
const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12;
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.16) * 1.8) * clamp((0.28 - slope) * 4.5);
let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028;
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52);
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20);
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
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.66, 1.13);
return clamp(shade, 0.54, 1.26);
}
function discreteColor(map, x, y, mode) {
@ -404,16 +407,16 @@ function discreteColor(map, x, y, mode) {
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: [242, 248, 238], // rural / natural land
1: [238, 246, 222], // farmland
2: [240, 238, 232], // old urban
3: [245, 230, 220], // CBD / DID core
4: [250, 248, 245], // suburb
5: [235, 235, 240], // industrial
6: [240, 245, 240], // logistics
7: [245, 248, 252], // new town
8: [250, 248, 240], // roadside
9: [225, 238, 220], // forest / mountain land
};
color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") {
@ -606,7 +609,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);
@ -756,8 +760,10 @@ 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 });
}
@ -818,6 +824,7 @@ export function drawMap(canvas, map, options) {
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
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)");
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "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") {
@ -832,14 +839,14 @@ export function drawMap(canvas, map, options) {
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
drawLabels(ctx, map.adminCenters || [], Infinity);
return;
}
const important = [
...map.modernCities,
...map.ports,
...(map.satelliteCities || []),
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway");
drawLabels(ctx, important, 60);
}
}