1302 lines
47 KiB
JavaScript
1302 lines
47 KiB
JavaScript
import { generateEntityName } from "./names.js";
|
|
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
|
|
|
|
|
|
export function neighbors8(x, y) {
|
|
const out = [];
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
if (dx === 0 && dy === 0) continue;
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function neighbors4(x, y) {
|
|
const out = [];
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (inside(nx, ny)) out.push([nx, ny, 1]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function distanceToNearest(points, x, y, fallback = 999) {
|
|
let best = fallback;
|
|
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
|
|
return best;
|
|
}
|
|
|
|
export function aStar(start, goal, costAt) {
|
|
const startIndex = indexOf(start.x, start.y);
|
|
const goalIndex = indexOf(goal.x, goal.y);
|
|
if (startIndex === goalIndex) return [[start.x, start.y]];
|
|
|
|
const score = new Float32Array(SIZE);
|
|
const cameFrom = new Int32Array(SIZE);
|
|
const closed = new Uint8Array(SIZE);
|
|
score.fill(INF);
|
|
cameFrom.fill(-1);
|
|
|
|
const heap = new MinHeap();
|
|
score[startIndex] = 0;
|
|
heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) });
|
|
|
|
let guard = 0;
|
|
while (heap.length > 0 && guard++ < SIZE * 3) {
|
|
const current = heap.pop();
|
|
if (!current || closed[current.i]) continue;
|
|
closed[current.i] = 1;
|
|
|
|
if (current.i === goalIndex) {
|
|
const path = [];
|
|
let p = goalIndex;
|
|
while (p !== -1) {
|
|
const [x, y] = xyOf(p);
|
|
path.push([x, y]);
|
|
if (p === startIndex) break;
|
|
p = cameFrom[p];
|
|
}
|
|
return path.reverse();
|
|
}
|
|
|
|
const [cx, cy] = xyOf(current.i);
|
|
for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) {
|
|
const nextIndex = indexOf(nx, ny);
|
|
if (closed[nextIndex]) continue;
|
|
const cost = costAt(nx, ny, cx, cy);
|
|
if (cost >= INF) continue;
|
|
const nextScore = score[current.i] + cost * stepDistance;
|
|
if (nextScore < score[nextIndex]) {
|
|
score[nextIndex] = nextScore;
|
|
cameFrom[nextIndex] = current.i;
|
|
heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 });
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function influenceFromPaths(paths, radius) {
|
|
const grid = new Float32Array(SIZE);
|
|
for (const path of paths) {
|
|
for (const [x, y] of path) {
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius) continue;
|
|
const i = indexOf(nx, ny);
|
|
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return grid;
|
|
}
|
|
|
|
export function pointKey(p) {
|
|
return `${p.x},${p.y}`;
|
|
}
|
|
|
|
export function getDegree(degreeMap, p) {
|
|
return degreeMap.get(pointKey(p)) || 0;
|
|
}
|
|
|
|
export function incrementDegree(degreeMap, p) {
|
|
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
|
|
}
|
|
|
|
export function nearestConnectable(points, target, degreeMap, maxDegree = 3) {
|
|
if (!points.length) return null;
|
|
const sorted = points
|
|
.map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) }))
|
|
.sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7));
|
|
return sorted.find((p) => p.degree < maxDegree) || sorted[0];
|
|
}
|
|
|
|
export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) {
|
|
if (!grid) return 0;
|
|
const value = grid[indexOf(x, y)];
|
|
if (value <= 0.0001) return 0;
|
|
|
|
const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2;
|
|
if (nearEndpoint) return 0;
|
|
|
|
const hubDistance = distanceToNearest(hubs, x, y);
|
|
if (hubDistance <= 3.5) return 0;
|
|
if (hubDistance <= 7.5) return value * strength * 0.28;
|
|
return value * strength;
|
|
}
|
|
|
|
export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) {
|
|
if (!points || points.length === 0) return 0;
|
|
if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0;
|
|
const d = distanceToNearest(points, x, y);
|
|
if (d >= radius) return 0;
|
|
return (radius - d) * strength;
|
|
}
|
|
|
|
export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) {
|
|
const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null;
|
|
return (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return base;
|
|
return base
|
|
+ corridorPenalty(grid, x, y, hubs, endpoints, strength)
|
|
+ nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength);
|
|
};
|
|
}
|
|
|
|
export function pathLength(path) {
|
|
let total = 0;
|
|
for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
|
return total;
|
|
}
|
|
|
|
export function pathEndpointDistance(path) {
|
|
if (!path || path.length < 2) return 0;
|
|
const a = path[0];
|
|
const b = path[path.length - 1];
|
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
}
|
|
|
|
export function pathCompactness(path) {
|
|
const direct = pathEndpointDistance(path);
|
|
if (direct <= 0.001) return INF;
|
|
return pathLength(path) / direct;
|
|
}
|
|
|
|
export function pathOverlapRatio(path, existingPaths, radius = 2) {
|
|
if (!path?.length || !existingPaths?.length) return 0;
|
|
const grid = influenceFromPaths(existingPaths, radius);
|
|
let overlap = 0;
|
|
for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++;
|
|
return overlap / Math.max(1, path.length);
|
|
}
|
|
|
|
export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) {
|
|
const kept = [];
|
|
for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) {
|
|
if (pathLength(path) < minLength) continue;
|
|
if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue;
|
|
kept.push(path);
|
|
if (kept.length >= maxCount) break;
|
|
}
|
|
paths.splice(0, paths.length, ...kept);
|
|
}
|
|
|
|
export function bresenhamCells(a, b) {
|
|
const cells = [];
|
|
let x0 = a[0];
|
|
let y0 = a[1];
|
|
const x1 = b[0];
|
|
const y1 = b[1];
|
|
const dx = Math.abs(x1 - x0);
|
|
const dy = Math.abs(y1 - y0);
|
|
const sx = x0 < x1 ? 1 : -1;
|
|
const sy = y0 < y1 ? 1 : -1;
|
|
let err = dx - dy;
|
|
while (true) {
|
|
cells.push([x0, y0]);
|
|
if (x0 === x1 && y0 === y1) break;
|
|
const e2 = 2 * err;
|
|
if (e2 > -dy) { err -= dy; x0 += sx; }
|
|
if (e2 < dx) { err += dx; y0 += sy; }
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
export function smoothPathByLineOfSight(path, passable, maxSegment = 9) {
|
|
if (!path || path.length < 3) return path || [];
|
|
const out = [path[0]];
|
|
let i = 0;
|
|
while (i < path.length - 1) {
|
|
let best = i + 1;
|
|
const limit = Math.min(path.length - 1, i + maxSegment);
|
|
for (let j = limit; j > i + 1; j--) {
|
|
const cells = bresenhamCells(path[i], path[j]);
|
|
if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; }
|
|
}
|
|
for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell);
|
|
i = best;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function averagePathField(path, field) {
|
|
if (!path?.length) return 0;
|
|
let sum = 0;
|
|
for (const [x, y] of path) sum += field[indexOf(x, y)] || 0;
|
|
return sum / path.length;
|
|
}
|
|
|
|
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
|
const grid = new Float32Array(SIZE);
|
|
for (const p of points) {
|
|
const weight = weightFn(p);
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const nx = p.x + dx;
|
|
const ny = p.y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius) continue;
|
|
const i = indexOf(nx, ny);
|
|
grid[i] = Math.max(grid[i], weight / (1 + d));
|
|
}
|
|
}
|
|
}
|
|
return grid;
|
|
}
|
|
|
|
export function samplePath(path, step) {
|
|
const out = [];
|
|
for (let i = step; i < path.length - step; i += step) {
|
|
const [x, y] = path[i];
|
|
out.push({ x, y, score: 1 });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function smoothMask(mask, passes = 2) {
|
|
let current = new Uint8Array(mask);
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
const next = new Uint8Array(current);
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
let count = 0;
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
if (current[indexOf(x + dx, y + dy)]) count++;
|
|
}
|
|
}
|
|
if (count >= 5) next[i] = 1;
|
|
else if (count <= 3) next[i] = 0;
|
|
}
|
|
}
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function largestConnectedMask(mask) {
|
|
const seen = new Uint8Array(SIZE);
|
|
let best = [];
|
|
const queue = [];
|
|
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!mask[i] || seen[i]) continue;
|
|
const component = [];
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
seen[i] = 1;
|
|
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
component.push(cur);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (!mask[ni] || seen[ni]) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
|
|
if (component.length > best.length) best = component;
|
|
}
|
|
|
|
const out = new Uint8Array(SIZE);
|
|
for (const i of best) out[i] = 1;
|
|
return out;
|
|
}
|
|
|
|
export function componentCount(mask) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const queue = [];
|
|
let count = 0;
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!mask[i] || seen[i]) continue;
|
|
count++;
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
seen[i] = 1;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const [x, y] = xyOf(queue[q]);
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (!mask[ni] || seen[ni]) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
|
|
export function makePrefectureMask(seed, sea, elevation, slope, river) {
|
|
const candidates = [];
|
|
for (let y = 8; y < MAP_H - 8; y++) {
|
|
for (let x = 8; x < MAP_W - 8; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
|
|
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
|
|
candidates.push({ x, y, score });
|
|
}
|
|
}
|
|
|
|
const regionSeeds = pickEntities(candidates, {
|
|
max: 1,
|
|
minDistance: 18,
|
|
threshold: 0.35,
|
|
seed: seed + 904,
|
|
jitter: 0.02,
|
|
});
|
|
|
|
const mask = new Uint8Array(SIZE);
|
|
const dist = new Float32Array(SIZE);
|
|
dist.fill(INF);
|
|
const heap = new MinHeap();
|
|
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
|
|
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
|
|
|
|
for (const s of regionSeeds) {
|
|
const i = indexOf(s.x, s.y);
|
|
dist[i] = 0;
|
|
heap.push({ i, f: 0 });
|
|
}
|
|
|
|
let claimed = 0;
|
|
while (heap.length > 0 && claimed < target) {
|
|
const current = heap.pop();
|
|
if (!current) continue;
|
|
const ci = current.i;
|
|
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
|
|
const [cx, cy] = xyOf(ci);
|
|
if (sea[ci]) continue;
|
|
|
|
mask[ci] = 1;
|
|
claimed++;
|
|
|
|
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni] || mask[ni]) continue;
|
|
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
|
|
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
|
|
const slopePenalty = slope[ni] * 4.1;
|
|
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
|
|
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
|
|
const nd = dist[ci] + cost;
|
|
if (nd < dist[ni]) {
|
|
dist[ni] = nd;
|
|
heap.push({ i: ni, f: nd });
|
|
}
|
|
}
|
|
}
|
|
|
|
return largestConnectedMask(smoothMask(mask, 2));
|
|
}
|
|
|
|
export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
|
const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask);
|
|
const beforeRegionId = new Int16Array(seeded.regionId);
|
|
const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum);
|
|
const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea);
|
|
const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore);
|
|
const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore);
|
|
|
|
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
|
|
const owner = new Int16Array(compartments.length);
|
|
owner.fill(-1);
|
|
for (let id = 0; id < seeded.centers.length; id++) {
|
|
const center = seeded.centers[id];
|
|
if (!center || !inside(center.x, center.y)) continue;
|
|
const ci = compartmentId[indexOf(center.x, center.y)];
|
|
if (ci >= 0) owner[ci] = id;
|
|
}
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0) continue;
|
|
if (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0;
|
|
}
|
|
for (let pass = 0; pass < compartments.length + 6; pass++) {
|
|
let changedThisPass = 0;
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
let bestOwner = -1;
|
|
let bestScore = -INF;
|
|
for (const [neighborId, edge] of unit.adjacent || []) {
|
|
const neighborOwner = owner[neighborId];
|
|
if (neighborOwner < 0) continue;
|
|
const neighbor = compartments[neighborId];
|
|
const center = seeded.centers[neighborOwner];
|
|
const barrier = edge.target / Math.max(1, edge.count);
|
|
const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0;
|
|
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
|
|
const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008;
|
|
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
|
}
|
|
if (bestOwner >= 0) {
|
|
owner[unit.id] = bestOwner;
|
|
changedThisPass++;
|
|
}
|
|
}
|
|
if (changedThisPass === 0) break;
|
|
}
|
|
for (const unit of compartments) {
|
|
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
|
|
let bestId = 0;
|
|
let best = -INF;
|
|
for (let id = 0; id < seeded.centers.length; id++) {
|
|
const center = seeded.centers[id];
|
|
if (!center) continue;
|
|
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
|
const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0);
|
|
if (score > best) { best = score; bestId = id; }
|
|
}
|
|
owner[unit.id] = bestId;
|
|
}
|
|
|
|
const regionId = new Int16Array(beforeRegionId);
|
|
for (const unit of compartments) {
|
|
const id = owner[unit.id];
|
|
if (id < 0) continue;
|
|
for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id;
|
|
}
|
|
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);
|
|
repairDisconnectedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask);
|
|
mergeTinyRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, 720);
|
|
rebalanceOversizedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
|
|
snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, 2);
|
|
repairFinalRegionalTopology(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
|
|
|
|
let changed = 0;
|
|
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
|
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
|
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
|
|
const afterNaturalAverage = measuredAfterNaturalAverage;
|
|
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
|
|
|
|
return {
|
|
regionId,
|
|
centers: seeded.centers,
|
|
naturalBarrierScore,
|
|
debug: {
|
|
regionalChangedAfterNaturalPartition: changed,
|
|
regionalBorderCountBefore: beforeBorderCount,
|
|
regionalBorderCountAfter: afterBorderCount,
|
|
regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate,
|
|
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
|
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
|
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
|
regionalDisplayBorderCount: countRegionBorderEdges(regionId, sea),
|
|
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(regionId, sea, naturalBarrierScore),
|
|
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
|
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
|
changedAfterCompartmentAssignment: changed,
|
|
borderNaturalBarrierAverage: afterNaturalAverage,
|
|
voronoiLikeRate: afterVoronoiLikeRate,
|
|
finalRegionConnectivityMaxComponents: Math.max(0, ...[...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].map((id) => collectRegionComponents(regionId, sea, id).length)),
|
|
finalRegionalEnclaveCount: countRegionalEnclaves(regionId, sea),
|
|
finalRegionalMaxAreaShare: maxRegionalAreaShare(regionId, sea),
|
|
},
|
|
};
|
|
}
|
|
|
|
function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
|
const centers = [];
|
|
let sx = 0;
|
|
let sy = 0;
|
|
let sc = 0;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (anchorMask[i]) { sx += x; sy += y; sc++; }
|
|
}
|
|
}
|
|
if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" });
|
|
|
|
const candidates = [];
|
|
const ax = centers[0]?.x ?? MAP_W / 2;
|
|
const ay = centers[0]?.y ?? MAP_H / 2;
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || anchorMask[i]) continue;
|
|
const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5));
|
|
const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H);
|
|
const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08;
|
|
const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06;
|
|
candidates.push({ x, y, score, kind: "Neighbor Prefecture" });
|
|
}
|
|
}
|
|
centers.push(...pickEntities(candidates, {
|
|
max: 6 + Math.floor(rand(seed, 6101) * 4),
|
|
minDistance: 31,
|
|
threshold: 0.42,
|
|
seed: seed + 6102,
|
|
jitter: 0.02,
|
|
}));
|
|
|
|
const regionId = new Int16Array(SIZE);
|
|
regionId.fill(-1);
|
|
const dist = new Float32Array(SIZE);
|
|
dist.fill(INF);
|
|
const heap = new MinHeap();
|
|
centers.forEach((center, id) => {
|
|
const i = indexOf(center.x, center.y);
|
|
if (sea[i]) return;
|
|
regionId[i] = id;
|
|
dist[i] = 0;
|
|
heap.push({ i, f: 0 });
|
|
});
|
|
|
|
let guard = 0;
|
|
while (heap.length > 0 && guard++ < SIZE * 16) {
|
|
const cur = heap.pop();
|
|
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
|
const [cx, cy] = xyOf(cur.i);
|
|
const curRegion = regionId[cur.i];
|
|
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) continue;
|
|
const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]);
|
|
const riverBarrier = Math.max(river[ni], river[cur.i]);
|
|
const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8;
|
|
const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7;
|
|
const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0;
|
|
const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step;
|
|
const nd = dist[cur.i] + stepCost;
|
|
if (nd < dist[ni]) {
|
|
dist[ni] = nd;
|
|
regionId[ni] = curRegion;
|
|
heap.push({ i: ni, f: nd });
|
|
}
|
|
}
|
|
}
|
|
return { regionId, centers };
|
|
}
|
|
|
|
function collectRegionComponents(regionId, sea, id) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const components = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || sea[i] || regionId[i] !== id) continue;
|
|
const queue = [i];
|
|
const cells = [];
|
|
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] || sea[ni] || regionId[ni] !== id) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
components.push(cells);
|
|
}
|
|
return components.sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
function chooseRegionalReassignment(cells, regionId, sea, centers, forbiddenId = -1) {
|
|
const adjacent = new Map();
|
|
let sx = 0, sy = 0;
|
|
for (const i of cells) {
|
|
const [x, y] = xyOf(i);
|
|
sx += x; sy += y;
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const id = regionId[ni];
|
|
if (sea[ni] || id < 0 || id === forbiddenId) continue;
|
|
adjacent.set(id, (adjacent.get(id) || 0) + 1);
|
|
}
|
|
}
|
|
let bestId = -1;
|
|
let bestScore = -INF;
|
|
const cx = sx / Math.max(1, cells.length);
|
|
const cy = sy / Math.max(1, cells.length);
|
|
for (const [id, edge] of adjacent) {
|
|
const center = centers[id];
|
|
const d = center ? Math.hypot(center.x - cx, center.y - cy) : 0;
|
|
const score = edge * 4 - d * 0.04 + (id === 0 ? -1.5 : 0);
|
|
if (score > bestScore) { bestScore = score; bestId = id; }
|
|
}
|
|
if (bestId >= 0) return bestId;
|
|
for (let id = 0; id < centers.length; id++) {
|
|
if (id === forbiddenId || !centers[id]) continue;
|
|
const d = Math.hypot(centers[id].x - cx, centers[id].y - cy);
|
|
const score = -d + (id === 0 ? -8 : 0);
|
|
if (score > bestScore) { bestScore = score; bestId = id; }
|
|
}
|
|
return bestId;
|
|
}
|
|
|
|
function nearestRegionalReassignmentByLand(cells, regionId, sea, forbiddenId = -1) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const queue = [];
|
|
for (const i of cells) {
|
|
seen[i] = 1;
|
|
queue.push(i);
|
|
}
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const [x, y] = xyOf(cur);
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni] || seen[ni]) continue;
|
|
const id = regionId[ni];
|
|
if (id >= 0 && id !== forbiddenId) return id;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask) {
|
|
let totalChanged = 0;
|
|
for (let pass = 0; pass < 10; pass++) {
|
|
let changedThisPass = 0;
|
|
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
|
for (const id of ids) {
|
|
const components = collectRegionComponents(regionId, sea, id);
|
|
if (components.length <= 1) continue;
|
|
let keepIndex = 0;
|
|
if (id === 0) {
|
|
const anchorIndex = components.findIndex((cells) => cells.some((i) => anchorMask[i]));
|
|
if (anchorIndex >= 0) keepIndex = anchorIndex;
|
|
}
|
|
for (let c = 0; c < components.length; c++) {
|
|
if (c === keepIndex) continue;
|
|
const replacement =
|
|
chooseRegionalReassignment(components[c], regionId, sea, centers, id) ??
|
|
nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
|
|
const target = replacement >= 0 ? replacement : nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
|
|
if (target < 0) continue;
|
|
for (const i of components[c]) {
|
|
if (anchorMask[i]) continue;
|
|
regionId[i] = target;
|
|
changedThisPass++;
|
|
}
|
|
}
|
|
}
|
|
totalChanged += changedThisPass;
|
|
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
|
if (changedThisPass === 0) break;
|
|
}
|
|
return totalChanged;
|
|
}
|
|
|
|
function componentTouchesOutside(cells, sea) {
|
|
for (const i of cells) {
|
|
const [x, y] = xyOf(i);
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
|
|
for (const [nx, ny] of neighbors4(x, y)) if (sea[indexOf(nx, ny)]) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function boundaryNeighborIds(cells, regionId, sea, ownId) {
|
|
const ids = new Set();
|
|
for (const i of cells) {
|
|
const [x, y] = xyOf(i);
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const id = regionId[ni];
|
|
if (!sea[ni] && id >= 0 && id !== ownId) ids.add(id);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function countRegionalEnclaves(regionId, sea) {
|
|
let count = 0;
|
|
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
|
for (const id of ids) {
|
|
for (const cells of collectRegionComponents(regionId, sea, id)) {
|
|
if (componentTouchesOutside(cells, sea)) continue;
|
|
if (boundaryNeighborIds(cells, regionId, sea, id).size === 1) count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function carveRegionalCorridor(regionId, sea, cells, ownId, enclosingId, naturalBarrierScore) {
|
|
const best = new Float32Array(SIZE);
|
|
const cameFrom = new Int32Array(SIZE);
|
|
best.fill(INF);
|
|
cameFrom.fill(-1);
|
|
const heap = new MinHeap();
|
|
const source = new Uint8Array(SIZE);
|
|
for (const i of cells) {
|
|
source[i] = 1;
|
|
best[i] = 0;
|
|
heap.push({ i, f: 0 });
|
|
}
|
|
let target = -1;
|
|
while (heap.length) {
|
|
const cur = heap.pop();
|
|
if (!cur || cur.f > best[cur.i] + 1e-5) continue;
|
|
const [x, y] = xyOf(cur.i);
|
|
if (!source[cur.i]) {
|
|
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) { target = cur.i; break; }
|
|
let seaAdjacent = false;
|
|
let otherAdjacent = false;
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) seaAdjacent = true;
|
|
else if (regionId[ni] >= 0 && regionId[ni] !== ownId && regionId[ni] !== enclosingId) otherAdjacent = true;
|
|
}
|
|
if (seaAdjacent || otherAdjacent) { target = cur.i; break; }
|
|
}
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni] || regionId[ni] === ownId) continue;
|
|
const id = regionId[ni];
|
|
const regionPenalty = id === enclosingId ? 0 : 8;
|
|
const barrier = naturalBarrierScore?.[ni] || 0;
|
|
const nd = cur.f + 1 + barrier * 3.2 + regionPenalty;
|
|
if (nd < best[ni]) {
|
|
best[ni] = nd;
|
|
cameFrom[ni] = cur.i;
|
|
heap.push({ i: ni, f: nd });
|
|
}
|
|
}
|
|
}
|
|
if (target < 0) return 0;
|
|
let changed = 0;
|
|
for (let i = target; i >= 0 && regionId[i] !== ownId; i = cameFrom[i]) {
|
|
if (!sea[i] && regionId[i] !== ownId) {
|
|
regionId[i] = ownId;
|
|
changed++;
|
|
}
|
|
if (cameFrom[i] < 0) break;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
|
let changed = 0;
|
|
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
|
for (const id of ids) {
|
|
const components = collectRegionComponents(regionId, sea, id);
|
|
for (const cells of components) {
|
|
if (componentTouchesOutside(cells, sea)) continue;
|
|
const neighbors = boundaryNeighborIds(cells, regionId, sea, id);
|
|
if (neighbors.size !== 1) continue;
|
|
const enclosingId = [...neighbors][0];
|
|
const protectedAnchor = id === 0 && cells.some((i) => anchorMask[i]);
|
|
if (protectedAnchor) {
|
|
changed += carveRegionalCorridor(regionId, sea, cells, id, enclosingId, naturalBarrierScore);
|
|
} else {
|
|
for (const i of cells) {
|
|
if (anchorMask[i]) continue;
|
|
regionId[i] = enclosingId;
|
|
changed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
|
return changed;
|
|
}
|
|
|
|
function repairFinalRegionalTopology(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
|
let totalChanged = 0;
|
|
for (let pass = 0; pass < 12; pass++) {
|
|
const disconnected = repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
|
|
const enclaves = repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore);
|
|
totalChanged += disconnected + enclaves;
|
|
if (disconnected + enclaves === 0) break;
|
|
}
|
|
return totalChanged;
|
|
}
|
|
|
|
function regionalAreaById(regionId, sea) {
|
|
const area = new Map();
|
|
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) area.set(regionId[i], (area.get(regionId[i]) || 0) + 1);
|
|
return area;
|
|
}
|
|
|
|
function maxRegionalAreaShare(regionId, sea) {
|
|
const area = regionalAreaById(regionId, sea);
|
|
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
|
|
return land ? Math.max(0, ...area.values()) / land : 0;
|
|
}
|
|
|
|
function canMoveRegionalBoundaryCell(regionId, sea, i, ownId) {
|
|
const [x, y] = xyOf(i);
|
|
let ownNeighbors = 0;
|
|
let otherNeighbors = 0;
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) continue;
|
|
if (regionId[ni] === ownId) ownNeighbors++;
|
|
else if (regionId[ni] >= 0) otherNeighbors++;
|
|
}
|
|
return ownNeighbors >= 2 && otherNeighbors > 0;
|
|
}
|
|
|
|
function rebalanceOversizedRegionalPrefectures(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
|
const minArea = 720;
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
const area = regionalAreaById(regionId, sea);
|
|
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
|
|
const maxArea = Math.max(minArea * 2, Math.floor(land * 0.32));
|
|
let changed = 0;
|
|
const oversized = [...area.entries()].filter(([, value]) => value > maxArea).sort((a, b) => b[1] - a[1]);
|
|
for (const [id] of oversized) {
|
|
const candidates = [];
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionId[i] !== id || anchorMask[i] || !canMoveRegionalBoundaryCell(regionId, sea, i, id)) continue;
|
|
const counts = new Map();
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const other = regionId[ni];
|
|
if (!sea[ni] && other >= 0 && other !== id && (area.get(other) || 0) < maxArea) counts.set(other, (counts.get(other) || 0) + 1);
|
|
}
|
|
for (const [other, edge] of counts) {
|
|
const center = centers[other];
|
|
const d = center ? Math.hypot(center.x - x, center.y - y) : 0;
|
|
candidates.push({ i, other, score: edge * 2.0 + (naturalBarrierScore?.[i] || 0) * 1.4 - d * 0.006 });
|
|
}
|
|
}
|
|
}
|
|
candidates.sort((a, b) => b.score - a.score || a.i - b.i);
|
|
for (const candidate of candidates) {
|
|
if ((area.get(id) || 0) <= maxArea) break;
|
|
if ((area.get(candidate.other) || 0) >= maxArea || regionId[candidate.i] !== id) continue;
|
|
regionId[candidate.i] = candidate.other;
|
|
area.set(id, (area.get(id) || 0) - 1);
|
|
area.set(candidate.other, (area.get(candidate.other) || 0) + 1);
|
|
changed++;
|
|
}
|
|
}
|
|
repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
|
|
if (!changed) break;
|
|
}
|
|
}
|
|
|
|
function snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, passes = 2) {
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
const before = new Int16Array(regionId);
|
|
let changed = 0;
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
const own = before[i];
|
|
if (sea[i] || own < 0 || anchorMask[i]) continue;
|
|
const hereBarrier = naturalBarrierScore?.[i] || 0;
|
|
if (hereBarrier > 0.54 || !canMoveRegionalBoundaryCell(before, sea, i, own)) continue;
|
|
const counts = new Map();
|
|
let bestNeighborBarrier = 0;
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const other = before[ni];
|
|
if (sea[ni] || other < 0) continue;
|
|
bestNeighborBarrier = Math.max(bestNeighborBarrier, naturalBarrierScore?.[ni] || 0);
|
|
if (other !== own) counts.set(other, (counts.get(other) || 0) + 1);
|
|
}
|
|
if (counts.size === 0 || bestNeighborBarrier < hereBarrier + 0.18) continue;
|
|
let target = -1, best = -1;
|
|
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
|
|
if (target >= 0 && best >= 2) {
|
|
regionId[i] = target;
|
|
changed++;
|
|
}
|
|
}
|
|
}
|
|
if (!changed) break;
|
|
}
|
|
}
|
|
|
|
function mergeTinyRegionalPrefectures(regionId, sea, centers, anchorMask, minArea) {
|
|
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
|
for (const id of ids) {
|
|
if (id === 0) continue;
|
|
const cells = [];
|
|
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] === id) cells.push(i);
|
|
if (cells.length === 0 || cells.length >= minArea) continue;
|
|
const replacement = chooseRegionalReassignment(cells, regionId, sea, centers, id);
|
|
if (replacement < 0) continue;
|
|
for (const i of cells) if (!anchorMask[i]) regionId[i] = replacement;
|
|
}
|
|
}
|
|
|
|
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
|
|
const score = new Float32Array(SIZE);
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
let coast = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
|
const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72);
|
|
const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80);
|
|
const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86);
|
|
const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72);
|
|
score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46);
|
|
}
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) {
|
|
if (sea[i]) return -1;
|
|
if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1;
|
|
if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2;
|
|
if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3;
|
|
if (elevation[i] < 0.36 && slope[i] < 0.20) return 4;
|
|
if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5;
|
|
return 6;
|
|
}
|
|
|
|
function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) {
|
|
const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB));
|
|
if (!sameFamily) return false;
|
|
const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
|
const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62;
|
|
return barrier < threshold && !majorRiver;
|
|
}
|
|
|
|
function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) {
|
|
const compartmentId = new Int32Array(SIZE);
|
|
const cellClass = new Int16Array(SIZE);
|
|
compartmentId.fill(-1);
|
|
cellClass.fill(-1);
|
|
for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum);
|
|
|
|
const compartments = [];
|
|
const queue = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
|
|
const id = compartments.length;
|
|
const klass = cellClass[i];
|
|
const cells = [];
|
|
let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0;
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
compartmentId[i] = id;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
const [x, y] = xyOf(cur);
|
|
cells.push(cur);
|
|
sx += x;
|
|
sy += y;
|
|
ridgeExposure += ridgeField[cur];
|
|
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
|
let coast = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
|
coastalExposure += coast;
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
|
|
const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
|
|
if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue;
|
|
compartmentId[ni] = id;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
const area = cells.length;
|
|
compartments.push({
|
|
id,
|
|
cells,
|
|
area,
|
|
classId: klass,
|
|
x: sx / Math.max(1, area),
|
|
y: sy / Math.max(1, area),
|
|
ridgeExposure: ridgeExposure / Math.max(1, area),
|
|
riverExposure: riverExposure / Math.max(1, area),
|
|
coastalExposure: coastalExposure / Math.max(1, area),
|
|
adjacent: new Map(),
|
|
});
|
|
}
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const a = compartmentId[i];
|
|
if (a < 0 || !compartments[a]) continue;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) continue;
|
|
const b = compartmentId[ni];
|
|
if (b < 0 || a === b || !compartments[b]) continue;
|
|
const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
|
const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 };
|
|
edgeA.count++;
|
|
edgeA.target += v;
|
|
compartments[a].adjacent.set(b, edgeA);
|
|
const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 };
|
|
edgeB.count++;
|
|
edgeB.target += v;
|
|
compartments[b].adjacent.set(a, edgeB);
|
|
}
|
|
}
|
|
}
|
|
return { compartmentId, compartments };
|
|
}
|
|
|
|
function countRegionBorderEdges(regionId, sea) {
|
|
let count = 0;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionId[i] < 0) continue;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionId[i] < 0) continue;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue;
|
|
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count ? sum / count : 0;
|
|
}
|
|
|
|
function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) {
|
|
let weak = 0;
|
|
let total = 0;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionId[i] < 0) continue;
|
|
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
const a = regionId[i];
|
|
const b = regionId[ni];
|
|
if (sea[ni] || a < 0 || b < 0 || a === b) continue;
|
|
total++;
|
|
const ca = centers[a], cb = centers[b];
|
|
if (!ca || !cb) continue;
|
|
const mx = (x + nx) * 0.5;
|
|
const my = (y + ny) * 0.5;
|
|
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.5;
|
|
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++;
|
|
}
|
|
}
|
|
}
|
|
return total ? weak / total : 0;
|
|
}
|
|
|
|
function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) {
|
|
const ids = new Set();
|
|
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[i]);
|
|
const queue = [];
|
|
for (const id of ids) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const components = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || sea[i] || regionId[i] !== id) continue;
|
|
const cells = [];
|
|
let hasAnchor = false;
|
|
let hasCenter = false;
|
|
const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1;
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
seen[i] = 1;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
cells.push(cur);
|
|
if (anchorMask[cur]) hasAnchor = true;
|
|
if (cur === centerIndex) hasCenter = true;
|
|
const [x, y] = xyOf(cur);
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || sea[ni] || regionId[ni] !== id) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
components.push({ cells, hasAnchor, hasCenter });
|
|
}
|
|
if (components.length <= 1) continue;
|
|
components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length));
|
|
for (const comp of components.slice(1)) {
|
|
const counts = new Map();
|
|
for (const ci of comp.cells) {
|
|
const [x, y] = xyOf(ci);
|
|
for (const [nx, ny] of neighbors4(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const other = regionId[ni];
|
|
if (!sea[ni] && other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
|
}
|
|
}
|
|
let target = -1;
|
|
let best = -1;
|
|
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
|
|
if (target >= 0) for (const ci of comp.cells) if (!anchorMask[ci]) regionId[ci] = target;
|
|
}
|
|
}
|
|
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
|
}
|
|
|
|
export function extractRegionBorderSegments(regionId, sea, options = {}) {
|
|
const segments = [];
|
|
const nameById = options.prefectureRegions
|
|
? new Map(options.prefectureRegions.map((region) => [region.id, region.name]))
|
|
: null;
|
|
const fail = (message) => {
|
|
if (options.throwOnInvalid) throw new Error(message);
|
|
if (options.logInvalid) console.error(message);
|
|
};
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionId[i] < 0) continue;
|
|
const a = regionId[i];
|
|
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
|
|
const ni = indexOf(x + 1, y);
|
|
const b = regionId[ni];
|
|
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x + 1},${y}): both id=${a}, name=${nameById?.get(a) || "-"}`);
|
|
}
|
|
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
|
|
const ni = indexOf(x, y + 1);
|
|
const b = regionId[ni];
|
|
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x},${y + 1}): both id=${a}, name=${nameById?.get(a) || "-"}`);
|
|
}
|
|
}
|
|
}
|
|
if (options.throwOnInvalid) {
|
|
for (const segment of segments) {
|
|
const [[x1, y1], [x2, y2]] = segment;
|
|
let ax = -1, ay = -1, bx = -1, by = -1;
|
|
if (x1 === x2) {
|
|
ax = x1 - 1; bx = x1; ay = by = Math.min(y1, y2);
|
|
} else if (y1 === y2) {
|
|
ax = bx = Math.min(x1, x2); ay = y1 - 1; by = y1;
|
|
}
|
|
if (!inside(ax, ay) || !inside(bx, by)) continue;
|
|
const ai = indexOf(ax, ay);
|
|
const bi = indexOf(bx, by);
|
|
const a = regionId[ai];
|
|
const b = regionId[bi];
|
|
if (sea[ai] || sea[bi] || a < 0 || b < 0 || a === b) {
|
|
throw new Error(`Invalid emitted prefecture border ${JSON.stringify(segment)} between (${ax},${ay}) id=${a} name=${nameById?.get(a) || "-"} and (${bx},${by}) id=${b} name=${nameById?.get(b) || "-"}`);
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
export function extractMaskBorder(mask, sea = null) {
|
|
const segments = [];
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
const a = mask[i];
|
|
if (x + 1 < MAP_W) {
|
|
const ni = indexOf(x + 1, y);
|
|
const b = mask[ni];
|
|
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
}
|
|
if (y + 1 < MAP_H) {
|
|
const ni = indexOf(x, y + 1);
|
|
const b = mask[ni];
|
|
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
export function extractAdminBorderSegments(adminId, prefectureMask) {
|
|
const segments = [];
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i]) continue;
|
|
const a = adminId[i];
|
|
if (a < 0) continue;
|
|
if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) {
|
|
const b = adminId[indexOf(x + 1, y)];
|
|
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
}
|
|
if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) {
|
|
const b = adminId[indexOf(x, y + 1)];
|
|
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
export function tagInsidePrefecture(points, prefectureMask) {
|
|
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
|
}
|
|
|
|
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
|
|
return points.map((p, i) => {
|
|
const id = `${prefix}-${i}`;
|
|
const kind = kindOverride || p.kind;
|
|
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
|
if (usedNames) usedNames.add(name);
|
|
return {
|
|
...p,
|
|
id,
|
|
name,
|
|
kind,
|
|
insidePrefecture: Boolean(p.insidePrefecture),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function applyOutputOptions(map, options = {}) {
|
|
if (options.includeDebugFields !== false) return map;
|
|
const slim = { ...map };
|
|
delete slim.settlementCluster;
|
|
delete slim.ridgeField;
|
|
delete slim.valleyField;
|
|
delete slim.basinField;
|
|
delete slim.coastalLowland;
|
|
delete slim.flowAccum;
|
|
delete slim.erosionField;
|
|
delete slim.depositionField;
|
|
delete slim.terrainTemplate;
|
|
delete slim.ocean;
|
|
delete slim.lake;
|
|
delete slim.arcSpineField;
|
|
delete slim.branchRidgeField;
|
|
delete slim.depositionalLowland;
|
|
delete slim.alluvialFanField;
|
|
delete slim.deltaField;
|
|
delete slim.naturalBarrierScore;
|
|
return slim;
|
|
}
|