359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
import { generateEntityName } from "./names.js";
|
|
import { INF, MAP_H, MAP_W, SIZE, MinHeap, 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 createPointSpatialIndex(points, cellSize = 12) {
|
|
const buckets = new Map();
|
|
const normalized = (points || [])
|
|
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))
|
|
.map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) }));
|
|
const keyOf = (cx, cy) => `${cx},${cy}`;
|
|
for (const p of normalized) {
|
|
const cx = Math.floor(p.x / cellSize);
|
|
const cy = Math.floor(p.y / cellSize);
|
|
const key = keyOf(cx, cy);
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
buckets.set(key, bucket);
|
|
}
|
|
bucket.push(p);
|
|
}
|
|
|
|
function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) {
|
|
if (!normalized.length) return maxDistance * maxDistance;
|
|
const cx = Math.floor(x / cellSize);
|
|
const cy = Math.floor(y / cellSize);
|
|
const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize);
|
|
let best = maxDistance * maxDistance;
|
|
for (let ring = 0; ring <= maxRing; ring++) {
|
|
for (let by = cy - ring; by <= cy + ring; by++) {
|
|
for (let bx = cx - ring; bx <= cx + ring; bx++) {
|
|
if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue;
|
|
const bucket = buckets.get(keyOf(bx, by));
|
|
if (!bucket) continue;
|
|
for (const p of bucket) {
|
|
const dx = p.x - x;
|
|
const dy = p.y - y;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 < best) best = d2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
return {
|
|
points: normalized,
|
|
hasWithin(x, y, radius) {
|
|
return nearestDistanceSq(x, y, radius) < radius * radius;
|
|
},
|
|
distance(x, y, fallback = 999) {
|
|
const d2 = nearestDistanceSq(x, y, fallback);
|
|
return d2 < fallback * fallback ? Math.sqrt(d2) : fallback;
|
|
},
|
|
nearestDistanceSq,
|
|
};
|
|
}
|
|
|
|
export function influenceFromPaths(paths, radius) {
|
|
const grid = new Float32Array(SIZE);
|
|
const r = Math.ceil(radius);
|
|
const r2 = radius * radius;
|
|
for (const path of paths) {
|
|
for (const [x, y] of path) {
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 > r2) continue;
|
|
const d = Math.sqrt(d2);
|
|
const i = indexOf(nx, ny);
|
|
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return grid;
|
|
}
|
|
|
|
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
|
const grid = new Float32Array(SIZE);
|
|
const r = Math.ceil(radius);
|
|
const r2 = radius * radius;
|
|
for (const p of points) {
|
|
const weight = weightFn(p);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const nx = p.x + dx;
|
|
const ny = p.y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 > r2) continue;
|
|
const d = Math.sqrt(d2);
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 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 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, prefectureRegionId = null, sea = null) {
|
|
const segments = [];
|
|
const validCell = (i) => Boolean(prefectureMask?.[i]) && !(sea?.[i]) && (adminId?.[i] ?? -1) >= 0;
|
|
const samePrefecture = (i, j) => !prefectureRegionId
|
|
|| ((prefectureRegionId[i] ?? -1) >= 0 && prefectureRegionId[i] === prefectureRegionId[j]);
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!validCell(i)) continue;
|
|
const a = adminId[i];
|
|
if (x + 1 < MAP_W) {
|
|
const ni = indexOf(x + 1, y);
|
|
if (validCell(ni) && samePrefecture(i, ni)) {
|
|
const b = adminId[ni];
|
|
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
}
|
|
}
|
|
if (y + 1 < MAP_H) {
|
|
const ni = indexOf(x, y + 1);
|
|
if (validCell(ni) && samePrefecture(i, ni)) {
|
|
const b = adminId[ni];
|
|
if (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;
|
|
if (prefix === "logistics") {
|
|
return {
|
|
...p,
|
|
id,
|
|
name: null,
|
|
facilityLabel: p.facilityLabel || "Logistics Park",
|
|
kind,
|
|
labelStyle: "facility",
|
|
suppressSettlementLabel: true,
|
|
insidePrefecture: Boolean(p.insidePrefecture),
|
|
};
|
|
}
|
|
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;
|
|
}
|