3011 lines
134 KiB
JavaScript
3011 lines
134 KiB
JavaScript
import { createNameDebug, generateEntityName } from "./names.js";
|
|
import {
|
|
applyLandscapeUnitAdminPartition,
|
|
generateAdminRegions,
|
|
lockSmallUrbanComponentsToMunicipality,
|
|
mergeTinyMunicipalities,
|
|
removeMunicipalExclaves,
|
|
smoothAdminRegionsTerrainAware,
|
|
snapAdminBoundariesToTerrain,
|
|
} from "./adminRegions.js";
|
|
import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, nearMapEdge, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js";
|
|
|
|
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function pointKey(p) {
|
|
return `${p.x},${p.y}`;
|
|
}
|
|
|
|
function getDegree(degreeMap, p) {
|
|
return degreeMap.get(pointKey(p)) || 0;
|
|
}
|
|
|
|
function incrementDegree(degreeMap, p) {
|
|
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
function pathCompactness(path) {
|
|
const direct = pathEndpointDistance(path);
|
|
if (direct <= 0.001) return INF;
|
|
return pathLength(path) / direct;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
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));
|
|
}
|
|
|
|
function generateRegionalPrefectures(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: 9 + Math.floor(rand(seed, 6101) * 6),
|
|
minDistance: 22,
|
|
threshold: 0.38,
|
|
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 extractRegionBorderSegments(regionId, sea) {
|
|
const segments = [];
|
|
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 b = regionId[indexOf(x + 1, y)];
|
|
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
}
|
|
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
|
|
const b = regionId[indexOf(x, y + 1)];
|
|
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function tagInsidePrefecture(points, prefectureMask) {
|
|
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
|
}
|
|
|
|
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,
|
|
insidePrefecture: Boolean(p.insidePrefecture),
|
|
};
|
|
});
|
|
}
|
|
|
|
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;
|
|
return slim;
|
|
}
|
|
|
|
function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
|
|
populationDensity.fill(0);
|
|
const allCities = [...modernCities, ...satelliteCities];
|
|
for (const city of allCities) {
|
|
const urbanR = Math.max(4, city.urbanRadius || 8);
|
|
const coreR = Math.max(2, city.coreRadius || 3);
|
|
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
|
|
const r = Math.ceil(urbanR * 2.2);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const lu = landuse[i];
|
|
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
|
|
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
|
|
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
|
|
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
|
|
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
|
|
}
|
|
}
|
|
}
|
|
let maxDensity = 0;
|
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
|
|
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
|
|
|
for (const city of allCities) {
|
|
let urbanCells = 0;
|
|
let coreCells = 0;
|
|
let densitySum = 0;
|
|
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > r) continue;
|
|
const lu = landuse[i];
|
|
if (lu >= 2 && lu <= 8) {
|
|
urbanCells++;
|
|
densitySum += populationDensity[i];
|
|
if (lu === 3) coreCells++;
|
|
}
|
|
}
|
|
}
|
|
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
|
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : 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);
|
|
}
|
|
}
|
|
|
|
export function generateMap(seedInput = 114514, options = {}) {
|
|
const seed = Number(seedInput) >>> 0;
|
|
|
|
let prefectureMask;
|
|
let prefectureBorder;
|
|
|
|
const {
|
|
elevation,
|
|
moisture,
|
|
slope,
|
|
sea,
|
|
river,
|
|
floodplain,
|
|
plain,
|
|
agriculture,
|
|
ridgeField,
|
|
valleyField,
|
|
basinField,
|
|
coastalLowland,
|
|
flowAccum,
|
|
erosionField,
|
|
depositionField,
|
|
flowTo,
|
|
portSuitability,
|
|
crossingSuitability,
|
|
passSuitability,
|
|
} = createMapFields();
|
|
|
|
const coastAngle = rand(seed, 11) * Math.PI * 2;
|
|
const coastX = Math.cos(coastAngle);
|
|
const coastY = Math.sin(coastAngle);
|
|
const coastThreshold = 0.22 + rand(seed, 12) * 0.22;
|
|
const coastStrength = 0.15 + rand(seed, 13) * 0.23;
|
|
|
|
const seaLevel = 0.285;
|
|
|
|
const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({
|
|
x: rand(seed, 100 + i) * MAP_W,
|
|
y: rand(seed, 200 + i) * MAP_H,
|
|
r: 10 + rand(seed, 300 + i) * 24,
|
|
h: 0.08 + rand(seed, 400 + i) * 0.16,
|
|
}));
|
|
|
|
const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({
|
|
x: rand(seed, 1500 + i) * MAP_W,
|
|
y: rand(seed, 1600 + i) * MAP_H,
|
|
angle: rand(seed, 1700 + i) * Math.PI * 2,
|
|
width: 3 + rand(seed, 1800 + i) * 7,
|
|
length: 42 + rand(seed, 1900 + i) * 92,
|
|
h: 0.11 + rand(seed, 2000 + i) * 0.22,
|
|
}));
|
|
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const nx = x / (MAP_W - 1) - 0.5;
|
|
const ny = y / (MAP_H - 1) - 0.5;
|
|
const i = indexOf(x, y);
|
|
|
|
const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13;
|
|
const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13;
|
|
const wx = x + warpX;
|
|
const wy = y + warpY;
|
|
|
|
let mountains = 0;
|
|
for (const blob of mountainBlobs) {
|
|
const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r;
|
|
mountains += Math.exp(-d * d * 2.35) * blob.h;
|
|
}
|
|
|
|
let ridges = 0;
|
|
for (const ridge of ridgeBands) {
|
|
const dx = wx - ridge.x;
|
|
const dy = wy - ridge.y;
|
|
const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle);
|
|
const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle);
|
|
const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length);
|
|
const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56;
|
|
ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration;
|
|
}
|
|
|
|
const directionalCoast = nx * coastX + ny * coastY;
|
|
const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08;
|
|
const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26);
|
|
// Four terrain-noise bands from continental structure to fine surface roughness.
|
|
const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710);
|
|
const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777);
|
|
const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777);
|
|
const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5);
|
|
const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035;
|
|
const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI);
|
|
const rawElevation =
|
|
0.30 * terrainLarge +
|
|
0.235 * terrainRegional +
|
|
0.105 * terrainLocal +
|
|
0.055 * terrainFine +
|
|
mountains * 0.54 +
|
|
ridges * 1.22 +
|
|
basin +
|
|
fineDissection -
|
|
coastLower * (coastStrength + 0.19) +
|
|
0.055;
|
|
|
|
elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26);
|
|
ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0);
|
|
basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7);
|
|
moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
const nx = x / (MAP_W - 1) - 0.5;
|
|
const ny = y / (MAP_H - 1) - 0.5;
|
|
const directionalCoast = nx * coastX + ny * coastY;
|
|
const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08;
|
|
const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055;
|
|
if (elevation[i] < seaLevel || oceanSide) sea[i] = 1;
|
|
if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012);
|
|
}
|
|
}
|
|
|
|
// Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs
|
|
// when the directional coastline cuts through a high terrain cell.
|
|
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 nearestSea = INF;
|
|
for (let dy = -7; dy <= 7; dy++) {
|
|
for (let dx = -7; dx <= 7; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue;
|
|
nearestSea = Math.min(nearestSea, Math.hypot(dx, dy));
|
|
}
|
|
}
|
|
if (nearestSea <= 7) {
|
|
const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022;
|
|
elevation[i] = Math.min(elevation[i], coastalCap);
|
|
coastalLowland[i] = clamp(1 - nearestSea / 7);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
|
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
|
slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
|
|
}
|
|
}
|
|
|
|
const landOrder = [];
|
|
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]) continue;
|
|
let low = i;
|
|
let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468);
|
|
let localMean = 0;
|
|
let localMax = elevation[i];
|
|
let localMin = elevation[i];
|
|
let nCount = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const ev = elevation[ni];
|
|
localMean += ev;
|
|
localMax = Math.max(localMax, ev);
|
|
localMin = Math.min(localMin, ev);
|
|
nCount++;
|
|
const directed = ev + 0.008 * hash2(nx, ny, seed + 2469);
|
|
if (directed < best || sea[ni]) {
|
|
best = directed;
|
|
low = ni;
|
|
}
|
|
}
|
|
if (low !== i) flowTo[i] = low;
|
|
localMean /= Math.max(1, nCount);
|
|
const hollow = Math.max(0, localMean - elevation[i]);
|
|
const relief = localMax - localMin;
|
|
valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18);
|
|
basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0));
|
|
flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55;
|
|
landOrder.push(i);
|
|
}
|
|
}
|
|
landOrder.sort((a, b) => elevation[b] - elevation[a]);
|
|
for (const i of landOrder) {
|
|
const to = flowTo[i];
|
|
if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82;
|
|
}
|
|
let maxFlowAccum = 0;
|
|
for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]);
|
|
if (maxFlowAccum > 0) {
|
|
for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum);
|
|
}
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48);
|
|
}
|
|
|
|
// First-order fluvial shaping: cut valley floors on steep/high-flow cells and
|
|
// deposit gently in coastal lowlands and basin floors. This gives visible
|
|
// river valleys without destroying the macro terrain structure.
|
|
const shapedElevation = new Float32Array(elevation);
|
|
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]) continue;
|
|
const flow = Math.pow(flowAccum[i], 0.46);
|
|
const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36;
|
|
const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise);
|
|
const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078);
|
|
const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82));
|
|
erosionField[i] = steepValley + lateralCut;
|
|
depositionField[i] = lowSettling;
|
|
shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1);
|
|
}
|
|
}
|
|
elevation.set(shapedElevation);
|
|
|
|
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]) continue;
|
|
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
|
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
|
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
|
|
valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06);
|
|
basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6);
|
|
}
|
|
}
|
|
|
|
const sourceCandidates = [];
|
|
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]) continue;
|
|
const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06;
|
|
if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score });
|
|
}
|
|
}
|
|
|
|
const sources = pickEntities(sourceCandidates, {
|
|
max: 20 + Math.floor(rand(seed, 910) * 28),
|
|
minDistance: 8,
|
|
threshold: 0.53 + rand(seed, 911) * 0.11,
|
|
seed,
|
|
});
|
|
|
|
function nearestWaterGoal(from) {
|
|
let bestSea = null;
|
|
let bestScore = INF;
|
|
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 d = Math.hypot(x - from.x, y - from.y);
|
|
const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2;
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
bestSea = { x, y };
|
|
}
|
|
}
|
|
}
|
|
return bestSea;
|
|
}
|
|
|
|
function riverRouteCost(x, y, cx, cy) {
|
|
const i = indexOf(x, y);
|
|
const ci = indexOf(cx, cy);
|
|
if (sea[i]) return 0.18;
|
|
const uphill = Math.max(0, elevation[i] - elevation[ci]);
|
|
const downhill = Math.max(0, elevation[ci] - elevation[i]);
|
|
if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF;
|
|
return Math.max(
|
|
0.18,
|
|
1 +
|
|
uphill * 86 +
|
|
slope[i] * 0.38 +
|
|
elevation[i] * 0.42 -
|
|
downhill * 2.1 -
|
|
valleyField[i] * 0.92 -
|
|
flowAccum[i] * 0.72 -
|
|
moisture[i] * 0.18 -
|
|
coastalLowland[i] * 0.22
|
|
);
|
|
}
|
|
|
|
function forceRiverToWater(path) {
|
|
if (!path.length) return path;
|
|
const [ex, ey] = path[path.length - 1];
|
|
if (sea[indexOf(ex, ey)]) return path;
|
|
const goal = nearestWaterGoal({ x: ex, y: ey });
|
|
if (!goal) return path;
|
|
const startElevation = elevation[indexOf(ex, ey)];
|
|
const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => {
|
|
const i = indexOf(x, y);
|
|
const ci = indexOf(cx, cy);
|
|
if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF;
|
|
return riverRouteCost(x, y, cx, cy);
|
|
});
|
|
if (tail.length <= 2) return path;
|
|
return path.concat(tail.slice(1));
|
|
}
|
|
|
|
function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) {
|
|
if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0;
|
|
let best = 0.16;
|
|
const inLen = Math.hypot(dx, dy) || 1;
|
|
for (const [rx, ry] of neighbors8(nx, ny)) {
|
|
if (river[indexOf(rx, ry)] < 0.22) continue;
|
|
const rdx = rx - nx;
|
|
const rdy = ry - ny;
|
|
const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1);
|
|
const angle = Math.acos(cos);
|
|
const shallow = angle < 0.45 ? 0.28 : 0;
|
|
best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function traceRiverPath(startX, startY, bonusSeed = 0) {
|
|
let x = startX;
|
|
let y = startY;
|
|
let lastDx = 0;
|
|
let lastDy = 0;
|
|
const path = [];
|
|
const seen = new Set();
|
|
let accum = 0;
|
|
|
|
for (let step = 0; step < 600; step++) {
|
|
const i = indexOf(x, y);
|
|
if (seen.has(i)) break;
|
|
seen.add(i);
|
|
path.push([x, y]);
|
|
river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55;
|
|
accum += river[i] + flowAccum[i];
|
|
if (sea[i]) break;
|
|
|
|
let best = null;
|
|
let bestValue = INF;
|
|
const currentElevation = elevation[i];
|
|
const preferred = flowTo[i];
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const dx = nx - x;
|
|
const dy = ny - y;
|
|
const drop = currentElevation - elevation[ni];
|
|
const uphill = Math.max(0, -drop);
|
|
if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue;
|
|
let surrounding = 0;
|
|
let surroundingCount = 0;
|
|
for (const [vx, vy] of neighbors8(nx, ny)) {
|
|
surrounding += elevation[indexOf(vx, vy)];
|
|
surroundingCount++;
|
|
}
|
|
const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]);
|
|
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
|
|
const straightPenalty = Math.max(0, sameDirection) * 0.075;
|
|
const turnPenalty = sameDirection < -0.35 ? 0.24 : 0;
|
|
const sideSwing = Math.abs(dx * lastDy - dy * lastDx);
|
|
const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5;
|
|
const meander = sideSwing * (0.032 + meanderPhase * 0.026);
|
|
const flowBonus = ni === preferred ? 0.62 : 0;
|
|
const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length);
|
|
const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04;
|
|
const value =
|
|
elevation[ni] * 1.45 +
|
|
uphill * 88 -
|
|
Math.max(0, drop) * 2.05 -
|
|
valley * 1.05 -
|
|
valleyField[ni] * 1.72 -
|
|
flowAccum[ni] * 0.94 -
|
|
moisture[ni] * 0.14 -
|
|
coastalLowland[ni] * 0.28 -
|
|
(river[ni] > 0 ? 0.22 : 0) -
|
|
flowBonus +
|
|
slope[ni] * 0.04 +
|
|
straightPenalty +
|
|
turnPenalty +
|
|
junctionPenalty * 1.35 -
|
|
meander +
|
|
noise -
|
|
(sea[ni] ? 0.6 : 0);
|
|
|
|
if (value < bestValue) {
|
|
bestValue = value;
|
|
best = [nx, ny, dx, dy];
|
|
}
|
|
}
|
|
if (!best) break;
|
|
x = best[0];
|
|
y = best[1];
|
|
lastDx = best[2];
|
|
lastDy = best[3];
|
|
}
|
|
|
|
const forced = forceRiverToWater(path);
|
|
if (forced.length > path.length) {
|
|
for (const [rx, ry] of forced.slice(path.length)) {
|
|
const ri = indexOf(rx, ry);
|
|
river[ri] += 0.32 + flowAccum[ri] * 0.4;
|
|
accum += river[ri] + flowAccum[ri];
|
|
}
|
|
}
|
|
return { path: forced, accum };
|
|
}
|
|
|
|
function traceSmallStreamPath(startX, startY, bonusSeed = 0) {
|
|
let x = startX;
|
|
let y = startY;
|
|
let lastDx = 0;
|
|
let lastDy = 0;
|
|
const path = [];
|
|
const seen = new Set();
|
|
for (let step = 0; step < 160; step++) {
|
|
const i = indexOf(x, y);
|
|
if (seen.has(i)) break;
|
|
seen.add(i);
|
|
path.push([x, y]);
|
|
river[i] += 0.12 + flowAccum[i] * 0.18;
|
|
if ((river[i] > 0.48 && path.length > 5) || sea[i]) break;
|
|
let best = null;
|
|
let bestValue = INF;
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
const dx = nx - x;
|
|
const dy = ny - y;
|
|
const drop = elevation[i] - elevation[ni];
|
|
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
|
|
const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05;
|
|
if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; }
|
|
}
|
|
if (!best) break;
|
|
x = best[0];
|
|
y = best[1];
|
|
lastDx = best[2];
|
|
lastDy = best[3];
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const riverPaths = [];
|
|
const riverScores = [];
|
|
for (const source of sources) {
|
|
const { path, accum } = traceRiverPath(source.x, source.y, 0);
|
|
if (path.length > 6) {
|
|
riverPaths.push(path);
|
|
riverScores.push(path.length + accum * 0.18);
|
|
}
|
|
}
|
|
|
|
const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
|
|
const tributarySources = pickEntities(sourceCandidates
|
|
.filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`))
|
|
.map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), {
|
|
max: 14 + Math.floor(rand(seed, 915) * 20),
|
|
minDistance: 6,
|
|
threshold: 0.45,
|
|
seed: seed + 916,
|
|
jitter: 0.02,
|
|
});
|
|
for (const source of tributarySources) {
|
|
const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11);
|
|
if (path.length > 8) {
|
|
riverPaths.push(path);
|
|
riverScores.push(path.length * 0.7 + accum * 0.12);
|
|
}
|
|
}
|
|
|
|
const streamPaths = [];
|
|
const streamSources = pickEntities(sourceCandidates
|
|
.map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 }))
|
|
.filter((p) => p.score > 0.18), {
|
|
max: 22 + Math.floor(rand(seed, 919) * 20),
|
|
minDistance: 4,
|
|
threshold: 0.18,
|
|
seed: seed + 919,
|
|
jitter: 0.015,
|
|
});
|
|
for (const source of streamSources) {
|
|
const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17);
|
|
if (path.length > 4) streamPaths.push(path);
|
|
}
|
|
|
|
if (riverPaths.length === 0 && sourceCandidates.length > 0) {
|
|
const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0];
|
|
let bestSea = null;
|
|
let bestSeaDist = INF;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
if (!sea[indexOf(x, y)]) continue;
|
|
const d = Math.hypot(x - fallback.x, y - fallback.y);
|
|
if (d < bestSeaDist) {
|
|
bestSeaDist = d;
|
|
bestSea = { x, y };
|
|
}
|
|
}
|
|
}
|
|
if (bestSea) {
|
|
const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => {
|
|
const i = indexOf(x, y);
|
|
const ci = indexOf(cx, cy);
|
|
if (sea[i]) return 0.25;
|
|
const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24;
|
|
const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8;
|
|
return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1));
|
|
});
|
|
if (fallbackPath.length > 6) {
|
|
let accum = 0;
|
|
for (const [x, y] of fallbackPath) {
|
|
const i = indexOf(x, y);
|
|
river[i] += 0.42;
|
|
accum += river[i];
|
|
}
|
|
riverPaths.push(fallbackPath);
|
|
riverScores.push(fallbackPath.length + accum * 0.18);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sanitizeDownhillRiverPath(path, tolerance = 0.040) {
|
|
if (!path || path.length < 2) return path || [];
|
|
const out = [path[0]];
|
|
for (let k = 1; k < path.length; k++) {
|
|
const [px, py] = out[out.length - 1];
|
|
const [x, y] = path[k];
|
|
const pi = indexOf(px, py);
|
|
const i = indexOf(x, y);
|
|
if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break;
|
|
out.push(path[k]);
|
|
if (sea[i]) break;
|
|
}
|
|
return out.length >= 2 ? out : [];
|
|
}
|
|
function trimMountainHeadwaters(path) {
|
|
if (!path || path.length < 4) return path || [];
|
|
let start = 0;
|
|
while (start < path.length - 3) {
|
|
const [x, y] = path[start];
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) break;
|
|
if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break;
|
|
start++;
|
|
}
|
|
return path.slice(start);
|
|
}
|
|
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032);
|
|
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
|
|
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026);
|
|
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
|
|
river.fill(0);
|
|
for (const path of riverPaths) {
|
|
for (let k = 0; k < path.length; k++) {
|
|
const [x, y] = path[k];
|
|
const i = indexOf(x, y);
|
|
river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55;
|
|
}
|
|
}
|
|
for (const path of streamPaths) {
|
|
for (let k = 0; k < path.length; k++) {
|
|
const [x, y] = path[k];
|
|
const i = indexOf(x, y);
|
|
river[i] += 0.11 + flowAccum[i] * 0.18;
|
|
}
|
|
}
|
|
|
|
const expandedRiver = new Float32Array(river);
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (river[i] <= 0) continue;
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35);
|
|
}
|
|
}
|
|
}
|
|
river.set(expandedRiver);
|
|
|
|
// Second fluvial pass uses the actual traced river network. Main channels cut
|
|
// visible V-shaped valleys; lower reaches accumulate alluvial deposits.
|
|
const fluvialElevation = new Float32Array(elevation);
|
|
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] || river[i] <= 0.02) continue;
|
|
const r = clamp(river[i] / 3.4);
|
|
const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038));
|
|
const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040));
|
|
const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0)));
|
|
erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden);
|
|
depositionField[i] = clamp(depositionField[i] + alluvium);
|
|
fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1);
|
|
valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4);
|
|
basinField[i] = clamp(basinField[i] + alluvium * 3.2);
|
|
}
|
|
}
|
|
// Lateral valley carving around the traced river network deepens valleys and
|
|
// makes ridge/valley contrast legible at the map scale.
|
|
for (const path of riverPaths) {
|
|
for (const [rx, ry] of path) {
|
|
const ri = indexOf(rx, ry);
|
|
const r = clamp(river[ri] / 3.0);
|
|
const radius = r > 0.48 ? 2 : 1;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const nx = rx + dx;
|
|
const ny = ry + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius || d === 0) continue;
|
|
const weight = (radius + 0.35 - d) / (radius + 0.35);
|
|
const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22);
|
|
fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1);
|
|
erosionField[ni] = clamp(erosionField[ni] + carve * 3.0);
|
|
valleyField[ni] = clamp(valleyField[ni] + carve * 12.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore rugged summit relief after strong river incision. This prevents highlands
|
|
// from becoming unnaturally flat or visually concave while keeping valleys cut.
|
|
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]) continue;
|
|
const high = clamp((fluvialElevation[i] - 0.62) / 0.26);
|
|
const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8);
|
|
const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035;
|
|
const uplift = summit * (0.018 + Math.max(0, rugged));
|
|
if (uplift > 0) {
|
|
fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1);
|
|
erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6);
|
|
}
|
|
}
|
|
}
|
|
|
|
elevation.set(fluvialElevation);
|
|
|
|
// Broad alluvial/coastal/basin plains. The plain score alone is not enough;
|
|
// the elevation surface must also be locally calm, otherwise every lowland
|
|
// still reads as rugged terrain. Smooth only low, wet depositional cells and
|
|
// leave ridges/headwaters untouched.
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
const nextElevation = new Float32Array(elevation);
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const lowland = clamp(
|
|
coastalLowland[i] * 0.72 +
|
|
basinField[i] * 0.54 +
|
|
valleyField[i] * 0.34 +
|
|
Math.pow(flowAccum[i], 0.58) * 0.24 -
|
|
ridgeField[i] * 0.62 -
|
|
Math.max(0, elevation[i] - 0.54) * 1.65 -
|
|
slope[i] * 0.74
|
|
);
|
|
if (lowland <= 0.12) continue;
|
|
let sum = 0;
|
|
let weight = 0;
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
const ni = indexOf(nx, ny);
|
|
if (sea[ni]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > 2.25) continue;
|
|
const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11);
|
|
const w = compatible / (1 + d);
|
|
sum += elevation[ni] * w;
|
|
weight += w;
|
|
}
|
|
}
|
|
if (weight <= 0) continue;
|
|
const localMean = sum / weight;
|
|
const terrace = Math.round(localMean * 42) / 42;
|
|
const target = lerp(localMean, terrace, 0.28);
|
|
nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1);
|
|
if (lowland > 0.55) {
|
|
depositionField[i] = clamp(depositionField[i] + lowland * 0.018);
|
|
erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012);
|
|
}
|
|
}
|
|
}
|
|
elevation.set(nextElevation);
|
|
}
|
|
|
|
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]) continue;
|
|
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
|
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
|
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2);
|
|
}
|
|
}
|
|
|
|
// Re-trim visible river paths after fluvial reshaping changes local elevation.
|
|
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028);
|
|
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
|
|
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022);
|
|
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
|
|
|
|
const mainRivers = riverPaths
|
|
.map((p, i) => ({ path: p, score: riverScores[i] }))
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, Math.min(6, riverPaths.length))
|
|
.map((x) => x.path);
|
|
|
|
if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]);
|
|
if (mainRivers.length === 0) {
|
|
let start = null;
|
|
let startScore = -INF;
|
|
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]) continue;
|
|
const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15;
|
|
if (score > startScore) {
|
|
startScore = score;
|
|
start = { x, y };
|
|
}
|
|
}
|
|
}
|
|
if (start) {
|
|
let goal = null;
|
|
let goalDist = INF;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
if (!sea[indexOf(x, y)]) continue;
|
|
const d = Math.hypot(x - start.x, y - start.y);
|
|
if (d < goalDist) {
|
|
goalDist = d;
|
|
goal = { x, y };
|
|
}
|
|
}
|
|
}
|
|
if (goal) {
|
|
const fallbackPath = aStar(start, goal, (x, y, cx, cy) => {
|
|
const i = indexOf(x, y);
|
|
const ci = indexOf(cx, cy);
|
|
if (sea[i]) return 0.2;
|
|
const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22;
|
|
const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7;
|
|
return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias);
|
|
});
|
|
if (fallbackPath.length > 4) {
|
|
riverPaths.push(fallbackPath);
|
|
mainRivers.push(fallbackPath);
|
|
for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
|
|
const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path));
|
|
const smallStreams = streamPaths.filter((path) => path.length >= 5);
|
|
|
|
prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
|
prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
|
const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
|
const prefectureRegionId = regionalPrefectures.regionId;
|
|
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
|
|
|
|
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]) continue;
|
|
const low = 1 - clamp((elevation[i] - 0.28) / 0.4);
|
|
const flat = 1 - slope[i];
|
|
const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55;
|
|
plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0));
|
|
|
|
let nearRiver = 0;
|
|
for (let dy = -4; dy <= 4; dy++) {
|
|
for (let dx = -4; dx <= 4; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy)));
|
|
}
|
|
}
|
|
|
|
const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55));
|
|
floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22);
|
|
agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06);
|
|
}
|
|
}
|
|
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
let seaNear = 0;
|
|
let riverNear = 0;
|
|
let sheltered = 0;
|
|
|
|
for (let dy = -5; dy <= 5; dy++) {
|
|
for (let dx = -5; dx <= 5; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d);
|
|
riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d));
|
|
}
|
|
}
|
|
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1;
|
|
}
|
|
}
|
|
|
|
const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18;
|
|
const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16;
|
|
portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16);
|
|
}
|
|
}
|
|
|
|
for (let y = 3; y < MAP_H - 3; y++) {
|
|
for (let x = 3; x < MAP_W - 3; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const r = river[i];
|
|
if (r < 0.2 || r > 1.85) continue;
|
|
let bankPlain = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)];
|
|
crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06);
|
|
}
|
|
}
|
|
|
|
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]) continue;
|
|
const e = elevation[i];
|
|
if (e < 0.43 || e > 0.82) continue;
|
|
const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2;
|
|
const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2;
|
|
const diagLow = Math.min(
|
|
elevation[indexOf(x - 3, y - 3)],
|
|
elevation[indexOf(x + 3, y + 3)],
|
|
elevation[indexOf(x - 3, y + 3)],
|
|
elevation[indexOf(x + 3, y - 3)]
|
|
);
|
|
passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2);
|
|
}
|
|
}
|
|
|
|
function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) {
|
|
const candidates = [];
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!predicate(x, y, i)) continue;
|
|
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08;
|
|
if (score >= threshold) candidates.push({ x, y, score });
|
|
}
|
|
}
|
|
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
|
}
|
|
|
|
let ports = pickPoints(portSuitability, {
|
|
threshold: 0.3 + rand(seed, 1001) * 0.08,
|
|
max: 3 + Math.floor(rand(seed, 1002) * 7),
|
|
minDistance: 10,
|
|
seedOffset: 1000,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
let seaEdge = 0;
|
|
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
|
|
const nx = p.x + dx;
|
|
const ny = p.y + dy;
|
|
if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy));
|
|
}
|
|
const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2;
|
|
return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" };
|
|
}).sort((a, b) => b.harborPotential - a.harborPotential)
|
|
.map((p, n) => {
|
|
const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32;
|
|
const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing";
|
|
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
|
|
return { ...p, portClass, kind, score: p.harborPotential };
|
|
});
|
|
if (!ports.some((p) => p.portClass === "major")) {
|
|
const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0];
|
|
if (fallbackMajor) {
|
|
fallbackMajor.portClass = "major";
|
|
fallbackMajor.kind = "Major Port";
|
|
fallbackMajor.score += 0.16;
|
|
}
|
|
}
|
|
const majorPorts = ports.filter((p) => p.portClass === "major");
|
|
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
|
|
|
|
let crossings = pickPoints(crossingSuitability, {
|
|
threshold: 0.28 + rand(seed, 1011) * 0.08,
|
|
max: 8 + Math.floor(rand(seed, 1012) * 15),
|
|
minDistance: 8,
|
|
seedOffset: 1010,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "River Crossing" }));
|
|
|
|
let passes = pickPoints(passSuitability, {
|
|
threshold: 0.16 + rand(seed, 1021) * 0.08,
|
|
max: 4 + Math.floor(rand(seed, 1022) * 10),
|
|
minDistance: 9,
|
|
seedOffset: 1020,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Pass" }));
|
|
|
|
const settlementCluster = new Float32Array(SIZE);
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16);
|
|
const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18);
|
|
const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1);
|
|
const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10);
|
|
const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038);
|
|
settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18));
|
|
}
|
|
}
|
|
|
|
const settlementScore = new Float32Array(SIZE);
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
let nearFeature = 0;
|
|
for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4));
|
|
const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16);
|
|
const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52;
|
|
const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75;
|
|
const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty;
|
|
settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13);
|
|
}
|
|
}
|
|
|
|
let villages = pickPoints(settlementScore, {
|
|
threshold: 0.32 + rand(seed, 1031) * 0.1,
|
|
max: 28 + Math.floor(rand(seed, 1032) * 44),
|
|
minDistance: 3 + Math.floor(rand(seed, 1033) * 3),
|
|
seedOffset: 1030,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Village" }));
|
|
|
|
const marketScore = new Float32Array(SIZE);
|
|
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]) continue;
|
|
|
|
let villagePull = 0;
|
|
let nearbyVillages = 0;
|
|
for (const v of villages) {
|
|
const d = Math.hypot(x - v.x, y - v.y);
|
|
if (d < 24) {
|
|
villagePull += 1 / (1 + d);
|
|
nearbyVillages++;
|
|
}
|
|
}
|
|
|
|
let featurePull = 0;
|
|
for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3));
|
|
const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0;
|
|
marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012);
|
|
}
|
|
}
|
|
|
|
let markets = pickPoints(marketScore, {
|
|
threshold: 0.2 + rand(seed, 1041) * 0.08,
|
|
max: 6 + Math.floor(rand(seed, 1042) * 12),
|
|
minDistance: 11,
|
|
seedOffset: 1040,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Market Town" }));
|
|
|
|
const defenseScore = new Float32Array(SIZE);
|
|
for (let y = 3; y < MAP_H - 3; y++) {
|
|
for (let x = 3; x < MAP_W - 3; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24);
|
|
let riverArms = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++;
|
|
const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0;
|
|
const roadJunctionProxy = (
|
|
(distanceToNearest(markets, x, y) < 7 ? 1 : 0) +
|
|
(distanceToNearest(crossings, x, y) < 6 ? 1 : 0) +
|
|
(distanceToNearest(passes, x, y) < 7 ? 1 : 0) +
|
|
(distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0)
|
|
) >= 2 ? 0.32 : 0;
|
|
const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0;
|
|
const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0;
|
|
const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0;
|
|
defenseScore[i] = validCastleSite
|
|
? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12)
|
|
: 0;
|
|
}
|
|
}
|
|
|
|
let castles = pickPoints(defenseScore, {
|
|
threshold: 0.34 + rand(seed, 1051) * 0.08,
|
|
max: 2 + Math.floor(rand(seed, 1052) * 4),
|
|
minDistance: 15,
|
|
seedOffset: 1050,
|
|
predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0,
|
|
}).map((p) => ({
|
|
...p,
|
|
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
|
|
}));
|
|
|
|
function normalEdgePenalty(x, y) {
|
|
if (nearMapEdge(x, y, 1)) return INF;
|
|
if (nearMapEdge(x, y, 2)) return 7;
|
|
if (nearMapEdge(x, y, 4)) return 2.8;
|
|
return 0;
|
|
}
|
|
|
|
function premodernCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0;
|
|
const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0;
|
|
const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0;
|
|
const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16);
|
|
}
|
|
|
|
const premodernRoads = [];
|
|
function addPremodernRoad(a, b) {
|
|
const path = aStar(a, b, premodernCost);
|
|
if (path.length > 3) premodernRoads.push(path);
|
|
}
|
|
|
|
for (const castle of castles) {
|
|
const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 });
|
|
for (const p of near) addPremodernRoad(castle, p);
|
|
}
|
|
for (const market of markets) {
|
|
const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 });
|
|
for (const p of near) addPremodernRoad(market, p);
|
|
}
|
|
|
|
function urbanSiteSuitability(p) {
|
|
const i = indexOf(p.x, p.y);
|
|
if (sea[i]) return 0;
|
|
const portBonus = p.kind === "Port Town" || p.portClass === "major" || p.portClass === "regional" ? 0.18 : 0;
|
|
const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" ? 0.05 : 0;
|
|
return clamp(
|
|
plain[i] * 0.46 +
|
|
agriculture[i] * 0.18 +
|
|
basinField[i] * 0.20 +
|
|
coastalLowland[i] * 0.20 +
|
|
valleyField[i] * 0.12 +
|
|
portBonus + historicalBonus -
|
|
slope[i] * 0.58 -
|
|
ridgeField[i] * 0.34 -
|
|
Math.max(0, elevation[i] - 0.55) * 1.35
|
|
);
|
|
}
|
|
|
|
function cityPopulationCap(p) {
|
|
const i = indexOf(p.x, p.y);
|
|
const suitability = urbanSiteSuitability(p);
|
|
if (suitability < 0.18 || elevation[i] > 0.66 || slope[i] > 0.82 || ridgeField[i] > 0.72) return 85000;
|
|
if (suitability < 0.28 || elevation[i] > 0.60 || slope[i] > 0.62) return 180000;
|
|
if (suitability < 0.38) return 420000;
|
|
return INF;
|
|
}
|
|
|
|
let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" }));
|
|
const cityCandidates = [
|
|
...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })),
|
|
...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })),
|
|
...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })),
|
|
].map((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
const suitability = urbanSiteSuitability(p);
|
|
return {
|
|
...p,
|
|
urbanSuitability: suitability,
|
|
score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78,
|
|
};
|
|
}).filter((p) => p.urbanSuitability >= 0.10 || p.kind === "Castle Town");
|
|
|
|
let modernCities = pickEntities(cityCandidates, {
|
|
max: 7 + Math.floor(rand(seed, 1061) * 10),
|
|
minDistance: 9,
|
|
threshold: 0.33 + rand(seed, 1062) * 0.12,
|
|
seed: seed + 1060,
|
|
}).map((p, n) => {
|
|
const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City";
|
|
const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y);
|
|
const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42;
|
|
const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000;
|
|
const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000;
|
|
const pi = indexOf(p.x, p.y);
|
|
const suitability = p.urbanSuitability ?? urbanSiteSuitability(p);
|
|
const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (p.kind === "Port Town" ? 0.22 : 0));
|
|
const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000;
|
|
const population = Math.min(rawPopulation, cityPopulationCap(p));
|
|
const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32);
|
|
const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9);
|
|
const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05);
|
|
return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" };
|
|
});
|
|
|
|
function fallbackCapitalCandidate() {
|
|
const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (const p of pools) {
|
|
const i = indexOf(p.x, p.y);
|
|
const score = urbanSiteSuitability(p) * 1.6 + plain[i] * 0.32 + populationDensityProxyForCapital(i) + (p.kind?.includes("Port") ? 0.18 : 0) + (p.score || 0);
|
|
if (score > bestScore) { bestScore = score; best = p; }
|
|
}
|
|
if (best) return { ...best, kind: "Market City", population: 360000, urbanRadius: 15, coreRadius: 4.6, urbanWeight: 1.9, score: bestScore };
|
|
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const score = plain[i] * 0.72 + agriculture[i] * 0.24 + basinField[i] * 0.18 + coastalLowland[i] * 0.14 - slope[i] * 0.72 - ridgeField[i] * 0.32;
|
|
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City" }; }
|
|
}
|
|
}
|
|
return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null;
|
|
}
|
|
|
|
function populationDensityProxyForCapital(i) {
|
|
return settlementScore[i] * 0.18 + marketScore[i] * 0.12;
|
|
}
|
|
|
|
if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) {
|
|
const fallbackCapital = fallbackCapitalCandidate();
|
|
if (fallbackCapital) modernCities.unshift(fallbackCapital);
|
|
}
|
|
|
|
if (modernCities.length > 0) {
|
|
modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000));
|
|
let capitalIndex = -1;
|
|
let capitalScore = -INF;
|
|
for (let i = 0; i < modernCities.length; i++) {
|
|
const city = modernCities[i];
|
|
const ci = indexOf(city.x, city.y);
|
|
if (!prefectureMask[ci] || sea[ci]) continue;
|
|
const suitability = urbanSiteSuitability(city);
|
|
const score = suitability * 900000 + (city.population || 0) * 0.55 + (city.score || 0) * 120000 - slope[ci] * 180000 - Math.max(0, elevation[ci] - 0.58) * 360000;
|
|
if (score > capitalScore) { capitalScore = score; capitalIndex = i; }
|
|
}
|
|
if (capitalIndex > 0) modernCities.unshift(modernCities.splice(capitalIndex, 1)[0]);
|
|
const capCell = indexOf(modernCities[0].x, modernCities[0].y);
|
|
const capPopulation = prefectureMask[capCell]
|
|
? Math.max(modernCities[0].population || 0, 620000)
|
|
: Math.min(modernCities[0].population || 0, 180000);
|
|
modernCities[0] = {
|
|
...modernCities[0],
|
|
rank: prefectureMask[capCell] ? "Prefectural Capital" : "Regional Center",
|
|
kind: prefectureMask[capCell] ? "Prefectural Capital" : (modernCities[0].kind || "City"),
|
|
isPrefecturalCapital: Boolean(prefectureMask[capCell]),
|
|
population: capPopulation,
|
|
urbanRadius: prefectureMask[capCell] ? Math.max(modernCities[0].urbanRadius || 0, 18) : modernCities[0].urbanRadius,
|
|
coreRadius: prefectureMask[capCell] ? Math.max(modernCities[0].coreRadius || 0, 5.5) : modernCities[0].coreRadius,
|
|
urbanWeight: prefectureMask[capCell] ? Math.max(modernCities[0].urbanWeight || 0, 2.15) : modernCities[0].urbanWeight,
|
|
};
|
|
for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false };
|
|
}
|
|
|
|
const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || modernCities.find((city) => prefectureMask[indexOf(city.x, city.y)]) || markets.find((p) => prefectureMask[indexOf(p.x, p.y)]) || ports.find((p) => prefectureMask[indexOf(p.x, p.y)]) || { x: Math.floor(MAP_W / 2), y: Math.floor(MAP_H / 2), score: 1, population: 0, urbanRadius: 12, coreRadius: 4, urbanWeight: 1, isPrefecturalCapital: true };
|
|
|
|
const populationDensity = new Float32Array(SIZE);
|
|
let maxPopulationDensity = 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]) continue;
|
|
let density = 0;
|
|
for (const city of modernCities) {
|
|
const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55);
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
const urbanR = Math.max(5, city.urbanRadius || 11);
|
|
const coreR = Math.max(2.4, city.coreRadius || 4);
|
|
density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35));
|
|
density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2));
|
|
}
|
|
for (const market of markets) {
|
|
const d = Math.hypot(market.x - x, market.y - y);
|
|
density += 0.22 / (1 + Math.pow(d / 7.5, 2.2));
|
|
}
|
|
for (const village of villages) {
|
|
const d = Math.hypot(village.x - x, village.y - y);
|
|
density += 0.055 / (1 + Math.pow(d / 4.2, 2));
|
|
}
|
|
density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22);
|
|
populationDensity[i] = density;
|
|
if (density > maxPopulationDensity) maxPopulationDensity = density;
|
|
}
|
|
}
|
|
if (maxPopulationDensity > 0) {
|
|
for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity);
|
|
}
|
|
|
|
function densityValue(x, y) {
|
|
return populationDensity[indexOf(x, y)] || 0;
|
|
}
|
|
|
|
function midDensityAffinity(x, y) {
|
|
const d = densityValue(x, y);
|
|
return clamp(1 - Math.abs(d - 0.38) / 0.38);
|
|
}
|
|
|
|
function nearPassPoint(x, y, radius = 5) {
|
|
return distanceToNearest(passes, x, y) <= radius;
|
|
}
|
|
|
|
function mountainBarrierPenalty(x, y, type = "rail") {
|
|
const i = indexOf(x, y);
|
|
const e = elevation[i];
|
|
const s = slope[i];
|
|
const pass = nearPassPoint(x, y, type === "express" ? 7 : type === "rail" ? 6 : 5);
|
|
if (e > 0.84) return INF;
|
|
if (pass && e > 0.80 && s > 0.16) return INF;
|
|
if (!pass && e > 0.78) return INF;
|
|
if (!pass && e > 0.70 && s > 0.16) return INF;
|
|
if (!pass && e > 0.66 && s > 0.28) return INF;
|
|
if (!pass && e > 0.72) return type === "express" ? 260 : type === "rail" ? 330 : type === "minor" ? 80 : 155;
|
|
if (!pass && e > 0.64 && s > 0.20) return type === "express" ? 145 : type === "rail" ? 180 : type === "minor" ? 54 : 96;
|
|
const passDiscount = pass ? (type === "minor" ? 0.35 : 0.22) : 1;
|
|
const mountain = Math.max(0, e - 0.48);
|
|
const steep = Math.max(0, s - 0.15);
|
|
const typeFactor = type === "express" ? 360 : type === "rail" ? 430 : type === "minor" ? 115 : 210;
|
|
return (mountain * mountain * typeFactor + steep * steep * 150 + ridgeField[i] * 9.5) * passDiscount;
|
|
}
|
|
|
|
function transportAccessPoint(node, mode = "road", salt = 0) {
|
|
if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node;
|
|
const minR = mode === "express" ? 10 : mode === "rail" ? 2 : 4;
|
|
const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10;
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (let dy = -maxR; dy <= maxR; dy++) {
|
|
for (let dx = -maxR; dx <= maxR; dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const x = node.x + dx;
|
|
const y = node.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) continue;
|
|
const targetD = (minR + maxR) * 0.5;
|
|
const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35;
|
|
const ring = -Math.abs(d - targetD) * 0.08;
|
|
const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12;
|
|
const density = densityValue(x, y);
|
|
const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24;
|
|
const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12;
|
|
const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node };
|
|
}
|
|
}
|
|
}
|
|
return best || node;
|
|
}
|
|
|
|
function routePoint(node, mode, salt = 0) {
|
|
return transportAccessPoint(node, mode, salt);
|
|
}
|
|
|
|
const townAvoidNodes = [...modernCities, ...markets, ...ports];
|
|
|
|
const urbanCenters = modernCities.map((city, n) => {
|
|
let best = { x: city.x, y: city.y, score: city.score + 0.5 };
|
|
let bestScore = -INF;
|
|
const searchR = Math.max(2, Math.round(city.coreRadius));
|
|
for (let dy = -searchR; dy <= searchR; dy++) {
|
|
for (let dx = -searchR; dx <= searchR; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07;
|
|
if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; }
|
|
}
|
|
}
|
|
return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) };
|
|
});
|
|
|
|
function railCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier;
|
|
const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0;
|
|
return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08);
|
|
}
|
|
|
|
const railways = [];
|
|
const branchRailways = [];
|
|
const railDegree = new Map();
|
|
const railCore = [capital];
|
|
const railHubs = [...modernCities, ...commercialPorts];
|
|
|
|
function addRailRoute(a, b, bucket = railways) {
|
|
const start = routePoint(a, "rail", a.x * 19 + a.y * 23);
|
|
const goal = routePoint(b, "rail", b.x * 19 + b.y * 23 + 11);
|
|
const existingRails = [...railways, ...branchRailways];
|
|
const cost = makeTransportCost(railCost, existingRails, railHubs, [start, goal], 5, 10.5, townAvoidNodes, 2.4, 4.2);
|
|
const path = aStar(start, goal, cost);
|
|
const length = pathLength(path);
|
|
const direct = pathEndpointDistance(path);
|
|
const overlap = pathOverlapRatio(path, existingRails, 2);
|
|
const densityPurpose = averagePathField(path, populationDensity) + averagePathField(path, plain) * 0.28 + averagePathField(path, valleyField) * 0.2;
|
|
const isMain = bucket === railways;
|
|
if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.1 : 3.4) && overlap < (isMain ? 0.30 : 0.20) && densityPurpose > (isMain ? 0.18 : 0.12)) {
|
|
bucket.push(path);
|
|
incrementDegree(railDegree, a);
|
|
incrementDegree(railDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000);
|
|
const mainRailTargets = transportCities.filter((city) => city !== capital).slice(0, 2 + Math.floor(rand(seed, 1070) * 3));
|
|
for (const city of mainRailTargets) {
|
|
const anchor = nearestConnectable(railCore, city, railDegree, 3) || capital;
|
|
if (addRailRoute(anchor, city, railways)) railCore.push(city);
|
|
}
|
|
for (const city of modernCities.filter((city) => city !== capital && !mainRailTargets.includes(city))) {
|
|
const anchor = nearestConnectable(railCore, city, railDegree, 2) || capital;
|
|
if (anchor && rand(seed, city.x * 10 + city.y) > 0.2) {
|
|
if (addRailRoute(city, anchor, branchRailways)) railCore.push(city);
|
|
}
|
|
}
|
|
for (const port of majorPorts.slice(0, 1 + Math.floor(rand(seed, 1071) * 2))) {
|
|
const anchor = nearestConnectable(railCore, port, railDegree, 2) || capital;
|
|
if (anchor && addRailRoute(port, anchor, branchRailways)) railCore.push(port);
|
|
}
|
|
|
|
compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 });
|
|
compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 });
|
|
|
|
const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5);
|
|
const stationCandidates = [
|
|
...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })),
|
|
...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })),
|
|
...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })),
|
|
];
|
|
|
|
let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 });
|
|
|
|
const industrialScore = new Float32Array(SIZE);
|
|
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]) continue;
|
|
const nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5);
|
|
const nearCity = distanceToNearest(modernCities, x, y);
|
|
const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0;
|
|
industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03);
|
|
}
|
|
}
|
|
|
|
let industrialZones = pickPoints(industrialScore, {
|
|
threshold: 0.31 + rand(seed, 1091) * 0.09,
|
|
max: 4 + Math.floor(rand(seed, 1092) * 13),
|
|
minDistance: 10,
|
|
seedOffset: 1090,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Industrial Zone" }));
|
|
|
|
function roadCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 17.8 + barrier + Math.max(0, elevation[i] - 0.54) * 9.2 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.22 - valleyField[i] * 0.28 - coastalLowland[i] * 0.20 + ridgeField[i] * 1.15 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08);
|
|
}
|
|
|
|
function expresswayCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const midDensity = midDensityAffinity(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0;
|
|
const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0;
|
|
const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0);
|
|
return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015);
|
|
}
|
|
|
|
const nationalRoads = [];
|
|
const roadDegree = new Map();
|
|
function transportDemand(p) {
|
|
const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700;
|
|
const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0;
|
|
const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0;
|
|
const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0;
|
|
const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0;
|
|
return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost;
|
|
}
|
|
|
|
function sameCorridorAffinity(a, b) {
|
|
const ai = indexOf(a.x, a.y);
|
|
const bi = indexOf(b.x, b.y);
|
|
return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10);
|
|
}
|
|
|
|
const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets, ...castles]
|
|
.map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.24 + ((p.population || 0) >= 180000 ? 0.18 : 0.05) }));
|
|
const pickedRoadTargets = pickEntities(roadTargetCandidates, {
|
|
max: 8 + Math.floor(rand(seed, 1101) * 10),
|
|
minDistance: 9,
|
|
threshold: 0,
|
|
seed: seed + 1100,
|
|
});
|
|
const roadTargets = [
|
|
capital,
|
|
...pickedRoadTargets
|
|
.filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2)
|
|
.sort((a, b) => transportDemand(b) - transportDemand(a)),
|
|
];
|
|
const roadHubs = [...modernCities, ...ports, ...markets, ...stations];
|
|
const roadCore = [capital];
|
|
|
|
function addNationalRoad(a, b) {
|
|
const start = routePoint(a, "road", a.x * 31 + a.y * 37);
|
|
const goal = routePoint(b, "road", b.x * 31 + b.y * 37 + 17);
|
|
const existing = [...nationalRoads, ...railways, ...branchRailways];
|
|
const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4));
|
|
const direct = pathEndpointDistance(path);
|
|
const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length;
|
|
const passBonusOk = urbanPasses >= 2 || direct >= 24;
|
|
if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) {
|
|
nationalRoads.push(path);
|
|
incrementDegree(roadDegree, a);
|
|
incrementDegree(roadDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
for (const target of roadTargets.slice(1, 8 + Math.floor(rand(seed, 1102) * 7))) {
|
|
const anchor = nearestConnectable(roadCore, target, roadDegree, 3) || capital;
|
|
if (addNationalRoad(anchor, target)) roadCore.push(target);
|
|
}
|
|
const roadLinkCandidates = [];
|
|
for (let i = 0; i < roadTargets.length; i++) {
|
|
for (let j = i + 1; j < roadTargets.length; j++) {
|
|
const a = roadTargets[i];
|
|
const b = roadTargets[j];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 18 || d > 58) continue;
|
|
const demand = Math.sqrt(transportDemand(a) * transportDemand(b));
|
|
roadLinkCandidates.push({ a, b, score: demand / (1 + d / 18) + sameCorridorAffinity(a, b) + hash2(a.x + b.x, a.y + b.y, seed + 1111) * 0.05 });
|
|
}
|
|
}
|
|
roadLinkCandidates.sort((a, b) => b.score - a.score);
|
|
let extraRoadLinks = 0;
|
|
for (const link of roadLinkCandidates) {
|
|
if (extraRoadLinks >= 4) break;
|
|
if (getDegree(roadDegree, link.a) >= 4 || getDegree(roadDegree, link.b) >= 4) continue;
|
|
if (addNationalRoad(link.a, link.b)) {
|
|
extraRoadLinks++;
|
|
}
|
|
}
|
|
|
|
// National roads should behave like long trunk corridors: they intentionally
|
|
// pass near as many urbanized cells/cities as possible, unlike expressways.
|
|
const trunkCities = modernCities
|
|
.filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000)
|
|
.slice()
|
|
.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
for (let i = 0; i < trunkCities.length - 1; i += 2) {
|
|
const a = trunkCities[i];
|
|
const b = trunkCities[Math.min(trunkCities.length - 1, i + 2)];
|
|
if (a && b && Math.hypot(a.x - b.x, a.y - b.y) >= 22 && getDegree(roadDegree, a) < 5) addNationalRoad(a, b);
|
|
}
|
|
|
|
const expressTargets = pickEntities(modernCities.filter((p) => p !== capital && (p.population || 0) >= 180000).map((p) => ({ ...p, score: p.score + Math.hypot(p.x - capital.x, p.y - capital.y) / 80 + 0.15 })).concat(majorPorts.map((p) => ({ ...p, score: p.score + 0.55 }))), {
|
|
max: 1 + Math.floor(rand(seed, 1120) * 3),
|
|
minDistance: 20,
|
|
threshold: 0.05,
|
|
seed: seed + 1120,
|
|
});
|
|
|
|
const expressways = [];
|
|
const expressDegree = new Map();
|
|
const expressCore = [capital];
|
|
|
|
function addExpressway(a, b, bucket = expressways) {
|
|
const start = routePoint(a, "express", a.x * 41 + a.y * 43);
|
|
const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29);
|
|
const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways];
|
|
let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0));
|
|
path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10);
|
|
const direct = pathEndpointDistance(path);
|
|
if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) {
|
|
bucket.push(path);
|
|
incrementDegree(expressDegree, a);
|
|
incrementDegree(expressDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
for (const target of expressTargets) {
|
|
const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital;
|
|
if (addExpressway(anchor, target)) expressCore.push(target);
|
|
}
|
|
|
|
const ringRoads = [];
|
|
const ringExpressways = [];
|
|
const ringRailways = [];
|
|
|
|
function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) {
|
|
const anchors = [];
|
|
const minR = Math.max(5, targetRadius - 5);
|
|
const maxR = targetRadius + 7;
|
|
for (let s = 0; s < sectors; s++) {
|
|
const angle0 = (s / sectors) * Math.PI * 2;
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) {
|
|
for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const angle = Math.atan2(dy, dx);
|
|
let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0)));
|
|
if (delta > Math.PI / sectors * 0.95) continue;
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i]) continue;
|
|
const barrier = mode === "road" ? mountainBarrierPenalty(x, y, "road") : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail");
|
|
if (barrier >= INF) continue;
|
|
const density = densityValue(x, y);
|
|
const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22;
|
|
const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y, score, kind: `${mode} ring anchor`, parent: city };
|
|
}
|
|
}
|
|
}
|
|
if (best) anchors.push(best);
|
|
}
|
|
return anchors;
|
|
}
|
|
|
|
function ringCost(baseCost, city, targetRadius, mode) {
|
|
return (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return base;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
const tooClose = Math.max(0, targetRadius * 0.46 - d);
|
|
const tooFar = Math.max(0, d - targetRadius * 1.55);
|
|
const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018;
|
|
const density = densityValue(x, y);
|
|
const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12;
|
|
return Math.max(0.36, base + bandPenalty + densityBias);
|
|
};
|
|
}
|
|
|
|
function softRingRailCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (sea[i] || barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
return Math.max(0.38, 1 + slope[i] * 14 + barrier + Math.max(0, elevation[i] - 0.56) * 22 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05);
|
|
}
|
|
|
|
function softRingExpressCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (sea[i] || barrier >= INF) return INF;
|
|
return Math.max(0.38, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.58) * 20 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.14 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05);
|
|
}
|
|
|
|
function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) {
|
|
const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8);
|
|
if (anchors.length < 3) return 0;
|
|
let made = 0;
|
|
const cost = ringCost(baseCost, city, targetRadius, mode);
|
|
for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) {
|
|
const a = anchors[i];
|
|
const b = anchors[(i + 1) % anchors.length];
|
|
if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue;
|
|
const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8));
|
|
if (path.length >= 5 && path.length <= targetRadius * 8.0) {
|
|
bucket.push(path);
|
|
made++;
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) {
|
|
const candidates = [];
|
|
const maxR = targetRadius + 11;
|
|
const minR = Math.max(5, targetRadius * 0.45);
|
|
for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) {
|
|
for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue;
|
|
const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06;
|
|
candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city });
|
|
}
|
|
}
|
|
return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 })
|
|
.sort((a, b) => a.angle - b.angle);
|
|
}
|
|
|
|
function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) {
|
|
let anchors = ringAnchorCandidates(city, "road", targetRadius, 6);
|
|
if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6);
|
|
if (anchors.length < 2) return 0;
|
|
let made = 0;
|
|
for (let i = 0; i < anchors.length; i++) {
|
|
const a = anchors[i];
|
|
const b = anchors[(i + 1) % anchors.length];
|
|
const path = aStar(a, b, (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return INF;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012;
|
|
return Math.max(0.3, base + band);
|
|
});
|
|
if (path.length >= 4 && path.length <= targetRadius * 9.0) {
|
|
bucket.push(path);
|
|
made++;
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6);
|
|
for (const city of mediumRingCities) {
|
|
const radius = clamp(8 + Math.sqrt(city.population || 100000) / 170, 10, 22);
|
|
addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius);
|
|
}
|
|
const largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1);
|
|
for (const city of largeRingCities) {
|
|
const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28);
|
|
const expressRadius = roadRadius + 3 + rand(seed, city.x * 71 + city.y * 73) * 3;
|
|
const railRadius = Math.max(8, roadRadius - 4);
|
|
addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius);
|
|
// Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors.
|
|
const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius);
|
|
// Expressway rings should be rare; do not force a fallback ring when terrain rejects it.
|
|
if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius);
|
|
}
|
|
ringExpressways.length = 0;
|
|
compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 });
|
|
compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 });
|
|
|
|
const gatewayCandidates = [];
|
|
for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); }
|
|
for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); }
|
|
|
|
let externalGateways = pickEntities(gatewayCandidates, {
|
|
max: 2 + Math.floor(rand(seed, 1201) * 3),
|
|
minDistance: 28,
|
|
threshold: 0.4,
|
|
seed: seed + 1201,
|
|
}).map((p) => ({ ...p, kind: "External Gateway" }));
|
|
|
|
function externalRoadCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0;
|
|
const density = densityValue(x, y);
|
|
const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.54) * 7.5 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.24 + borderPenalty + hash2(x, y, seed + 333) * 0.06);
|
|
};
|
|
}
|
|
function externalExpresswayCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0;
|
|
const density = densityValue(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0;
|
|
const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0;
|
|
return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05);
|
|
};
|
|
}
|
|
function externalRailCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0;
|
|
const density = densityValue(x, y);
|
|
return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05);
|
|
};
|
|
}
|
|
|
|
const externalRoads = [];
|
|
const externalExpressways = [];
|
|
const externalRailways = [];
|
|
|
|
function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) {
|
|
const sorted = pool
|
|
.filter(Boolean)
|
|
.map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) }))
|
|
.sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0)));
|
|
return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital;
|
|
}
|
|
|
|
externalGateways.forEach((gate, idx) => {
|
|
const makeExpressLink = idx === 0 || rand(seed, 1210 + idx) > 0.4;
|
|
const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 3);
|
|
const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59);
|
|
const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways];
|
|
const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate);
|
|
const roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6));
|
|
if (roadPath.length > 6) {
|
|
if (makeExpressLink) {
|
|
externalExpressways.push(roadPath);
|
|
incrementDegree(expressDegree, roadStartRaw);
|
|
incrementDegree(expressDegree, gate);
|
|
expressCore.push(gate);
|
|
} else {
|
|
externalRoads.push(roadPath);
|
|
incrementDegree(roadDegree, roadStartRaw);
|
|
incrementDegree(roadDegree, gate);
|
|
}
|
|
}
|
|
if ((idx === 0 || rand(seed, 1220 + idx) > 0.5) && modernCities.length > 0) {
|
|
const railStartRaw = selectExternalStart([...railCore, ...modernCities, ...ports], gate, railDegree, 2);
|
|
const railStart = routePoint(railStartRaw, "rail", gate.x * 61 + gate.y * 67);
|
|
const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...expressways, ...externalExpressways];
|
|
const railPath = aStar(railStart, gate, makeTransportCost(externalRailCost(gate), railExisting, railHubs, [railStart, gate], 4, 8.2, townAvoidNodes, 2.5, 4.4));
|
|
if (railPath.length > 6) {
|
|
externalRailways.push(railPath);
|
|
incrementDegree(railDegree, railStartRaw);
|
|
incrementDegree(railDegree, gate);
|
|
}
|
|
}
|
|
});
|
|
|
|
function pruneHighMountainTransport(paths, threshold = 0.82) {
|
|
for (let i = paths.length - 1; i >= 0; i--) {
|
|
if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1);
|
|
}
|
|
}
|
|
for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82);
|
|
|
|
const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6);
|
|
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4);
|
|
|
|
const icCandidates = [];
|
|
for (const path of [...expressways, ...externalExpressways]) {
|
|
icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" })));
|
|
for (const city of modernCities) {
|
|
let best = null;
|
|
let bestDistance = 999;
|
|
for (const [x, y] of path) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d < bestDistance) { bestDistance = d; best = { x, y }; }
|
|
}
|
|
if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" });
|
|
}
|
|
}
|
|
|
|
let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 });
|
|
|
|
const icAccessRoads = [];
|
|
const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8));
|
|
for (const ic of interchanges) {
|
|
const accessTargets = [
|
|
...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })),
|
|
...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })),
|
|
...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })),
|
|
];
|
|
const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0];
|
|
if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue;
|
|
const path = aStar(ic, target, roadCost);
|
|
if (path.length > 2 && path.length < 36) icAccessRoads.push(path);
|
|
}
|
|
|
|
const logisticsScore = new Float32Array(SIZE);
|
|
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]) continue;
|
|
const nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3);
|
|
const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0;
|
|
logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty);
|
|
}
|
|
}
|
|
|
|
let logisticsParks = pickPoints(logisticsScore, {
|
|
threshold: 0.32 + rand(seed, 1141) * 0.1,
|
|
max: 3 + Math.floor(rand(seed, 1142) * 13),
|
|
minDistance: 9,
|
|
seedOffset: 1140,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Logistics Park" }));
|
|
|
|
const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2);
|
|
const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2);
|
|
const stationInfluence = influenceFromPoints(stations, 10, () => 1);
|
|
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6);
|
|
const satelliteScore = new Float32Array(SIZE);
|
|
const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000);
|
|
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] || !prefectureMask[i]) continue;
|
|
let ringPull = 0;
|
|
let parent = null;
|
|
for (const city of largeCitiesForSatellites) {
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27);
|
|
const v = clamp(1 - Math.abs(d - ideal) / 9);
|
|
if (v > ringPull) { ringPull = v; parent = city; }
|
|
}
|
|
if (!parent) continue;
|
|
const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84);
|
|
const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0;
|
|
satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035);
|
|
}
|
|
}
|
|
let satelliteCities = pickPoints(satelliteScore, {
|
|
threshold: 0.43 + rand(seed, 1161) * 0.07,
|
|
max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)),
|
|
minDistance: 8,
|
|
seedOffset: 1160,
|
|
predicate: (x, y, i) => !sea[i] && prefectureMask[i],
|
|
}).map((p, n) => {
|
|
const parent = largeCitiesForSatellites.slice().sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0];
|
|
const basePop = parent ? parent.population * (0.045 + rand(seed, 1165 + n) * 0.11) : 42000 + rand(seed, 1165 + n) * 90000;
|
|
return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 };
|
|
});
|
|
const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8);
|
|
const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1);
|
|
const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1);
|
|
const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1);
|
|
const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1);
|
|
const premodernInfluence = influenceFromPaths(premodernRoads, 4);
|
|
const villageInfluence = influenceFromPoints(villages, 7, () => 1);
|
|
|
|
const newTownScore = new Float32Array(SIZE);
|
|
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]) continue;
|
|
const dCity = distanceToNearest(modernCities, x, y);
|
|
const ring = dCity > 8 && dCity < 22 ? 1 : 0;
|
|
const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0;
|
|
newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18);
|
|
}
|
|
}
|
|
|
|
let newTowns = pickPoints(newTownScore, {
|
|
threshold: 0.32 + rand(seed, 1151) * 0.1,
|
|
max: 2 + Math.floor(rand(seed, 1152) * 10),
|
|
minDistance: 11,
|
|
seedOffset: 1150,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "New Town" }));
|
|
|
|
const minorRoads = [];
|
|
const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)];
|
|
const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3);
|
|
|
|
function minorRoadCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || elevation[i] > 0.72) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "minor");
|
|
if (barrier >= INF) return INF;
|
|
return Math.max(0.3, 1 + slope[i] * 8.4 + barrier * 0.55 + Math.max(0, elevation[i] - 0.58) * 4.4 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.24 - valleyField[i] * 0.36 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.58 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15);
|
|
}
|
|
|
|
const connectedPairs = new Set();
|
|
function addMinorRoad(a, b) {
|
|
const key = `${a.x},${a.y}|${b.x},${b.y}`;
|
|
if (connectedPairs.has(key)) return;
|
|
connectedPairs.add(key);
|
|
const path = aStar(a, b, minorRoadCost);
|
|
if (path.length > 2 && path.length < 90) minorRoads.push(path);
|
|
}
|
|
|
|
for (const village of villages) {
|
|
if (rand(seed, village.x * 13 + village.y * 17) < 0.42) {
|
|
const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target);
|
|
}
|
|
}
|
|
for (const market of markets) {
|
|
const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 3, minDistance: 1, threshold: 0 });
|
|
for (const v of localVillages) addMinorRoad(market, v);
|
|
}
|
|
for (const pass of passes.slice(0, 8)) {
|
|
const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target) addMinorRoad(pass, target);
|
|
}
|
|
|
|
const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1);
|
|
const landuse = new Uint8Array(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;
|
|
const mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64;
|
|
const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25);
|
|
let nearestCity = null;
|
|
let nearestCityDistance = INF;
|
|
for (const city of modernCities) {
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; }
|
|
}
|
|
const dCity = nearestCityDistance;
|
|
const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5;
|
|
const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99;
|
|
const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0;
|
|
const density = populationDensity[i];
|
|
const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08;
|
|
const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56;
|
|
const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08);
|
|
const satelliteEnvelope = satelliteInfluence[i] * 0.54;
|
|
const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty;
|
|
const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28;
|
|
const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22;
|
|
const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02;
|
|
const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18;
|
|
const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08;
|
|
|
|
if (mountain) landuse[i] = 9;
|
|
else if (industrialInfluence[i] > 0.44) landuse[i] = 5;
|
|
else if (logisticsInfluence[i] > 0.42) landuse[i] = 6;
|
|
else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7;
|
|
else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3;
|
|
else if (oldTownScore > 0.49) landuse[i] = 2;
|
|
else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4;
|
|
else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8;
|
|
else if (farm) landuse[i] = 1;
|
|
else if (ruralScore > 0.3) landuse[i] = 0;
|
|
else landuse[i] = 0;
|
|
}
|
|
}
|
|
|
|
function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) {
|
|
let urban = 0;
|
|
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 lu = landuse[indexOf(nx, ny)];
|
|
if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++;
|
|
}
|
|
}
|
|
return urban >= minUrban;
|
|
}
|
|
|
|
function removeIsolatedUrbanPatches(maxCells = 22) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations];
|
|
const queue = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
|
|
const lu0 = landuse[i];
|
|
if (!(lu0 >= 2 && lu0 <= 8)) continue;
|
|
const component = [];
|
|
let maxDensity = 0;
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
seen[i] = 1;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
component.push(cur);
|
|
maxDensity = Math.max(maxDensity, populationDensity[cur]);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
|
if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
if (component.length > maxCells) continue;
|
|
let hasAnchor = false;
|
|
for (const ci of component) {
|
|
const [x, y] = xyOf(ci);
|
|
if (distanceToNearest(namedCenters, x, y) <= 5.8) {
|
|
hasAnchor = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasAnchor) {
|
|
for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36);
|
|
|
|
// CBD is no longer a marker. It is a DID-like contiguous high-density core:
|
|
// first remove isolated core cells, then grow connected high-density cells
|
|
// from each urban center according to population scale.
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4;
|
|
}
|
|
}
|
|
|
|
function growDidCore(center, city, salt) {
|
|
if (!center || !city) return 0;
|
|
const start = indexOf(center.x, center.y);
|
|
if (sea[start] || !prefectureMask[start]) return 0;
|
|
if ((city.population || 0) < 220000) return 0;
|
|
const targetCells = Math.round(clamp(2 + Math.sqrt(city.population || 80000) / 74, 4, 22));
|
|
const maxRadius = clamp((city.coreRadius || 3) * 2.4 + Math.sqrt(city.population || 80000) / 260, 6, 16);
|
|
const selected = new Set();
|
|
const queued = new Set([start]);
|
|
const heap = new MinHeap();
|
|
heap.push({ i: start, f: -10 });
|
|
let made = 0;
|
|
|
|
while (heap.length > 0 && made < targetCells) {
|
|
const cur = heap.pop();
|
|
if (!cur || selected.has(cur.i)) continue;
|
|
const [x, y] = xyOf(cur.i);
|
|
const i = cur.i;
|
|
const d = Math.hypot(x - center.x, y - center.y);
|
|
const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22;
|
|
if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue;
|
|
if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue;
|
|
|
|
selected.add(i);
|
|
landuse[i] = 3;
|
|
made++;
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (queued.has(ni) || selected.has(ni) || sea[ni] || !prefectureMask[ni]) continue;
|
|
const nd = Math.hypot(nx - center.x, ny - center.y);
|
|
if (nd > maxRadius + 1) continue;
|
|
const score = populationDensity[ni] * 1.24 + cityInfluence[ni] * 0.22 + stationInfluence[ni] * 0.18 + plain[ni] * 0.12 - slope[ni] * 1.25 - ridgeField[ni] * 0.54 - nd / maxRadius * 0.22 + hash2(nx, ny, seed + salt) * 0.03;
|
|
queued.add(ni);
|
|
heap.push({ i: ni, f: -score });
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17));
|
|
for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42);
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4;
|
|
}
|
|
}
|
|
|
|
const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0);
|
|
const municipalityCandidates = [];
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28;
|
|
const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025;
|
|
if (score > 0.40) municipalityCandidates.push({ x, y, score });
|
|
}
|
|
}
|
|
const majorMunicipalSeeds = modernCities
|
|
.filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)])
|
|
.map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city }));
|
|
const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => {
|
|
const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28));
|
|
const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y);
|
|
return !nearMajor && !nearSmallUrban;
|
|
});
|
|
const satelliteMunicipalSeeds = (satelliteCities || [])
|
|
.filter((city) => prefectureMask[indexOf(city.x, city.y)])
|
|
.map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city }));
|
|
let adminCentersRaw = [
|
|
...majorMunicipalSeeds,
|
|
...satelliteMunicipalSeeds,
|
|
...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), {
|
|
max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))),
|
|
minDistance: 9 + Math.floor(rand(seed, 1302) * 3),
|
|
threshold: 0.40,
|
|
seed: seed + 1300,
|
|
jitter: 0.025,
|
|
}),
|
|
];
|
|
if (adminCentersRaw.length < 12) {
|
|
const fallback = [...modernCities, ...satelliteCities, ...markets, ...newTowns, ...stations, ...villages]
|
|
.filter((p) => prefectureMask[indexOf(p.x, p.y)])
|
|
.map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 }));
|
|
adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 });
|
|
}
|
|
if (adminCentersRaw.length < 10) {
|
|
const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 });
|
|
adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6)));
|
|
}
|
|
const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse);
|
|
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7);
|
|
|
|
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
|
|
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
|
|
let bestAdmin = -1;
|
|
let bestD = INF;
|
|
adminCentersRaw.forEach((center, id) => {
|
|
const d = Math.hypot(center.x - city.x, center.y - city.y);
|
|
if (d < bestD) { bestD = d; bestAdmin = id; }
|
|
});
|
|
if (bestAdmin < 0) return;
|
|
const r = Math.ceil(radius);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d > radius) continue;
|
|
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
|
|
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
|
|
}
|
|
}
|
|
}
|
|
for (const city of modernCities) {
|
|
const radius = (city.population || 0) >= 500000
|
|
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
|
|
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
|
|
lockUrbanClusterToMunicipality(city, radius, true);
|
|
}
|
|
// Satellite cities should remain independent municipalities, not swallowed by the parent core city.
|
|
for (const sat of satelliteCities || []) {
|
|
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
|
|
let bestAdmin = -1;
|
|
let bestD = INF;
|
|
adminCentersRaw.forEach((center, id) => {
|
|
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
|
|
if (d < bestD) { bestD = d; bestAdmin = id; }
|
|
});
|
|
if (bestAdmin >= 0) {
|
|
const r = 5;
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
const x = sat.x + dx;
|
|
const y = sat.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i] || Math.hypot(dx, dy) > r) continue;
|
|
if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.18) adminId[i] = bestAdmin;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
|
|
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
|
|
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260);
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180);
|
|
applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw);
|
|
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5);
|
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360);
|
|
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220);
|
|
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
|
|
|
// 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);
|
|
for (const city of modernCities) {
|
|
if (city.isPrefecturalCapital) continue;
|
|
const cap = cityPopulationCap(city);
|
|
if (cap < INF && (city.population || 0) > cap) {
|
|
city.population = Math.round(cap / 1000) * 1000;
|
|
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
|
|
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
|
|
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
|
|
}
|
|
}
|
|
|
|
function makeHarborWorks(ports) {
|
|
const out = [];
|
|
for (const port of ports) {
|
|
const parts = [];
|
|
const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1;
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
|
const sx = port.x + dx;
|
|
const sy = port.y + dy;
|
|
if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue;
|
|
parts.push([[port.x, port.y], [sx, sy]]);
|
|
const wx = sx + dx;
|
|
const wy = sy + dy;
|
|
if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]);
|
|
if (parts.length >= limit) break;
|
|
}
|
|
if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Bridge and tunnel icon systems were removed from the visual model.
|
|
// Arrays remain empty for backward-compatible tests and downstream code.
|
|
const bridges = [];
|
|
const tunnels = [];
|
|
const harborWorks = makeHarborWorks(ports);
|
|
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
|
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
|
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
|
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
|
|
const usedNames = new Set();
|
|
const nameDebug = createNameDebug();
|
|
|
|
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
|
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
|
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
|
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
|
|
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
|
|
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
|
|
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
|
|
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
|
|
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
|
|
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
|
|
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
|
|
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
|
|
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
|
|
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
|
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
|
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 entitiesForNames = [
|
|
...modernCities,
|
|
...ports,
|
|
...markets,
|
|
...castles,
|
|
...stations,
|
|
...industrialZones,
|
|
...interchanges,
|
|
...logisticsParks,
|
|
...satelliteCities,
|
|
...newTowns,
|
|
...passes,
|
|
...crossings,
|
|
...externalGateways,
|
|
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
|
|
|
return applyOutputOptions({
|
|
width: MAP_W,
|
|
height: MAP_H,
|
|
cellSize: CELL_SIZE,
|
|
prefectureMask,
|
|
prefectureBorder,
|
|
prefectureRegionId,
|
|
regionalPrefectureBorders,
|
|
elevation,
|
|
moisture,
|
|
slope,
|
|
sea,
|
|
river,
|
|
floodplain,
|
|
plain,
|
|
agriculture,
|
|
settlementCluster,
|
|
ridgeField,
|
|
valleyField,
|
|
basinField,
|
|
coastalLowland,
|
|
flowAccum,
|
|
erosionField,
|
|
depositionField,
|
|
villages,
|
|
ports,
|
|
crossings,
|
|
passes,
|
|
markets,
|
|
castles,
|
|
castleTowns,
|
|
premodernRoads,
|
|
minorRoads,
|
|
modernCities,
|
|
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
|
|
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
|
|
populationDensity,
|
|
railways,
|
|
branchRailways,
|
|
ringRailways,
|
|
externalRailways,
|
|
stations,
|
|
industrialZones,
|
|
nationalRoads,
|
|
ringRoads,
|
|
expressways,
|
|
ringExpressways,
|
|
icAccessRoads,
|
|
externalRoads,
|
|
externalExpressways,
|
|
interchanges,
|
|
logisticsParks,
|
|
satelliteCities,
|
|
newTowns,
|
|
bridges,
|
|
tunnels,
|
|
harborWorks,
|
|
landuse,
|
|
adminCenters,
|
|
adminId,
|
|
adminBorders,
|
|
abandonedRailways,
|
|
castleRuins,
|
|
preservedOldRoads,
|
|
riverPaths,
|
|
mainRivers,
|
|
tributaryRivers,
|
|
smallStreams,
|
|
externalGateways,
|
|
entitiesForNames,
|
|
nameDebug,
|
|
}, options);
|
|
}
|