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);
|
||||
if (!bothUrban && !bothLivingCorridor) return false;
|
||||
}
|
||||
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
||||
const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68;
|
||||
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
|
||||
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
|
||||
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge;
|
||||
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
|
||||
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
|
||||
const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50;
|
||||
return barrier < threshold && (!majorRiverEdge || urbanEdge);
|
||||
}
|
||||
|
||||
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
|
||||
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0;
|
||||
let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0;
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x; sy += y; pop += populationDensity[i];
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[i];
|
||||
coastalExposure += coastalLowland[i];
|
||||
|
|
@ -584,6 +586,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
|
|||
unit.area = area;
|
||||
unit.x = sx / Math.max(1, area);
|
||||
unit.y = sy / Math.max(1, area);
|
||||
unit.minX = area ? minX : 0;
|
||||
unit.minY = area ? minY : 0;
|
||||
unit.maxX = area ? maxX : 0;
|
||||
unit.maxY = area ? maxY : 0;
|
||||
unit.width = area ? maxX - minX + 1 : 0;
|
||||
unit.height = area ? maxY - minY + 1 : 0;
|
||||
unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height));
|
||||
unit.population = pop;
|
||||
unit.urbanWeight = urbanWeight / Math.max(1, area);
|
||||
unit.ridgeExposure = ridgeExposure / Math.max(1, area);
|
||||
|
|
@ -596,13 +605,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
|
|||
}
|
||||
|
||||
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
||||
if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
||||
let first = -1, second = -1, bestA = -INF, bestB = -INF;
|
||||
if (!unit || unit.area < 24) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
|
||||
const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20)));
|
||||
|
||||
let first = -1;
|
||||
let second = -1;
|
||||
let bestA = -INF;
|
||||
let bestB = -INF;
|
||||
const width = unit.width || (unit.maxX - unit.minX + 1) || 1;
|
||||
const height = unit.height || (unit.maxY - unit.minY + 1) || 1;
|
||||
const horizontal = width >= height;
|
||||
const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65;
|
||||
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
const score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04;
|
||||
const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0);
|
||||
const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0;
|
||||
const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10;
|
||||
if (score > bestA) { bestA = score; first = i; }
|
||||
}
|
||||
if (first < 0) return null;
|
||||
|
|
@ -610,36 +631,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
|
|||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0;
|
||||
const d = Math.hypot(x - fx, y - fy);
|
||||
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
|
||||
const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08;
|
||||
if (score > bestB) { bestB = score; second = i; }
|
||||
}
|
||||
if (second < 0 || second === first) return null;
|
||||
|
||||
const cellSet = new Set(unit.cells);
|
||||
const localOwner = new Map([[first, 0], [second, 1]]);
|
||||
const queue = [first, second];
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const owner = localOwner.get(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const owner = new Int8Array(SIZE);
|
||||
owner.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
|
||||
owner[source] = sourceOwner;
|
||||
dist[source] = 0;
|
||||
heap.push({ i: source, f: 0, owner: sourceOwner });
|
||||
}
|
||||
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
|
||||
localOwner.set(ni, owner);
|
||||
queue.push(ni);
|
||||
if (!cellSet.has(ni)) continue;
|
||||
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
|
||||
const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32;
|
||||
const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25;
|
||||
const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12));
|
||||
const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step;
|
||||
const nd = cur.f + stepCost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
owner[ni] = cur.owner;
|
||||
heap.push({ i: ni, f: nd, owner: cur.owner });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ci of unit.cells) if (!localOwner.has(ci)) {
|
||||
const [x, y] = xyOf(ci);
|
||||
const d0 = Math.hypot(x - fx, y - fy);
|
||||
const [sx, sy] = xyOf(second);
|
||||
const d1 = Math.hypot(x - sx, y - sy);
|
||||
localOwner.set(ci, d0 <= d1 ? 0 : 1);
|
||||
|
||||
const aCells = [];
|
||||
const bCells = [];
|
||||
for (const ci of unit.cells) {
|
||||
if (owner[ci] === 1) bCells.push(ci);
|
||||
else aCells.push(ci);
|
||||
}
|
||||
const aCells = [], bCells = [];
|
||||
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
|
||||
if (aCells.length < 10 || bCells.length < 10) return null;
|
||||
if (aCells.length < minPart || bCells.length < minPart) return null;
|
||||
|
||||
unit.cells = aCells;
|
||||
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
|
||||
for (const ci of bCells) compartmentId[ci] = newId;
|
||||
|
|
@ -663,7 +702,369 @@ function naturalGroupKey(unit) {
|
|||
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
|
||||
}
|
||||
|
||||
|
||||
function collectLandComponents(prefectureMask, sea) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
|
||||
const cells = [];
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) {
|
||||
const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8;
|
||||
const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
|
||||
const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0));
|
||||
const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0));
|
||||
const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08);
|
||||
const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22);
|
||||
return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10;
|
||||
}
|
||||
|
||||
function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
|
||||
const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||
const seeds = [];
|
||||
const seedComponentId = [];
|
||||
const minCellsPerUnit = 9;
|
||||
let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit)));
|
||||
|
||||
const sortedComponents = landComponents
|
||||
.map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length }))
|
||||
.sort((a, b) => b.area - a.area);
|
||||
|
||||
for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) {
|
||||
const { cells, componentIndex, area } = sortedComponents[componentOrder];
|
||||
if (area <= 0) continue;
|
||||
const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea));
|
||||
let localTarget = Math.max(1, proportional);
|
||||
localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit)));
|
||||
if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget));
|
||||
remainingTarget -= localTarget;
|
||||
|
||||
const candidates = cells
|
||||
.map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const localSeeds = [];
|
||||
const idealSpacing = Math.sqrt(area / Math.max(1, localTarget));
|
||||
const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34];
|
||||
for (const factor of spacingPasses) {
|
||||
const minDist = Math.max(2.2, idealSpacing * factor);
|
||||
for (const candidate of candidates) {
|
||||
if (localSeeds.length >= localTarget) break;
|
||||
const [x, y] = xyOf(candidate.i);
|
||||
let ok = true;
|
||||
for (const existing of localSeeds) {
|
||||
const [ex, ey] = xyOf(existing);
|
||||
if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; }
|
||||
}
|
||||
if (ok) localSeeds.push(candidate.i);
|
||||
}
|
||||
if (localSeeds.length >= localTarget) break;
|
||||
}
|
||||
for (const i of localSeeds) {
|
||||
seeds.push(i);
|
||||
seedComponentId.push(componentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (seeds.length === 0 && landComponents[0]?.length) {
|
||||
seeds.push(landComponents[0][0]);
|
||||
seedComponentId.push(0);
|
||||
}
|
||||
return { seeds, seedComponentId };
|
||||
}
|
||||
|
||||
function naturalStepCost(a, b, cellClass, fields) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields;
|
||||
const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5;
|
||||
const ridge = Math.max(ridgeField[a], ridgeField[b]);
|
||||
const riverEdge = Math.max(river[a], river[b]);
|
||||
const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0);
|
||||
const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55;
|
||||
const elevationBreak = Math.abs(elevation[a] - elevation[b]);
|
||||
const slopeBreak = Math.max(slope[a], slope[b]);
|
||||
const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08;
|
||||
const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) &&
|
||||
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30);
|
||||
const lowlandContinuity = Math.min(
|
||||
(plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20,
|
||||
(plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20
|
||||
);
|
||||
const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45);
|
||||
const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0));
|
||||
const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0;
|
||||
return Math.max(0.16,
|
||||
0.72 +
|
||||
barrier * 5.1 +
|
||||
ridge * 0.82 +
|
||||
elevationBreak * 3.0 +
|
||||
slopeBreak * 0.56 +
|
||||
riverPenalty +
|
||||
classBreak -
|
||||
corridorBonus
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) {
|
||||
const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields;
|
||||
let maxId = -1;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i];
|
||||
const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 }));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
const id = compartmentId[i];
|
||||
if (id >= 0 && units[id]) units[id].cells.push(i);
|
||||
}
|
||||
for (const unit of units) {
|
||||
if (!unit.cells.length) { unit.area = 0; continue; }
|
||||
const counts = new Map();
|
||||
for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1);
|
||||
let klass = -1, best = -1;
|
||||
for (const [k, count] of counts) if (count > best) { best = count; klass = k; }
|
||||
unit.classId = klass;
|
||||
unit.dominantLandscapeClass = klass;
|
||||
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
let riverExposure = 0;
|
||||
for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45;
|
||||
unit.riverExposure = riverExposure / Math.max(1, unit.area);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
|
||||
function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) {
|
||||
const queue = [];
|
||||
for (const unit of [...compartments]) {
|
||||
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
|
||||
const unitCellSet = new Set(unit.cells);
|
||||
const seen = new Set();
|
||||
const components = [];
|
||||
for (const start of unit.cells) {
|
||||
if (seen.has(start) || compartmentId[start] !== unit.id) continue;
|
||||
const cells = [];
|
||||
queue.length = 0;
|
||||
queue.push(start);
|
||||
seen.add(start);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue;
|
||||
seen.add(ni);
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => b.length - a.length);
|
||||
unit.cells = components[0];
|
||||
for (const extra of components.slice(1)) {
|
||||
const newId = compartments.length;
|
||||
for (const ci of extra) compartmentId[ci] = newId;
|
||||
compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renumberCompartments(compartmentId, compartments, prefectureMask, sea) {
|
||||
const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length);
|
||||
const idMap = new Map();
|
||||
active.forEach((unit, newId) => idMap.set(unit.id, newId));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
const id = compartmentId[i];
|
||||
if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1;
|
||||
else if (idMap.has(id)) compartmentId[i] = idMap.get(id);
|
||||
}
|
||||
active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; });
|
||||
return active;
|
||||
}
|
||||
|
||||
function refreshAllCompartmentStats(compartments, fields) {
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || !unit.cells?.length) continue;
|
||||
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
let riverExposure = 0;
|
||||
const counts = new Map();
|
||||
for (const ci of unit.cells) {
|
||||
riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45;
|
||||
if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1);
|
||||
}
|
||||
unit.riverExposure = riverExposure / Math.max(1, unit.area);
|
||||
}
|
||||
}
|
||||
|
||||
function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) {
|
||||
if (!unit || unit.area < 20) return null;
|
||||
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields;
|
||||
const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18)));
|
||||
let cx = unit.x || 0, cy = unit.y || 0;
|
||||
let first = -1, second = -1, bestA = -INF, bestB = -INF;
|
||||
const elongated = (unit.elongation || 1) > 2.3;
|
||||
const horizontal = (unit.width || 0) >= (unit.height || 0);
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const centerDist = Math.hypot(x - cx, y - cy);
|
||||
const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0;
|
||||
const interior = 1 - (naturalBarrierScore[i] || 0);
|
||||
const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10;
|
||||
if (score > bestA) { bestA = score; first = i; }
|
||||
}
|
||||
if (first < 0) return null;
|
||||
const [fx, fy] = xyOf(first);
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const d = Math.hypot(x - fx, y - fy);
|
||||
const interior = 1 - (naturalBarrierScore[i] || 0);
|
||||
const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08;
|
||||
if (score > bestB) { bestB = score; second = i; }
|
||||
}
|
||||
if (second < 0 || second === first) return null;
|
||||
|
||||
const cellSet = new Set(unit.cells);
|
||||
const owner = new Int8Array(SIZE);
|
||||
owner.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
|
||||
owner[source] = sourceOwner;
|
||||
dist[source] = 0;
|
||||
heap.push({ i: source, f: 0, owner: sourceOwner });
|
||||
}
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!cellSet.has(ni)) continue;
|
||||
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
owner[ni] = cur.owner;
|
||||
heap.push({ i: ni, f: nd, owner: cur.owner });
|
||||
}
|
||||
}
|
||||
}
|
||||
const aCells = [], bCells = [];
|
||||
for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci);
|
||||
if (aCells.length < minPart || bCells.length < minPart) return null;
|
||||
unit.cells = aCells;
|
||||
for (const ci of bCells) compartmentId[ci] = newId;
|
||||
const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass };
|
||||
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
|
||||
return newUnit;
|
||||
}
|
||||
|
||||
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
cellClass.fill(-1);
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass };
|
||||
const landComponents = collectLandComponents(prefectureMask, sea);
|
||||
const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0);
|
||||
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
||||
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
||||
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
seeds.forEach((i, id) => {
|
||||
compartmentId[i] = id;
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0, id });
|
||||
});
|
||||
while (heap.length > 0) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
for (const [nx, ny, step] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni]) continue;
|
||||
const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
compartmentId[ni] = cur.id;
|
||||
heap.push({ i: ni, f: nd, id: cur.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
|
||||
|
||||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
||||
let guard = Math.max(80, targetCount * 3);
|
||||
while (guard-- > 0) {
|
||||
let active = compartments.filter((unit) => unit && unit.area > 0);
|
||||
const needMore = active.length < targetCount;
|
||||
const worst = active
|
||||
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
||||
.sort((a, b) => {
|
||||
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
|
||||
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
|
||||
return sb - sa;
|
||||
})[0];
|
||||
if (!worst) break;
|
||||
const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97);
|
||||
if (!newUnit) {
|
||||
worst._splitRejected = (worst._splitRejected || 0) + 1;
|
||||
if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1);
|
||||
if (!needMore) break;
|
||||
continue;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) {
|
||||
const stillBad = compartments.some((unit) => unit && unit.area >= 20 && (
|
||||
unit.area > maxNaturalCompartmentArea * 1.35 ||
|
||||
((unit.elongation || 1) > 4.2 && unit.area > 28)
|
||||
));
|
||||
if (!stillBad) break;
|
||||
}
|
||||
}
|
||||
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
return { compartmentId, compartments, naturalBarrierScore };
|
||||
}
|
||||
|
||||
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
|
|
@ -679,6 +1080,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
const startClass = cellClass[i];
|
||||
const cells = [];
|
||||
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
compartmentId[i] = id;
|
||||
|
|
@ -687,6 +1089,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
const [x, y] = xyOf(cur);
|
||||
cells.push(cur);
|
||||
sx += x; sy += y; pop += populationDensity[cur];
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
|
|
@ -711,6 +1114,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
y: sy / area,
|
||||
classId: startClass,
|
||||
dominantLandscapeClass: startClass,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX + 1,
|
||||
height: maxY - minY + 1,
|
||||
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
|
||||
population: pop,
|
||||
urbanWeight: urbanWeight / area,
|
||||
ridgeExposure: ridgeExposure / area,
|
||||
|
|
@ -730,22 +1140,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
|
|||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
const targetCount = options.targetCompartmentCount || 0;
|
||||
if (targetCount > 0) {
|
||||
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore };
|
||||
let guard = targetCount * 3;
|
||||
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
|
||||
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
|
||||
const splitScore = (unit) => {
|
||||
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
|
||||
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
|
||||
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
|
||||
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
|
||||
};
|
||||
let guard = Math.max(targetCount * 4, 80);
|
||||
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
|
||||
const candidates = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
|
||||
.sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a));
|
||||
const target = candidates[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
||||
if (!newUnit) {
|
||||
target.lowlandFitness = 0;
|
||||
target._splitRejected = (target._splitRejected || 0) + 1;
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
|
||||
if (target._splitRejected > 2) target.area = target.cells.length;
|
||||
continue;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
|
||||
guard = Math.max(targetCount * 2, 60);
|
||||
while (guard-- > 0) {
|
||||
const target = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a))[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
|
||||
if (!newUnit) {
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
|
||||
break;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
return { compartmentId, compartments, naturalBarrierScore };
|
||||
|
|
@ -1081,6 +1516,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
|||
...relationMetrics,
|
||||
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
|
||||
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
||||
maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0,
|
||||
maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1,
|
||||
worstNaturalCompartments: activeCompartments
|
||||
.map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) }))
|
||||
.sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area))))
|
||||
.slice(0, 8),
|
||||
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
||||
voronoiLikeRateBefore: 0,
|
||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||
|
|
|
|||
1776
adminRegions.notrace.js
Normal file
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 { drawMap } from "./renderer.js";
|
||||
import { landuseLabel } from "./landuseCodes.js";
|
||||
|
||||
const modes = [
|
||||
["all", "All"],
|
||||
|
|
@ -57,6 +58,8 @@ function countText(items) {
|
|||
|
||||
function getStats(map) {
|
||||
return [
|
||||
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
||||
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
||||
["Villages", countText(map.villages)],
|
||||
["Market Towns", countText(map.markets)],
|
||||
["Castles", countText(map.castles)],
|
||||
|
|
@ -66,6 +69,7 @@ function getStats(map) {
|
|||
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
|
||||
["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"],
|
||||
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
|
||||
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
|
||||
["Modern Cities", countText(map.modernCities)],
|
||||
["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`],
|
||||
["Satellite Cities", countText(map.satelliteCities || [])],
|
||||
|
|
@ -148,18 +152,7 @@ function nearestEntity(map, x, y, maxDistance = 5) {
|
|||
}
|
||||
|
||||
function landuseName(value) {
|
||||
return {
|
||||
0: "Agriculture",
|
||||
1: "Plain",
|
||||
2: "Old urban area",
|
||||
3: "CBD / DID core",
|
||||
4: "Suburban urban area",
|
||||
5: "Industrial zone",
|
||||
6: "Logistics area",
|
||||
7: "New town",
|
||||
8: "Roadside development",
|
||||
9: "Forest / rural land",
|
||||
}[value] || "Land";
|
||||
return landuseLabel(value);
|
||||
}
|
||||
|
||||
function adminName(map, adminId) {
|
||||
|
|
|
|||
37
landuseCodes.js
Normal file
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 { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
|
||||
|
||||
const OUTER_ANCHOR_REGION_ID = -2;
|
||||
|
||||
function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) {
|
||||
if (prefectureMask[i]) return 0;
|
||||
const regionalId = prefectureRegionId?.[i] ?? -1;
|
||||
if (regionalId === 0) return OUTER_ANCHOR_REGION_ID;
|
||||
return regionalId;
|
||||
}
|
||||
|
||||
function changedCellsSince(before, after, prefectureMask, sea) {
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
|
||||
|
|
@ -580,7 +589,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c
|
|||
return changed;
|
||||
}
|
||||
|
||||
export function generateAdminLayout({
|
||||
function generateAdminLayoutForMask({
|
||||
seed,
|
||||
prefectureMask,
|
||||
sea,
|
||||
|
|
@ -617,8 +626,8 @@ export function generateAdminLayout({
|
|||
: ridgeField;
|
||||
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
|
||||
const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5);
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240);
|
||||
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
|
||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
|
|
@ -648,6 +657,7 @@ export function generateAdminLayout({
|
|||
seed,
|
||||
targetMunicipalityCount,
|
||||
targetCompartmentCount,
|
||||
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
||||
});
|
||||
const adminId = compartmentAssignment.adminId;
|
||||
let previousSnapshot = new Int16Array(adminId);
|
||||
|
|
@ -876,3 +886,179 @@ export function generateAdminLayout({
|
|||
|
||||
return { adminCentersRaw, adminId, adminBorders, adminDebug };
|
||||
}
|
||||
|
||||
|
||||
function filterPointsForMask(points = [], mask, sea) {
|
||||
return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||
}
|
||||
|
||||
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
|
||||
const mask = new Uint8Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function maskLandArea(mask, sea) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
|
||||
return area;
|
||||
}
|
||||
|
||||
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
|
||||
if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id);
|
||||
}
|
||||
return [...ids].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function generateAdminLayout(context) {
|
||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context;
|
||||
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
||||
.filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120);
|
||||
|
||||
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
||||
|
||||
const combinedAdminId = new Int16Array(SIZE);
|
||||
combinedAdminId.fill(-1);
|
||||
const combinedHumanMask = new Uint8Array(SIZE);
|
||||
const combinedCenters = [];
|
||||
const combinedCompartmentBorders = [];
|
||||
const perRegion = [];
|
||||
let idOffset = 0;
|
||||
|
||||
for (const regionId of regionIds) {
|
||||
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const regionArea = maskLandArea(regionMask, sea);
|
||||
if (regionArea < 120) continue;
|
||||
|
||||
const localContext = {
|
||||
...context,
|
||||
seed: (context.seed + regionId * 10007) >>> 0,
|
||||
prefectureMask: regionMask,
|
||||
modernCities: filterPointsForMask(context.modernCities, regionMask, sea),
|
||||
satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea),
|
||||
newTowns: filterPointsForMask(context.newTowns, regionMask, sea),
|
||||
markets: filterPointsForMask(context.markets, regionMask, sea),
|
||||
villages: filterPointsForMask(context.villages, regionMask, sea),
|
||||
ports: filterPointsForMask(context.ports, regionMask, sea),
|
||||
stations: filterPointsForMask(context.stations, regionMask, sea),
|
||||
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
||||
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
||||
};
|
||||
|
||||
const local = generateAdminLayoutForMask(localContext);
|
||||
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
|
||||
let localMaxAdminId = -1;
|
||||
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
|
||||
const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1);
|
||||
const localCenters = [];
|
||||
for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) {
|
||||
let center = local.adminCentersRaw?.[localAdminId];
|
||||
if (!center) {
|
||||
let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF;
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue;
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
count++;
|
||||
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
if (count && bestI >= 0) {
|
||||
const [bx, by] = xyOf(bestI);
|
||||
center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true };
|
||||
}
|
||||
}
|
||||
if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true };
|
||||
localCenters.push({
|
||||
...center,
|
||||
regionId,
|
||||
localAdminId,
|
||||
adminIdOffset: idOffset,
|
||||
});
|
||||
}
|
||||
combinedCenters.push(...localCenters);
|
||||
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!regionMask[i] || sea[i]) continue;
|
||||
combinedHumanMask[i] = 1;
|
||||
const localId = local.adminId?.[i] ?? -1;
|
||||
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
|
||||
}
|
||||
|
||||
perRegion.push({
|
||||
regionId,
|
||||
area: regionArea,
|
||||
centerCount: localCenters.length,
|
||||
municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size,
|
||||
naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0,
|
||||
targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0,
|
||||
averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0,
|
||||
maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0,
|
||||
maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1,
|
||||
worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [],
|
||||
singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0,
|
||||
});
|
||||
idOffset += localSlotCount;
|
||||
}
|
||||
|
||||
const leftoverByRegion = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
|
||||
if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue;
|
||||
if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []);
|
||||
leftoverByRegion.get(regionId).push(i);
|
||||
}
|
||||
for (const [regionId, cells] of leftoverByRegion) {
|
||||
let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [cx, cy] = xyOf(bestI);
|
||||
const id = combinedCenters.length;
|
||||
combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
|
||||
for (const i of cells) {
|
||||
combinedHumanMask[i] = 1;
|
||||
combinedAdminId[i] = id;
|
||||
}
|
||||
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
}
|
||||
|
||||
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
||||
const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size;
|
||||
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
|
||||
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
|
||||
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
|
||||
const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0);
|
||||
const adminDebug = {
|
||||
multiRegionAdmin: true,
|
||||
adminRegionCount: perRegion.length,
|
||||
perRegion,
|
||||
finalMunicipalityCount: totalMunicipalityCount,
|
||||
actualMunicipalityCount: totalMunicipalityCount,
|
||||
candidateSeedCount: combinedCenters.length,
|
||||
naturalCompartmentCount: totalNaturalCompartmentCount,
|
||||
compartmentCount: totalNaturalCompartmentCount,
|
||||
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
|
||||
averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0,
|
||||
maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)),
|
||||
maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)),
|
||||
averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0,
|
||||
singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0,
|
||||
compartmentBorders: combinedCompartmentBorders,
|
||||
};
|
||||
|
||||
return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug };
|
||||
}
|
||||
|
|
|
|||
1064
mapAdminStage.nolog.js
Normal file
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 pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
|
||||
|
||||
const displayRegionId = new Int16Array(beforeRegionId);
|
||||
for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200);
|
||||
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||
|
|
@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
|
||||
return {
|
||||
regionId,
|
||||
displayRegionId,
|
||||
centers: seeded.centers,
|
||||
naturalBarrierScore,
|
||||
debug: {
|
||||
|
|
@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
|
||||
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
|
||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
changedAfterCompartmentAssignment: changed,
|
||||
|
|
@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities,
|
|||
}
|
||||
}
|
||||
}
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const capitalLike = city.isPrefecturalCapital || city.isRegionalCapital;
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const coreComponent = coreCells * 3200;
|
||||
const densityComponent = densitySum * 650;
|
||||
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
|
||||
const densityComponent = densitySum * 360;
|
||||
const computedPopulation = base + urbanComponent + coreComponent + densityComponent;
|
||||
const footprintCells = city.urbanFootprintCells || urbanCells;
|
||||
const footprintCoreCells = city.coreFootprintCells || coreCells;
|
||||
const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000);
|
||||
city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
||||
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
|
||||
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const land = fields.landuse?.[i] ?? 0;
|
||||
const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite;
|
||||
const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46;
|
||||
if (urban) return "市";
|
||||
if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村";
|
||||
return "町";
|
||||
}
|
||||
|
||||
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
||||
let value = String(root || center?.name || "").trim();
|
||||
if (!value) value = `自治${ordinal + 1}`;
|
||||
value = value.replace(/[駅港城跡宿]$/u, "");
|
||||
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
|
||||
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
|
||||
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
|
||||
}
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
|
|
@ -84,8 +106,12 @@ export function finishMapOutput({
|
|||
regionalPrefectureBorders,
|
||||
}) {
|
||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
||||
// This keeps population figures proportional to the actually rendered urbanized area.
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
||||
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
|
||||
for (let i = 0; i < humanRegionMask.length; i++) {
|
||||
humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0;
|
||||
}
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
for (const city of modernCities) {
|
||||
if (city.isPrefecturalCapital) continue;
|
||||
const cap = cityPopulationCap(city);
|
||||
|
|
@ -145,13 +171,13 @@ export function finishMapOutput({
|
|||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const representativeFeatures = [
|
||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
|
||||
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
|
||||
].filter((p) => p.insidePrefecture && p.name);
|
||||
].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)]));
|
||||
for (const center of adminCenters) {
|
||||
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
|
||||
let best = null;
|
||||
|
|
@ -169,21 +195,30 @@ export function finishMapOutput({
|
|||
}
|
||||
if (best) break;
|
||||
}
|
||||
center.generatedMunicipalityName = center.generatedMunicipalityName || center.name;
|
||||
if (best) {
|
||||
center.representativeFeatureId = best.id;
|
||||
center.representativeFeatureName = best.name;
|
||||
center.generatedMunicipalityName = center.name;
|
||||
center.name = best.name;
|
||||
center.municipalityRootName = best.name;
|
||||
} else {
|
||||
center.municipalityRootName = center.generatedMunicipalityName;
|
||||
}
|
||||
}
|
||||
const usedAdminNames = new Set();
|
||||
for (const center of adminCenters) {
|
||||
let candidate = center.name;
|
||||
const generated = String(center.generatedMunicipalityName || "");
|
||||
for (const [index, center] of adminCenters.entries()) {
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
|
||||
candidate = `${base}${index + 1}${suffix}`;
|
||||
}
|
||||
center.name = candidate;
|
||||
center.labelName = candidate;
|
||||
center.municipalityName = candidate;
|
||||
usedAdminNames.add(center.name);
|
||||
}
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
|
|
@ -212,6 +247,7 @@ export function finishMapOutput({
|
|||
terrainTemplate,
|
||||
seaLevel,
|
||||
prefectureMask,
|
||||
humanRegionMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
adminPrefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
|
|
@ -61,7 +62,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
} = features;
|
||||
|
||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
||||
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
});
|
||||
|
||||
|
|
|
|||
469
mapTerrain.js
469
mapTerrain.js
|
|
@ -38,6 +38,15 @@ function quantile(values, q) {
|
|||
return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f);
|
||||
}
|
||||
|
||||
function softCapElevation(e, start = 0.91, cap = 1.08) {
|
||||
if (e <= start) return e;
|
||||
const over = e - start;
|
||||
const span = Math.max(0.001, cap - start);
|
||||
// Hard clipping made high mountains become flat mesas. This keeps peaks high,
|
||||
// but compresses only the excess so local relief survives near the top.
|
||||
return start + span * (1 - Math.exp(-over / span));
|
||||
}
|
||||
|
||||
function forDisk(cx, cy, radius, fn) {
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
|
|
@ -131,7 +140,12 @@ function ellipticalMask(px, py, system) {
|
|||
const dy = py - system.y;
|
||||
const { u, v } = rotate(dx, dy, system.angle);
|
||||
const a = Math.max(0.01, system.length * 0.5);
|
||||
const b = Math.max(0.01, system.width * 0.5);
|
||||
const along = clamp((u / a + 1) * 0.5);
|
||||
const widthWave = 1
|
||||
+ (system.widthVariance ?? 0.28) * Math.sin((along + (system.phase ?? 0)) * Math.PI * 2.0)
|
||||
+ (system.widthVariance ?? 0.28) * 0.50 * Math.sin((along * 2.7 + (system.phase ?? 0) * 1.7) * Math.PI * 2.0);
|
||||
const endTaper = lerp(0.60, 1.0, Math.sin(along * Math.PI));
|
||||
const b = Math.max(0.012, system.width * 0.5 * clamp(widthWave, 0.62, 1.60) * endTaper);
|
||||
const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2);
|
||||
return clamp(1 - smoothstep((r - 0.55) / 0.65));
|
||||
}
|
||||
|
|
@ -142,35 +156,203 @@ function sampleInsideUnitDisk(seed, n) {
|
|||
return { x: Math.cos(a) * r, y: Math.sin(a) * r, r };
|
||||
}
|
||||
|
||||
const TERRAIN_TYPES = [
|
||||
{
|
||||
id: "tohoku_spine",
|
||||
label: "東北型・長大脊梁",
|
||||
weight: 0.24,
|
||||
coastStyle: "parallel_spine",
|
||||
mountainMode: "range",
|
||||
massifnessRange: [0.06, 0.26],
|
||||
seaRatioRange: [0.13, 0.23],
|
||||
twoSidedChance: 0.96,
|
||||
mountainOffsetRange: [0.47, 0.53],
|
||||
baseHeightRange: [0.74, 1.10],
|
||||
primaryLengthRange: [0.76, 0.96],
|
||||
primaryWidthRange: [0.17, 0.30],
|
||||
systemCountRange: [12, 16],
|
||||
beltCountRange: [3, 4],
|
||||
angleSpread: 0.14,
|
||||
crossSpread: 0.54,
|
||||
lengthScale: 1.22,
|
||||
widthScale: 1.16,
|
||||
heightScale: 1.24,
|
||||
coastStrength: 0.90,
|
||||
plainBiasRange: [0.16, 0.34],
|
||||
riverRichnessRange: [0.70, 1.18],
|
||||
bigRiverChanceRange: [0.22, 0.46],
|
||||
},
|
||||
{
|
||||
id: "chubu_mountain",
|
||||
label: "中部型・交差高山地",
|
||||
weight: 0.24,
|
||||
coastStyle: "outer_coast",
|
||||
mountainMode: "massif",
|
||||
massifnessRange: [0.42, 0.74],
|
||||
seaRatioRange: [0.10, 0.20],
|
||||
twoSidedChance: 0.20,
|
||||
mountainOffsetRange: [0.16, 0.36],
|
||||
baseHeightRange: [0.68, 1.04],
|
||||
primaryLengthRange: [0.62, 0.92],
|
||||
primaryWidthRange: [0.30, 0.58],
|
||||
systemCountRange: [16, 20],
|
||||
beltCountRange: [3, 5],
|
||||
angleSpread: 0.92,
|
||||
crossSpread: 0.82,
|
||||
lengthScale: 1.34,
|
||||
widthScale: 1.30,
|
||||
heightScale: 1.12,
|
||||
coastStrength: 0.74,
|
||||
plainBiasRange: [0.08, 0.24],
|
||||
riverRichnessRange: [0.72, 1.12],
|
||||
bigRiverChanceRange: [0.28, 0.58],
|
||||
},
|
||||
{
|
||||
id: "setouchi_inland_sea",
|
||||
label: "瀬戸内型・内海多島",
|
||||
weight: 0.16,
|
||||
coastStyle: "inland_sea",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.24, 0.48],
|
||||
seaRatioRange: [0.20, 0.33],
|
||||
twoSidedChance: 0.92,
|
||||
mountainOffsetRange: [0.22, 0.34],
|
||||
baseHeightRange: [0.56, 1.00],
|
||||
primaryLengthRange: [0.52, 0.76],
|
||||
primaryWidthRange: [0.18, 0.34],
|
||||
systemCountRange: [12, 16],
|
||||
beltCountRange: [2, 3],
|
||||
angleSpread: 0.24,
|
||||
crossSpread: 0.70,
|
||||
lengthScale: 0.98,
|
||||
widthScale: 1.08,
|
||||
heightScale: 1.08,
|
||||
coastStrength: 1.10,
|
||||
plainBiasRange: [0.26, 0.50],
|
||||
riverRichnessRange: [0.58, 0.96],
|
||||
bigRiverChanceRange: [0.18, 0.42],
|
||||
},
|
||||
{
|
||||
id: "kanto_alluvial",
|
||||
label: "関東・濃尾型・大河川平野",
|
||||
weight: 0.16,
|
||||
coastStyle: "open_bay",
|
||||
mountainMode: "range",
|
||||
massifnessRange: [0.18, 0.44],
|
||||
seaRatioRange: [0.15, 0.26],
|
||||
twoSidedChance: 0.18,
|
||||
mountainOffsetRange: [0.28, 0.46],
|
||||
baseHeightRange: [0.62, 1.04],
|
||||
primaryLengthRange: [0.42, 0.70],
|
||||
primaryWidthRange: [0.20, 0.36],
|
||||
systemCountRange: [10, 14],
|
||||
beltCountRange: [2, 3],
|
||||
angleSpread: 0.34,
|
||||
crossSpread: 0.62,
|
||||
lengthScale: 0.92,
|
||||
widthScale: 1.10,
|
||||
heightScale: 1.10,
|
||||
coastStrength: 0.92,
|
||||
plainBiasRange: [0.56, 0.86],
|
||||
riverRichnessRange: [0.98, 1.38],
|
||||
bigRiverChanceRange: [0.62, 0.90],
|
||||
},
|
||||
{
|
||||
id: "mixed_archipelago",
|
||||
label: "混合型・列島変化",
|
||||
weight: 0.20,
|
||||
coastStyle: "mixed_archipelago",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.16, 0.72],
|
||||
seaRatioRange: [0.13, 0.29],
|
||||
twoSidedChance: 0.42,
|
||||
mountainOffsetRange: [0.18, 0.40],
|
||||
baseHeightRange: [0.68, 1.18],
|
||||
primaryLengthRange: [0.46, 0.82],
|
||||
primaryWidthRange: [0.20, 0.48],
|
||||
systemCountRange: [14, 17],
|
||||
beltCountRange: [3, 4],
|
||||
angleSpread: 0.50,
|
||||
crossSpread: 0.74,
|
||||
lengthScale: 1.00,
|
||||
widthScale: 1.18,
|
||||
heightScale: 1.25,
|
||||
coastStrength: 0.96,
|
||||
plainBiasRange: [0.22, 0.52],
|
||||
riverRichnessRange: [0.72, 1.26],
|
||||
bigRiverChanceRange: [0.34, 0.68],
|
||||
},
|
||||
];
|
||||
|
||||
function pickTerrainType(seed) {
|
||||
const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0);
|
||||
let r = rand(seed, 10001) * total;
|
||||
for (const type of TERRAIN_TYPES) {
|
||||
r -= type.weight;
|
||||
if (r <= 0) return type;
|
||||
}
|
||||
return TERRAIN_TYPES[TERRAIN_TYPES.length - 1];
|
||||
}
|
||||
|
||||
function rangeValue(seed, salt, [lo, hi]) {
|
||||
return lo + rand(seed, salt) * (hi - lo);
|
||||
}
|
||||
|
||||
function rangeInt(seed, salt, [lo, hi]) {
|
||||
return Math.round(lo + rand(seed, salt) * (hi - lo));
|
||||
}
|
||||
|
||||
export function buildTerrainTemplate(seed) {
|
||||
const mountainModeRoll = rand(seed, 12);
|
||||
const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif";
|
||||
const mountainMassifness = mountainMode === "massif" ? 0.72 + rand(seed, 13) * 0.24 : mountainMode === "mixed" ? 0.34 + rand(seed, 14) * 0.36 : rand(seed, 15) * 0.24;
|
||||
const coastAngle = rand(seed, 21) * Math.PI * 2;
|
||||
const twoSidedCoast = rand(seed, 22) < 0.36;
|
||||
const seaRatio = 0.14 + rand(seed, 23) * 0.16;
|
||||
const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48);
|
||||
const baseHeight = 0.52 + rand(seed, 25) * 0.46;
|
||||
const primaryLength = lerp(0.70 + rand(seed, 26) * 0.22, 0.38 + rand(seed, 27) * 0.20, mountainMassifness);
|
||||
const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness);
|
||||
const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness));
|
||||
// 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、
|
||||
// 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。
|
||||
const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16
|
||||
const terrainType = pickTerrainType(seed);
|
||||
const mountainMode = terrainType.mountainMode === "mixed"
|
||||
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
|
||||
: terrainType.mountainMode;
|
||||
let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange);
|
||||
if (mountainMode === "range") mountainMassifness *= 0.70;
|
||||
if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12);
|
||||
|
||||
let coastAngle = rand(seed, 21) * Math.PI * 2;
|
||||
let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance;
|
||||
const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange);
|
||||
let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange));
|
||||
if (terrainType.id === "tohoku_spine") {
|
||||
// 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。
|
||||
// coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。
|
||||
coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10;
|
||||
twoSidedCoast = true;
|
||||
mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16;
|
||||
}
|
||||
const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange);
|
||||
const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange);
|
||||
const primaryWidth = rangeValue(seed, 28, terrainType.primaryWidthRange);
|
||||
const scratchCount = Math.round(lerp(24 + rand(seed, 30) * 20, 16 + rand(seed, 31) * 18, mountainMassifness));
|
||||
const mountainSystemCount = rangeInt(seed, 32, terrainType.systemCountRange);
|
||||
const mountainBeltCount = rangeInt(seed, 46, terrainType.beltCountRange);
|
||||
|
||||
return {
|
||||
seed,
|
||||
terrainType: terrainType.id,
|
||||
terrainTypeLabel: terrainType.label,
|
||||
coastStyle: terrainType.coastStyle,
|
||||
seaRatio,
|
||||
coastAngle,
|
||||
twoSidedCoast,
|
||||
coastNoise: 0.045 + rand(seed, 33) * 0.045,
|
||||
coastNoise: 0.040 + rand(seed, 33) * 0.056,
|
||||
coastStrength: terrainType.coastStrength,
|
||||
mountainMode,
|
||||
mountainMassifness,
|
||||
mountainAngle,
|
||||
mountainAngleSpread: terrainType.angleSpread,
|
||||
mountainCrossSpread: terrainType.crossSpread,
|
||||
mountainLengthScale: terrainType.lengthScale,
|
||||
mountainWidthScale: terrainType.widthScale,
|
||||
mountainHeightScale: terrainType.heightScale,
|
||||
mountainBeltCount,
|
||||
mountainBaseHeight: baseHeight,
|
||||
mountainDensity: 0.62 + rand(seed, 34) * 0.35,
|
||||
mountainDensity: 0.58 + rand(seed, 34) * 0.39,
|
||||
primaryMountain: {
|
||||
x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.28, 0.22, 0.78),
|
||||
y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.28, 0.22, 0.78),
|
||||
x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.36, 0.18, 0.82),
|
||||
y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82),
|
||||
angle: mountainAngle,
|
||||
length: primaryLength,
|
||||
width: primaryWidth,
|
||||
|
|
@ -186,9 +368,9 @@ export function buildTerrainTemplate(seed) {
|
|||
roughness: 0.40 + rand(seed, 40) * 0.50,
|
||||
erosion: 0.34 + rand(seed, 41) * 0.48,
|
||||
deposition: 0.28 + rand(seed, 42) * 0.56,
|
||||
riverRichness: 0.72 + rand(seed, 43) * 0.60,
|
||||
bigRiverChance: 0.34 + rand(seed, 44) * 0.34,
|
||||
plainBias: 0.32 + rand(seed, 45) * 0.46,
|
||||
riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange),
|
||||
bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange),
|
||||
plainBias: rangeValue(seed, 45, terrainType.plainBiasRange),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -196,85 +378,146 @@ function buildMountainSystems(template, seed) {
|
|||
const systems = [];
|
||||
const targetCount = Math.max(8, template.mountainSystemCount ?? 15);
|
||||
const baseAngle = template.mountainAngle;
|
||||
const angleSpread = template.mountainAngleSpread ?? 0.42;
|
||||
const crossSpread = template.mountainCrossSpread ?? 0.70;
|
||||
const lengthScale = template.mountainLengthScale ?? 1;
|
||||
const widthScale = template.mountainWidthScale ?? 1;
|
||||
const heightScale = template.mountainHeightScale ?? 1;
|
||||
const isChubu = template.terrainType === "chubu_mountain";
|
||||
const isTohoku = template.terrainType === "tohoku_spine";
|
||||
|
||||
// 複数の脊梁山脈システムを、画面中央ではなくマップ全域に分散配置する。
|
||||
// 5x3 / 4x4 に近い粗い格子へ jitter を入れ、さらに farthest-candidate で
|
||||
// 既存システムから離れた候補を選ぶ。これにより「中央に単一山塊」化しにくくする。
|
||||
const cols = targetCount >= 14 ? 5 : 4;
|
||||
const rows = Math.ceil(targetCount / cols);
|
||||
const cellOrder = Array.from({ length: cols * rows }, (_, i) => i)
|
||||
.map((v) => ({ v, key: rand(seed, 1000 + v * 17) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((o) => o.v);
|
||||
// 山脈システムは完全ランダムではなく、複数の広い造山帯に沿って配置する。
|
||||
// これにより「方向性はそこそこ揃う」が、「中央一点に集まらない」分布になる。
|
||||
const beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3));
|
||||
const belts = [];
|
||||
for (let b = 0; b < beltCount; b++) {
|
||||
const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5);
|
||||
const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread;
|
||||
const axisX = Math.cos(angle);
|
||||
const axisY = Math.sin(angle);
|
||||
const crossX = Math.cos(angle + Math.PI / 2);
|
||||
const crossY = Math.sin(angle + Math.PI / 2);
|
||||
const crossOffset = t * crossSpread + (rand(seed, 1010 + b) - 0.5) * (0.10 + crossSpread * 0.08);
|
||||
const alongShift = (rand(seed, 1020 + b) - 0.5) * 0.22;
|
||||
belts.push({
|
||||
angle,
|
||||
x: clamp(0.50 + axisX * alongShift / ASPECT + crossX * crossOffset / ASPECT, 0.08, 0.92),
|
||||
y: clamp(0.50 + axisY * alongShift + crossY * crossOffset, 0.08, 0.92),
|
||||
lengthBias: 0.82 + rand(seed, 1030 + b) * 0.32,
|
||||
heightBias: 0.82 + rand(seed, 1040 + b) * 0.42,
|
||||
});
|
||||
}
|
||||
|
||||
function gridCandidate(k, attempt) {
|
||||
const cell = cellOrder[(k + attempt * 7) % cellOrder.length];
|
||||
const cx = cell % cols;
|
||||
const cy = Math.floor(cell / cols);
|
||||
const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62;
|
||||
const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62;
|
||||
const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945);
|
||||
const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945);
|
||||
const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92;
|
||||
const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16;
|
||||
function beltCandidate(k, attempt) {
|
||||
const beltIndex = (k + Math.floor(k / beltCount)) % beltCount;
|
||||
const belt = belts[beltIndex];
|
||||
const perBelt = Math.ceil(targetCount / beltCount);
|
||||
const ordinal = Math.floor(k / beltCount);
|
||||
const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5;
|
||||
const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28);
|
||||
const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40);
|
||||
const along = (baseT + alongJitter) * 1.03 * belt.lengthBias;
|
||||
const cross = crossJitter;
|
||||
const axisX = Math.cos(belt.angle);
|
||||
const axisY = Math.sin(belt.angle);
|
||||
const crossX = Math.cos(belt.angle + Math.PI / 2);
|
||||
const crossY = Math.sin(belt.angle + Math.PI / 2);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
angle: baseAngle + localTurn + diagonalBias,
|
||||
x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955),
|
||||
y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955),
|
||||
angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82,
|
||||
beltIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function randomCandidate(k, attempt) {
|
||||
return {
|
||||
x: clamp(0.055 + rand(seed, 2000 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945),
|
||||
y: clamp(0.055 + rand(seed, 2100 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945),
|
||||
angle: baseAngle + (rand(seed, 2200 + k * 137 + attempt) - 0.5) * Math.PI * 1.05,
|
||||
};
|
||||
function edgeAwareScore(c) {
|
||||
const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y);
|
||||
const centerD = distNorm(c.x, c.y, 0.5, 0.5);
|
||||
return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08;
|
||||
}
|
||||
|
||||
function candidateAt(k, attempt) {
|
||||
return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt);
|
||||
if (isTohoku) {
|
||||
const centralAngle = baseAngle + (rand(seed, 3330) - 0.5) * 0.06;
|
||||
const centralAlong = (rand(seed, 3331) - 0.5) * 0.10;
|
||||
const centralCross = (rand(seed, 3332) - 0.5) * 0.045;
|
||||
systems.push({
|
||||
x: clamp(0.50 + Math.cos(centralAngle) * centralAlong / ASPECT + Math.cos(centralAngle + Math.PI / 2) * centralCross / ASPECT, 0.12, 0.88),
|
||||
y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88),
|
||||
angle: centralAngle,
|
||||
length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale,
|
||||
width: (0.17 + rand(seed, 3334) * 0.11) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18),
|
||||
scratchCount: Math.round(20 + rand(seed, 3336) * 10),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55),
|
||||
role: "central-primary",
|
||||
beltIndex: 0,
|
||||
widthVariance: 0.30 + rand(seed, 3337) * 0.46,
|
||||
phase: rand(seed, 3338),
|
||||
});
|
||||
|
||||
if (rand(seed, 3339) < 0.72) {
|
||||
const side = rand(seed, 3340) < 0.5 ? -1 : 1;
|
||||
systems.push({
|
||||
x: clamp(0.50 + Math.cos(centralAngle) * (centralAlong + side * 0.18) / ASPECT + Math.cos(centralAngle + Math.PI / 2) * (centralCross + side * 0.028) / ASPECT, 0.10, 0.90),
|
||||
y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90),
|
||||
angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08,
|
||||
length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale,
|
||||
width: (0.11 + rand(seed, 3343) * 0.08) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16),
|
||||
scratchCount: Math.round(12 + rand(seed, 3345) * 8),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65),
|
||||
role: "central-secondary",
|
||||
beltIndex: 0,
|
||||
widthVariance: 0.24 + rand(seed, 3346) * 0.36,
|
||||
phase: rand(seed, 3347),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let k = 0; k < targetCount; k++) {
|
||||
let best = candidateAt(k, 0);
|
||||
for (let k = systems.length; k < targetCount; k++) {
|
||||
let best = beltCandidate(k, 0);
|
||||
let bestScore = -INF;
|
||||
for (let attempt = 0; attempt < 18; attempt++) {
|
||||
const c = candidateAt(k, attempt);
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const c = beltCandidate(k, attempt);
|
||||
let minD = 999;
|
||||
for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y));
|
||||
// 中央集中を避けるため、中心距離を少し加点する。ただし端に張り付きすぎないよう edge も見る。
|
||||
const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y);
|
||||
const centerD = distNorm(c.x, c.y, 0.5, 0.5);
|
||||
const score =
|
||||
minD * 1.25 +
|
||||
centerD * 0.18 +
|
||||
Math.min(edgeD, 0.16) * 0.22 +
|
||||
rand(seed, 2300 + k * 101 + attempt) * 0.04;
|
||||
const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035;
|
||||
if (score > bestScore) { bestScore = score; best = c; }
|
||||
}
|
||||
|
||||
const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.50);
|
||||
const belt = belts[best.beltIndex];
|
||||
const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.38);
|
||||
const isMassif = m > 0.58;
|
||||
const major = k < 4 || rand(seed, 2500 + k) > 0.68;
|
||||
const major = k < beltCount || rand(seed, 2500 + k) > 0.72;
|
||||
const lengthBaseRange = isChubu
|
||||
? (major ? [0.48, 0.78] : [0.34, 0.58])
|
||||
: isTohoku
|
||||
? (major ? [0.44, 0.72] : [0.28, 0.48])
|
||||
: (major ? [0.32, 0.52] : [0.21, 0.36]);
|
||||
const lengthMassifRange = isChubu
|
||||
? (major ? [0.38, 0.58] : [0.28, 0.44])
|
||||
: (major ? [0.23, 0.35] : [0.17, 0.27]);
|
||||
const length = lerp(
|
||||
major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16,
|
||||
major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12,
|
||||
lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]),
|
||||
lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]),
|
||||
m
|
||||
);
|
||||
) * belt.lengthBias * lengthScale;
|
||||
const widthRangeA = major ? [0.082, 0.170] : [0.060, 0.120];
|
||||
const widthRangeB = major ? [0.150, 0.260] : [0.110, 0.200];
|
||||
const width = lerp(
|
||||
major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045,
|
||||
major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070,
|
||||
widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]),
|
||||
widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]),
|
||||
m
|
||||
);
|
||||
const height = template.mountainBaseHeight * (
|
||||
) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46);
|
||||
const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * (
|
||||
major
|
||||
? 0.34 + rand(seed, 2800 + k) * 0.24
|
||||
: 0.20 + rand(seed, 2810 + k) * 0.18
|
||||
? 0.44 + rand(seed, 2800 + k) * 0.28
|
||||
: 0.26 + rand(seed, 2810 + k) * 0.20
|
||||
);
|
||||
const height = isChubu ? heightBase * 0.82 : heightBase;
|
||||
const scratchCount = Math.round(lerp(
|
||||
major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7,
|
||||
isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7,
|
||||
major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6,
|
||||
isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6,
|
||||
m
|
||||
));
|
||||
|
||||
|
|
@ -287,7 +530,10 @@ function buildMountainSystems(template, seed) {
|
|||
height,
|
||||
scratchCount,
|
||||
massifness: m,
|
||||
role: major ? (k < 4 ? "primary" : "major") : "minor",
|
||||
role: major ? (k < beltCount ? "primary" : "major") : "minor",
|
||||
beltIndex: best.beltIndex,
|
||||
widthVariance: 0.18 + rand(seed, 3100 + k) * 0.46,
|
||||
phase: rand(seed, 3200 + k),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -308,8 +554,8 @@ function buildScratchRidges(system, seed, systemId) {
|
|||
const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97);
|
||||
const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97);
|
||||
const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness);
|
||||
const width = lerp(0.010 + rand(seed, 2300 + i) * 0.012, 0.018 + rand(seed, 2300 + i) * 0.020, system.massifness) * (0.80 + density * 0.60);
|
||||
const height = system.height * (0.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035);
|
||||
const width = lerp(0.014 + rand(seed, 2300 + i) * 0.018, 0.024 + rand(seed, 2300 + i) * 0.026, system.massifness) * (0.85 + density * 0.70);
|
||||
const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050);
|
||||
ridges.push({
|
||||
x, y,
|
||||
angle: localAngle,
|
||||
|
|
@ -327,13 +573,45 @@ function buildScratchRidges(system, seed, systemId) {
|
|||
}
|
||||
|
||||
function computeCoastLower(px, py, template, seed) {
|
||||
const axis = (px - 0.5) * Math.cos(template.coastAngle) * ASPECT + (py - 0.5) * Math.sin(template.coastAngle);
|
||||
const angle = template.coastAngle;
|
||||
const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle);
|
||||
const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle);
|
||||
const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise;
|
||||
const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055;
|
||||
const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26);
|
||||
const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0;
|
||||
const pressure = Math.max(sideA, sideB);
|
||||
return { pressure, signedAxis: axis };
|
||||
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
||||
let pressure = 0;
|
||||
|
||||
if (template.coastStyle === "inland_sea") {
|
||||
// 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。
|
||||
const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26);
|
||||
const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27);
|
||||
const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82;
|
||||
pressure = Math.max(sideA, sideB, channel);
|
||||
} else if (template.coastStyle === "parallel_spine") {
|
||||
// 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。
|
||||
// 内海的な中央水路は作らない。
|
||||
const edgeA = smoothstep((-axis - 0.26 + wave * 0.42 + bay * 0.35) / 0.20);
|
||||
const edgeB = template.twoSidedCoast ? smoothstep((axis - 0.26 - wave * 0.42 + bay * 0.25) / 0.22) * 0.86 : 0;
|
||||
pressure = Math.max(edgeA, edgeB);
|
||||
} else if (template.coastStyle === "outer_coast") {
|
||||
// 中部型: 外縁海を中心にし、内陸へ海が入り込みすぎないようにする。
|
||||
const radial = distNorm(px, py, 0.5, 0.5);
|
||||
const outer = smoothstep((radial - 0.44 + wave * 0.8 + bay * 0.5) / 0.24);
|
||||
const side = smoothstep((-axis + 0.30 + wave) / 0.30) * 0.45;
|
||||
pressure = Math.max(outer, side);
|
||||
} else if (template.coastStyle === "open_bay") {
|
||||
// 関東・濃尾型: 一方向に開いた湾と、その背後の沖積平野を作りやすくする。
|
||||
const openSide = smoothstep((-axis + 0.29 + wave + bay) / 0.25);
|
||||
const bayMouth = smoothstep((0.22 - Math.abs(cross + wave * 0.8)) / 0.25) * smoothstep((-axis + 0.16 + bay) / 0.22) * 0.68;
|
||||
pressure = Math.max(openSide, bayMouth);
|
||||
} else {
|
||||
const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26);
|
||||
const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0;
|
||||
const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25;
|
||||
pressure = Math.max(sideA, sideB, outerBite);
|
||||
}
|
||||
|
||||
return { pressure: clamp(pressure), signedAxis: axis };
|
||||
}
|
||||
|
||||
function recomputeSlope(elevation, sea, slope) {
|
||||
|
|
@ -593,10 +871,10 @@ function deriveFields(seed, template, fields, seaLevel) {
|
|||
const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34);
|
||||
ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge));
|
||||
basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32);
|
||||
const low = clamp((0.58 - e) * 1.55);
|
||||
const low = clamp((0.52 - e) * 1.70);
|
||||
const lowSlope = clamp((0.34 - slope[i]) * 2.8);
|
||||
const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40);
|
||||
plain[i] = clamp(low * lowSlope * (0.18 + template.plainBias * 0.42 + riverGate * 0.92));
|
||||
plain[i] = clamp(low * lowSlope * (0.12 + template.plainBias * 0.34 + riverGate * 0.84));
|
||||
floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52));
|
||||
deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72);
|
||||
alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4));
|
||||
|
|
@ -675,13 +953,13 @@ export function generateTerrainAndRivers(seed) {
|
|||
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23;
|
||||
const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (0.22 + terrainTemplate.deposition * 0.040);
|
||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
||||
let mountainMaskMax = 0;
|
||||
for (let s = 0; s < systems.length; s++) {
|
||||
const system = systems[s];
|
||||
const mask = ellipticalMask(px, py, system);
|
||||
mountainMaskMax = Math.max(mountainMaskMax, mask);
|
||||
const broad = Math.pow(mask, lerp(2.0, 1.35, system.massifness)) * system.height * lerp(0.13, 0.25, system.massifness);
|
||||
const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness);
|
||||
e += broad;
|
||||
arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72));
|
||||
}
|
||||
|
|
@ -698,7 +976,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||
e += global * 0.020;
|
||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||
elevation[i] = clamp(e, 0.025, 1.08);
|
||||
elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08);
|
||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||
|
|
@ -707,7 +985,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
}
|
||||
|
||||
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
|
||||
seaLevel = clamp(seaLevel, 0.20, 0.47);
|
||||
seaLevel = clamp(seaLevel, 0.14, 0.47);
|
||||
classifyWater(elevation, seaLevel, sea, ocean, lake);
|
||||
recomputeSlope(elevation, sea, slope);
|
||||
|
||||
|
|
@ -721,8 +999,9 @@ export function generateTerrainAndRivers(seed) {
|
|||
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
||||
const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
||||
const prefectureRegionId = regional.regionId;
|
||||
const adminPrefectureRegionId = regional.displayRegionId || regional.regionId;
|
||||
const regionalDebug = regional.debug;
|
||||
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
|
||||
const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea);
|
||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||
|
||||
let landCount = 0;
|
||||
|
|
@ -734,12 +1013,15 @@ export function generateTerrainAndRivers(seed) {
|
|||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) { waterCount++; continue; }
|
||||
landCount++;
|
||||
if (elevation[i] > 0.60 || ridgeField[i] > 0.58) mountainCount++;
|
||||
if (elevation[i] > 0.56 || ridgeField[i] > 0.52) mountainCount++;
|
||||
if (plain[i] > 0.36) plainCount++;
|
||||
if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; }
|
||||
}
|
||||
primarySpineStrength /= Math.max(1, spineSamples);
|
||||
const terrainDebug = {
|
||||
terrainType: terrainTemplate.terrainType,
|
||||
terrainTypeLabel: terrainTemplate.terrainTypeLabel,
|
||||
coastStyle: terrainTemplate.coastStyle,
|
||||
primarySpineStrength,
|
||||
riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0,
|
||||
smallIslandCount: 0,
|
||||
|
|
@ -790,6 +1072,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
adminPrefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
|
|
|
|||
79
renderer.js
79
renderer.js
|
|
@ -358,42 +358,45 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
}
|
||||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
const eL = fieldSample(map.elevation, fx - 0.6, fy);
|
||||
const eR = fieldSample(map.elevation, fx + 0.6, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - 0.6);
|
||||
const eD = fieldSample(map.elevation, fx, fy + 0.6);
|
||||
const step = 0.50;
|
||||
const eC = fieldSample(map.elevation, fx, fy);
|
||||
const eL = fieldSample(map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map.elevation, fx, fy + step);
|
||||
|
||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||
// 光源は北西上空(日本の地形表現で一般的な見え方)。
|
||||
const dzdx = (eR - eL) / 1.2;
|
||||
const dzdy = (eD - eU) / 1.2;
|
||||
const nx = -dzdx * 2.5;
|
||||
const ny = -dzdy * 2.5;
|
||||
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||
const dzdx = (eR - eL) / (step * 2);
|
||||
const dzdy = (eD - eU) / (step * 2);
|
||||
const nx = -dzdx * 4.4;
|
||||
const ny = -dzdy * 4.4;
|
||||
const nz = 1.0;
|
||||
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||||
|
||||
const lx = -0.5;
|
||||
const ly = -0.5;
|
||||
const lz = 0.7071067811865476;
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.5 + 0.5);
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
||||
|
||||
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.75, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
|
||||
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
|
||||
|
||||
// 谷底の低傾斜面では陰影を少し圧縮し、標高色がそのまま見えるようにする。
|
||||
const valleyFloor = clamp((valley - 0.16) * 1.8) * clamp((0.28 - slope) * 4.5);
|
||||
let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028;
|
||||
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52);
|
||||
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20);
|
||||
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
|
||||
const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6);
|
||||
let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030;
|
||||
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26);
|
||||
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12);
|
||||
|
||||
return clamp(shade, 0.66, 1.13);
|
||||
return clamp(shade, 0.54, 1.26);
|
||||
}
|
||||
|
||||
function discreteColor(map, x, y, mode) {
|
||||
|
|
@ -404,16 +407,16 @@ function discreteColor(map, x, y, mode) {
|
|||
color = [160, 205, 239];
|
||||
} else if (mode === "landuse") {
|
||||
const colors = {
|
||||
0: [242, 248, 238],
|
||||
1: [248, 250, 245],
|
||||
2: [240, 238, 232],
|
||||
3: [245, 230, 220],
|
||||
4: [250, 248, 245],
|
||||
5: [235, 235, 240],
|
||||
6: [240, 245, 240],
|
||||
7: [245, 248, 252],
|
||||
8: [250, 248, 240],
|
||||
9: [240, 245, 238],
|
||||
0: [242, 248, 238], // rural / natural land
|
||||
1: [238, 246, 222], // farmland
|
||||
2: [240, 238, 232], // old urban
|
||||
3: [245, 230, 220], // CBD / DID core
|
||||
4: [250, 248, 245], // suburb
|
||||
5: [235, 235, 240], // industrial
|
||||
6: [240, 245, 240], // logistics
|
||||
7: [245, 248, 252], // new town
|
||||
8: [250, 248, 240], // roadside
|
||||
9: [225, 238, 220], // forest / mountain land
|
||||
};
|
||||
color = colors[map.landuse[i]] || colors[0];
|
||||
} else if (mode === "admin") {
|
||||
|
|
@ -606,7 +609,8 @@ function drawDebugCells(ctx, map, field, color) {
|
|||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!map.prefectureMask[i] || map.sea[i]) continue;
|
||||
const debugMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (!debugMask[i] || map.sea[i]) continue;
|
||||
const v = clamp(field[i] || 0, 0, 1);
|
||||
if (v <= 0.12) continue;
|
||||
ctx.fillStyle = color(v);
|
||||
|
|
@ -756,8 +760,10 @@ export function drawMap(canvas, map, options) {
|
|||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
|
||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
|
||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||
// artificial horizontal hatching, so only strong terrain dividers are shown.
|
||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
|
||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
|
||||
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
|
||||
if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
|
@ -818,6 +824,7 @@ export function drawMap(canvas, map, options) {
|
|||
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
|
||||
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
|
||||
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)");
|
||||
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "transparent", "rgba(190,95,95,0.62)");
|
||||
}
|
||||
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
|
|
@ -832,14 +839,14 @@ export function drawMap(canvas, map, options) {
|
|||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
return;
|
||||
}
|
||||
const important = [
|
||||
...map.modernCities,
|
||||
...map.ports,
|
||||
...(map.satelliteCities || []),
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway");
|
||||
drawLabels(ctx, important, 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue