town tweak
This commit is contained in:
parent
84ad22f7af
commit
1ea8ba1701
12 changed files with 5005 additions and 379 deletions
505
adminRegions.js
505
adminRegions.js
|
|
@ -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);
|
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
|
||||||
if (!bothUrban && !bothLivingCorridor) return false;
|
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) &&
|
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);
|
((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 valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
|
||||||
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
|
const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50;
|
||||||
return barrier < threshold && (!majorRiverEdge || urbanEdge);
|
return barrier < threshold && (!majorRiverEdge || urbanEdge);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
|
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 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 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) {
|
for (const i of unit.cells) {
|
||||||
const [x, y] = xyOf(i);
|
const [x, y] = xyOf(i);
|
||||||
sx += x; sy += y; pop += populationDensity[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);
|
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
|
||||||
ridgeExposure += ridgeField[i];
|
ridgeExposure += ridgeField[i];
|
||||||
coastalExposure += coastalLowland[i];
|
coastalExposure += coastalLowland[i];
|
||||||
|
|
@ -584,6 +586,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
|
||||||
unit.area = area;
|
unit.area = area;
|
||||||
unit.x = sx / Math.max(1, area);
|
unit.x = sx / Math.max(1, area);
|
||||||
unit.y = sy / 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.population = pop;
|
||||||
unit.urbanWeight = urbanWeight / Math.max(1, area);
|
unit.urbanWeight = urbanWeight / Math.max(1, area);
|
||||||
unit.ridgeExposure = ridgeExposure / 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) {
|
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
||||||
if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
|
if (!unit || unit.area < 24) return null;
|
||||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
|
||||||
let first = -1, second = -1, bestA = -INF, bestB = -INF;
|
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) {
|
for (const i of unit.cells) {
|
||||||
const [x, y] = xyOf(i);
|
const [x, y] = xyOf(i);
|
||||||
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
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 (score > bestA) { bestA = score; first = i; }
|
||||||
}
|
}
|
||||||
if (first < 0) return null;
|
if (first < 0) return null;
|
||||||
|
|
@ -610,36 +631,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
||||||
for (const i of unit.cells) {
|
for (const i of unit.cells) {
|
||||||
const [x, y] = xyOf(i);
|
const [x, y] = xyOf(i);
|
||||||
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
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 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 (score > bestB) { bestB = score; second = i; }
|
||||||
}
|
}
|
||||||
if (second < 0 || second === first) return null;
|
if (second < 0 || second === first) return null;
|
||||||
|
|
||||||
const cellSet = new Set(unit.cells);
|
const cellSet = new Set(unit.cells);
|
||||||
const localOwner = new Map([[first, 0], [second, 1]]);
|
const owner = new Int8Array(SIZE);
|
||||||
const queue = [first, second];
|
owner.fill(-1);
|
||||||
for (let q = 0; q < queue.length; q++) {
|
const dist = new Float32Array(SIZE);
|
||||||
const cur = queue[q];
|
dist.fill(INF);
|
||||||
const owner = localOwner.get(cur);
|
const heap = new MinHeap();
|
||||||
const [x, y] = xyOf(cur);
|
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
|
||||||
for (const [nx, ny] of neighbors4(x, y)) {
|
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);
|
const ni = indexOf(nx, ny);
|
||||||
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
|
if (!cellSet.has(ni)) continue;
|
||||||
localOwner.set(ni, owner);
|
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
|
||||||
queue.push(ni);
|
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 = [], bCells = [];
|
|
||||||
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
|
const aCells = [];
|
||||||
if (aCells.length < 10 || bCells.length < 10) return null;
|
const bCells = [];
|
||||||
|
for (const ci of unit.cells) {
|
||||||
|
if (owner[ci] === 1) bCells.push(ci);
|
||||||
|
else aCells.push(ci);
|
||||||
|
}
|
||||||
|
if (aCells.length < minPart || bCells.length < minPart) return null;
|
||||||
|
|
||||||
unit.cells = aCells;
|
unit.cells = aCells;
|
||||||
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
|
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
|
||||||
for (const ci of bCells) compartmentId[ci] = newId;
|
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)}`;
|
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 = {}) {
|
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 naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||||
const compartmentId = new Int32Array(SIZE);
|
const compartmentId = new Int32Array(SIZE);
|
||||||
compartmentId.fill(-1);
|
compartmentId.fill(-1);
|
||||||
|
|
@ -679,6 +1080,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
||||||
const startClass = cellClass[i];
|
const startClass = cellClass[i];
|
||||||
const cells = [];
|
const cells = [];
|
||||||
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
|
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.length = 0;
|
||||||
queue.push(i);
|
queue.push(i);
|
||||||
compartmentId[i] = id;
|
compartmentId[i] = id;
|
||||||
|
|
@ -687,6 +1089,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
||||||
const [x, y] = xyOf(cur);
|
const [x, y] = xyOf(cur);
|
||||||
cells.push(cur);
|
cells.push(cur);
|
||||||
sx += x; sy += y; pop += populationDensity[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);
|
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
||||||
ridgeExposure += ridgeField[cur];
|
ridgeExposure += ridgeField[cur];
|
||||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||||
|
|
@ -711,6 +1114,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
||||||
y: sy / area,
|
y: sy / area,
|
||||||
classId: startClass,
|
classId: startClass,
|
||||||
dominantLandscapeClass: 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,
|
population: pop,
|
||||||
urbanWeight: urbanWeight / area,
|
urbanWeight: urbanWeight / area,
|
||||||
ridgeExposure: ridgeExposure / area,
|
ridgeExposure: ridgeExposure / area,
|
||||||
|
|
@ -730,22 +1140,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
||||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
const targetCount = options.targetCompartmentCount || 0;
|
const targetCount = options.targetCompartmentCount || 0;
|
||||||
if (targetCount > 0) {
|
if (targetCount > 0) {
|
||||||
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore };
|
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
|
||||||
let guard = targetCount * 3;
|
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) {
|
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
|
||||||
const candidates = compartments
|
const candidates = compartments
|
||||||
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
|
.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) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
|
.sort((a, b) => splitScore(b) - splitScore(a));
|
||||||
const target = candidates[0];
|
const target = candidates[0];
|
||||||
if (!target) break;
|
if (!target) break;
|
||||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
||||||
if (!newUnit) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
compartments.push(newUnit);
|
compartments.push(newUnit);
|
||||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
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);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
}
|
}
|
||||||
return { compartmentId, compartments, naturalBarrierScore };
|
return { compartmentId, compartments, naturalBarrierScore };
|
||||||
|
|
@ -1081,6 +1516,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
||||||
...relationMetrics,
|
...relationMetrics,
|
||||||
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
|
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
|
||||||
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
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),
|
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
||||||
voronoiLikeRateBefore: 0,
|
voronoiLikeRateBefore: 0,
|
||||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||||
|
|
|
||||||
1776
adminRegions.notrace.js
Normal file
1776
adminRegions.notrace.js
Normal file
File diff suppressed because it is too large
Load diff
17
app.js
17
app.js
|
|
@ -1,5 +1,6 @@
|
||||||
import { generateMap } from "./mapGenerator.js";
|
import { generateMap } from "./mapGenerator.js";
|
||||||
import { drawMap } from "./renderer.js";
|
import { drawMap } from "./renderer.js";
|
||||||
|
import { landuseLabel } from "./landuseCodes.js";
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
["all", "All"],
|
["all", "All"],
|
||||||
|
|
@ -57,6 +58,8 @@ function countText(items) {
|
||||||
|
|
||||||
function getStats(map) {
|
function getStats(map) {
|
||||||
return [
|
return [
|
||||||
|
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
||||||
|
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
||||||
["Villages", countText(map.villages)],
|
["Villages", countText(map.villages)],
|
||||||
["Market Towns", countText(map.markets)],
|
["Market Towns", countText(map.markets)],
|
||||||
["Castles", countText(map.castles)],
|
["Castles", countText(map.castles)],
|
||||||
|
|
@ -66,6 +69,7 @@ function getStats(map) {
|
||||||
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
|
["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` : "-"],
|
["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 || "-"],
|
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
|
||||||
|
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
|
||||||
["Modern Cities", countText(map.modernCities)],
|
["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`],
|
["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 || [])],
|
["Satellite Cities", countText(map.satelliteCities || [])],
|
||||||
|
|
@ -148,18 +152,7 @@ function nearestEntity(map, x, y, maxDistance = 5) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function landuseName(value) {
|
function landuseName(value) {
|
||||||
return {
|
return landuseLabel(value);
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminName(map, adminId) {
|
function adminName(map, adminId) {
|
||||||
|
|
|
||||||
37
landuseCodes.js
Normal file
37
landuseCodes.js
Normal 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;
|
||||||
|
}
|
||||||
192
mapAdminStage.js
192
mapAdminStage.js
|
|
@ -11,6 +11,15 @@ import {
|
||||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||||
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.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) {
|
function changedCellsSince(before, after, prefectureMask, sea) {
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
|
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;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAdminLayout({
|
function generateAdminLayoutForMask({
|
||||||
seed,
|
seed,
|
||||||
prefectureMask,
|
prefectureMask,
|
||||||
sea,
|
sea,
|
||||||
|
|
@ -617,8 +626,8 @@ export function generateAdminLayout({
|
||||||
: ridgeField;
|
: ridgeField;
|
||||||
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
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 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);
|
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
||||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240);
|
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
|
||||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||||
seed,
|
seed,
|
||||||
targetMunicipalityCount,
|
targetMunicipalityCount,
|
||||||
|
|
@ -648,6 +657,7 @@ export function generateAdminLayout({
|
||||||
seed,
|
seed,
|
||||||
targetMunicipalityCount,
|
targetMunicipalityCount,
|
||||||
targetCompartmentCount,
|
targetCompartmentCount,
|
||||||
|
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
||||||
});
|
});
|
||||||
const adminId = compartmentAssignment.adminId;
|
const adminId = compartmentAssignment.adminId;
|
||||||
let previousSnapshot = new Int16Array(adminId);
|
let previousSnapshot = new Int16Array(adminId);
|
||||||
|
|
@ -876,3 +886,179 @@ export function generateAdminLayout({
|
||||||
|
|
||||||
return { adminCentersRaw, adminId, adminBorders, adminDebug };
|
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
1064
mapAdminStage.nolog.js
Normal file
File diff suppressed because it is too large
Load diff
1157
mapFeatures.js
1157
mapFeatures.js
File diff suppressed because it is too large
Load diff
|
|
@ -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 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);
|
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;
|
let changed = 0;
|
||||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||||
|
|
@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
||||||
|
|
||||||
return {
|
return {
|
||||||
regionId,
|
regionId,
|
||||||
|
displayRegionId,
|
||||||
centers: seeded.centers,
|
centers: seeded.centers,
|
||||||
naturalBarrierScore,
|
naturalBarrierScore,
|
||||||
debug: {
|
debug: {
|
||||||
|
|
@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
||||||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||||
|
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
|
||||||
|
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
|
||||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||||
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||||
changedAfterCompartmentAssignment: changed,
|
changedAfterCompartmentAssignment: changed,
|
||||||
|
|
@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
const capitalLike = city.isPrefecturalCapital || city.isRegionalCapital;
|
||||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
|
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 coreComponent = coreCells * 3200;
|
||||||
const densityComponent = densitySum * 650;
|
const densityComponent = densitySum * 360;
|
||||||
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
|
const computedPopulation = base + urbanComponent + coreComponent + densityComponent;
|
||||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
|
const footprintCells = city.urbanFootprintCells || urbanCells;
|
||||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
mapOutput.js
54
mapOutput.js
|
|
@ -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 { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||||
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.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({
|
export function finishMapOutput({
|
||||||
seed,
|
seed,
|
||||||
options,
|
options,
|
||||||
|
|
@ -84,8 +106,12 @@ export function finishMapOutput({
|
||||||
regionalPrefectureBorders,
|
regionalPrefectureBorders,
|
||||||
}) {
|
}) {
|
||||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
// 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.
|
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
||||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
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) {
|
for (const city of modernCities) {
|
||||||
if (city.isPrefecturalCapital) continue;
|
if (city.isPrefecturalCapital) continue;
|
||||||
const cap = cityPopulationCap(city);
|
const cap = cityPopulationCap(city);
|
||||||
|
|
@ -145,13 +171,13 @@ export function finishMapOutput({
|
||||||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", 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);
|
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 = [
|
const representativeFeatures = [
|
||||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||||
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
|
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
|
||||||
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
|
...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) {
|
for (const center of adminCenters) {
|
||||||
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
|
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
|
||||||
let best = null;
|
let best = null;
|
||||||
|
|
@ -169,21 +195,30 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
if (best) break;
|
if (best) break;
|
||||||
}
|
}
|
||||||
|
center.generatedMunicipalityName = center.generatedMunicipalityName || center.name;
|
||||||
if (best) {
|
if (best) {
|
||||||
center.representativeFeatureId = best.id;
|
center.representativeFeatureId = best.id;
|
||||||
center.representativeFeatureName = best.name;
|
center.representativeFeatureName = best.name;
|
||||||
center.generatedMunicipalityName = center.name;
|
center.municipalityRootName = best.name;
|
||||||
center.name = best.name;
|
} else {
|
||||||
|
center.municipalityRootName = center.generatedMunicipalityName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const usedAdminNames = new Set();
|
const usedAdminNames = new Set();
|
||||||
for (const center of adminCenters) {
|
for (const [index, center] of adminCenters.entries()) {
|
||||||
let candidate = center.name;
|
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||||
const generated = String(center.generatedMunicipalityName || "");
|
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||||
candidate = 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.name = candidate;
|
||||||
|
center.labelName = candidate;
|
||||||
|
center.municipalityName = candidate;
|
||||||
usedAdminNames.add(center.name);
|
usedAdminNames.add(center.name);
|
||||||
}
|
}
|
||||||
nameDebug.maxDerivedPerBase = 0;
|
nameDebug.maxDerivedPerBase = 0;
|
||||||
|
|
@ -212,6 +247,7 @@ export function finishMapOutput({
|
||||||
terrainTemplate,
|
terrainTemplate,
|
||||||
seaLevel,
|
seaLevel,
|
||||||
prefectureMask,
|
prefectureMask,
|
||||||
|
humanRegionMask,
|
||||||
prefectureBorder,
|
prefectureBorder,
|
||||||
prefectureRegionId,
|
prefectureRegionId,
|
||||||
regionalDebug,
|
regionalDebug,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
prefectureMask,
|
prefectureMask,
|
||||||
prefectureBorder,
|
prefectureBorder,
|
||||||
prefectureRegionId,
|
prefectureRegionId,
|
||||||
|
adminPrefectureRegionId,
|
||||||
regionalDebug,
|
regionalDebug,
|
||||||
terrainDebug,
|
terrainDebug,
|
||||||
regionalPrefectureBorders,
|
regionalPrefectureBorders,
|
||||||
|
|
@ -61,7 +62,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
} = features;
|
} = features;
|
||||||
|
|
||||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
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,
|
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
475
mapTerrain.js
475
mapTerrain.js
|
|
@ -38,6 +38,15 @@ function quantile(values, q) {
|
||||||
return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f);
|
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) {
|
function forDisk(cx, cy, radius, fn) {
|
||||||
const r = Math.ceil(radius);
|
const r = Math.ceil(radius);
|
||||||
for (let dy = -r; dy <= r; dy++) {
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
|
@ -131,7 +140,12 @@ function ellipticalMask(px, py, system) {
|
||||||
const dy = py - system.y;
|
const dy = py - system.y;
|
||||||
const { u, v } = rotate(dx, dy, system.angle);
|
const { u, v } = rotate(dx, dy, system.angle);
|
||||||
const a = Math.max(0.01, system.length * 0.5);
|
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);
|
const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2);
|
||||||
return clamp(1 - smoothstep((r - 0.55) / 0.65));
|
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 };
|
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) {
|
export function buildTerrainTemplate(seed) {
|
||||||
const mountainModeRoll = rand(seed, 12);
|
const terrainType = pickTerrainType(seed);
|
||||||
const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif";
|
const mountainMode = terrainType.mountainMode === "mixed"
|
||||||
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;
|
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
|
||||||
const coastAngle = rand(seed, 21) * Math.PI * 2;
|
: terrainType.mountainMode;
|
||||||
const twoSidedCoast = rand(seed, 22) < 0.36;
|
let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange);
|
||||||
const seaRatio = 0.14 + rand(seed, 23) * 0.16;
|
if (mountainMode === "range") mountainMassifness *= 0.70;
|
||||||
const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48);
|
if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12);
|
||||||
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);
|
let coastAngle = rand(seed, 21) * Math.PI * 2;
|
||||||
const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness);
|
let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance;
|
||||||
const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness));
|
const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange);
|
||||||
// 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、
|
let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange));
|
||||||
// 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。
|
if (terrainType.id === "tohoku_spine") {
|
||||||
const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16
|
// 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。
|
||||||
|
// 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 {
|
return {
|
||||||
seed,
|
seed,
|
||||||
|
terrainType: terrainType.id,
|
||||||
|
terrainTypeLabel: terrainType.label,
|
||||||
|
coastStyle: terrainType.coastStyle,
|
||||||
seaRatio,
|
seaRatio,
|
||||||
coastAngle,
|
coastAngle,
|
||||||
twoSidedCoast,
|
twoSidedCoast,
|
||||||
coastNoise: 0.045 + rand(seed, 33) * 0.045,
|
coastNoise: 0.040 + rand(seed, 33) * 0.056,
|
||||||
|
coastStrength: terrainType.coastStrength,
|
||||||
mountainMode,
|
mountainMode,
|
||||||
mountainMassifness,
|
mountainMassifness,
|
||||||
mountainAngle,
|
mountainAngle,
|
||||||
|
mountainAngleSpread: terrainType.angleSpread,
|
||||||
|
mountainCrossSpread: terrainType.crossSpread,
|
||||||
|
mountainLengthScale: terrainType.lengthScale,
|
||||||
|
mountainWidthScale: terrainType.widthScale,
|
||||||
|
mountainHeightScale: terrainType.heightScale,
|
||||||
|
mountainBeltCount,
|
||||||
mountainBaseHeight: baseHeight,
|
mountainBaseHeight: baseHeight,
|
||||||
mountainDensity: 0.62 + rand(seed, 34) * 0.35,
|
mountainDensity: 0.58 + rand(seed, 34) * 0.39,
|
||||||
primaryMountain: {
|
primaryMountain: {
|
||||||
x: clamp(0.50 + (rand(seed, 35) - 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.28, 0.22, 0.78),
|
y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82),
|
||||||
angle: mountainAngle,
|
angle: mountainAngle,
|
||||||
length: primaryLength,
|
length: primaryLength,
|
||||||
width: primaryWidth,
|
width: primaryWidth,
|
||||||
|
|
@ -186,9 +368,9 @@ export function buildTerrainTemplate(seed) {
|
||||||
roughness: 0.40 + rand(seed, 40) * 0.50,
|
roughness: 0.40 + rand(seed, 40) * 0.50,
|
||||||
erosion: 0.34 + rand(seed, 41) * 0.48,
|
erosion: 0.34 + rand(seed, 41) * 0.48,
|
||||||
deposition: 0.28 + rand(seed, 42) * 0.56,
|
deposition: 0.28 + rand(seed, 42) * 0.56,
|
||||||
riverRichness: 0.72 + rand(seed, 43) * 0.60,
|
riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange),
|
||||||
bigRiverChance: 0.34 + rand(seed, 44) * 0.34,
|
bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange),
|
||||||
plainBias: 0.32 + rand(seed, 45) * 0.46,
|
plainBias: rangeValue(seed, 45, terrainType.plainBiasRange),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,85 +378,146 @@ function buildMountainSystems(template, seed) {
|
||||||
const systems = [];
|
const systems = [];
|
||||||
const targetCount = Math.max(8, template.mountainSystemCount ?? 15);
|
const targetCount = Math.max(8, template.mountainSystemCount ?? 15);
|
||||||
const baseAngle = template.mountainAngle;
|
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 beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3));
|
||||||
const cols = targetCount >= 14 ? 5 : 4;
|
const belts = [];
|
||||||
const rows = Math.ceil(targetCount / cols);
|
for (let b = 0; b < beltCount; b++) {
|
||||||
const cellOrder = Array.from({ length: cols * rows }, (_, i) => i)
|
const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5);
|
||||||
.map((v) => ({ v, key: rand(seed, 1000 + v * 17) }))
|
const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread;
|
||||||
.sort((a, b) => a.key - b.key)
|
const axisX = Math.cos(angle);
|
||||||
.map((o) => o.v);
|
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) {
|
function beltCandidate(k, attempt) {
|
||||||
const cell = cellOrder[(k + attempt * 7) % cellOrder.length];
|
const beltIndex = (k + Math.floor(k / beltCount)) % beltCount;
|
||||||
const cx = cell % cols;
|
const belt = belts[beltIndex];
|
||||||
const cy = Math.floor(cell / cols);
|
const perBelt = Math.ceil(targetCount / beltCount);
|
||||||
const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62;
|
const ordinal = Math.floor(k / beltCount);
|
||||||
const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62;
|
const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5;
|
||||||
const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945);
|
const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28);
|
||||||
const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945);
|
const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40);
|
||||||
const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92;
|
const along = (baseT + alongJitter) * 1.03 * belt.lengthBias;
|
||||||
const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16;
|
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 {
|
return {
|
||||||
x,
|
x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955),
|
||||||
y,
|
y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955),
|
||||||
angle: baseAngle + localTurn + diagonalBias,
|
angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82,
|
||||||
|
beltIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomCandidate(k, attempt) {
|
function edgeAwareScore(c) {
|
||||||
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 candidateAt(k, attempt) {
|
|
||||||
return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = 0; k < targetCount; k++) {
|
|
||||||
let best = candidateAt(k, 0);
|
|
||||||
let bestScore = -INF;
|
|
||||||
for (let attempt = 0; attempt < 18; attempt++) {
|
|
||||||
const c = candidateAt(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 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 centerD = distNorm(c.x, c.y, 0.5, 0.5);
|
||||||
const score =
|
return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08;
|
||||||
minD * 1.25 +
|
}
|
||||||
centerD * 0.18 +
|
|
||||||
Math.min(edgeD, 0.16) * 0.22 +
|
if (isTohoku) {
|
||||||
rand(seed, 2300 + k * 101 + attempt) * 0.04;
|
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 = systems.length; k < targetCount; k++) {
|
||||||
|
let best = beltCandidate(k, 0);
|
||||||
|
let bestScore = -INF;
|
||||||
|
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));
|
||||||
|
const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035;
|
||||||
if (score > bestScore) { bestScore = score; best = c; }
|
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 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(
|
const length = lerp(
|
||||||
major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16,
|
lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]),
|
||||||
major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12,
|
lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]),
|
||||||
m
|
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(
|
const width = lerp(
|
||||||
major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045,
|
widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]),
|
||||||
major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070,
|
widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]),
|
||||||
m
|
m
|
||||||
);
|
) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46);
|
||||||
const height = template.mountainBaseHeight * (
|
const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * (
|
||||||
major
|
major
|
||||||
? 0.34 + rand(seed, 2800 + k) * 0.24
|
? 0.44 + rand(seed, 2800 + k) * 0.28
|
||||||
: 0.20 + rand(seed, 2810 + k) * 0.18
|
: 0.26 + rand(seed, 2810 + k) * 0.20
|
||||||
);
|
);
|
||||||
|
const height = isChubu ? heightBase * 0.82 : heightBase;
|
||||||
const scratchCount = Math.round(lerp(
|
const scratchCount = Math.round(lerp(
|
||||||
major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7,
|
major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6,
|
||||||
isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7,
|
isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6,
|
||||||
m
|
m
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -287,7 +530,10 @@ function buildMountainSystems(template, seed) {
|
||||||
height,
|
height,
|
||||||
scratchCount,
|
scratchCount,
|
||||||
massifness: m,
|
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 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 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 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 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.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035);
|
const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050);
|
||||||
ridges.push({
|
ridges.push({
|
||||||
x, y,
|
x, y,
|
||||||
angle: localAngle,
|
angle: localAngle,
|
||||||
|
|
@ -327,13 +573,45 @@ function buildScratchRidges(system, seed, systemId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCoastLower(px, py, template, seed) {
|
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 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 bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055;
|
||||||
|
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 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 sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0;
|
||||||
const pressure = Math.max(sideA, sideB);
|
const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25;
|
||||||
return { pressure, signedAxis: axis };
|
pressure = Math.max(sideA, sideB, outerBite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pressure: clamp(pressure), signedAxis: axis };
|
||||||
}
|
}
|
||||||
|
|
||||||
function recomputeSlope(elevation, sea, slope) {
|
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);
|
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));
|
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);
|
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 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);
|
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));
|
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);
|
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));
|
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 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 terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
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;
|
let mountainMaskMax = 0;
|
||||||
for (let s = 0; s < systems.length; s++) {
|
for (let s = 0; s < systems.length; s++) {
|
||||||
const system = systems[s];
|
const system = systems[s];
|
||||||
const mask = ellipticalMask(px, py, system);
|
const mask = ellipticalMask(px, py, system);
|
||||||
mountainMaskMax = Math.max(mountainMaskMax, mask);
|
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;
|
e += broad;
|
||||||
arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72));
|
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 += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||||
e += global * 0.020;
|
e += global * 0.020;
|
||||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
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);
|
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);
|
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||||
|
|
@ -707,7 +985,7 @@ export function generateTerrainAndRivers(seed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
|
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);
|
classifyWater(elevation, seaLevel, sea, ocean, lake);
|
||||||
recomputeSlope(elevation, sea, slope);
|
recomputeSlope(elevation, sea, slope);
|
||||||
|
|
||||||
|
|
@ -721,8 +999,9 @@ export function generateTerrainAndRivers(seed) {
|
||||||
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
||||||
const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
||||||
const prefectureRegionId = regional.regionId;
|
const prefectureRegionId = regional.regionId;
|
||||||
|
const adminPrefectureRegionId = regional.displayRegionId || regional.regionId;
|
||||||
const regionalDebug = regional.debug;
|
const regionalDebug = regional.debug;
|
||||||
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
|
const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea);
|
||||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||||
|
|
||||||
let landCount = 0;
|
let landCount = 0;
|
||||||
|
|
@ -734,12 +1013,15 @@ export function generateTerrainAndRivers(seed) {
|
||||||
for (let i = 0; i < SIZE; i++) {
|
for (let i = 0; i < SIZE; i++) {
|
||||||
if (sea[i]) { waterCount++; continue; }
|
if (sea[i]) { waterCount++; continue; }
|
||||||
landCount++;
|
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 (plain[i] > 0.36) plainCount++;
|
||||||
if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; }
|
if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; }
|
||||||
}
|
}
|
||||||
primarySpineStrength /= Math.max(1, spineSamples);
|
primarySpineStrength /= Math.max(1, spineSamples);
|
||||||
const terrainDebug = {
|
const terrainDebug = {
|
||||||
|
terrainType: terrainTemplate.terrainType,
|
||||||
|
terrainTypeLabel: terrainTemplate.terrainTypeLabel,
|
||||||
|
coastStyle: terrainTemplate.coastStyle,
|
||||||
primarySpineStrength,
|
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,
|
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,
|
smallIslandCount: 0,
|
||||||
|
|
@ -790,6 +1072,7 @@ export function generateTerrainAndRivers(seed) {
|
||||||
prefectureMask,
|
prefectureMask,
|
||||||
prefectureBorder,
|
prefectureBorder,
|
||||||
prefectureRegionId,
|
prefectureRegionId,
|
||||||
|
adminPrefectureRegionId,
|
||||||
regionalDebug,
|
regionalDebug,
|
||||||
terrainDebug,
|
terrainDebug,
|
||||||
regionalPrefectureBorders,
|
regionalPrefectureBorders,
|
||||||
|
|
|
||||||
79
renderer.js
79
renderer.js
|
|
@ -358,42 +358,45 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function terrainShadeContinuous(map, fx, fy) {
|
function terrainShadeContinuous(map, fx, fy) {
|
||||||
const eL = fieldSample(map.elevation, fx - 0.6, fy);
|
const step = 0.50;
|
||||||
const eR = fieldSample(map.elevation, fx + 0.6, fy);
|
const eC = fieldSample(map.elevation, fx, fy);
|
||||||
const eU = fieldSample(map.elevation, fx, fy - 0.6);
|
const eL = fieldSample(map.elevation, fx - step, fy);
|
||||||
const eD = fieldSample(map.elevation, fx, fy + 0.6);
|
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)。
|
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||||
// 光源は北西上空(日本の地形表現で一般的な見え方)。
|
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||||
const dzdx = (eR - eL) / 1.2;
|
const dzdx = (eR - eL) / (step * 2);
|
||||||
const dzdy = (eD - eU) / 1.2;
|
const dzdy = (eD - eU) / (step * 2);
|
||||||
const nx = -dzdx * 2.5;
|
const nx = -dzdx * 4.4;
|
||||||
const ny = -dzdy * 2.5;
|
const ny = -dzdy * 4.4;
|
||||||
const nz = 1.0;
|
const nz = 1.0;
|
||||||
const nLen = Math.hypot(nx, ny, nz) || 1;
|
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
|
||||||
const lx = -0.5;
|
const lx = -0.5;
|
||||||
const ly = -0.5;
|
const ly = -0.5;
|
||||||
const lz = 0.7071067811865476;
|
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 slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, 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 rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0;
|
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0;
|
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0;
|
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||||
const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12;
|
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);
|
const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6);
|
||||||
let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028;
|
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.52);
|
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26);
|
||||||
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20);
|
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) {
|
function discreteColor(map, x, y, mode) {
|
||||||
|
|
@ -404,16 +407,16 @@ function discreteColor(map, x, y, mode) {
|
||||||
color = [160, 205, 239];
|
color = [160, 205, 239];
|
||||||
} else if (mode === "landuse") {
|
} else if (mode === "landuse") {
|
||||||
const colors = {
|
const colors = {
|
||||||
0: [242, 248, 238],
|
0: [242, 248, 238], // rural / natural land
|
||||||
1: [248, 250, 245],
|
1: [238, 246, 222], // farmland
|
||||||
2: [240, 238, 232],
|
2: [240, 238, 232], // old urban
|
||||||
3: [245, 230, 220],
|
3: [245, 230, 220], // CBD / DID core
|
||||||
4: [250, 248, 245],
|
4: [250, 248, 245], // suburb
|
||||||
5: [235, 235, 240],
|
5: [235, 235, 240], // industrial
|
||||||
6: [240, 245, 240],
|
6: [240, 245, 240], // logistics
|
||||||
7: [245, 248, 252],
|
7: [245, 248, 252], // new town
|
||||||
8: [250, 248, 240],
|
8: [250, 248, 240], // roadside
|
||||||
9: [240, 245, 238],
|
9: [225, 238, 220], // forest / mountain land
|
||||||
};
|
};
|
||||||
color = colors[map.landuse[i]] || colors[0];
|
color = colors[map.landuse[i]] || colors[0];
|
||||||
} else if (mode === "admin") {
|
} else if (mode === "admin") {
|
||||||
|
|
@ -606,7 +609,8 @@ function drawDebugCells(ctx, map, field, color) {
|
||||||
for (let y = 0; y < MAP_H; y++) {
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
for (let x = 0; x < MAP_W; x++) {
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
const i = indexOf(x, y);
|
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);
|
const v = clamp(field[i] || 0, 0, 1);
|
||||||
if (v <= 0.12) continue;
|
if (v <= 0.12) continue;
|
||||||
ctx.fillStyle = color(v);
|
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 });
|
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") {
|
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
|
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
|
// 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)");
|
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 });
|
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;
|
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)");
|
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.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)");
|
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") {
|
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||||
|
|
@ -832,14 +839,14 @@ export function drawMap(canvas, map, options) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||||
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
|
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const important = [
|
const important = [
|
||||||
...map.modernCities,
|
...map.modernCities,
|
||||||
...map.ports,
|
...map.ports,
|
||||||
...(map.satelliteCities || []),
|
...(map.satelliteCities || []),
|
||||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway");
|
||||||
drawLabels(ctx, important, 60);
|
drawLabels(ctx, important, 60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue