Compare commits
7 commits
b17be0e0d2
...
27ceb6568a
| Author | SHA1 | Date | |
|---|---|---|---|
| 27ceb6568a | |||
| f2d0306d96 | |||
| 112e6bf86b | |||
| ea0067dc0a | |||
| c523bf4380 | |||
| 860471f805 | |||
| d2e7a80e72 |
28 changed files with 8127 additions and 2017 deletions
|
|
@ -302,6 +302,101 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) {
|
||||
// Final cell-level invariant: each municipality should be one contiguous land
|
||||
// component. Earlier stages are allowed to leave sizeable satellite pieces
|
||||
// while boundaries are still being snapped; this pass removes the remaining
|
||||
// visual exclaves by attaching every non-primary component to the neighboring
|
||||
// municipality with the largest shared boundary. A component that contains a
|
||||
// protected point may become the primary component, but it no longer protects
|
||||
// additional detached pieces.
|
||||
const protectedByAdmin = new Map();
|
||||
for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) {
|
||||
if (!p || !inside(p.x, p.y)) continue;
|
||||
const i = indexOf(Math.round(p.x), Math.round(p.y));
|
||||
const id = adminId[i];
|
||||
if (id < 0) continue;
|
||||
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
|
||||
protectedByAdmin.get(id).add(i);
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
const queue = [];
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
let passChanged = 0;
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
|
||||
for (const id of ids) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
|
||||
const comp = [];
|
||||
let protectedHits = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
comp.push(cur);
|
||||
if (protectedByAdmin.get(id)?.has(cur)) protectedHits++;
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push({ cells: comp, protectedHits });
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) =>
|
||||
(b.protectedHits ? 1_000_000 : 0) + b.cells.length -
|
||||
((a.protectedHits ? 1_000_000 : 0) + a.cells.length)
|
||||
);
|
||||
const primary = components[0];
|
||||
for (const component of components.slice(1)) {
|
||||
const counts = new Map();
|
||||
for (const ci of component.cells) {
|
||||
const [x, y] = xyOf(ci);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni]) continue;
|
||||
const other = adminId[ni];
|
||||
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
||||
}
|
||||
}
|
||||
let target = -1;
|
||||
let best = -1;
|
||||
for (const [other, count] of counts) {
|
||||
const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0;
|
||||
const score = count + bonus;
|
||||
if (score > best || (score === best && other < target)) { best = score; target = other; }
|
||||
}
|
||||
if (target < 0) {
|
||||
// Very rare: a detached island component has no labeled neighbor.
|
||||
// Keep the largest/protected primary and merge the component into it
|
||||
// only if it is directly adjacent after previous changes; otherwise
|
||||
// leave it for the next pass rather than inventing over-sea ownership.
|
||||
target = id;
|
||||
}
|
||||
if (target >= 0 && target !== id) {
|
||||
for (const ci of component.cells) adminId[ci] = target;
|
||||
passChanged += component.cells.length;
|
||||
} else if (component !== primary) {
|
||||
// If no external target exists, still mark it as handled by keeping it;
|
||||
// another pass may expose a target after surrounding cells change.
|
||||
}
|
||||
}
|
||||
}
|
||||
changed += passChanged;
|
||||
if (!passChanged) break;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
|
||||
const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse);
|
||||
const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
|
||||
|
|
|
|||
791
app.js
791
app.js
|
|
@ -1,6 +1,10 @@
|
|||
import { generateMapAsync } from "./mapGenerator.js";
|
||||
import { drawMap } from "./renderer.js";
|
||||
import { landuseLabel } from "./landuseCodes.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
|
||||
import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js";
|
||||
import { getViewportMap } from "./worldViewport.js";
|
||||
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
|
||||
|
||||
const modes = [
|
||||
["all", "All"],
|
||||
|
|
@ -16,89 +20,487 @@ const modes = [
|
|||
|
||||
const state = {
|
||||
seedText: "114514",
|
||||
generationType: "auto",
|
||||
mode: "all",
|
||||
showFeatures: true,
|
||||
showLabels: true,
|
||||
map: null,
|
||||
world: null,
|
||||
camera: { x: 0, y: 0 },
|
||||
viewportMap: null,
|
||||
viewWidth: MAP_W,
|
||||
viewHeight: MAP_H,
|
||||
hoverEntities: [],
|
||||
selectionRect: null,
|
||||
patchVariant: 0,
|
||||
zoom: 1,
|
||||
lastPatchResult: null,
|
||||
};
|
||||
|
||||
const canvas = document.getElementById("mapCanvas");
|
||||
const canvasShell = document.querySelector(".canvas-shell");
|
||||
const seedInput = document.getElementById("seed");
|
||||
const generationTypeInput = document.getElementById("generationType");
|
||||
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
|
||||
const patchVariantInput = document.getElementById("patchVariant");
|
||||
const generatePatchButton = document.getElementById("generatePatch");
|
||||
const alternativePatchButton = document.getElementById("alternativePatch");
|
||||
const patchStatusEl = document.getElementById("patchStatus");
|
||||
const randomSeedButton = document.getElementById("randomSeed");
|
||||
const showFeaturesInput = document.getElementById("showFeatures");
|
||||
const showLabelsInput = document.getElementById("showLabels");
|
||||
const modeGrid = document.getElementById("modeGrid");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const tooltipEl = document.getElementById("mapTooltip");
|
||||
const selectionSvgEl = document.getElementById("mapSelectionSvg");
|
||||
const selectionEl = document.getElementById("mapSelection");
|
||||
const progressEl = document.getElementById("generationProgress");
|
||||
const progressStageEl = document.getElementById("generationProgressStage");
|
||||
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
||||
let generationStartedAt = 0;
|
||||
let generationCurrentStage = "";
|
||||
let generationTimer = null;
|
||||
let zoomRedrawRaf = null;
|
||||
let zoomSettledTimer = null;
|
||||
|
||||
const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
|
||||
const dragState = {
|
||||
mode: null,
|
||||
pointerId: null,
|
||||
startClientX: 0,
|
||||
startClientY: 0,
|
||||
startCameraX: 0,
|
||||
startCameraY: 0,
|
||||
selectStart: null,
|
||||
selectEnd: null,
|
||||
selectPath: null,
|
||||
pendingCamera: null,
|
||||
panRaf: null,
|
||||
};
|
||||
|
||||
function mapClientToCell(event) {
|
||||
if (!state.map) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const relX = (event.clientX - rect.left) / rect.width;
|
||||
const relY = (event.clientY - rect.top) / rect.height;
|
||||
function activeMap() {
|
||||
return state.viewportMap || state.map;
|
||||
}
|
||||
|
||||
function clampZoom(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 1;
|
||||
return Math.min(Math.max(parsed, 0.55), 2.8);
|
||||
}
|
||||
|
||||
function viewportSizeForZoom(zoom = state.zoom) {
|
||||
const z = clampZoom(zoom || 1);
|
||||
return {
|
||||
x: Math.floor(relX * state.map.width),
|
||||
y: Math.floor(relY * state.map.height),
|
||||
width: Math.max(1, Math.ceil(MAP_W / z)),
|
||||
height: Math.max(1, Math.ceil(MAP_H / z)),
|
||||
};
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target) return false;
|
||||
const tag = target.tagName?.toLowerCase?.();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
|
||||
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
|
||||
}
|
||||
|
||||
function panFrame(time) {
|
||||
if (!canvasShell || panState.keys.size === 0) {
|
||||
panState.raf = null;
|
||||
panState.lastTime = 0;
|
||||
function syncViewportSize() {
|
||||
const size = viewportSizeForZoom(state.zoom);
|
||||
state.viewWidth = size.width;
|
||||
state.viewHeight = size.height;
|
||||
return size;
|
||||
}
|
||||
|
||||
function mapCellScreenSize(map = activeMap()) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
|
||||
}
|
||||
|
||||
function applyCanvasZoom() {
|
||||
if (!canvas) return;
|
||||
state.zoom = clampZoom(state.zoom || 1);
|
||||
// Keep the canvas element at a stable size. Zoom is applied inside the
|
||||
// renderer transform, not by resizing the scrollable shell.
|
||||
canvas.style.width = `${MAP_W * CELL_SIZE}px`;
|
||||
canvas.style.height = `${MAP_H * CELL_SIZE}px`;
|
||||
updateSelectionOverlayFromWorldRect();
|
||||
}
|
||||
|
||||
function displayedCellSize() {
|
||||
return mapCellScreenSize();
|
||||
}
|
||||
|
||||
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
|
||||
}
|
||||
|
||||
function mapPixelToScreenPoint(px, py) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return {
|
||||
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
|
||||
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapClientToCell(event, sizeOverride = null) {
|
||||
const map = activeMap();
|
||||
if (!map && !sizeOverride) return null;
|
||||
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
|
||||
if (!p) return null;
|
||||
return {
|
||||
x: Math.floor(p.x / CELL_SIZE),
|
||||
y: Math.floor(p.y / CELL_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
function viewportCellToWorldCell(cell) {
|
||||
if (!cell || !state.camera) return null;
|
||||
return {
|
||||
x: Math.round(state.camera.x || 0) + cell.x,
|
||||
y: Math.round(state.camera.y || 0) + cell.y,
|
||||
};
|
||||
}
|
||||
|
||||
function clampCanvasPoint(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
|
||||
y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function screenPointToWorldCell(point) {
|
||||
const map = activeMap();
|
||||
if (!map || !point) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H);
|
||||
const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||
const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||
const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth)));
|
||||
const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight)));
|
||||
const cameraX = Math.round(state.camera?.x || 0);
|
||||
const cameraY = Math.round(state.camera?.y || 0);
|
||||
return {
|
||||
x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)),
|
||||
y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function worldCellToOverlayPoint(point) {
|
||||
const cameraX = Math.round(state.camera?.x || 0);
|
||||
const cameraY = Math.round(state.camera?.y || 0);
|
||||
const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE);
|
||||
return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop };
|
||||
}
|
||||
|
||||
function simplifySelectionPath(points) {
|
||||
const out = [];
|
||||
for (const p of points || []) {
|
||||
if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= 6) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function polygonArea(points) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i];
|
||||
const b = points[(i + 1) % points.length];
|
||||
area += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return Math.abs(area) * 0.5;
|
||||
}
|
||||
|
||||
function selectionPathToShape(points) {
|
||||
const simplified = simplifySelectionPath(points || []);
|
||||
if (simplified.length < 3) return null;
|
||||
const polygon = simplified.map(screenPointToWorldCell).filter(Boolean);
|
||||
if (polygon.length < 3) return null;
|
||||
const xs = polygon.map((p) => p.x);
|
||||
const ys = polygon.map((p) => p.y);
|
||||
return {
|
||||
kind: "lasso",
|
||||
polygon,
|
||||
x0: Math.min(...xs),
|
||||
y0: Math.min(...ys),
|
||||
x1: Math.max(...xs) + 1,
|
||||
y1: Math.max(...ys) + 1,
|
||||
areaCells: Math.max(1, Math.round(polygonArea(polygon))),
|
||||
};
|
||||
}
|
||||
|
||||
function drawSelectionSvg(points, invalid = false) {
|
||||
if (!selectionSvgEl) return;
|
||||
if (!points || points.length < 3) {
|
||||
selectionSvgEl.style.display = "none";
|
||||
selectionSvgEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
|
||||
panState.lastTime = time;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (panState.keys.has("a")) dx -= 1;
|
||||
if (panState.keys.has("d")) dx += 1;
|
||||
if (panState.keys.has("w")) dy -= 1;
|
||||
if (panState.keys.has("s")) dy += 1;
|
||||
if (dx || dy) {
|
||||
const normalizer = dx && dy ? Math.SQRT1_2 : 1;
|
||||
const amount = panState.speedPxPerSecond * dt;
|
||||
canvasShell.scrollLeft += dx * normalizer * amount;
|
||||
canvasShell.scrollTop += dy * normalizer * amount;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
|
||||
selectionSvgEl.innerHTML = `<polygon points="${pts}" />`;
|
||||
selectionSvgEl.style.display = "block";
|
||||
selectionSvgEl.classList.toggle("invalid", !!invalid);
|
||||
}
|
||||
|
||||
function hideSelectionSvg() {
|
||||
if (!selectionSvgEl) return;
|
||||
selectionSvgEl.style.display = "none";
|
||||
selectionSvgEl.innerHTML = "";
|
||||
selectionSvgEl.classList.remove("invalid");
|
||||
}
|
||||
|
||||
function updateSelectionOverlay() {
|
||||
if (!dragState.selectPath?.length) return;
|
||||
const liveShape = selectionPathToShape(dragState.selectPath);
|
||||
const validation = validatePatchRect(liveShape, state.world);
|
||||
drawSelectionSvg(dragState.selectPath, !validation.ok);
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
if (generatePatchButton) generatePatchButton.disabled = true;
|
||||
if (alternativePatchButton) alternativePatchButton.disabled = true;
|
||||
if (patchStatusEl) {
|
||||
const current = validation.rect || liveShape;
|
||||
patchStatusEl.textContent = validation.ok
|
||||
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
|
||||
: `${validation.reason} Current: ${formatRectSize(current)}.`;
|
||||
patchStatusEl.classList.toggle("invalid", !validation.ok);
|
||||
}
|
||||
panState.raf = requestAnimationFrame(panFrame);
|
||||
}
|
||||
|
||||
function startKeyboardPan() {
|
||||
if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
|
||||
function updateSelectionOverlayFromWorldRect() {
|
||||
if (!state.selectionRect || !state.camera || !activeMap()) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) {
|
||||
const points = state.selectionRect.polygon.map(worldCellToOverlayPoint)
|
||||
.map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) }));
|
||||
drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok);
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const cameraX = Math.round(state.camera.x || 0);
|
||||
const cameraY = Math.round(state.camera.y || 0);
|
||||
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
|
||||
const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE);
|
||||
const vx0 = p0.x - canvas.offsetLeft;
|
||||
const vy0 = p0.y - canvas.offsetTop;
|
||||
const vx1 = p1.x - canvas.offsetLeft;
|
||||
const vy1 = p1.y - canvas.offsetTop;
|
||||
const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
|
||||
const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
|
||||
const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
|
||||
const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
|
||||
if (x1 - x0 < 1 || y1 - y0 < 1) {
|
||||
selectionEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
hideSelectionSvg();
|
||||
selectionEl.style.display = "block";
|
||||
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
|
||||
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
|
||||
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
|
||||
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
selectionEl.classList.toggle("invalid", !validation.ok);
|
||||
}
|
||||
|
||||
function handlePanKeyDown(event) {
|
||||
const key = event.key?.toLowerCase?.();
|
||||
if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
|
||||
panState.keys.add(key);
|
||||
startKeyboardPan();
|
||||
function formatRectSize(rect) {
|
||||
if (!rect) return "-";
|
||||
const w = Math.max(0, rect.x1 - rect.x0);
|
||||
const h = Math.max(0, rect.y1 - rect.y0);
|
||||
const area = Math.max(0, rect.areaCells || (w * h));
|
||||
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
|
||||
}
|
||||
|
||||
function normalizePatchVariant(value) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0;
|
||||
}
|
||||
|
||||
function setPatchVariant(value, { update = true } = {}) {
|
||||
state.patchVariant = normalizePatchVariant(value);
|
||||
if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) {
|
||||
patchVariantInput.value = String(state.patchVariant);
|
||||
}
|
||||
if (update) updatePatchControls();
|
||||
return state.patchVariant;
|
||||
}
|
||||
|
||||
function readPatchVariant() {
|
||||
return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false });
|
||||
}
|
||||
|
||||
function resetPatchVariant({ update = true } = {}) {
|
||||
return setPatchVariant(0, { update });
|
||||
}
|
||||
|
||||
function updatePatchControls() {
|
||||
if (!patchStatusEl && !generatePatchButton) return;
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
const variant = readPatchVariant();
|
||||
if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
|
||||
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
|
||||
if (!patchStatusEl) return;
|
||||
if (!state.selectionRect) {
|
||||
patchStatusEl.textContent = `Right-drag a freeform area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`;
|
||||
patchStatusEl.classList.toggle("invalid", false);
|
||||
return;
|
||||
}
|
||||
if (!validation.ok) {
|
||||
patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
|
||||
patchStatusEl.classList.toggle("invalid", true);
|
||||
return;
|
||||
}
|
||||
const rects = buildPatchRects(validation.rect, state.world);
|
||||
const patchText = state.lastPatchResult
|
||||
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
|
||||
: "";
|
||||
patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
|
||||
patchStatusEl.classList.toggle("invalid", false);
|
||||
}
|
||||
|
||||
function clearDragMode() {
|
||||
dragState.mode = null;
|
||||
dragState.pointerId = null;
|
||||
dragState.pendingCamera = null;
|
||||
if (dragState.panRaf != null) {
|
||||
cancelAnimationFrame(dragState.panRaf);
|
||||
dragState.panRaf = null;
|
||||
}
|
||||
canvasShell?.classList.remove("panning", "selecting");
|
||||
}
|
||||
|
||||
function schedulePanRedraw(camera) {
|
||||
dragState.pendingCamera = camera;
|
||||
if (dragState.panRaf != null) return;
|
||||
dragState.panRaf = requestAnimationFrame(() => {
|
||||
dragState.panRaf = null;
|
||||
if (!dragState.pendingCamera) return;
|
||||
const next = dragState.pendingCamera;
|
||||
dragState.pendingCamera = null;
|
||||
if (next.x === state.camera.x && next.y === state.camera.y) return;
|
||||
state.camera = next;
|
||||
redraw({ fastTerrain: true });
|
||||
});
|
||||
}
|
||||
|
||||
function hideSelectionOverlay() {
|
||||
dragState.selectStart = null;
|
||||
dragState.selectEnd = null;
|
||||
dragState.selectPath = null;
|
||||
state.selectionRect = null;
|
||||
resetPatchVariant({ update: false });
|
||||
hideSelectionSvg();
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
updatePatchControls();
|
||||
}
|
||||
|
||||
function selectionPixelsToCells(start, end) {
|
||||
const a = screenPointToWorldCell(start);
|
||||
const b = screenPointToWorldCell(end);
|
||||
if (!a || !b) return null;
|
||||
return {
|
||||
x0: Math.min(a.x, b.x),
|
||||
y0: Math.min(a.y, b.y),
|
||||
x1: Math.max(a.x, b.x) + 1,
|
||||
y1: Math.max(a.y, b.y) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function selectionPixelsToShape(start, end, path = null) {
|
||||
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
|
||||
return selectionPixelsToCells(start, end);
|
||||
}
|
||||
|
||||
function handleMapPointerDown(event) {
|
||||
if (!state.world || !canvasShell) return;
|
||||
if (event.button !== 0 && event.button !== 2) return;
|
||||
dragState.pointerId = event.pointerId;
|
||||
dragState.startClientX = event.clientX;
|
||||
dragState.startClientY = event.clientY;
|
||||
dragState.startCameraX = state.camera.x;
|
||||
dragState.startCameraY = state.camera.y;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (event.button === 0) {
|
||||
dragState.mode = "pan";
|
||||
canvasShell.classList.add("panning");
|
||||
} else {
|
||||
dragState.mode = "select";
|
||||
dragState.selectStart = clampCanvasPoint(event);
|
||||
dragState.selectEnd = dragState.selectStart;
|
||||
dragState.selectPath = [dragState.selectStart];
|
||||
canvasShell.classList.add("selecting");
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handlePanKeyUp(event) {
|
||||
const key = event.key?.toLowerCase?.();
|
||||
if (!key || !"wasd".includes(key)) return;
|
||||
panState.keys.delete(key);
|
||||
function handleMapPointerMove(event) {
|
||||
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (dragState.mode === "pan") {
|
||||
const cellSize = Math.max(1, displayedCellSize());
|
||||
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
|
||||
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
|
||||
const nextCamera = clampCameraForView({
|
||||
x: dragState.startCameraX - dxCells,
|
||||
y: dragState.startCameraY - dyCells,
|
||||
}, viewportSizeForZoom(state.zoom));
|
||||
schedulePanRedraw(nextCamera);
|
||||
} else if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
|
||||
dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd];
|
||||
}
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleMapPointerUp(event) {
|
||||
if (dragState.pointerId !== event.pointerId) return;
|
||||
const wasPanning = dragState.mode === "pan";
|
||||
if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
|
||||
else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd];
|
||||
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
|
||||
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
|
||||
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
|
||||
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
|
||||
state.selectionRect = shape;
|
||||
state.lastPatchResult = null;
|
||||
resetPatchVariant({ update: false });
|
||||
updateSelectionOverlayFromWorldRect();
|
||||
updatePatchControls();
|
||||
} else {
|
||||
hideSelectionOverlay();
|
||||
}
|
||||
}
|
||||
canvas.releasePointerCapture?.(event.pointerId);
|
||||
if (wasPanning && dragState.pendingCamera) {
|
||||
state.camera = dragState.pendingCamera;
|
||||
dragState.pendingCamera = null;
|
||||
}
|
||||
clearDragMode();
|
||||
if (wasPanning) redraw({ fastTerrain: false });
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +655,8 @@ function buildHoverEntities(map) {
|
|||
function nearestEntity(items, x, y, maxDistance = 5) {
|
||||
let best = null;
|
||||
let bestD = maxDistance;
|
||||
for (const item of items) {
|
||||
for (const item of items || []) {
|
||||
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
|
||||
const d = Math.hypot(item.x - x, item.y - y);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
|
|
@ -264,46 +667,140 @@ function landuseName(value) {
|
|||
return landuseLabel(value);
|
||||
}
|
||||
|
||||
function adminName(map, adminId) {
|
||||
const center = (map.adminCenters || [])[adminId];
|
||||
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
|
||||
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
|
||||
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
|
||||
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
|
||||
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
|
||||
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
|
||||
|
||||
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
|
||||
for (const key of keys) {
|
||||
const value = item?.[key];
|
||||
if (Number.isFinite(value)) return Math.floor(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function adminPopulation(map, adminId) {
|
||||
const center = (map.adminCenters || [])[adminId];
|
||||
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
|
||||
function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
|
||||
if (!item || id == null || id < 0) return false;
|
||||
return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id));
|
||||
}
|
||||
|
||||
function firstUsableText(item, keys) {
|
||||
for (const key of keys) {
|
||||
const value = item?.[key];
|
||||
if (!looksNumericName(value)) return String(value).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstPopulation(item) {
|
||||
for (const key of POPULATION_KEYS) {
|
||||
const value = item?.[key];
|
||||
if (Number.isFinite(value) && value > 0) return Math.round(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function adminCenterForId(map, adminId) {
|
||||
if (!map || adminId == null || adminId < 0) return null;
|
||||
const centers = (map.adminCenters || []).filter(Boolean);
|
||||
const exact = centers.find((center) => hasNumericId(center, adminId));
|
||||
if (exact) return exact;
|
||||
// Some legacy/admin debug arrays were once addressed by array index. Keep this
|
||||
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
|
||||
const direct = map.adminCenters?.[adminId];
|
||||
return hasNumericId(direct, adminId) ? direct : null;
|
||||
}
|
||||
|
||||
function looksNumericName(name) {
|
||||
if (!name) return true;
|
||||
const text = String(name).trim();
|
||||
return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
|
||||
}
|
||||
|
||||
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
|
||||
if (!map || cellIndex < 0) return null;
|
||||
const x = cellIndex % map.width;
|
||||
const y = Math.floor(cellIndex / map.width);
|
||||
let best = null;
|
||||
let bestD = maxDistance;
|
||||
for (const center of map.adminCenters || []) {
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
|
||||
if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue;
|
||||
if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue;
|
||||
const d = Math.hypot(center.x - x, center.y - y);
|
||||
if (d < bestD) { best = center; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function adminName(map, adminId, cellIndex = -1) {
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
|
||||
if (name) return name;
|
||||
return adminId >= 0 ? "Unnamed municipality" : "-";
|
||||
}
|
||||
|
||||
function adminPopulation(map, adminId, cellIndex = -1) {
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||
const centerPop = firstPopulation(center);
|
||||
if (centerPop !== null) return centerPop;
|
||||
let sum = 0;
|
||||
let found = false;
|
||||
for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) {
|
||||
for (const p of map?.[key] || []) {
|
||||
if (!hasNumericId(p, adminId)) continue;
|
||||
const pop = firstPopulation(p);
|
||||
if (pop !== null) { sum += pop; found = true; }
|
||||
}
|
||||
}
|
||||
return found ? sum : null;
|
||||
}
|
||||
|
||||
function prefectureNameForCell(map, i) {
|
||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
||||
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
|
||||
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
|
||||
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
|
||||
if (regionName) return regionName;
|
||||
const adminId = map.adminId?.[i] ?? -1;
|
||||
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
|
||||
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
|
||||
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
|
||||
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
|
||||
}
|
||||
|
||||
function updateTooltip(event) {
|
||||
if (!state.map || !tooltipEl) return;
|
||||
const map = activeMap();
|
||||
if (!map || !tooltipEl || dragState.mode) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cell = mapClientToCell(event);
|
||||
if (!cell) return;
|
||||
const { x, y } = cell;
|
||||
if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
|
||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
|
||||
tooltipEl.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
const i = y * state.map.width + x;
|
||||
const i = y * map.width + x;
|
||||
const worldCell = viewportCellToWorldCell({ x, y });
|
||||
const entity = nearestEntity(state.hoverEntities, x, y);
|
||||
const elevation = state.map.elevation?.[i] ?? 0;
|
||||
const density = state.map.populationDensity?.[i] ?? 0;
|
||||
const hoveredAdminId = state.map.adminId?.[i] ?? -1;
|
||||
const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
|
||||
const elevation = map.elevation?.[i] ?? 0;
|
||||
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
|
||||
const hoveredAdminId = map.adminId?.[i] ?? -1;
|
||||
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
|
||||
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
|
||||
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
|
||||
const entityTitle = entity
|
||||
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||
: coordinateText;
|
||||
const lines = [
|
||||
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
|
||||
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
|
||||
`Admin: ${adminName(state.map, hoveredAdminId)}`,
|
||||
`<strong>${entityTitle}</strong>`,
|
||||
`Prefecture: ${prefectureNameForCell(map, i)}`,
|
||||
`Admin: ${adminName(map, hoveredAdminId, i)}`,
|
||||
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
||||
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
|
||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
||||
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
|
||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||
`River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
||||
];
|
||||
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
|
||||
tooltipEl.innerHTML = lines.join("<br>");
|
||||
|
|
@ -336,11 +833,16 @@ function renderModeButtons() {
|
|||
|
||||
async function regenerate() {
|
||||
state.seedText = seedInput.value;
|
||||
state.generationType = generationTypeInput?.value || "auto";
|
||||
setProgressVisible(true, "Preparing generation...");
|
||||
await nextFrame();
|
||||
try {
|
||||
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
|
||||
state.hoverEntities = buildHoverEntities(state.map);
|
||||
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
|
||||
state.world = createWorldMap(state.map);
|
||||
state.camera = createInitialCamera(state.world);
|
||||
state.lastPatchResult = null;
|
||||
resetPatchVariant({ update: false });
|
||||
hideSelectionOverlay();
|
||||
renderStats(state.map);
|
||||
redraw();
|
||||
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
||||
|
|
@ -352,13 +854,133 @@ async function regenerate() {
|
|||
}
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!state.map) return;
|
||||
drawMap(canvas, state.map, {
|
||||
|
||||
function derivePatchSeed(rect, terrainType, variant = 0) {
|
||||
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
|
||||
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
|
||||
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
|
||||
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
|
||||
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
|
||||
h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0;
|
||||
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
|
||||
function handleCanvasWheel(event) {
|
||||
if (!state.world || !activeMap()) return;
|
||||
event.preventDefault();
|
||||
tooltipEl?.classList.remove("visible");
|
||||
const beforeSize = viewportSizeForZoom(state.zoom);
|
||||
const beforeCell = mapClientToCell(event, beforeSize);
|
||||
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
|
||||
const oldZoom = clampZoom(state.zoom || 1);
|
||||
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
|
||||
const nextZoom = clampZoom(oldZoom * delta);
|
||||
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
|
||||
state.zoom = nextZoom;
|
||||
const nextSize = syncViewportSize();
|
||||
if (beforeWorld) {
|
||||
const afterCell = mapClientToCell(event, nextSize);
|
||||
if (afterCell) {
|
||||
state.camera = clampCameraForView({
|
||||
x: beforeWorld.x - afterCell.x,
|
||||
y: beforeWorld.y - afterCell.y,
|
||||
}, nextSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Wheel events can fire dozens of times per second. Do one lightweight redraw
|
||||
// per frame, then a full labeled/continuous redraw once zooming settles.
|
||||
if (zoomRedrawRaf == null) {
|
||||
zoomRedrawRaf = requestAnimationFrame(() => {
|
||||
zoomRedrawRaf = null;
|
||||
redraw({ fastTerrain: true, allowWorldExpand: false });
|
||||
});
|
||||
}
|
||||
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
|
||||
zoomSettledTimer = window.setTimeout(() => {
|
||||
zoomSettledTimer = null;
|
||||
redraw({ fastTerrain: false, allowWorldExpand: false });
|
||||
}, 140);
|
||||
}
|
||||
|
||||
async function generateSelectedPatch() {
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
if (!validation.ok) {
|
||||
updatePatchControls();
|
||||
return;
|
||||
}
|
||||
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
|
||||
const variant = readPatchVariant();
|
||||
const seed = derivePatchSeed(validation.rect, terrainType, variant);
|
||||
setProgressVisible(true, "Generating selected patch...");
|
||||
await nextFrame();
|
||||
try {
|
||||
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
|
||||
if (!result.ok) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
|
||||
updatePatchControls();
|
||||
window.setTimeout(() => setProgressVisible(false), 1200);
|
||||
return;
|
||||
}
|
||||
state.lastPatchResult = result;
|
||||
redraw();
|
||||
renderStats(state.map);
|
||||
updatePatchControls();
|
||||
const human = result.humanGeography;
|
||||
const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
|
||||
renderTimingRows(result.patchTimings || []);
|
||||
window.setTimeout(() => setProgressVisible(false), 900);
|
||||
} catch (error) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAlternativePatch() {
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
if (!validation.ok) {
|
||||
updatePatchControls();
|
||||
return;
|
||||
}
|
||||
setPatchVariant(readPatchVariant() + 1, { update: false });
|
||||
await generateSelectedPatch();
|
||||
}
|
||||
|
||||
function redraw(options = {}) {
|
||||
if (!state.world) return;
|
||||
const viewSize = syncViewportSize();
|
||||
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
|
||||
if (expansion?.expanded) {
|
||||
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
|
||||
if (state.selectionRect) {
|
||||
const dx = expansion.dx || 0;
|
||||
const dy = expansion.dy || 0;
|
||||
state.selectionRect = {
|
||||
...state.selectionRect,
|
||||
x0: state.selectionRect.x0 + dx,
|
||||
y0: state.selectionRect.y0 + dy,
|
||||
x1: state.selectionRect.x1 + dx,
|
||||
y1: state.selectionRect.y1 + dy,
|
||||
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
|
||||
};
|
||||
}
|
||||
}
|
||||
state.camera = clampCameraForView(state.camera, viewSize);
|
||||
state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
|
||||
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
|
||||
drawMap(canvas, state.viewportMap, {
|
||||
mode: state.mode,
|
||||
showFeatures: state.showFeatures,
|
||||
showLabels: state.showLabels,
|
||||
showFeatures: state.showFeatures && !options.fastTerrain,
|
||||
showLabels: state.showLabels && !options.fastTerrain,
|
||||
continuousTerrain: !options.fastTerrain,
|
||||
fastTerrain: !!options.fastTerrain,
|
||||
zoom: state.zoom || 1,
|
||||
});
|
||||
applyCanvasZoom();
|
||||
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
|
@ -369,6 +991,22 @@ function init() {
|
|||
if (event.key === "Enter") regenerate();
|
||||
});
|
||||
|
||||
generationTypeInput?.addEventListener("change", regenerate);
|
||||
patchTerrainTypeInput?.addEventListener("change", () => {
|
||||
state.lastPatchResult = null;
|
||||
resetPatchVariant({ update: false });
|
||||
updatePatchControls();
|
||||
});
|
||||
patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value));
|
||||
patchVariantInput?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
setPatchVariant(patchVariantInput.value);
|
||||
generateSelectedPatch();
|
||||
}
|
||||
});
|
||||
generatePatchButton?.addEventListener("click", generateSelectedPatch);
|
||||
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
|
||||
|
||||
randomSeedButton.addEventListener("click", () => {
|
||||
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
||||
regenerate();
|
||||
|
|
@ -385,13 +1023,18 @@ function init() {
|
|||
});
|
||||
|
||||
canvasShell?.setAttribute("tabindex", "0");
|
||||
window.addEventListener("keydown", handlePanKeyDown);
|
||||
window.addEventListener("keyup", handlePanKeyUp);
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
|
||||
canvas.addEventListener("pointerdown", handleMapPointerDown);
|
||||
canvas.addEventListener("pointermove", handleMapPointerMove);
|
||||
canvas.addEventListener("pointerup", handleMapPointerUp);
|
||||
canvas.addEventListener("pointercancel", handleMapPointerUp);
|
||||
canvas.addEventListener("mousemove", updateTooltip);
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
tooltipEl?.classList.remove("visible");
|
||||
});
|
||||
|
||||
updatePatchControls();
|
||||
regenerate();
|
||||
}
|
||||
|
||||
|
|
|
|||
40
index.html
40
index.html
|
|
@ -13,12 +13,14 @@
|
|||
<header class="header">
|
||||
<div>
|
||||
<h1>Prefecture Map Generator v17</h1>
|
||||
<p>Terrain, municipalities, transport, land use, and hover inspection in one generated map.</p>
|
||||
<p>Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="canvas-shell">
|
||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
|
||||
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
|
||||
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||
<div class="progress-title">Generating map...</div>
|
||||
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
||||
|
|
@ -32,9 +34,43 @@
|
|||
<section class="card">
|
||||
<label class="label" for="seed">Seed</label>
|
||||
<input id="seed" class="input" value="114514" />
|
||||
<label class="label inline-label" for="generationType">Generation Type</label>
|
||||
<select id="generationType" class="input">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="card">
|
||||
<div class="card-title">Patch Generation</div>
|
||||
<label class="label" for="patchTerrainType">Patch Terrain Type</label>
|
||||
<select id="patchTerrainType" class="input">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
<div class="patch-variant-row">
|
||||
<label class="label patch-variant-label" for="patchVariant">Patch Variant</label>
|
||||
<input id="patchVariant" class="input patch-variant-input" type="number" min="0" step="1" value="0" />
|
||||
</div>
|
||||
<div class="patch-button-row">
|
||||
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
||||
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
|
||||
</div>
|
||||
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-title">Display Layers</div>
|
||||
<div id="modeGrid" class="mode-grid"></div>
|
||||
|
|
@ -77,7 +113,7 @@
|
|||
|
||||
<section class="card legend">
|
||||
<div class="card-title">Notes</div>
|
||||
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
|
||||
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.</p>
|
||||
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
lockSmallUrbanComponentsToMunicipality,
|
||||
mergeTinyMunicipalities,
|
||||
removeMunicipalExclaves,
|
||||
enforceMunicipalityConnectivityStrict,
|
||||
smoothAdminRegionsTerrainAware,
|
||||
snapAdminBoundariesToTerrain,
|
||||
} from "./adminRegions.js";
|
||||
|
|
@ -162,8 +163,10 @@ function generateAdminLayoutForMask({
|
|||
});
|
||||
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
|
||||
const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8);
|
||||
const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4);
|
||||
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
|
||||
const actualMunicipalityCount = compacted.activeMunicipalityCount;
|
||||
const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
|
||||
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
|
||||
const adminDebug = {
|
||||
...compartmentAssignment.debug,
|
||||
|
|
@ -179,6 +182,8 @@ function generateAdminLayoutForMask({
|
|||
targetMunicipalityCount,
|
||||
actualMunicipalityCount,
|
||||
finalMunicipalityCount: actualMunicipalityCount,
|
||||
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
|
||||
changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
|
||||
candidateSeedCount: adminCentersRaw.length,
|
||||
municipalOfficePointCount: compacted.adminCentersRaw.length,
|
||||
seedCellRevivalCount: 0,
|
||||
|
|
@ -436,6 +441,8 @@ function generateAdminLayoutForMask({
|
|||
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
|
||||
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
|
||||
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||
adminDebug.changedAfterStrictMunicipalityConnectivity = enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 8);
|
||||
adminDebug.changedAfterStrictMunicipalityEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 4);
|
||||
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const satelliteAreas = [];
|
||||
|
|
|
|||
327
mapFeatureContext.js
Normal file
327
mapFeatureContext.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
|
||||
|
||||
export function buildFeatureContext(seed, terrain) {
|
||||
const {
|
||||
elevation,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
arcSpineField,
|
||||
branchRidgeField,
|
||||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
portSuitability,
|
||||
prefectureMask,
|
||||
prefectureRegionId,
|
||||
naturalBarrierScore,
|
||||
} = terrain;
|
||||
|
||||
const geography = terrain.geography || {};
|
||||
const geoHabitability = geography.habitability || null;
|
||||
const geoAccessibility = geography.accessibility || null;
|
||||
const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null;
|
||||
const geoLowlandCapacity = geography.lowlandCapacity || null;
|
||||
const geoValleyAccess = geography.valleyAccess || null;
|
||||
const geoCoastalAccess = geography.coastalAccess || null;
|
||||
const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null;
|
||||
const geoCorridorSuitability = geography.corridorSuitability || null;
|
||||
|
||||
function fieldValue(field, i, fallback = 0) {
|
||||
const v = field?.[i];
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function regionIdAt(x, y) {
|
||||
if (!inside(x, y)) return -1;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) return -1;
|
||||
if (prefectureMask?.[i]) return 0;
|
||||
if (!prefectureRegionId) return 0;
|
||||
const id = prefectureRegionId?.[i];
|
||||
return id !== undefined && id >= 0 ? id : -1;
|
||||
}
|
||||
|
||||
function inFocusedPrefecture(p) {
|
||||
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||
}
|
||||
|
||||
function localConfluenceScore(x, y) {
|
||||
let arms = 0;
|
||||
let strong = 0;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const rv = river[indexOf(nx, ny)];
|
||||
if (rv > 0.18) arms++;
|
||||
if (rv > 0.34) strong++;
|
||||
}
|
||||
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
|
||||
}
|
||||
|
||||
// --- 1. Human context: one full raster pass -----------------------------
|
||||
const developable = new Float32Array(SIZE);
|
||||
const ruralSuitability = new Float32Array(SIZE);
|
||||
const townSuitability = new Float32Array(SIZE);
|
||||
const valleySettlement = new Float32Array(SIZE);
|
||||
const coastalSettlement = new Float32Array(SIZE);
|
||||
const confluenceField = new Float32Array(SIZE);
|
||||
const barrierCost = new Float32Array(SIZE);
|
||||
const corridorCost = new Float32Array(SIZE);
|
||||
const settlementCluster = new Float32Array(SIZE);
|
||||
const settlementScore = new Float32Array(SIZE);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) {
|
||||
barrierCost[i] = INF;
|
||||
corridorCost[i] = INF;
|
||||
continue;
|
||||
}
|
||||
const naturalBarrier = naturalBarrierScore?.[i] || 0;
|
||||
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
|
||||
const highPenalty = Math.max(0, elevation[i] - 0.56);
|
||||
const lowSlope = clamp(1 - slope[i] * 2.3);
|
||||
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
|
||||
const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
|
||||
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||
confluenceField[i] = confluence;
|
||||
|
||||
const geoH = fieldValue(geoHabitability, i, 0);
|
||||
const geoLow = fieldValue(geoLowlandCapacity, i, 0);
|
||||
const geoValley = fieldValue(geoValleyAccess, i, 0);
|
||||
const geoCoast = fieldValue(geoCoastalAccess, i, 0);
|
||||
const geoB = fieldValue(geoBarrier, i, naturalBarrier);
|
||||
const localDevelopable = clamp(
|
||||
plain[i] * 0.34 +
|
||||
agriculture[i] * 0.24 +
|
||||
basinField[i] * 0.24 +
|
||||
valleyField[i] * 0.24 +
|
||||
coastalLowland[i] * 0.18 +
|
||||
depositional * 0.22 +
|
||||
lowSlope * 0.10 -
|
||||
slope[i] * 0.82 -
|
||||
ridgeField[i] * 0.52 -
|
||||
spine * 0.24 -
|
||||
highPenalty * 1.14 -
|
||||
floodplain[i] * 0.03
|
||||
);
|
||||
developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05);
|
||||
valleySettlement[i] = clamp((
|
||||
valleyField[i] * 0.52 +
|
||||
river[i] * 0.08 +
|
||||
confluence * 0.38 +
|
||||
depositional * 0.20 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.08 +
|
||||
lowSlope * 0.12 -
|
||||
slope[i] * 0.54 -
|
||||
ridgeField[i] * 0.30 -
|
||||
spine * 0.16 -
|
||||
highPenalty * 0.70 -
|
||||
floodplain[i] * 0.10
|
||||
) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04);
|
||||
coastalSettlement[i] = clamp((
|
||||
coastalLowland[i] * 0.50 +
|
||||
(portSuitability?.[i] || 0) * 0.30 +
|
||||
(deltaField?.[i] || 0) * 0.20 +
|
||||
plain[i] * 0.10 -
|
||||
slope[i] * 0.52 -
|
||||
ridgeField[i] * 0.24 -
|
||||
spine * 0.12
|
||||
) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04);
|
||||
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
|
||||
settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
|
||||
ruralSuitability[i] = clamp(
|
||||
agriculture[i] * 0.54 +
|
||||
developable[i] * 0.30 +
|
||||
valleySettlement[i] * 0.18 +
|
||||
coastalSettlement[i] * 0.20 +
|
||||
openPlainPotential * 0.34 +
|
||||
settlementCluster[i] * 0.30 -
|
||||
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||
);
|
||||
townSuitability[i] = clamp(
|
||||
developable[i] * 0.38 +
|
||||
agriculture[i] * 0.18 +
|
||||
valleySettlement[i] * 0.16 +
|
||||
coastalSettlement[i] * 0.30 +
|
||||
confluence * 0.20 +
|
||||
basinField[i] * 0.18 +
|
||||
plain[i] * 0.26 +
|
||||
openPlainPotential * 0.44 +
|
||||
settlementCluster[i] * 0.22 -
|
||||
slope[i] * 0.34 -
|
||||
ridgeField[i] * 0.17 -
|
||||
spine * 0.10
|
||||
);
|
||||
settlementScore[i] = clamp(
|
||||
ruralSuitability[i] * 0.48 +
|
||||
townSuitability[i] * 0.30 +
|
||||
confluence * 0.08 +
|
||||
fieldValue(geoHabitability, i, developable[i]) * 0.18 +
|
||||
fieldValue(geoNaturalCentrality, i, 0) * 0.12 -
|
||||
fieldValue(geoBarrier, i, 0) * 0.06
|
||||
);
|
||||
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
|
||||
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// --- region statistics ---------------------------------------------------
|
||||
const regionStats = new Map();
|
||||
function ensureRegion(regionId) {
|
||||
let st = regionStats.get(regionId);
|
||||
if (!st) {
|
||||
st = {
|
||||
id: regionId,
|
||||
area: 0,
|
||||
developableCells: 0,
|
||||
developableSum: 0,
|
||||
valleyCells: 0,
|
||||
coastCells: 0,
|
||||
townCells: 0,
|
||||
plainCells: 0,
|
||||
highCentralityCells: 0,
|
||||
habitabilitySum: 0,
|
||||
accessibilitySum: 0,
|
||||
centralitySum: 0,
|
||||
lowlandCapacitySum: 0,
|
||||
minX: MAP_W,
|
||||
minY: MAP_H,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
};
|
||||
regionStats.set(regionId, st);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
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 regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const st = ensureRegion(regionId);
|
||||
st.area++;
|
||||
const gHabit = fieldValue(geoHabitability, i, developable[i]);
|
||||
const gAccess = fieldValue(geoAccessibility, i, 0);
|
||||
const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]);
|
||||
const gLow = fieldValue(geoLowlandCapacity, i, plain[i]);
|
||||
st.developableSum += developable[i];
|
||||
st.habitabilitySum += gHabit;
|
||||
st.accessibilitySum += gAccess;
|
||||
st.centralitySum += gCentral;
|
||||
st.lowlandCapacitySum += gLow;
|
||||
if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++;
|
||||
if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++;
|
||||
if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++;
|
||||
if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++;
|
||||
if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++;
|
||||
if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++;
|
||||
st.minX = Math.min(st.minX, x);
|
||||
st.minY = Math.min(st.minY, y);
|
||||
st.maxX = Math.max(st.maxX, x);
|
||||
st.maxY = Math.max(st.maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityFactor(regionId, st) {
|
||||
if (!st || st.area <= 0) return 0;
|
||||
// Treat the focused prefecture and neighboring prefectures with the same
|
||||
// density curve. Only genuinely clipped map-edge slivers are downscaled.
|
||||
return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05);
|
||||
}
|
||||
|
||||
function pickRegionalPoints(scoreArray, {
|
||||
stride = 1,
|
||||
threshold = 0.25,
|
||||
minDistance = 6,
|
||||
totalMax = 100,
|
||||
seedOffset = 0,
|
||||
quotaForRegion,
|
||||
predicate = () => true,
|
||||
kind = "Point",
|
||||
extraScore = () => 0,
|
||||
}) {
|
||||
const byRegion = new Map();
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const regionId = regionIdAt(x, y);
|
||||
if (regionId < 0) continue;
|
||||
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
|
||||
if (score < threshold) continue;
|
||||
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
|
||||
byRegion.get(regionId).push({ x, y, score, kind, regionId });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const st = regionStats.get(regionId);
|
||||
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
|
||||
if (quota <= 0) continue;
|
||||
out.push(...pickEntities(candidates, {
|
||||
max: quota,
|
||||
minDistance,
|
||||
threshold,
|
||||
seed: seed + seedOffset + regionId * 1009,
|
||||
jitter: 0.04,
|
||||
}));
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
|
||||
}
|
||||
|
||||
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
|
||||
const candidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
|
||||
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
geography,
|
||||
geoHabitability,
|
||||
geoAccessibility,
|
||||
geoNaturalCentrality,
|
||||
geoLowlandCapacity,
|
||||
geoValleyAccess,
|
||||
geoCoastalAccess,
|
||||
geoBarrier,
|
||||
geoCorridorSuitability,
|
||||
fieldValue,
|
||||
regionIdAt,
|
||||
inFocusedPrefecture,
|
||||
developable,
|
||||
ruralSuitability,
|
||||
townSuitability,
|
||||
valleySettlement,
|
||||
coastalSettlement,
|
||||
confluenceField,
|
||||
barrierCost,
|
||||
corridorCost,
|
||||
settlementCluster,
|
||||
settlementScore,
|
||||
regionStats,
|
||||
visibilityFactor,
|
||||
pickRegionalPoints,
|
||||
pickGlobalPoints,
|
||||
};
|
||||
}
|
||||
213
mapFeatureLanduse.js
Normal file
213
mapFeatureLanduse.js
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
|
||||
export function buildFeatureLanduse(ctx) {
|
||||
const {
|
||||
seed,
|
||||
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
||||
developable, ruralSuitability, valleySettlement, coastalSettlement,
|
||||
modernCities, logisticsParks,
|
||||
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
|
||||
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
|
||||
} = ctx;
|
||||
const populationDensity = new Float32Array(SIZE);
|
||||
|
||||
const landuse = new Uint8Array(SIZE);
|
||||
|
||||
// Re-run land-use classification after landuse allocation. The loop above is
|
||||
// intentionally inside a helper to keep all thresholds in one place.
|
||||
function classifyLanduse() {
|
||||
landuse.fill(LANDUSE.RURAL);
|
||||
let maxDensity = 0;
|
||||
const baseNoiseSeed = seed + 15000;
|
||||
const urbanCapacity = new Float32Array(SIZE);
|
||||
const ruralDensityFloor = new Float32Array(SIZE);
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
|
||||
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10;
|
||||
const core = coreInfluence[i];
|
||||
const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38;
|
||||
const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30;
|
||||
const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10);
|
||||
urbanCapacity[i] = clamp(
|
||||
developable[i] * 0.66 +
|
||||
plain[i] * 0.16 +
|
||||
basinField[i] * 0.16 +
|
||||
valleyField[i] * 0.16 +
|
||||
coastalLowland[i] * 0.12 +
|
||||
roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 +
|
||||
riverUrban * 0.14 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.12 -
|
||||
floodplain[i] * 0.08
|
||||
);
|
||||
const highPenaltyDensity = Math.max(0, elevation[i] - 0.58);
|
||||
const agrarianDensity = clamp(
|
||||
agriculture[i] * 0.045 +
|
||||
ruralSuitability[i] * 0.035 +
|
||||
developable[i] * 0.028 +
|
||||
plain[i] * 0.018 +
|
||||
basinField[i] * 0.014 +
|
||||
valleySettlement[i] * 0.014 +
|
||||
coastalSettlement[i] * 0.012 +
|
||||
villageInfluence[i] * 0.040 +
|
||||
townInfluence[i] * 0.022 +
|
||||
roadDensityInfluence[i] * 0.038 +
|
||||
stationDensityInfluence[i] * 0.020 +
|
||||
railInfluence2[i] * 0.012 -
|
||||
slope[i] * 0.030 -
|
||||
ridgeField[i] * 0.020 -
|
||||
highPenaltyDensity * 0.058
|
||||
);
|
||||
const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025;
|
||||
ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105);
|
||||
populationDensity[i] = clamp(
|
||||
urban * 0.66 +
|
||||
core * 0.46 +
|
||||
oldTown * 0.28 +
|
||||
townInfluence[i] * 0.16 +
|
||||
villageInfluence[i] * 0.14 +
|
||||
roadDensityInfluence[i] * 0.42 +
|
||||
stationDensityInfluence[i] * 0.34 +
|
||||
railInfluence2[i] * 0.12 +
|
||||
transport * 0.05 +
|
||||
agrarianDensity * 0.34
|
||||
);
|
||||
maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
|
||||
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) {
|
||||
landuse[i] = LANDUSE.FOREST;
|
||||
continue;
|
||||
}
|
||||
if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.INDUSTRIAL;
|
||||
continue;
|
||||
}
|
||||
if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) {
|
||||
landuse[i] = LANDUSE.LOGISTICS;
|
||||
continue;
|
||||
}
|
||||
if (core > 0.38 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if (oldTown > 0.18 && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = LANDUSE.OLD_URBAN;
|
||||
continue;
|
||||
}
|
||||
|
||||
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
|
||||
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28);
|
||||
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
|
||||
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
|
||||
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
} else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
} else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) {
|
||||
landuse[i] = LANDUSE.FARMLAND;
|
||||
} else {
|
||||
const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18);
|
||||
landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseLanduse = landuse.slice();
|
||||
const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE;
|
||||
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] || baseLanduse[i] === LANDUSE.FOREST) continue;
|
||||
const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
|
||||
let urbanNeighbors = 0;
|
||||
let cbdNeighbors = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const lu = baseLanduse[i + dy * MAP_W + dx];
|
||||
if (isBuilt(lu)) urbanNeighbors++;
|
||||
if (lu === LANDUSE.CBD) cbdNeighbors++;
|
||||
}
|
||||
}
|
||||
if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
|
||||
landuse[i] = LANDUSE.CBD;
|
||||
continue;
|
||||
}
|
||||
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
|
||||
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
|
||||
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
|
||||
if (fringeChance > 0.34 + noise) {
|
||||
landuse[i] = LANDUSE.SUBURB;
|
||||
}
|
||||
}
|
||||
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) {
|
||||
landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const park of logisticsParks) {
|
||||
const r = 3;
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = park.x + dx;
|
||||
const y = park.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue;
|
||||
if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) {
|
||||
landuse[i] = LANDUSE.LOGISTICS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDensity > 0) {
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const lu = landuse[i];
|
||||
let floor = ruralDensityFloor[i];
|
||||
if (lu === LANDUSE.FARMLAND) {
|
||||
floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110));
|
||||
} else if (lu === LANDUSE.LOGISTICS) {
|
||||
floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060));
|
||||
} else if (lu === LANDUSE.RURAL) {
|
||||
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
|
||||
} else if (lu === LANDUSE.FOREST) {
|
||||
floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0);
|
||||
}
|
||||
const normalized = populationDensity[i] / maxDensity;
|
||||
populationDensity[i] = clamp(Math.max(normalized, floor));
|
||||
if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
classifyLanduse();
|
||||
|
||||
for (const city of modernCities) {
|
||||
let urbanFootprintCells = 0;
|
||||
let coreFootprintCells = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
|
||||
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]) continue;
|
||||
if (Math.hypot(dx, dy) > r) continue;
|
||||
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
|
||||
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
|
||||
}
|
||||
}
|
||||
city.urbanFootprintCells = urbanFootprintCells;
|
||||
city.coreFootprintCells = coreFootprintCells;
|
||||
}
|
||||
|
||||
return { landuse, populationDensity };
|
||||
}
|
||||
47
mapFeatureSettlements.js
Normal file
47
mapFeatureSettlements.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { MAP_H, MAP_W, SIZE, clamp, indexOf } from "./mapUtils.js";
|
||||
import { influenceFromPoints } from "./mapGeneratorHelpers.js";
|
||||
|
||||
export function buildSettlementDemandFields(ctx) {
|
||||
const {
|
||||
sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
|
||||
modernCities, markets, commercialPorts, villages,
|
||||
} = ctx;
|
||||
|
||||
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
|
||||
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
|
||||
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
|
||||
const settlementDemand = new Float32Array(SIZE);
|
||||
const urbanEdge = new Float32Array(SIZE);
|
||||
const logisticsPreSuitability = new Float32Array(SIZE);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
|
||||
settlementDemand[i] = density;
|
||||
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
|
||||
logisticsPreSuitability[i] = clamp(
|
||||
agriculture[i] * 0.30 +
|
||||
plain[i] * 0.24 +
|
||||
basinField[i] * 0.14 +
|
||||
coastalLowland[i] * 0.12 +
|
||||
preliminaryTownInfluence[i] * 0.18 +
|
||||
urbanEdge[i] * 0.34 -
|
||||
preliminaryUrbanInfluence[i] * 0.20 -
|
||||
slope[i] * 0.50 -
|
||||
ridgeField[i] * 0.32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
preliminaryUrbanInfluence,
|
||||
preliminaryTownInfluence,
|
||||
preliminaryVillageInfluence,
|
||||
settlementDemand,
|
||||
urbanEdge,
|
||||
logisticsPreSuitability,
|
||||
};
|
||||
}
|
||||
191
mapFeatureTransportTools.js
Normal file
191
mapFeatureTransportTools.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
|
||||
export function buildFeatureTransportCostFields(ctx) {
|
||||
const {
|
||||
seed,
|
||||
sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
||||
portSuitability, passSuitability, crossingSuitability, naturalBarrierScore,
|
||||
settlementDemand, urbanEdge, logisticsPreSuitability,
|
||||
preliminaryTownInfluence, preliminaryVillageInfluence,
|
||||
valleySettlement, coastalSettlement, developable,
|
||||
} = ctx;
|
||||
const expressway = new Float32Array(SIZE);
|
||||
const rail = new Float32Array(SIZE);
|
||||
const national = new Float32Array(SIZE);
|
||||
const local = new Float32Array(SIZE);
|
||||
const expresswayPotential = new Float32Array(SIZE);
|
||||
const railPotential = new Float32Array(SIZE);
|
||||
const nationalPotential = new Float32Array(SIZE);
|
||||
const localPotential = new Float32Array(SIZE);
|
||||
|
||||
function seaAdjacency(x, y, radius = 1) {
|
||||
let sum = 0;
|
||||
let total = 0;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
total++;
|
||||
if (sea[indexOf(nx, ny)]) sum += 1;
|
||||
}
|
||||
}
|
||||
return total > 0 ? sum / total : 0;
|
||||
}
|
||||
|
||||
function highAltitudeTransportClosed(i) {
|
||||
// Above this contour the generator should treat mountains as no-road
|
||||
// terrain. A strong mapped pass is the exception, so genuine saddle
|
||||
// crossings can still exist without roads drilling through entire ranges.
|
||||
return elevation[i] >= 0.70;
|
||||
}
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || highAltitudeTransportClosed(i)) {
|
||||
expressway[i] = rail[i] = national[i] = local[i] = INF;
|
||||
expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const density = settlementDemand[i];
|
||||
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
|
||||
const highDensity = clamp((density - 0.32) / 0.50);
|
||||
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
|
||||
const pass = passSuitability?.[i] || 0;
|
||||
const crossing = crossingSuitability?.[i] || 0;
|
||||
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0;
|
||||
const seaNear = seaAdjacency(x, y, 1);
|
||||
const seaBroad = seaAdjacency(x, y, 3);
|
||||
const seaWide = seaAdjacency(x, y, 5);
|
||||
// Roads should use coastal lowlands when there is a settlement/port reason,
|
||||
// but should not casually trace beaches or hop over small bays.
|
||||
const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30);
|
||||
const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12);
|
||||
const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80);
|
||||
const denseCorePenalty = clamp((density - 0.66) / 0.28);
|
||||
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
|
||||
const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2);
|
||||
|
||||
if (extremeMountain > 0.92 && pass < 0.34) {
|
||||
expressway[i] = rail[i] = INF;
|
||||
national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty;
|
||||
local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55;
|
||||
expresswayPotential[i] = 0;
|
||||
railPotential[i] = 0;
|
||||
nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55);
|
||||
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28);
|
||||
continue;
|
||||
}
|
||||
|
||||
expresswayPotential[i] = clamp(
|
||||
mediumDensity * 0.62 +
|
||||
urbanEdge[i] * 0.38 +
|
||||
logisticsPreSuitability[i] * 0.54 +
|
||||
lowland * 0.36 +
|
||||
agriculture[i] * 0.16 -
|
||||
denseCorePenalty * 0.54 -
|
||||
slope[i] * 0.82 -
|
||||
highMountain * 0.92 -
|
||||
coastalTraversePenalty * 0.32 -
|
||||
river[i] * 0.14
|
||||
);
|
||||
railPotential[i] = clamp(
|
||||
highDensity * 0.80 +
|
||||
preliminaryTownInfluence[i] * 0.22 +
|
||||
lowland * 0.46 +
|
||||
valleyField[i] * 0.22 +
|
||||
coastalLowland[i] * 0.22 -
|
||||
slope[i] * 1.28 -
|
||||
highMountain * 1.10 -
|
||||
coastalTraversePenalty * 0.26 -
|
||||
ridgeField[i] * 0.34
|
||||
);
|
||||
nationalPotential[i] = clamp(
|
||||
density * 0.46 +
|
||||
preliminaryTownInfluence[i] * 0.32 +
|
||||
preliminaryVillageInfluence[i] * 0.20 +
|
||||
agriculture[i] * 0.24 +
|
||||
valleyField[i] * 0.28 +
|
||||
coastalLowland[i] * 0.26 +
|
||||
pass * 0.18 +
|
||||
crossing * 0.18 -
|
||||
slope[i] * 0.48 -
|
||||
highMountain * 0.24 -
|
||||
coastalTraversePenalty * 0.16 -
|
||||
ridgeField[i] * 0.18
|
||||
);
|
||||
localPotential[i] = clamp(
|
||||
preliminaryVillageInfluence[i] * 0.52 +
|
||||
agriculture[i] * 0.38 +
|
||||
coastalSettlement[i] * 0.30 +
|
||||
valleySettlement[i] * 0.30 +
|
||||
developable[i] * 0.18 -
|
||||
slope[i] * 0.34 -
|
||||
coastalTraversePenalty * 0.08 -
|
||||
ridgeField[i] * 0.10
|
||||
);
|
||||
|
||||
expressway[i] = Math.max(0.18,
|
||||
1.62 - expresswayPotential[i] * 0.96 +
|
||||
denseCorePenalty * 1.30 +
|
||||
slope[i] * 5.4 +
|
||||
highMountain * 5.8 +
|
||||
extremeMountain * 4.2 +
|
||||
boundaryRidgePenalty * 4.2 +
|
||||
waterCrossingPenalty * 2.1 +
|
||||
coastalTraversePenalty * 2.65 +
|
||||
seaNear * 2.35 +
|
||||
seaWide * 1.10 +
|
||||
openPlainParallelPenalty * 0.12 +
|
||||
hash2(x, y, seed + 13301) * 0.04
|
||||
);
|
||||
rail[i] = Math.max(0.16,
|
||||
1.48 - railPotential[i] * 1.02 +
|
||||
slope[i] * 7.2 +
|
||||
highMountain * 7.0 +
|
||||
extremeMountain * 4.8 +
|
||||
boundaryRidgePenalty * 2.4 +
|
||||
waterCrossingPenalty * 1.7 +
|
||||
coastalTraversePenalty * 1.75 +
|
||||
seaNear * 1.50 +
|
||||
seaWide * 0.70 +
|
||||
hash2(x, y, seed + 13302) * 0.03
|
||||
);
|
||||
national[i] = Math.max(0.16,
|
||||
1.28 - nationalPotential[i] * 0.84 +
|
||||
slope[i] * 2.8 +
|
||||
ridgeField[i] * 1.18 +
|
||||
Math.max(0, elevation[i] - 0.62) * 3.0 +
|
||||
highMountain * 2.9 +
|
||||
boundaryRidgePenalty * 1.8 +
|
||||
waterCrossingPenalty * 1.25 -
|
||||
valleyField[i] * 0.18 -
|
||||
coastalLowland[i] * 0.08 +
|
||||
coastalTraversePenalty * 1.55 +
|
||||
seaNear * 0.84 +
|
||||
seaWide * 0.48 -
|
||||
pass * 0.42 +
|
||||
hash2(x, y, seed + 13303) * 0.05
|
||||
);
|
||||
local[i] = Math.max(0.14,
|
||||
1.12 - localPotential[i] * 0.86 +
|
||||
slope[i] * 1.72 +
|
||||
ridgeField[i] * 0.82 +
|
||||
Math.max(0, elevation[i] - 0.68) * 1.9 +
|
||||
highMountain * 1.24 +
|
||||
boundaryRidgePenalty * 0.72 +
|
||||
waterCrossingPenalty * 0.65 -
|
||||
valleyField[i] * 0.22 -
|
||||
coastalLowland[i] * 0.10 +
|
||||
coastalTraversePenalty * 0.82 +
|
||||
seaNear * 0.48 +
|
||||
seaWide * 0.26 +
|
||||
hash2(x, y, seed + 13304) * 0.07
|
||||
);
|
||||
}
|
||||
}
|
||||
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
|
||||
}
|
||||
|
||||
1215
mapFeatures.js
1215
mapFeatures.js
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,61 @@ export function distanceToNearest(points, x, y, fallback = 999) {
|
|||
return best;
|
||||
}
|
||||
|
||||
export function createPointSpatialIndex(points, cellSize = 12) {
|
||||
const buckets = new Map();
|
||||
const normalized = (points || [])
|
||||
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||
.map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) }));
|
||||
const keyOf = (cx, cy) => `${cx},${cy}`;
|
||||
for (const p of normalized) {
|
||||
const cx = Math.floor(p.x / cellSize);
|
||||
const cy = Math.floor(p.y / cellSize);
|
||||
const key = keyOf(cx, cy);
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.push(p);
|
||||
}
|
||||
|
||||
function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) {
|
||||
if (!normalized.length) return maxDistance * maxDistance;
|
||||
const cx = Math.floor(x / cellSize);
|
||||
const cy = Math.floor(y / cellSize);
|
||||
const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize);
|
||||
let best = maxDistance * maxDistance;
|
||||
for (let ring = 0; ring <= maxRing; ring++) {
|
||||
for (let by = cy - ring; by <= cy + ring; by++) {
|
||||
for (let bx = cx - ring; bx <= cx + ring; bx++) {
|
||||
if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue;
|
||||
const bucket = buckets.get(keyOf(bx, by));
|
||||
if (!bucket) continue;
|
||||
for (const p of bucket) {
|
||||
const dx = p.x - x;
|
||||
const dy = p.y - y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < best) best = d2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
return {
|
||||
points: normalized,
|
||||
hasWithin(x, y, radius) {
|
||||
return nearestDistanceSq(x, y, radius) < radius * radius;
|
||||
},
|
||||
distance(x, y, fallback = 999) {
|
||||
const d2 = nearestDistanceSq(x, y, fallback);
|
||||
return d2 < fallback * fallback ? Math.sqrt(d2) : fallback;
|
||||
},
|
||||
nearestDistanceSq,
|
||||
};
|
||||
}
|
||||
|
||||
export function aStar(start, goal, costAt) {
|
||||
const startIndex = indexOf(start.x, start.y);
|
||||
const goalIndex = indexOf(goal.x, goal.y);
|
||||
|
|
@ -83,15 +138,18 @@ export function aStar(start, goal, costAt) {
|
|||
|
||||
export function influenceFromPaths(paths, radius) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
const r = Math.ceil(radius);
|
||||
const r2 = radius * radius;
|
||||
for (const path of paths) {
|
||||
for (const [x, y] of path) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > r2) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
||||
}
|
||||
|
|
@ -239,15 +297,18 @@ export function averagePathField(path, field) {
|
|||
|
||||
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
const r = Math.ceil(radius);
|
||||
const r2 = radius * radius;
|
||||
for (const p of points) {
|
||||
const weight = weightFn(p);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const nx = p.x + dx;
|
||||
const ny = p.y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > r2) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], weight / (1 + d));
|
||||
}
|
||||
|
|
@ -1266,6 +1327,18 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam
|
|||
return points.map((p, i) => {
|
||||
const id = `${prefix}-${i}`;
|
||||
const kind = kindOverride || p.kind;
|
||||
if (prefix === "logistics") {
|
||||
return {
|
||||
...p,
|
||||
id,
|
||||
name: null,
|
||||
facilityLabel: p.facilityLabel || "Logistics Park",
|
||||
kind,
|
||||
labelStyle: "facility",
|
||||
suppressSettlementLabel: true,
|
||||
insidePrefecture: Boolean(p.insidePrefecture),
|
||||
};
|
||||
}
|
||||
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||
if (usedNames) usedNames.add(name);
|
||||
return {
|
||||
|
|
|
|||
265
mapMunicipalCoherence.js
Normal file
265
mapMunicipalCoherence.js
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
|
||||
|
||||
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
|
||||
|
||||
function coordIndex(width, height, x, y) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
|
||||
return y * width + x;
|
||||
}
|
||||
|
||||
function numericAdminId(point) {
|
||||
for (const key of ADMIN_ID_KEYS) {
|
||||
const value = point?.[key];
|
||||
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function fieldValue(fields, name, i) {
|
||||
return fields?.[name]?.[i] || 0;
|
||||
}
|
||||
|
||||
function bestCellScore(fields, i) {
|
||||
return fieldValue(fields, "populationDensity", i) * 3.0
|
||||
+ fieldValue(fields, "plain", i) * 0.32
|
||||
+ fieldValue(fields, "agriculture", i) * 0.16
|
||||
- fieldValue(fields, "slope", i) * 0.30
|
||||
- fieldValue(fields, "ridgeField", i) * 0.18;
|
||||
}
|
||||
|
||||
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
|
||||
const stats = new Map();
|
||||
for (let i = 0; i < adminId.length; i++) {
|
||||
const id = adminId[i];
|
||||
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
|
||||
const x = i % width;
|
||||
const y = Math.floor(i / width);
|
||||
row.area++;
|
||||
row.sx += x;
|
||||
row.sy += y;
|
||||
const pref = prefectureRegionId?.[i] ?? -1;
|
||||
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
|
||||
const score = bestCellScore(fields, i);
|
||||
if (score > row.bestScore) {
|
||||
row.bestScore = score;
|
||||
row.bestI = i;
|
||||
}
|
||||
stats.set(id, row);
|
||||
}
|
||||
for (const row of stats.values()) {
|
||||
let bestPref = -1;
|
||||
let bestVotes = -1;
|
||||
for (const [pref, count] of row.prefVotes) {
|
||||
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
|
||||
bestPref = pref;
|
||||
bestVotes = count;
|
||||
}
|
||||
}
|
||||
row.prefectureRegionId = bestPref;
|
||||
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
|
||||
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
|
||||
return {
|
||||
x: Math.round((point?.x || 0) + pointOffsetX),
|
||||
y: Math.round((point?.y || 0) + pointOffsetY),
|
||||
};
|
||||
}
|
||||
|
||||
function centerQuality(center, id, stat, context) {
|
||||
if (!center) return -INF;
|
||||
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
|
||||
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
|
||||
const i = coordIndex(width, height, p.x, p.y);
|
||||
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
|
||||
return (ownsCell ? 100000 : 0)
|
||||
+ (center.name ? 5000 : 0)
|
||||
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
|
||||
+ (center.generatedOfficePoint ? -200 : 0)
|
||||
- Math.hypot(p.x - stat.x, p.y - stat.y);
|
||||
}
|
||||
|
||||
function normalizeCenter(center, id, stat, context, generated = false) {
|
||||
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
|
||||
const out = {
|
||||
...(center || {}),
|
||||
x: stat.x - pointOffsetX,
|
||||
y: stat.y - pointOffsetY,
|
||||
adminId: id,
|
||||
adminNumericId: id,
|
||||
municipalityId: id,
|
||||
prefectureRegionId: stat.prefectureRegionId,
|
||||
municipalArea: stat.area,
|
||||
insidePrefecture: true,
|
||||
};
|
||||
if (generated) {
|
||||
out.generatedOfficePoint = true;
|
||||
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
|
||||
out.kind ||= "Municipal Center";
|
||||
out.generatedMunicipalityName ||= `自治${id + 1}`;
|
||||
out.name ||= out.generatedMunicipalityName;
|
||||
out.labelName ||= out.generatedMunicipalityName;
|
||||
out.municipalityName ||= out.generatedMunicipalityName;
|
||||
}
|
||||
const i = coordIndex(context.width, context.height, stat.x, stat.y);
|
||||
if (i >= 0) {
|
||||
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
|
||||
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function reconcileMunicipalMetadata({
|
||||
adminId,
|
||||
municipalityId = null,
|
||||
prefectureRegionId = null,
|
||||
sea = null,
|
||||
adminCenters = [],
|
||||
municipalityToPrefectureId = null,
|
||||
fields = {},
|
||||
width = MAP_W,
|
||||
height = MAP_H,
|
||||
pointOffsetX = 0,
|
||||
pointOffsetY = 0,
|
||||
seed = 0,
|
||||
} = {}) {
|
||||
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
|
||||
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
|
||||
if (municipalityId) {
|
||||
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
|
||||
}
|
||||
|
||||
const byId = new Map();
|
||||
let ghostCentersRemoved = 0;
|
||||
let centersMovedToOwnedCells = 0;
|
||||
for (const [index, center] of (adminCenters || []).entries()) {
|
||||
if (!center) continue;
|
||||
const explicitId = numericAdminId(center);
|
||||
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
|
||||
const stat = stats.get(id);
|
||||
if (!stat) {
|
||||
ghostCentersRemoved++;
|
||||
continue;
|
||||
}
|
||||
const current = byId.get(id);
|
||||
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
|
||||
byId.set(id, center);
|
||||
}
|
||||
}
|
||||
|
||||
let fallbackCentersAdded = 0;
|
||||
const nextCenters = [];
|
||||
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const existing = byId.get(id);
|
||||
if (!existing) fallbackCentersAdded++;
|
||||
else {
|
||||
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
|
||||
const pi = coordIndex(width, height, p.x, p.y);
|
||||
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
|
||||
}
|
||||
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
|
||||
}
|
||||
|
||||
let maxId = Math.max(-1, ...stats.keys());
|
||||
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
|
||||
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
|
||||
nextMapping.fill(-1);
|
||||
if (municipalityToPrefectureId) {
|
||||
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
|
||||
}
|
||||
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
|
||||
|
||||
return {
|
||||
adminCenters: nextCenters,
|
||||
municipalityToPrefectureId: nextMapping,
|
||||
stats,
|
||||
debug: {
|
||||
activeMunicipalities: stats.size,
|
||||
ghostCentersRemoved,
|
||||
fallbackCentersAdded,
|
||||
centersMovedToOwnedCells,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshPrefectureRegionsMetadata({
|
||||
prefectureRegionId,
|
||||
sea,
|
||||
existing = [],
|
||||
fields = {},
|
||||
width = MAP_W,
|
||||
height = MAP_H,
|
||||
pointOffsetX = 0,
|
||||
pointOffsetY = 0,
|
||||
} = {}) {
|
||||
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
const id = prefectureRegionId[i];
|
||||
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
|
||||
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
|
||||
const x = i % width;
|
||||
const y = Math.floor(i / width);
|
||||
row.area++;
|
||||
row.sx += x;
|
||||
row.sy += y;
|
||||
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
|
||||
if (score > row.bestScore) {
|
||||
row.bestScore = score;
|
||||
row.bestI = i;
|
||||
}
|
||||
byId.set(id, row);
|
||||
}
|
||||
const existingById = new Map();
|
||||
for (const region of existing || []) {
|
||||
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
|
||||
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
|
||||
}
|
||||
let fallbackRegionsAdded = 0;
|
||||
const prefectureRegions = [];
|
||||
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const base = existingById.get(id);
|
||||
if (!base) fallbackRegionsAdded++;
|
||||
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
|
||||
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
|
||||
prefectureRegions.push({
|
||||
...(base || {}),
|
||||
id,
|
||||
prefectureRegionId: id,
|
||||
featureId: id,
|
||||
x: x - pointOffsetX,
|
||||
y: y - pointOffsetY,
|
||||
area: row.area,
|
||||
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
|
||||
name: base?.name || `県域${id + 1}`,
|
||||
labelName: base?.labelName || base?.name || `県域${id + 1}`,
|
||||
forceLabel: true,
|
||||
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
|
||||
});
|
||||
}
|
||||
return {
|
||||
prefectureRegions,
|
||||
debug: {
|
||||
activePrefectureRegions: byId.size,
|
||||
fallbackRegionsAdded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function municipalCoherenceForMap(map) {
|
||||
return reconcileMunicipalMetadata({
|
||||
adminId: map?.adminId,
|
||||
municipalityId: map?.municipalityId,
|
||||
prefectureRegionId: map?.prefectureRegionId,
|
||||
sea: map?.sea,
|
||||
adminCenters: map?.adminCenters,
|
||||
municipalityToPrefectureId: map?.municipalityToPrefectureId,
|
||||
fields: map,
|
||||
width: map?.width || MAP_W,
|
||||
height: map?.height || MAP_H,
|
||||
});
|
||||
}
|
||||
481
mapOutput.js
481
mapOutput.js
|
|
@ -1,6 +1,7 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
|
||||
import { routeQualityAcceptable } from "./mapTransport.js";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
|
@ -30,15 +31,33 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
|||
}
|
||||
|
||||
|
||||
function centerMunicipalityId(center, fallback = -1) {
|
||||
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
|
||||
const value = center?.[key];
|
||||
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
|
||||
if (!adminCenters?.length || !adminId) return;
|
||||
const totals = new Float64Array(adminCenters.length);
|
||||
const settlementTotals = new Float64Array(adminCenters.length);
|
||||
const landCells = new Uint32Array(adminCenters.length);
|
||||
const inhabitedCells = new Uint32Array(adminCenters.length);
|
||||
const centerById = new Map();
|
||||
let maxId = -1;
|
||||
for (const center of adminCenters) {
|
||||
const id = centerMunicipalityId(center);
|
||||
if (id >= 0 && !centerById.has(id)) {
|
||||
centerById.set(id, center);
|
||||
maxId = Math.max(maxId, id);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
|
||||
const totals = new Float64Array(maxId + 1);
|
||||
const settlementTotals = new Float64Array(maxId + 1);
|
||||
const landCells = new Uint32Array(maxId + 1);
|
||||
const inhabitedCells = new Uint32Array(maxId + 1);
|
||||
for (let i = 0; i < adminId.length; i++) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
|
||||
if (id < 0 || fields.sea?.[i]) continue;
|
||||
landCells[id]++;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const lu = fields.landuse?.[i] ?? 0;
|
||||
|
|
@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
let skippedDuplicateSettlementPopulation = 0;
|
||||
for (const { feature, i } of uniqueSettlementByCell.values()) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length) continue;
|
||||
if (id < 0 || id >= settlementTotals.length) continue;
|
||||
settlementTotals[id] += feature.population;
|
||||
}
|
||||
for (const feature of settlementFeatures || []) {
|
||||
|
|
@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
const kept = uniqueSettlementByCell.get(key)?.feature;
|
||||
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
|
||||
}
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
for (const [id, center] of centerById) {
|
||||
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
|
||||
const minimumResidentPopulation = landCells[id] > 0
|
||||
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
|
||||
|
|
@ -85,18 +104,18 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
|
|||
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
|
||||
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
|
||||
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
|
||||
adminCenters[id].municipalityPopulation = safePopulation;
|
||||
center.municipalityPopulation = safePopulation;
|
||||
// Some consumers still read the generic `population` field from municipal
|
||||
// centers. Mirror the municipality total there so no municipality is shown
|
||||
// as 0人 merely because it is not a canonical city/market entity.
|
||||
adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
|
||||
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||
center.population = Math.max(center.population || 0, safePopulation);
|
||||
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) {
|
||||
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000, focusedPrefectureMask = null) {
|
||||
if (!prefectureRegionId) return 0;
|
||||
const prefIds = new Set();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
|
|
@ -135,6 +154,24 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
|
|||
if (!p || !inside(p.x, p.y)) return -1;
|
||||
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
|
||||
}
|
||||
const focusedPrefCounts = new Map();
|
||||
if (focusedPrefectureMask) {
|
||||
for (let i = 0; i < focusedPrefectureMask.length; i++) {
|
||||
if (!focusedPrefectureMask[i] || sea[i]) continue;
|
||||
const prefId = prefectureRegionId[i] ?? -1;
|
||||
if (prefId >= 0) focusedPrefCounts.set(prefId, (focusedPrefCounts.get(prefId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const focusedPrefId = focusedPrefCounts.size
|
||||
? [...focusedPrefCounts.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
: 0;
|
||||
for (const city of modernCities || []) {
|
||||
if (city.isPrefecturalCapital) {
|
||||
city.isPrefecturalCapital = false;
|
||||
city.rank = city.isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||
city.kind = city.rank;
|
||||
}
|
||||
}
|
||||
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
|
||||
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
|
||||
let target = cities.slice().sort((a, b) =>
|
||||
|
|
@ -175,10 +212,11 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
|
|||
target.population = promotedPopulation;
|
||||
promoted++;
|
||||
}
|
||||
target.isPrefecturalCapital = true;
|
||||
target.isRegionalCapital = true;
|
||||
target.rank = target.rank || "Regional Capital";
|
||||
target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
|
||||
target.isPrefecturalCapital = prefId === focusedPrefId;
|
||||
target.rank = target.isPrefecturalCapital ? "Prefectural Capital" : "Regional Capital";
|
||||
target.kind = target.rank;
|
||||
target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150);
|
||||
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
|
||||
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
|
||||
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
|
||||
|
|
@ -210,8 +248,9 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
|||
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
|
||||
if (prefId < 0) continue;
|
||||
const current = capitalNameByPref.get(prefId);
|
||||
const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0);
|
||||
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" });
|
||||
const tier = city.isPrefecturalCapital || city.rank === "Prefectural Capital" || city.kind === "Prefectural Capital" ? 3 : city.isRegionalCapital || city.rank === "Regional Capital" || city.kind === "Regional Capital" ? 2 : 1;
|
||||
const score = tier * 50_000_000 + (city.population || 0);
|
||||
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: tier === 3 ? "prefecture-capital" : "city" });
|
||||
}
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y) || !center.name) continue;
|
||||
|
|
@ -265,7 +304,7 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
|||
x = labelI % MAP_W;
|
||||
y = Math.floor(labelI / MAP_W);
|
||||
}
|
||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
|
||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 950 + Math.sqrt(row.area), forceLabel: true, capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
|
||||
}
|
||||
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
||||
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
|
||||
|
|
@ -447,7 +486,20 @@ export function finishMapOutput({
|
|||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||
outputProgress("municipality naming");
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const municipalCoherence = reconcileMunicipalMetadata({
|
||||
adminId,
|
||||
prefectureRegionId,
|
||||
sea,
|
||||
adminCenters: adminCentersRaw,
|
||||
municipalityToPrefectureId,
|
||||
fields: nameFields,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
seed,
|
||||
});
|
||||
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
|
||||
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
|
||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||
const representativeFeatures = [
|
||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
|
||||
|
|
@ -478,31 +530,44 @@ export function finishMapOutput({
|
|||
center.canonicalSettlementId = best.id;
|
||||
center.canonicalSettlementName = best.name;
|
||||
center.municipalityRootName = best.name;
|
||||
const bestAdmin = adminId?.[indexOf(best.x, best.y)];
|
||||
const canSnapOffice = inside(best.x, best.y) && !sea[indexOf(best.x, best.y)] && (
|
||||
centerAdmin == null || centerAdmin < 0 || bestAdmin == null || bestAdmin < 0 || bestAdmin === centerAdmin
|
||||
);
|
||||
if (canSnapOffice) {
|
||||
center.generatedOfficeX = center.generatedOfficeX ?? center.x;
|
||||
center.generatedOfficeY = center.generatedOfficeY ?? center.y;
|
||||
center.x = best.x;
|
||||
center.y = best.y;
|
||||
center.officeSnappedToSettlement = true;
|
||||
}
|
||||
} else {
|
||||
center.municipalityRootName = center.generatedMunicipalityName;
|
||||
}
|
||||
}
|
||||
const usedAdminNames = new Set();
|
||||
for (const [index, center] of adminCenters.entries()) {
|
||||
center.adminNumericId = index;
|
||||
center.municipalityId = index;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
const municipalId = centerMunicipalityId(center, index);
|
||||
center.adminId = municipalId;
|
||||
center.adminNumericId = municipalId;
|
||||
center.municipalityId = municipalId;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
|
||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const chars = Array.from(rootSource || "里郷");
|
||||
const alternates = [
|
||||
chars.slice(0, 2).join(""),
|
||||
chars.slice(-2).join(""),
|
||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
|
||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
|
||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
|
||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
|
||||
].filter((v) => Array.from(v).length >= 2);
|
||||
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
|
||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`;
|
||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(municipalId + attempt) % 10}`;
|
||||
candidate = `${root}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +576,7 @@ export function finishMapOutput({
|
|||
center.municipalityName = candidate;
|
||||
usedAdminNames.add(center.name);
|
||||
}
|
||||
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000);
|
||||
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask);
|
||||
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
|
||||
...modernCities,
|
||||
...markets,
|
||||
|
|
@ -553,7 +618,7 @@ export function finishMapOutput({
|
|||
return bs - as;
|
||||
})
|
||||
.filter((p) => {
|
||||
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
||||
const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
|
||||
const used = perPrefectureQuota.get(prefId) || 0;
|
||||
if (used >= 18) return false;
|
||||
perPrefectureQuota.set(prefId, used + 1);
|
||||
|
|
@ -720,6 +785,24 @@ export function finishMapOutput({
|
|||
return false;
|
||||
}
|
||||
|
||||
function pathNearRequiredExpresswayCity(path) {
|
||||
if (!path || path.length < 2) return false;
|
||||
for (const city of modernCities || []) {
|
||||
if (!city || (city.population || 0) < 100000 || !inside(city.x, city.y)) continue;
|
||||
const inner = Math.max(7, (city.coreRadius || 4) + 4.5);
|
||||
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.15);
|
||||
let inBand = false;
|
||||
let exits = false;
|
||||
for (const [x, y] of path) {
|
||||
const d = Math.hypot(city.x - x, city.y - y);
|
||||
if (d >= inner && d <= outer) inBand = true;
|
||||
if (d >= Math.max(22, (city.urbanRadius || 12) * 1.45)) exits = true;
|
||||
if (inBand && exits) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function components() {
|
||||
const occ = new Uint8Array(MAP_W * MAP_H);
|
||||
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
|
||||
|
|
@ -751,8 +834,165 @@ export function finishMapOutput({
|
|||
}
|
||||
return out.sort((a, b) => b.size - a.size);
|
||||
}
|
||||
function buildLandComponentIds() {
|
||||
const ids = new Int32Array(MAP_W * MAP_H);
|
||||
ids.fill(-1);
|
||||
let id = 0;
|
||||
const q = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (ids[i] >= 0 || sea[i]) continue;
|
||||
ids[i] = id;
|
||||
q.length = 0;
|
||||
q.push(i);
|
||||
for (let h = 0; h < q.length; h++) {
|
||||
const cur = q[h];
|
||||
const [x, y] = xyOf(cur);
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || ids[ni] >= 0) continue;
|
||||
ids[ni] = id;
|
||||
q.push(ni);
|
||||
}
|
||||
}
|
||||
id++;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function majorityLandId(cells, landIds) {
|
||||
const counts = new Map();
|
||||
for (const i of cells || []) {
|
||||
const id = landIds[i];
|
||||
if (id < 0) continue;
|
||||
counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
let best = -1, bestN = 0;
|
||||
for (const [id, n] of counts) if (n > bestN) { best = id; bestN = n; }
|
||||
return best;
|
||||
}
|
||||
|
||||
function componentNearAdminCenter(comp, radius = 3.2) {
|
||||
if (!comp?.cells?.length) return false;
|
||||
const mask = new Uint8Array(MAP_W * MAP_H);
|
||||
for (const ci of comp.cells) mask[ci] = 1;
|
||||
const r = Math.ceil(radius);
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const cx = Math.round(center.x), cy = Math.round(center.y);
|
||||
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
const x = cx + dx, y = cy + dy;
|
||||
if (inside(x, y) && mask[indexOf(x, y)]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function routeIsolatedComponentToMain(comp, mainMask, mainCentroid, landIds, landIdValue) {
|
||||
if (!comp?.cells?.length || landIdValue < 0) return [];
|
||||
const dist = new Float64Array(MAP_W * MAP_H);
|
||||
dist.fill(INF);
|
||||
const prev = new Int32Array(MAP_W * MAP_H);
|
||||
prev.fill(-1);
|
||||
const heap = new MinHeap();
|
||||
let seeded = 0;
|
||||
const stride = Math.max(1, Math.floor(comp.cells.length / 96));
|
||||
for (let k = 0; k < comp.cells.length; k += stride) {
|
||||
const i = comp.cells[k];
|
||||
if (sea[i] || landIds[i] !== landIdValue) continue;
|
||||
dist[i] = 0;
|
||||
prev[i] = i;
|
||||
const [x, y] = xyOf(i);
|
||||
heap.push({ i, f: Math.hypot(x - mainCentroid.x, y - mainCentroid.y) * 0.22 });
|
||||
seeded++;
|
||||
}
|
||||
if (!seeded) return [];
|
||||
let goal = -1;
|
||||
let expanded = 0;
|
||||
const maxExpanded = 22000;
|
||||
while (heap.length && expanded < maxExpanded) {
|
||||
const current = heap.pop();
|
||||
if (!current) break;
|
||||
const cur = current.i;
|
||||
expanded++;
|
||||
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
|
||||
const [x, y] = xyOf(cur);
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || landIds[ni] !== landIdValue) continue;
|
||||
const step = Math.hypot(dx, dy);
|
||||
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.28 + (ridgeField?.[ni] || 0) * 0.66 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 1.75 - (valleyField?.[ni] || 0) * 0.42 - (plain?.[ni] || 0) * 0.18 - (coastalLowland?.[ni] || 0) * 0.08;
|
||||
const nd = dist[cur] + step * Math.max(0.38, terrainCost);
|
||||
if (nd >= dist[ni]) continue;
|
||||
dist[ni] = nd;
|
||||
prev[ni] = cur;
|
||||
const h = Math.hypot(nx - mainCentroid.x, ny - mainCentroid.y) * 0.22;
|
||||
heap.push({ i: ni, f: nd + h });
|
||||
}
|
||||
}
|
||||
if (goal < 0) return [];
|
||||
const path = [];
|
||||
let cur = goal;
|
||||
for (let guard = 0; guard < 240 && cur >= 0; guard++) {
|
||||
const [x, y] = xyOf(cur);
|
||||
path.push([x, y]);
|
||||
if (prev[cur] === cur) break;
|
||||
cur = prev[cur];
|
||||
}
|
||||
path.reverse();
|
||||
if (path.length < 4 || path.length > 150) return [];
|
||||
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, highElevationThreshold: 0.80, steepThreshold: 0.55 }, {
|
||||
minLength: 4,
|
||||
maxLength: 150,
|
||||
maxCompactness: 5.4,
|
||||
maxHighElevationShare: 0.42,
|
||||
maxSteepShare: 0.66,
|
||||
}) ? path : [];
|
||||
}
|
||||
|
||||
function attemptConnectSameLandmassAdminRoadComponents(comps) {
|
||||
const result = { attempted: 0, added: 0, skippedIsland: 0, failed: 0 };
|
||||
if (!comps || comps.length <= 1) return result;
|
||||
const landIds = buildLandComponentIds();
|
||||
const mainLand = majorityLandId(comps[0].cells, landIds);
|
||||
const mainMask = new Uint8Array(MAP_W * MAP_H);
|
||||
let sx = 0, sy = 0, sn = 0;
|
||||
for (const ci of comps[0].cells) {
|
||||
const [cx, cy] = xyOf(ci);
|
||||
sx += cx; sy += cy; sn++;
|
||||
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
||||
if (dx * dx + dy * dy > 5) continue;
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
|
||||
}
|
||||
}
|
||||
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
|
||||
for (const comp of comps.slice(1, 24)) {
|
||||
if (!componentNearAdminCenter(comp)) continue;
|
||||
const land = majorityLandId(comp.cells, landIds);
|
||||
if (land !== mainLand) { result.skippedIsland++; continue; }
|
||||
result.attempted++;
|
||||
const path = routeIsolatedComponentToMain(comp, mainMask, centroid, landIds, land);
|
||||
if (path.length >= 4) {
|
||||
minorRoads.push(path);
|
||||
result.added++;
|
||||
} else {
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let comps = components();
|
||||
const before = comps.length;
|
||||
const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps);
|
||||
if (mountainConnect.added > 0) comps = components();
|
||||
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
|
||||
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
|
||||
const mainMask = new Uint8Array(MAP_W * MAP_H);
|
||||
|
|
@ -776,7 +1016,7 @@ export function finishMapOutput({
|
|||
for (const [key, paths] of groups) {
|
||||
const kept = [];
|
||||
for (const path of paths || []) {
|
||||
if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path);
|
||||
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
|
||||
else pruned[key]++;
|
||||
}
|
||||
paths.length = 0;
|
||||
|
|
@ -784,7 +1024,7 @@ export function finishMapOutput({
|
|||
}
|
||||
comps = components();
|
||||
}
|
||||
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned };
|
||||
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
|
||||
}
|
||||
pruneIsolatedFinalRoadComponents();
|
||||
|
||||
|
|
@ -815,6 +1055,178 @@ export function finishMapOutput({
|
|||
}
|
||||
ensureAdminCenterCellsAfterOutputPrune();
|
||||
|
||||
function connectNearbyRoadEndpoints() {
|
||||
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
|
||||
const ordinaryGroups = [
|
||||
{ key: "minor", paths: minorRoads || [] },
|
||||
{ key: "national", paths: nationalRoads || [] },
|
||||
{ key: "external", paths: externalRoads || [] },
|
||||
{ key: "ring", paths: ringRoads || [] },
|
||||
];
|
||||
const occ = new Uint8Array(MAP_W * MAP_H);
|
||||
function rasterize(path, fn) {
|
||||
for (let k = 1; k < (path?.length || 0); k++) {
|
||||
const [x0, y0] = path[k - 1];
|
||||
const [x1, y1] = path[k];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const x = Math.round(x0 + (x1 - x0) * t);
|
||||
const y = Math.round(y0 + (y1 - y0) * t);
|
||||
if (inside(x, y) && !sea[indexOf(x, y)]) fn(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; });
|
||||
const comp = new Int32Array(MAP_W * MAP_H);
|
||||
comp.fill(-1);
|
||||
let compId = 0;
|
||||
const queue = [];
|
||||
for (let i = 0; i < occ.length; i++) {
|
||||
if (!occ[i] || comp[i] >= 0) continue;
|
||||
comp[i] = compId;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!occ[ni] || comp[ni] >= 0) continue;
|
||||
comp[ni] = compId;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
compId++;
|
||||
}
|
||||
function endpointComponent(x, y) {
|
||||
if (!inside(x, y) || sea[indexOf(x, y)]) return -1;
|
||||
const here = comp[indexOf(x, y)];
|
||||
if (here >= 0) return here;
|
||||
for (let r = 1; r <= 2; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue;
|
||||
const id = comp[indexOf(nx, ny)];
|
||||
if (id >= 0) return id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function directConnector(a, b) {
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
||||
const path = [];
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const x = Math.round(a.x + (b.x - a.x) * t);
|
||||
const y = Math.round(a.y + (b.y - a.y) * t);
|
||||
if (!inside(x, y) || sea[indexOf(x, y)]) return [];
|
||||
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
|
||||
}
|
||||
return path.length >= 2 ? path : [];
|
||||
}
|
||||
const endpoints = [];
|
||||
for (const group of ordinaryGroups) {
|
||||
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||
const path = group.paths[pathIdx];
|
||||
if (!path || path.length < 2) continue;
|
||||
for (const end of [0, 1]) {
|
||||
const raw = end === 0 ? path[0] : path[path.length - 1];
|
||||
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
||||
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
|
||||
const ci = indexOf(x, y);
|
||||
const ruralBias = Math.max(0, 0.42 - (populationDensity?.[ci] || 0));
|
||||
endpoints.push({ group: group.key, pathIdx, end, x, y, comp: endpointComponent(x, y), ruralBias });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pairs = [];
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const a = endpoints[i];
|
||||
if (a.comp < 0) continue;
|
||||
for (let j = i + 1; j < endpoints.length; j++) {
|
||||
const b = endpoints[j];
|
||||
if (b.comp < 0 || a.comp === b.comp) continue;
|
||||
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const limit = (a.ruralBias + b.ruralBias) > 0.38 ? 6.5 : 4.4;
|
||||
if (d < 1.1 || d > limit) continue;
|
||||
const path = directConnector(a, b);
|
||||
if (path.length < 2 || path.length > 9) continue;
|
||||
pairs.push({ a, b, d, path, score: d - (a.ruralBias + b.ruralBias) * 1.25 + (a.group === "minor" && b.group === "minor" ? 0.25 : 0) });
|
||||
}
|
||||
}
|
||||
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
|
||||
const used = new Set();
|
||||
let added = 0;
|
||||
for (const pair of pairs) {
|
||||
if (added >= 180) break;
|
||||
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
|
||||
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
|
||||
if (used.has(ak) || used.has(bk)) continue;
|
||||
minorRoads.push(pair.path);
|
||||
used.add(ak); used.add(bk);
|
||||
added++;
|
||||
}
|
||||
debugLayers.nearbyRoadEndpointConnectorsAdded = added;
|
||||
return added;
|
||||
}
|
||||
|
||||
connectNearbyRoadEndpoints();
|
||||
|
||||
function renameInterchangesFromMunicipalities() {
|
||||
if (!interchanges?.length || !adminCenters?.length || !adminId) return 0;
|
||||
const centerByAdmin = new Map();
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const id = adminId[indexOf(center.x, center.y)];
|
||||
if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center);
|
||||
}
|
||||
const allCenters = [...centerByAdmin.values()].filter((c) => c?.name);
|
||||
const used = new Set();
|
||||
const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"];
|
||||
let renamed = 0;
|
||||
function cleanBase(name) {
|
||||
return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, "");
|
||||
}
|
||||
for (const [idx, ic] of interchanges.entries()) {
|
||||
if (!ic || !inside(ic.x, ic.y)) continue;
|
||||
const cell = indexOf(ic.x, ic.y);
|
||||
const admin = adminId[cell];
|
||||
const primary = centerByAdmin.get(admin) || allCenters.slice().sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y))[0];
|
||||
const nearbyCenters = allCenters
|
||||
.slice()
|
||||
.sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y));
|
||||
const candidates = [];
|
||||
if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`);
|
||||
for (const center of nearbyCenters.slice(0, 12)) {
|
||||
const base = cleanBase(center.municipalityName || center.name);
|
||||
if (base) candidates.push(`${base}IC`);
|
||||
}
|
||||
if (primary?.name) {
|
||||
const base = cleanBase(primary.municipalityName || primary.name);
|
||||
for (const dir of directionNames) candidates.push(`${base}${dir}IC`);
|
||||
}
|
||||
candidates.push(`自治${idx + 1}IC`);
|
||||
let name = candidates.find((candidate) => candidate && !used.has(candidate));
|
||||
if (!name) name = `自治${idx + 1}IC`;
|
||||
ic.name = name;
|
||||
ic.labelName = name;
|
||||
ic.municipalityNameBased = true;
|
||||
used.add(name);
|
||||
renamed++;
|
||||
}
|
||||
if (transportDebug) {
|
||||
transportDebug.layers ||= {};
|
||||
transportDebug.layers.municipalityBasedInterchangeNames = renamed;
|
||||
}
|
||||
return renamed;
|
||||
}
|
||||
renameInterchangesFromMunicipalities();
|
||||
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
||||
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
||||
|
|
@ -845,6 +1257,9 @@ export function finishMapOutput({
|
|||
return applyOutputOptions({
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
originX: Number.isFinite(terrain?.originX) ? terrain.originX : (Number.isFinite(options?.originX) ? options.originX : 0),
|
||||
originY: Number.isFinite(terrain?.originY) ? terrain.originY : (Number.isFinite(options?.originY) ? options.originY : 0),
|
||||
generationContext: options?.generationContext || terrain?.generationContext || null,
|
||||
cellSize: CELL_SIZE,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
|
|
@ -852,7 +1267,7 @@ export function finishMapOutput({
|
|||
humanRegionMask,
|
||||
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
|
||||
prefectureRegionId,
|
||||
municipalityToPrefectureId,
|
||||
municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
|
||||
prefectureRegions,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
|
|
|
|||
2272
mapPatch.js
Normal file
2272
mapPatch.js
Normal file
File diff suppressed because it is too large
Load diff
129
mapPipeline.js
129
mapPipeline.js
|
|
@ -8,6 +8,93 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
|
|||
|
||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
|
||||
function normalizeRectLike(rect) {
|
||||
if (!rect) return null;
|
||||
const x0 = Math.floor(Math.min(Number(rect.x0), Number(rect.x1)));
|
||||
const y0 = Math.floor(Math.min(Number(rect.y0), Number(rect.y1)));
|
||||
const x1 = Math.ceil(Math.max(Number(rect.x0), Number(rect.x1)));
|
||||
const y1 = Math.ceil(Math.max(Number(rect.y0), Number(rect.y1)));
|
||||
if (![x0, y0, x1, y1].every(Number.isFinite)) return null;
|
||||
return { x0, y0, x1, y1 };
|
||||
}
|
||||
|
||||
function normalizeGenerationContext(options = {}) {
|
||||
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
|
||||
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
|
||||
const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : MAP_W));
|
||||
const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : MAP_H));
|
||||
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : 0)) >>> 0;
|
||||
const contextRect = normalizeRectLike(options.contextRect);
|
||||
return {
|
||||
worldNative: options.worldNative === true,
|
||||
legacyTerrain: options.legacyTerrain !== false,
|
||||
originX,
|
||||
originY,
|
||||
width,
|
||||
height,
|
||||
variant,
|
||||
contextRect,
|
||||
hasBoundaryWorld: !!options.boundaryWorld,
|
||||
};
|
||||
}
|
||||
|
||||
function mixUint(h, value) {
|
||||
h = Math.imul((h ^ (value >>> 0)) >>> 0, 2246822519) >>> 0;
|
||||
h ^= h >>> 13;
|
||||
return Math.imul(h, 3266489917) >>> 0;
|
||||
}
|
||||
|
||||
function mixString(h, value) {
|
||||
const text = String(value ?? "");
|
||||
for (let i = 0; i < text.length; i++) h = mixUint(h, text.charCodeAt(i));
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function contextualSeed(seed, context, options = {}) {
|
||||
let h = seed >>> 0;
|
||||
if (!context.worldNative && !context.variant && !context.originX && !context.originY) return h;
|
||||
h = mixString(h, options.terrainType || options.generationType || "auto");
|
||||
h = mixUint(h, context.variant);
|
||||
h = mixUint(h, context.originX | 0);
|
||||
h = mixUint(h, context.originY | 0);
|
||||
h = mixUint(h, context.width);
|
||||
h = mixUint(h, context.height);
|
||||
if (context.contextRect) {
|
||||
h = mixUint(h, context.contextRect.x0 | 0);
|
||||
h = mixUint(h, context.contextRect.y0 | 0);
|
||||
h = mixUint(h, context.contextRect.x1 | 0);
|
||||
h = mixUint(h, context.contextRect.y1 | 0);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function makeRuntimeOptions(options, baseSeed) {
|
||||
const generationContext = normalizeGenerationContext(options);
|
||||
const effectiveSeed = contextualSeed(baseSeed, generationContext, options);
|
||||
return {
|
||||
...options,
|
||||
generationContext,
|
||||
baseSeed,
|
||||
effectiveSeed,
|
||||
originX: generationContext.originX,
|
||||
originY: generationContext.originY,
|
||||
width: generationContext.width,
|
||||
height: generationContext.height,
|
||||
variant: generationContext.variant,
|
||||
};
|
||||
}
|
||||
|
||||
function generateInitialTerrain(seed, options = {}) {
|
||||
// Production generation is intentionally pinned to the legacy high-detail
|
||||
// terrain/natural-compartment pipeline. Rect-native terrain helpers may remain
|
||||
// in the codebase for experiments, but they are not reachable from generateMap.
|
||||
return generateTerrainAndRivers(seed, options);
|
||||
}
|
||||
|
||||
function terrainStageLabel() {
|
||||
return "Terrain, rivers, and natural compartments";
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
}
|
||||
|
|
@ -45,13 +132,15 @@ async function timedStageAsync(timings, options, key, label, fn) {
|
|||
|
||||
|
||||
export function generateMap(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
const baseSeed = Number(seedInput) >>> 0;
|
||||
options = makeRuntimeOptions(options, baseSeed);
|
||||
const seed = options.effectiveSeed;
|
||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||
|
||||
const generationTimings = [];
|
||||
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
||||
|
||||
const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
||||
const terrain = stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
|
||||
const {
|
||||
elevation,
|
||||
slope,
|
||||
|
|
@ -71,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
||||
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||
|
||||
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
||||
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||
const {
|
||||
settlementScore,
|
||||
villages,
|
||||
|
|
@ -94,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
villageInfluence,
|
||||
} = features;
|
||||
|
||||
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
||||
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||
|
||||
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
|
|
@ -112,7 +201,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
}),
|
||||
}));
|
||||
|
||||
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
|
||||
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
|
||||
|
||||
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||
seed,
|
||||
|
|
@ -124,17 +213,22 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
}));
|
||||
output.generationTimings = generationTimings;
|
||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||
output.baseSeed = options.baseSeed;
|
||||
output.effectiveSeed = seed;
|
||||
output.generationContext = { ...options.generationContext };
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
const baseSeed = Number(seedInput) >>> 0;
|
||||
options = makeRuntimeOptions(options, baseSeed);
|
||||
const seed = options.effectiveSeed;
|
||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||
|
||||
const generationTimings = [];
|
||||
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
|
||||
|
||||
const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
||||
const terrain = await stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
|
||||
const {
|
||||
elevation,
|
||||
slope,
|
||||
|
|
@ -154,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
||||
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||
|
||||
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
||||
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||
const {
|
||||
settlementScore,
|
||||
villages,
|
||||
|
|
@ -177,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
villageInfluence,
|
||||
} = features;
|
||||
|
||||
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
||||
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||
|
||||
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
|
|
@ -195,7 +289,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
}),
|
||||
}));
|
||||
|
||||
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography }));
|
||||
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
|
||||
|
||||
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||
seed,
|
||||
|
|
@ -207,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
}));
|
||||
output.generationTimings = generationTimings;
|
||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||
output.baseSeed = options.baseSeed;
|
||||
output.effectiveSeed = seed;
|
||||
output.generationContext = { ...options.generationContext };
|
||||
return output;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
|
||||
import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js";
|
||||
import { pathLengthCells } from "./mapTransport.js";
|
||||
|
||||
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
|
||||
|
|
@ -19,15 +19,38 @@ function pathTerrainRuns(path, terrain = null) {
|
|||
const ridgeField = terrain?.ridgeField;
|
||||
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
||||
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
||||
for (const [x, y] of path || []) {
|
||||
if (!inside(x, y)) continue;
|
||||
let sampled = 0;
|
||||
const visit = (x, y) => {
|
||||
if (!inside(x, y)) {
|
||||
seaRun++;
|
||||
maxSeaRun = Math.max(maxSeaRun, seaRun);
|
||||
tunnelRun = 0;
|
||||
sampled++;
|
||||
return;
|
||||
}
|
||||
const i = indexOf(x, y);
|
||||
const isSea = Boolean(sea?.[i]);
|
||||
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
|
||||
// Use the same sensitive tunnel proxy as the main transport validator.
|
||||
// Sampling every raster cell along each segment prevents smoothed or direct
|
||||
// paths from hiding over-limit tunnel runs between sparse vertices.
|
||||
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72);
|
||||
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
|
||||
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
|
||||
sampled++;
|
||||
};
|
||||
for (let k = 1; k < (path?.length || 0); k++) {
|
||||
const a = path[k - 1];
|
||||
const b = path[k];
|
||||
if (!a || !b) continue;
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
if (k > 1 && s === 0) continue;
|
||||
const t = s / steps;
|
||||
visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
||||
}
|
||||
}
|
||||
return { maxSeaRun, maxTunnelRun };
|
||||
if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]);
|
||||
return { maxSeaRun, maxTunnelRun, sampled };
|
||||
}
|
||||
|
||||
function directPath(a, b, options = {}) {
|
||||
|
|
@ -50,6 +73,70 @@ function directPath(a, b, options = {}) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function routeTerrainPath(a, b, terrain = null, options = {}) {
|
||||
if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return [];
|
||||
const sea = terrain?.sea;
|
||||
const elevation = terrain?.elevation;
|
||||
const slope = terrain?.slope;
|
||||
const ridgeField = terrain?.ridgeField;
|
||||
const start = indexOf(Math.round(a.x), Math.round(a.y));
|
||||
const goal = indexOf(Math.round(b.x), Math.round(b.y));
|
||||
if (sea?.[start] || sea?.[goal]) return [];
|
||||
const straight = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const maxLength = options.maxLength ?? straight * 2.8 + 60;
|
||||
const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5)));
|
||||
const dist = new Float64Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const prev = new Int32Array(SIZE);
|
||||
prev.fill(-1);
|
||||
const closed = new Uint8Array(SIZE);
|
||||
const heap = new MinHeap();
|
||||
dist[start] = 0;
|
||||
prev[start] = start;
|
||||
heap.push({ i: start, f: straight * 0.42 });
|
||||
let hit = -1;
|
||||
let expanded = 0;
|
||||
while (heap.length && expanded++ < maxExpanded) {
|
||||
const current = heap.pop();
|
||||
if (!current || closed[current.i]) continue;
|
||||
const cur = current.i;
|
||||
closed[cur] = 1;
|
||||
const [x, y] = xyOf(cur);
|
||||
if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; }
|
||||
if (Math.hypot(x - a.x, y - a.y) > maxLength) continue;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (closed[ni] || sea?.[ni]) continue;
|
||||
const step = Math.hypot(dx, dy);
|
||||
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05;
|
||||
const nd = dist[cur] + step * Math.max(0.42, terrainCost);
|
||||
if (nd >= dist[ni]) continue;
|
||||
dist[ni] = nd;
|
||||
prev[ni] = cur;
|
||||
const h = Math.hypot(nx - b.x, ny - b.y) * 0.42;
|
||||
heap.push({ i: ni, f: nd + h });
|
||||
}
|
||||
}
|
||||
if (hit < 0) return [];
|
||||
const path = [];
|
||||
let cur = hit;
|
||||
for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) {
|
||||
const [x, y] = xyOf(cur);
|
||||
path.push([x, y]);
|
||||
if (prev[cur] === cur) break;
|
||||
cur = prev[cur];
|
||||
}
|
||||
path.reverse();
|
||||
if (path.length < 2 || pathLengthCells(path) > maxLength) return [];
|
||||
const runs = pathTerrainRuns(path, terrain);
|
||||
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
|
||||
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
|
||||
return path;
|
||||
}
|
||||
|
||||
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
||||
let best = null;
|
||||
for (const path of paths || []) {
|
||||
|
|
@ -155,7 +242,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
|||
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
||||
];
|
||||
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 };
|
||||
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 };
|
||||
|
||||
// Local roads after admin: every municipal office cell should lie on a road.
|
||||
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
||||
|
|
@ -194,6 +281,44 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
|||
function townWeight(p) {
|
||||
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
|
||||
}
|
||||
|
||||
function relayGeometryAcceptable(points, options = {}) {
|
||||
const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||
if (pts.length < 3) return true;
|
||||
const first = pts[0];
|
||||
const last = pts[pts.length - 1];
|
||||
const vx = last.x - first.x;
|
||||
const vy = last.y - first.y;
|
||||
const direct = Math.hypot(vx, vy);
|
||||
if (direct < 0.001) return false;
|
||||
let via = 0;
|
||||
for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
||||
const maxDetour = options.maxDetour ?? 1.68;
|
||||
if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false;
|
||||
const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32)));
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const p = pts[i];
|
||||
const wx = p.x - first.x;
|
||||
const wy = p.y - first.y;
|
||||
const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct);
|
||||
const projX = first.x + vx * t;
|
||||
const projY = first.y + vy * t;
|
||||
const offset = Math.hypot(p.x - projX, p.y - projY);
|
||||
if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false;
|
||||
if (offset > maxOffset) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function pathGeometryAcceptable(path, options = {}) {
|
||||
if (!path || path.length < 3) return true;
|
||||
const step = Math.max(1, Math.floor(path.length / 10));
|
||||
const pts = [];
|
||||
for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] });
|
||||
const last = path[path.length - 1];
|
||||
pts.push({ x: last[0], y: last[1] });
|
||||
return relayGeometryAcceptable(pts, options);
|
||||
}
|
||||
function nearestTrunkOrHub(p, maxDistance = 85) {
|
||||
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
|
||||
if (trunk) return trunk;
|
||||
|
|
@ -225,30 +350,41 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
|||
let townsCovered = 0;
|
||||
while (uncovered.length) {
|
||||
const start = uncovered.shift();
|
||||
const chain = buildTownChain(start, uncovered, 7);
|
||||
let chain = buildTownChain(start, uncovered, 7);
|
||||
uncovered = uncovered.filter((town) => !chain.includes(town));
|
||||
const parts = [];
|
||||
const before = nearestTrunkOrHub(chain[0], 80);
|
||||
let before = nearestTrunkOrHub(chain[0], 80);
|
||||
let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
||||
const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]];
|
||||
if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) {
|
||||
// The town-chain pass is a coverage fallback, not a mandate to drag a
|
||||
// road through a remote off-axis waypoint. Collapse to a single spur
|
||||
// when the waypoint chain would create a hooked or S-shaped route.
|
||||
chain = [chain[0]];
|
||||
before = nearestTrunkOrHub(chain[0], 80);
|
||||
after = null;
|
||||
}
|
||||
if (before) {
|
||||
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
for (let i = 1; i < chain.length; i++) {
|
||||
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
|
||||
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
const segmentPoints = [chain[i - 1], chain[i]];
|
||||
if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue;
|
||||
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
||||
if (after) {
|
||||
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
let path = concatPaths(parts);
|
||||
if (path.length < 2) {
|
||||
const target = nearestTrunkOrHub(chain[0], 90);
|
||||
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : [];
|
||||
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
|
||||
}
|
||||
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
||||
if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
||||
nationalRoads.push(path);
|
||||
chainsAdded++;
|
||||
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
|
||||
|
|
@ -261,27 +397,384 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge
|
|||
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
||||
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
||||
|
||||
function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; }
|
||||
function expresswayCityComponents(cities, radius = 8.0) {
|
||||
const parent = new Map();
|
||||
function find(k) {
|
||||
const p = parent.get(k);
|
||||
if (p === k) return k;
|
||||
const r = find(p);
|
||||
parent.set(k, r);
|
||||
return r;
|
||||
}
|
||||
function unite(a, b) {
|
||||
const ra = find(a), rb = find(b);
|
||||
if (ra !== rb) parent.set(ra, rb);
|
||||
}
|
||||
for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city));
|
||||
for (const path of [...expressways, ...externalExpressways]) {
|
||||
const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius));
|
||||
if (near.length >= 2) {
|
||||
const first = majorCityKey(near[0]);
|
||||
for (const city of near.slice(1)) unite(first, majorCityKey(city));
|
||||
}
|
||||
}
|
||||
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
|
||||
}
|
||||
|
||||
function suburbanExpresswayAnchorForCity(city, target = null) {
|
||||
if (!city || !inside(city.x, city.y)) return null;
|
||||
const sea = terrain?.sea;
|
||||
const elevation = terrain?.elevation;
|
||||
const slope = terrain?.slope;
|
||||
const ridgeField = terrain?.ridgeField;
|
||||
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6));
|
||||
const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9));
|
||||
let best = null;
|
||||
for (let dy = -outer; dy <= outer; dy++) {
|
||||
for (let dx = -outer; dx <= outer; dx++) {
|
||||
const x = city.x + dx, y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < inner || d > outer) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea?.[i]) continue;
|
||||
const radial = Math.abs(d - (inner + outer) * 0.52);
|
||||
const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0;
|
||||
const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75;
|
||||
if (!best || score > best.score) best = { x, y, score };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function suburbanExpresswayStubForCity(city, preferredAnchor = null) {
|
||||
if (!city || !inside(city.x, city.y)) return [];
|
||||
const angles = [];
|
||||
if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x));
|
||||
for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0));
|
||||
const seenAngles = new Set();
|
||||
for (const angle of angles) {
|
||||
const bucket = Math.round(angle * 100) / 100;
|
||||
if (seenAngles.has(bucket)) continue;
|
||||
seenAngles.add(bucket);
|
||||
const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) };
|
||||
const anchor = suburbanExpresswayAnchorForCity(city, hint);
|
||||
if (!anchor) continue;
|
||||
const minD = Math.max(20, (city.urbanRadius || 12) * 1.35);
|
||||
const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65);
|
||||
let bestEnd = null;
|
||||
for (let d = minD; d <= maxD; d += 2) {
|
||||
const x = Math.round(city.x + Math.cos(angle) * d);
|
||||
const y = Math.round(city.y + Math.sin(angle) * d);
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (terrain?.sea?.[i]) continue;
|
||||
bestEnd = { x, y };
|
||||
}
|
||||
if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue;
|
||||
const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y);
|
||||
let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 });
|
||||
if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 });
|
||||
if (path.length >= 4) return path;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function expresswayServesCityFringe(city) {
|
||||
const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5);
|
||||
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0);
|
||||
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
||||
let inBand = false;
|
||||
let exits = false;
|
||||
for (const [x, y] of path || []) {
|
||||
const d = Math.hypot(x - city.x, y - city.y);
|
||||
if (d >= inner && d <= outer) inBand = true;
|
||||
if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true;
|
||||
if (inBand && exits) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
|
||||
const cities = (features.modernCities || [])
|
||||
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
||||
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||
const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 };
|
||||
features.expressways ||= [];
|
||||
if (!cities.length) return result;
|
||||
for (const city of cities) {
|
||||
if (expresswayServesCityFringe(city)) { result.covered++; continue; }
|
||||
const existing = [...(features.expressways || []), ...(features.externalExpressways || [])];
|
||||
let target = nearestPointOnPaths(existing, city, 145);
|
||||
if (!target) {
|
||||
const other = cities.find((c) => c !== city && expresswayServesCityFringe(c));
|
||||
target = other ? suburbanExpresswayAnchorForCity(other, city) : null;
|
||||
}
|
||||
if (!target) { result.noTarget++; continue; }
|
||||
const anchor = suburbanExpresswayAnchorForCity(city, target);
|
||||
if (!anchor) { result.noTarget++; continue; }
|
||||
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
||||
if (d < 4) { result.covered++; continue; }
|
||||
let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||
if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||
if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
||||
if (!path.length) {
|
||||
const cityTargets = cities
|
||||
.filter((other) => other !== city)
|
||||
.map((other) => {
|
||||
const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor);
|
||||
return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((row) => row.d >= 16 && row.d <= 185)
|
||||
.sort((a, b) => a.d - b.d);
|
||||
for (const row of cityTargets.slice(0, 6)) {
|
||||
let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||
if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 });
|
||||
if (candidate.length >= 4) { path = candidate; break; }
|
||||
}
|
||||
}
|
||||
if (!path.length) path = suburbanExpresswayStubForCity(city, anchor);
|
||||
if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; }
|
||||
features.expressways.push(smoothPath(path, 1));
|
||||
result.added++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cityRailChain(minPopulation = 50000) {
|
||||
const cities = (features.modernCities || [])
|
||||
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
|
||||
.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 };
|
||||
if (cities.length < 2) return result;
|
||||
const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
|
||||
const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2));
|
||||
if (!uncovered.length) return result;
|
||||
const remaining = uncovered.slice();
|
||||
let chain = [remaining.shift()];
|
||||
while (remaining.length) {
|
||||
const cur = chain[chain.length - 1];
|
||||
let bestIndex = 0;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y);
|
||||
if (d < bestD) { bestD = d; bestIndex = i; }
|
||||
}
|
||||
chain.push(remaining.splice(bestIndex, 1)[0]);
|
||||
}
|
||||
const parts = [];
|
||||
for (let i = 1; i < chain.length; i++) {
|
||||
const a = chain[i - 1], b = chain[i];
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
const path = concatPaths(parts);
|
||||
if (path.length >= 2) {
|
||||
features.railways = features.railways || [];
|
||||
features.railways.push(smoothPath(path, 1));
|
||||
result.chainsAdded = 1;
|
||||
result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const railDebug = cityRailChain(50000);
|
||||
debug.railCityChainsAdded = railDebug.chainsAdded;
|
||||
debug.railCityChainCitiesCovered = railDebug.citiesCovered;
|
||||
|
||||
const expressDebug = ensureMajorCityExpresswayLinks(100000);
|
||||
debug.expresswayMajorCityLinksAdded = expressDebug.added;
|
||||
debug.expresswayMajorCityLinksCovered = expressDebug.covered;
|
||||
debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget;
|
||||
debug.expresswayMajorCityLinksNoPath = expressDebug.noPath;
|
||||
|
||||
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
|
||||
for (let i = 0; i < expressways.length; i++) {
|
||||
const smoothed = smoothPath(expressways[i], 2);
|
||||
if (smoothed.length >= 2) {
|
||||
expressways[i] = smoothed;
|
||||
debug.expresswaysSmoothed++;
|
||||
const runs = pathTerrainRuns(smoothed, terrain);
|
||||
if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) {
|
||||
expressways[i] = smoothed;
|
||||
debug.expresswaysSmoothed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const path of [...expressways, ...externalExpressways]) {
|
||||
if (!path || path.length < 2) continue;
|
||||
const a = path[0];
|
||||
const b = path[path.length - 1];
|
||||
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
|
||||
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
|
||||
const expresswayBeforeTerrainPrune = expressways.length;
|
||||
for (let i = expressways.length - 1; i >= 0; i--) {
|
||||
const runs = pathTerrainRuns(expressways[i], terrain);
|
||||
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1);
|
||||
}
|
||||
debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length;
|
||||
|
||||
function pointInsideCityNodeBuffer(x, y) {
|
||||
for (const city of features.modernCities || []) {
|
||||
if (!city || (city.population || 0) < 25000) continue;
|
||||
const r = Math.max(4.2, (city.coreRadius || 3) + 1.6);
|
||||
if (Math.hypot(x - city.x, y - city.y) <= r) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function splitExpresswayAwayFromCityNodes(path) {
|
||||
const chunks = [];
|
||||
let cur = [];
|
||||
for (const [x, y] of path || []) {
|
||||
if (pointInsideCityNodeBuffer(x, y)) {
|
||||
if (cur.length >= 2) chunks.push(cur);
|
||||
cur = [];
|
||||
continue;
|
||||
}
|
||||
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
|
||||
}
|
||||
if (cur.length >= 2) chunks.push(cur);
|
||||
return chunks.filter((chunk) => pathLengthCells(chunk) >= 12);
|
||||
}
|
||||
const expresswayBeforeCityNodePrune = expressways.length;
|
||||
const separatedExpressways = [];
|
||||
for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path));
|
||||
expressways.length = 0;
|
||||
expressways.push(...dedupePaths(separatedExpressways, 2));
|
||||
debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length;
|
||||
|
||||
function connectNearbyExpresswayTermini() {
|
||||
const result = { candidates: 0, added: 0, failed: 0 };
|
||||
const expressGroups = [
|
||||
{ key: "expressway", paths: expressways },
|
||||
{ key: "externalExpressway", paths: externalExpressways },
|
||||
];
|
||||
const endpoints = [];
|
||||
for (const group of expressGroups) {
|
||||
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||
const path = group.paths[pathIdx];
|
||||
if (!path || path.length < 2) continue;
|
||||
for (const end of [0, 1]) {
|
||||
const raw = end === 0 ? path[0] : path[path.length - 1];
|
||||
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
||||
if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue;
|
||||
endpoints.push({ group: group.key, pathIdx, end, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pairs = [];
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const a = endpoints[i];
|
||||
for (let j = i + 1; j < endpoints.length; j++) {
|
||||
const b = endpoints[j];
|
||||
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (d < 3.0 || d > 24.0) continue;
|
||||
pairs.push({ a, b, d, kind: "terminus-terminus" });
|
||||
}
|
||||
}
|
||||
// Also snap a dead-end to the side of a nearby expressway if no terminal is
|
||||
// close enough. This removes visible half-built expressway stubs without
|
||||
// requiring every segment to be merged into a single polyline.
|
||||
for (const a of endpoints) {
|
||||
let best = null;
|
||||
for (const group of expressGroups) {
|
||||
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
|
||||
if (a.group === group.key && a.pathIdx === pathIdx) continue;
|
||||
const path = group.paths[pathIdx];
|
||||
for (let k = 1; k < (path?.length || 0) - 1; k += 2) {
|
||||
const [x, y] = path[k];
|
||||
const d = Math.hypot(a.x - x, a.y - y);
|
||||
if (d < 3.0 || d > 14.0) continue;
|
||||
if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) pairs.push(best);
|
||||
}
|
||||
pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1));
|
||||
const used = new Set();
|
||||
for (const pair of pairs) {
|
||||
if (result.added >= 10) break;
|
||||
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
|
||||
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
|
||||
if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue;
|
||||
result.candidates++;
|
||||
let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 });
|
||||
if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 });
|
||||
if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; }
|
||||
const runs = pathTerrainRuns(path, terrain);
|
||||
if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; }
|
||||
expressways.push(smoothPath(path, 1));
|
||||
used.add(ak);
|
||||
if (pair.b.end >= 0) used.add(bk);
|
||||
result.added++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini();
|
||||
debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added;
|
||||
debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates;
|
||||
debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed;
|
||||
|
||||
function pointOnExpressway(p, radius = 1.5) {
|
||||
return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
|
||||
}
|
||||
const icBeforePrune = interchanges.length;
|
||||
const pairedInterchanges = [];
|
||||
const pairedAccessRoads = [];
|
||||
for (let i = 0; i < interchanges.length; i++) {
|
||||
const ic = interchanges[i];
|
||||
const access = (features.icAccessRoads || [])[i];
|
||||
if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) {
|
||||
pairedInterchanges.push(ic);
|
||||
pairedAccessRoads.push(access);
|
||||
}
|
||||
}
|
||||
interchanges.length = 0;
|
||||
interchanges.push(...pairedInterchanges);
|
||||
features.icAccessRoads = pairedAccessRoads;
|
||||
debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length;
|
||||
|
||||
function ensureTerminalInterchangesWithAccess() {
|
||||
const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 };
|
||||
features.icAccessRoads ||= [];
|
||||
const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])];
|
||||
for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) {
|
||||
if (!path || path.length < 2) continue;
|
||||
for (const raw of [path[0], path[path.length - 1]]) {
|
||||
const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) };
|
||||
result.endpointsChecked++;
|
||||
if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue;
|
||||
if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue;
|
||||
const hit = nearestPointOnPaths(ordinaryRoads, p, 58);
|
||||
let access = [];
|
||||
if (hit) {
|
||||
const d = Math.hypot(p.x - hit.x, p.y - hit.y);
|
||||
access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 });
|
||||
if (access.length >= 2) {
|
||||
features.icAccessRoads.push(access);
|
||||
features.minorRoads ||= [];
|
||||
features.minorRoads.push(access);
|
||||
result.accessAdded++;
|
||||
}
|
||||
}
|
||||
addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access");
|
||||
result.added++;
|
||||
if (access.length < 2) result.withoutAccess++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const terminalIcDebug = ensureTerminalInterchangesWithAccess();
|
||||
debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added;
|
||||
debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded;
|
||||
debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess;
|
||||
|
||||
features.minorRoads = dedupePaths(minorRoads, 2);
|
||||
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
||||
features.externalRoads = dedupePaths(externalRoads, 1);
|
||||
features.expressways = dedupePaths(expressways, 2);
|
||||
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
||||
features.railways = dedupePaths(features.railways || [], 2);
|
||||
features.interchanges = interchanges;
|
||||
|
||||
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
|
||||
|
|
|
|||
|
|
@ -748,6 +748,98 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo
|
|||
return changed;
|
||||
}
|
||||
|
||||
|
||||
export function relaxHighBoundaryShareMunicipalities(nodes, owner, options = {}) {
|
||||
const threshold = options.threshold ?? 0.50;
|
||||
const maxPasses = options.maxPasses ?? 5;
|
||||
const minSharedToTarget = options.minSharedToTarget ?? 2;
|
||||
let changed = 0;
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
let passChanged = 0;
|
||||
const counts = prefectureMunicipalityCounts(owner);
|
||||
const candidates = [];
|
||||
for (const [id, pref] of owner) {
|
||||
if (pref === undefined || pref < 0) continue;
|
||||
const node = nodes.get(id);
|
||||
if (!node || !node.adjacent?.size) continue;
|
||||
if ((node.cityPopulation || 0) >= 180000 || (node.majorCityCount || 0) > 0) continue;
|
||||
let totalBoundary = 0;
|
||||
let sameBoundary = 0;
|
||||
const byPref = new Map();
|
||||
for (const [nextId, edge] of node.adjacent) {
|
||||
const nPref = owner.get(nextId);
|
||||
if (nPref === undefined || nPref < 0) continue;
|
||||
const w = Math.max(1, edge.count || 1);
|
||||
totalBoundary += w;
|
||||
if (nPref === pref) sameBoundary += w;
|
||||
else {
|
||||
const row = byPref.get(nPref) || { pref: nPref, shared: 0, score: 0, minCrossing: INF };
|
||||
row.shared += w;
|
||||
row.score += w * 2.8 - (edge.crossingCost ?? (1 + (edge.barrier || 0) * 8.0)) * 0.55;
|
||||
row.minCrossing = Math.min(row.minCrossing, edge.crossingCost ?? 1);
|
||||
byPref.set(nPref, row);
|
||||
}
|
||||
}
|
||||
if (totalBoundary <= 0) continue;
|
||||
const borderBoundary = totalBoundary - sameBoundary;
|
||||
const borderShare = borderBoundary / totalBoundary;
|
||||
if (borderShare < threshold || !byPref.size) continue;
|
||||
if ((counts.get(pref) || 0) <= Math.max(5, options.minSourceCount ?? 8)) continue;
|
||||
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
|
||||
const best = [...byPref.values()]
|
||||
.filter((row) => row.shared >= minSharedToTarget)
|
||||
.sort((a, b) => b.score - a.score || b.shared - a.shared || a.pref - b.pref)[0];
|
||||
if (!best) continue;
|
||||
const compactnessGain = best.shared - sameBoundary * 0.72 + borderShare * 6.0;
|
||||
if (compactnessGain < 1.2 && best.score < 1.0) continue;
|
||||
candidates.push({ id, from: pref, to: best.pref, borderShare, score: compactnessGain + best.score * 0.08 });
|
||||
}
|
||||
candidates.sort((a, b) => b.borderShare - a.borderShare || b.score - a.score || a.id - b.id);
|
||||
const touched = new Set();
|
||||
for (const cand of candidates) {
|
||||
if (touched.has(cand.id) || owner.get(cand.id) !== cand.from) continue;
|
||||
if (!wouldRemainConnectedAfterRemoval(nodes, owner, cand.id, cand.from)) continue;
|
||||
owner.set(cand.id, cand.to);
|
||||
touched.add(cand.id);
|
||||
passChanged++;
|
||||
}
|
||||
if (!passChanged) break;
|
||||
changed += passChanged;
|
||||
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) {
|
||||
let changed = 0;
|
||||
const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined));
|
||||
for (const seedNode of seeds || []) {
|
||||
if (!seedNode || !nodes.has(seedNode.id)) continue;
|
||||
const prefId = owner.get(seedNode.id);
|
||||
if (prefId === undefined || prefId < 0) continue;
|
||||
const neighbors = [...(nodes.get(seedNode.id)?.adjacent || [])]
|
||||
.map(([id, edge]) => ({ node: nodes.get(id), id, edge }))
|
||||
.filter((row) => row.node && owner.get(row.id) !== prefId && !seedIds.has(row.id))
|
||||
.sort((a, b) => (a.edge.crossingCost ?? 1) - (b.edge.crossingCost ?? 1) || Math.hypot(a.node.x - seedNode.x, a.node.y - seedNode.y) - Math.hypot(b.node.x - seedNode.x, b.node.y - seedNode.y));
|
||||
let taken = 0;
|
||||
for (const row of neighbors) {
|
||||
if (taken >= maxNeighbors) break;
|
||||
const donorPref = owner.get(row.id);
|
||||
if (donorPref === undefined || donorPref < 0 || donorPref === prefId) continue;
|
||||
if (!wouldRemainConnectedAfterRemoval(nodes, owner, row.id, donorPref)) continue;
|
||||
owner.set(row.id, prefId);
|
||||
changed++;
|
||||
taken++;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||
repairPrefectureMunicipalityEnclaves(nodes, owner, 6);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) {
|
||||
if (!field) return 0;
|
||||
let sum = 0;
|
||||
|
|
@ -803,6 +895,14 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
|
|||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7);
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
const changedForBoundaryShareRelaxation = relaxHighBoundaryShareMunicipalities(graph.nodes, owner, { threshold: 0.50, maxPasses: 6 });
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
|
||||
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
|
||||
municipalityToPrefectureId.fill(-1);
|
||||
|
|
@ -846,6 +946,8 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
|
|||
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
||||
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
||||
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
||||
prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
|
||||
prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0,
|
||||
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
|
||||
finalRegionalMunicipalityCountCap: 88,
|
||||
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
|
||||
|
|
|
|||
754
mapTerrain.js
754
mapTerrain.js
|
|
@ -5,6 +5,7 @@ import {
|
|||
neighbors8,
|
||||
} from "./mapGeneratorHelpers.js";
|
||||
import { buildNaturalCompartments } from "./adminRegions.js";
|
||||
import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, rectNeighbors8, rectQuantile } from "./rectContext.js";
|
||||
|
||||
const ASPECT = MAP_W / MAP_H;
|
||||
const SQRT2 = Math.SQRT2;
|
||||
|
|
@ -168,13 +169,13 @@ const TERRAIN_TYPES = [
|
|||
mountainOffsetRange: [0.47, 0.53],
|
||||
baseHeightRange: [0.56, 0.82],
|
||||
primaryLengthRange: [0.76, 0.96],
|
||||
primaryWidthRange: [0.13, 0.22],
|
||||
systemCountRange: [12, 16],
|
||||
beltCountRange: [3, 4],
|
||||
angleSpread: 0.14,
|
||||
crossSpread: 0.38,
|
||||
primaryWidthRange: [0.18, 0.30],
|
||||
systemCountRange: [14, 18],
|
||||
beltCountRange: [4, 5],
|
||||
angleSpread: 0.18,
|
||||
crossSpread: 0.58,
|
||||
lengthScale: 1.22,
|
||||
widthScale: 0.92,
|
||||
widthScale: 1.16,
|
||||
heightScale: 0.86,
|
||||
coastStrength: 0.90,
|
||||
plainBiasRange: [0.16, 0.34],
|
||||
|
|
@ -206,6 +207,31 @@ const TERRAIN_TYPES = [
|
|||
riverRichnessRange: [0.74, 1.14],
|
||||
bigRiverChanceRange: [0.30, 0.60],
|
||||
},
|
||||
{
|
||||
id: "oceanic_archipelago",
|
||||
label: "海洋型・多島海",
|
||||
weight: 0.16,
|
||||
coastStyle: "oceanic_archipelago",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.04, 0.28],
|
||||
seaRatioRange: [0.72, 0.90],
|
||||
twoSidedChance: 1.0,
|
||||
mountainOffsetRange: [0.25, 0.55],
|
||||
baseHeightRange: [0.30, 0.62],
|
||||
primaryLengthRange: [0.20, 0.52],
|
||||
primaryWidthRange: [0.08, 0.24],
|
||||
systemCountRange: [7, 14],
|
||||
beltCountRange: [2, 4],
|
||||
angleSpread: 0.70,
|
||||
crossSpread: 0.90,
|
||||
lengthScale: 0.78,
|
||||
widthScale: 0.82,
|
||||
heightScale: 0.58,
|
||||
coastStrength: 1.55,
|
||||
plainBiasRange: [0.12, 0.34],
|
||||
riverRichnessRange: [0.18, 0.52],
|
||||
bigRiverChanceRange: [0.02, 0.12],
|
||||
},
|
||||
{
|
||||
id: "setouchi_inland_sea",
|
||||
label: "瀬戸内型・内海多島",
|
||||
|
|
@ -283,7 +309,12 @@ const TERRAIN_TYPES = [
|
|||
},
|
||||
];
|
||||
|
||||
function pickTerrainType(seed) {
|
||||
function pickTerrainType(seed, requestedType = "auto") {
|
||||
if (requestedType && requestedType !== "auto") {
|
||||
const normalizedType = requestedType === "touhoku_spine" ? "tohoku_spine" : requestedType;
|
||||
const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType);
|
||||
if (selected) return selected;
|
||||
}
|
||||
// Terrain type selection is intentionally uniform. Individual terrain
|
||||
// templates still contain their own parameter ranges, but there is no
|
||||
// terrain-type appearance weighting.
|
||||
|
|
@ -299,8 +330,8 @@ function rangeInt(seed, salt, [lo, hi]) {
|
|||
return Math.round(lo + rand(seed, salt) * (hi - lo));
|
||||
}
|
||||
|
||||
export function buildTerrainTemplate(seed) {
|
||||
const terrainType = pickTerrainType(seed);
|
||||
export function buildTerrainTemplate(seed, options = {}) {
|
||||
const terrainType = pickTerrainType(seed, options.terrainType || options.generationType || "auto");
|
||||
const mountainMode = terrainType.mountainMode === "mixed"
|
||||
? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif")
|
||||
: terrainType.mountainMode;
|
||||
|
|
@ -579,7 +610,15 @@ function computeCoastLower(px, py, template, seed) {
|
|||
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
||||
let pressure = 0;
|
||||
|
||||
if (template.coastStyle === "inland_sea") {
|
||||
if (template.coastStyle === "oceanic_archipelago") {
|
||||
// 海洋型: 外洋を強く取り、島列・湾・水道が点在する低標高圧を作る。
|
||||
const radial = distNorm(px, py, 0.5, 0.5);
|
||||
const outerOcean = smoothstep((radial - 0.30 + wave * 1.15 + bay * 0.85) / 0.18) * 0.96;
|
||||
const diagonalChannel = smoothstep((0.13 - Math.abs(cross + wave * 0.82 + islandNoise * 0.70)) / 0.14) * 0.70;
|
||||
const openSide = smoothstep((-axis + 0.12 + wave + bay) / 0.23) * 0.82;
|
||||
const islandGaps = clamp((valueNoise(px * 780 + 31, py * 780 - 19, seed + 303, 20) - 0.42) * 1.25) * 0.22;
|
||||
pressure = clamp(Math.max(outerOcean, diagonalChannel, openSide) + islandGaps);
|
||||
} else if (template.coastStyle === "inland_sea") {
|
||||
// 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
|
||||
// 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
|
||||
// 連続した水道形状で海を増やす。
|
||||
|
|
@ -1132,7 +1171,656 @@ function enforceLandGradient(elevation, sea, seaLevel) {
|
|||
}
|
||||
}
|
||||
|
||||
export function generateTerrainAndRivers(seed) {
|
||||
function rectTerrainProfile(template) {
|
||||
const id = String(template?.terrainType || "auto");
|
||||
if (id.includes("oceanic")) return {
|
||||
base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28,
|
||||
seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86,
|
||||
};
|
||||
if (id.includes("setouchi") || id.includes("archipelago")) return {
|
||||
base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20,
|
||||
seaQuantile: Math.max(0.30, template.seaRatio ?? 0.36), plain: 0.34, moisture: 0.58, capStart: 0.72, capMax: 0.94,
|
||||
};
|
||||
if (id.includes("chubu") || id.includes("mountain")) return {
|
||||
base: 0.52, relief: 0.26, ridge: 0.62, ridgeWidth: 36, ridgeSpacing: 108, coast: 0.12, archipelago: 0.03,
|
||||
seaQuantile: Math.min(0.22, template.seaRatio ?? 0.18), plain: 0.15, moisture: 0.48, capStart: 0.90, capMax: 1.10,
|
||||
};
|
||||
if (id.includes("kanto") || id.includes("alluvial")) return {
|
||||
base: 0.48, relief: 0.12, ridge: 0.18, ridgeWidth: 42, ridgeSpacing: 130, coast: 0.16, archipelago: 0.04,
|
||||
seaQuantile: template.seaRatio ?? 0.13, plain: 0.66, moisture: 0.56, capStart: 0.84, capMax: 1.00,
|
||||
};
|
||||
if (id.includes("tohoku") || id.includes("spine")) return {
|
||||
base: 0.49, relief: 0.19, ridge: 0.46, ridgeWidth: 24, ridgeSpacing: 88, coast: 0.18, archipelago: 0.03,
|
||||
seaQuantile: template.seaRatio ?? 0.22, plain: 0.26, moisture: 0.52, capStart: 0.78, capMax: 0.98,
|
||||
};
|
||||
return {
|
||||
base: 0.45, relief: 0.18, ridge: 0.34, ridgeWidth: 32, ridgeSpacing: 100, coast: 0.18, archipelago: 0.06,
|
||||
seaQuantile: template.seaRatio ?? 0.20, plain: 0.30, moisture: 0.52, capStart: 0.86, capMax: 1.04,
|
||||
};
|
||||
}
|
||||
|
||||
function rectSeed(seed, variant, salt) {
|
||||
let h = (seed >>> 0) ^ Math.imul((variant || 0) >>> 0, 0x9e3779b9) ^ (salt >>> 0);
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x7feb352d) >>> 0;
|
||||
h ^= h >>> 15;
|
||||
h = Math.imul(h, 0x846ca68b) >>> 0;
|
||||
return (h ^ (h >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function periodicRidgeField(wx, wy, template, profile, seed) {
|
||||
const angle = template.mountainAngle || 0;
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
const u = wx * c + wy * s;
|
||||
const v = -wx * s + wy * c;
|
||||
const spacing = Math.max(18, profile.ridgeSpacing);
|
||||
const shifted = v / spacing + valueNoise(wx, wy, seed ^ 0x654f6d23, 115) * 0.70;
|
||||
const nearest = Math.abs((shifted - Math.round(shifted)) * spacing);
|
||||
const ridgeCore = Math.exp(-Math.pow(nearest / Math.max(4, profile.ridgeWidth), 2.0));
|
||||
const along = valueNoise(u, v, seed ^ 0x27d4eb2f, 86);
|
||||
const cut = valueNoise(u, v, seed ^ 0x165667b1, 31);
|
||||
return clamp(ridgeCore * (0.62 + along * 0.62) * (0.74 + cut * 0.40));
|
||||
}
|
||||
|
||||
function worldMarinePressure(wx, wy, template, profile, seed) {
|
||||
const angle = template.coastAngle || 0;
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
const axis = wx * c + wy * s;
|
||||
const cross = -wx * s + wy * c;
|
||||
const period = template.coastStyle === "oceanic_archipelago" ? 160 : template.coastStyle === "inland_sea" ? 220 : 300;
|
||||
const broad = Math.sin((axis + valueNoise(wx, wy, seed ^ 0xc2b2ae35, 190) * 90) / period * Math.PI * 2);
|
||||
const channel = Math.exp(-Math.pow((cross + (valueNoise(wx, wy, seed ^ 0x85ebca6b, 130) - 0.5) * 80) / (profile.ridgeSpacing * 0.85), 2.0));
|
||||
const radial = valueNoise(wx, wy, seed ^ 0x9e3779b9, 260);
|
||||
let pressure = clamp((broad * 0.5 + 0.5) * profile.coast + channel * profile.coast * 0.62 + radial * profile.coast * 0.52);
|
||||
if (template.coastStyle === "oceanic_archipelago") {
|
||||
const gap = clamp((fbm(wx * 0.75 + 33, wy * 0.75 - 17, seed ^ 0x3c6ef372) - 0.42) * 2.2);
|
||||
pressure = clamp(pressure + gap * profile.archipelago);
|
||||
}
|
||||
if (template.coastStyle === "open_bay") pressure = clamp(pressure + channel * 0.12);
|
||||
return pressure;
|
||||
}
|
||||
|
||||
function classifyRectWater(ctx, fields, seaLevel) {
|
||||
const { elevation, sea, ocean, lake } = fields;
|
||||
sea.fill(0); ocean.fill(0); lake.fill(0);
|
||||
const water = new Uint8Array(ctx.size);
|
||||
for (let i = 0; i < ctx.size; i++) water[i] = elevation[i] <= seaLevel ? 1 : 0;
|
||||
const seen = new Uint8Array(ctx.size);
|
||||
let oceanCells = 0;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (!water[i] || seen[i]) continue;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
let touchesEdge = false;
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const x = cur % ctx.width;
|
||||
const y = Math.floor(cur / ctx.width);
|
||||
if (x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) touchesEdge = true;
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
if (!water[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
const isOcean = touchesEdge || cells.length > Math.max(96, ctx.size * 0.018);
|
||||
if (isOcean || cells.length >= 20) {
|
||||
for (const ci of cells) {
|
||||
sea[ci] = 1;
|
||||
if (isOcean) ocean[ci] = 1;
|
||||
else lake[ci] = 1;
|
||||
}
|
||||
if (isOcean) oceanCells += cells.length;
|
||||
} else {
|
||||
for (const ci of cells) elevation[ci] = seaLevel + 0.012;
|
||||
}
|
||||
}
|
||||
return oceanCells;
|
||||
}
|
||||
|
||||
function recomputeRectSlope(ctx, fields) {
|
||||
const { elevation, sea, slope } = fields;
|
||||
slope.fill(0);
|
||||
for (let y = 1; y < ctx.height - 1; y++) {
|
||||
for (let x = 1; x < ctx.width - 1; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i]) continue;
|
||||
const gx = elevation[rectIndexOf(ctx, x + 1, y)] - elevation[rectIndexOf(ctx, x - 1, y)];
|
||||
const gy = elevation[rectIndexOf(ctx, x, y + 1)] - elevation[rectIndexOf(ctx, x, y - 1)];
|
||||
slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function priorityFloodRect(ctx, fields) {
|
||||
const { elevation, sea, flowTo } = fields;
|
||||
const filled = new Float32Array(elevation);
|
||||
const visited = new Uint8Array(ctx.size);
|
||||
const heap = new MinHeap();
|
||||
let seeds = 0;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
const x = i % ctx.width;
|
||||
const y = Math.floor(i / ctx.width);
|
||||
if (sea[i] || x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) {
|
||||
visited[i] = 1;
|
||||
heap.push({ i, f: filled[i] });
|
||||
seeds++;
|
||||
}
|
||||
}
|
||||
if (!seeds) return filled;
|
||||
while (heap.length) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > filled[cur.i] + 1e-5) continue;
|
||||
const x = cur.i % ctx.width;
|
||||
const y = Math.floor(cur.i / ctx.width);
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
if (visited[ni]) continue;
|
||||
visited[ni] = 1;
|
||||
if (filled[ni] < filled[cur.i] + 0.00002) filled[ni] = filled[cur.i] + 0.00002;
|
||||
heap.push({ i: ni, f: filled[ni] });
|
||||
}
|
||||
}
|
||||
flowTo.fill(-1);
|
||||
for (let y = 0; y < ctx.height; y++) {
|
||||
for (let x = 0; x < ctx.width; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i]) continue;
|
||||
let best = -1;
|
||||
let bestScore = filled[i];
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
const stepPenalty = (nx !== x && ny !== y) ? 0.000015 : 0;
|
||||
const score = filled[ni] + stepPenalty + hash2(ctx.originX + nx, ctx.originY + ny, 9000) * 0.000002;
|
||||
if (score < bestScore - 0.000001 || sea[ni]) {
|
||||
bestScore = score;
|
||||
best = ni;
|
||||
if (sea[ni]) break;
|
||||
}
|
||||
}
|
||||
flowTo[i] = best;
|
||||
}
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
function computeRectFlowAccumulation(ctx, fields, filled) {
|
||||
const { sea, flowTo, flowAccum } = fields;
|
||||
const area = new Float32Array(ctx.size);
|
||||
const order = [];
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (sea[i]) continue;
|
||||
area[i] = 1;
|
||||
order.push(i);
|
||||
}
|
||||
order.sort((a, b) => filled[b] - filled[a]);
|
||||
for (const i of order) {
|
||||
const to = flowTo[i];
|
||||
if (to >= 0 && !sea[to]) area[to] += area[i];
|
||||
}
|
||||
let maxArea = 1;
|
||||
for (let i = 0; i < ctx.size; i++) if (!sea[i]) maxArea = Math.max(maxArea, area[i]);
|
||||
for (let i = 0; i < ctx.size; i++) flowAccum[i] = sea[i] ? 0 : clamp(Math.pow(area[i] / maxArea, 0.42));
|
||||
}
|
||||
|
||||
function rectStableId(seed, wx, wy, salt) {
|
||||
const x = Math.floor(wx) | 0;
|
||||
const y = Math.floor(wy) | 0;
|
||||
let h = (seed >>> 0) ^ Math.imul(x, 0x9e3779b1) ^ Math.imul(y, 0x85ebca77) ^ (salt >>> 0);
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x7feb352d) >>> 0;
|
||||
h ^= h >>> 15;
|
||||
h = Math.imul(h, 0x846ca68b) >>> 0;
|
||||
return (h ^ (h >>> 16)) & 0x7fffffff;
|
||||
}
|
||||
|
||||
function traceRectSink(ctx, start, fields, maxSteps = 4096) {
|
||||
const { sea, flowTo } = fields;
|
||||
let i = start;
|
||||
let last = i;
|
||||
const seen = new Set();
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
if (i < 0 || i >= ctx.size || seen.has(i)) break;
|
||||
seen.add(i);
|
||||
last = i;
|
||||
if (sea[i]) break;
|
||||
const next = flowTo[i];
|
||||
if (next < 0 || next === i) break;
|
||||
i = next;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function buildRectWatershedId(ctx, fields, seed) {
|
||||
const { sea, flowAccum, watershedId } = fields;
|
||||
if (!watershedId) return { watershedCount: 0 };
|
||||
watershedId.fill(-1);
|
||||
const sinkToId = new Map();
|
||||
let watershedCount = 0;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (sea[i]) continue;
|
||||
const sink = traceRectSink(ctx, i, fields);
|
||||
const sx = sink % ctx.width;
|
||||
const sy = Math.floor(sink / ctx.width);
|
||||
const wx = ctx.originX + sx;
|
||||
const wy = ctx.originY + sy;
|
||||
const coarseX = Math.round(wx / 12);
|
||||
const coarseY = Math.round(wy / 12);
|
||||
const key = `${coarseX},${coarseY}`;
|
||||
let id = sinkToId.get(key);
|
||||
if (!Number.isFinite(id)) {
|
||||
id = 50000000 + rectStableId(seed, coarseX, coarseY, 0x51ed270b) % 40000000;
|
||||
sinkToId.set(key, id);
|
||||
watershedCount++;
|
||||
}
|
||||
watershedId[i] = id;
|
||||
}
|
||||
// Merge tiny or noisy drainage islands into their strongest neighbor.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const changes = [];
|
||||
for (let y = 1; y < ctx.height - 1; y++) {
|
||||
for (let x = 1; x < ctx.width - 1; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i] || watershedId[i] < 0) continue;
|
||||
const counts = new Map();
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
const id = watershedId[ni];
|
||||
if (id < 0) continue;
|
||||
counts.set(id, (counts.get(id) || 0) + 1 + (flowAccum[ni] || 0));
|
||||
}
|
||||
let best = watershedId[i];
|
||||
let bestScore = counts.get(best) || 0;
|
||||
for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; }
|
||||
if (best !== watershedId[i] && bestScore >= 5.5) changes.push([i, best]);
|
||||
}
|
||||
}
|
||||
for (const [i, id] of changes) watershedId[i] = id;
|
||||
if (!changes.length) break;
|
||||
}
|
||||
return { watershedCount };
|
||||
}
|
||||
|
||||
function buildRectNaturalRegions(ctx, fields, seed, template) {
|
||||
const { sea, ridgeField, valleyField, basinField, flowAccum, naturalBarrierScore, watershedId, naturalCompartmentId, regionId } = fields;
|
||||
if (!naturalCompartmentId || !regionId) return { naturalCompartmentCount: 0, regionCount: 0 };
|
||||
naturalCompartmentId.fill(-1);
|
||||
regionId.fill(-1);
|
||||
const type = String(template?.terrainType || "auto");
|
||||
const spacing = type.includes("oceanic") ? 30 : type.includes("kanto") ? 44 : type.includes("chubu") ? 34 : 38;
|
||||
const coarseSpacing = spacing * 2.55;
|
||||
const seeds = [];
|
||||
const gx0 = Math.floor((ctx.originX - spacing) / spacing) - 1;
|
||||
const gx1 = Math.ceil((ctx.originX + ctx.width + spacing) / spacing) + 1;
|
||||
const gy0 = Math.floor((ctx.originY - spacing) / spacing) - 1;
|
||||
const gy1 = Math.ceil((ctx.originY + ctx.height + spacing) / spacing) + 1;
|
||||
for (let gy = gy0; gy <= gy1; gy++) {
|
||||
for (let gx = gx0; gx <= gx1; gx++) {
|
||||
const jitterX = (hash2(gx, gy, seed ^ 0x6a09e667) - 0.5) * spacing * 0.74;
|
||||
const jitterY = (hash2(gx, gy, seed ^ 0xbb67ae85) - 0.5) * spacing * 0.74;
|
||||
const wx = gx * spacing + spacing * 0.5 + jitterX;
|
||||
const wy = gy * spacing + spacing * 0.5 + jitterY;
|
||||
const lx = Math.round(wx - ctx.originX);
|
||||
const ly = Math.round(wy - ctx.originY);
|
||||
let viability = 0.8;
|
||||
if (rectInside(ctx, lx, ly)) {
|
||||
const i = rectIndexOf(ctx, lx, ly);
|
||||
viability += (basinField[i] || 0) * 0.25 + (valleyField[i] || 0) * 0.16 - (ridgeField[i] || 0) * 0.14;
|
||||
if (sea[i]) viability -= 1.2;
|
||||
}
|
||||
if (viability < 0.18 && hash2(gx, gy, seed ^ 0x3c6ef372) < 0.82) continue;
|
||||
seeds.push({
|
||||
wx,
|
||||
wy,
|
||||
id: 40000000 + rectStableId(seed, gx, gy, 0xb5c0fbcf) % 42000000,
|
||||
coarseId: 30000000 + rectStableId(seed, Math.floor((gx * spacing) / coarseSpacing), Math.floor((gy * spacing) / coarseSpacing), 0xc2b2ae35) % 42000000,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!seeds.length) return { naturalCompartmentCount: 0, regionCount: 0 };
|
||||
for (let y = 0; y < ctx.height; y++) {
|
||||
for (let x = 0; x < ctx.width; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i]) continue;
|
||||
const wx = ctx.originX + x;
|
||||
const wy = ctx.originY + y;
|
||||
let best = seeds[0];
|
||||
let bestScore = Infinity;
|
||||
const barrier = (naturalBarrierScore[i] || 0) + (ridgeField[i] || 0) * 0.55 + (flowAccum[i] || 0) * 0.18;
|
||||
const basinBonus = (basinField[i] || 0) * 0.18 + (valleyField[i] || 0) * 0.10;
|
||||
for (const s of seeds) {
|
||||
const dx = (wx - s.wx) * 1.05;
|
||||
const dy = wy - s.wy;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const tileNoise = (valueNoise(wx + s.wx * 0.13, wy + s.wy * 0.13, seed ^ 0xa54ff53a, 52) - 0.5) * spacing * 0.34;
|
||||
const watershedPenalty = watershedId?.[i] >= 0 ? ((watershedId[i] ^ s.id) & 7) * 0.16 : 0;
|
||||
const score = d + barrier * spacing * 0.42 - basinBonus * spacing * 0.32 + tileNoise + watershedPenalty;
|
||||
if (score < bestScore) { bestScore = score; best = s; }
|
||||
}
|
||||
naturalCompartmentId[i] = best.id;
|
||||
regionId[i] = best.coarseId;
|
||||
}
|
||||
}
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const changes = [];
|
||||
for (let y = 1; y < ctx.height - 1; y++) {
|
||||
for (let x = 1; x < ctx.width - 1; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i]) continue;
|
||||
if ((ridgeField[i] || 0) > 0.78) continue;
|
||||
const counts = new Map();
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
const id = naturalCompartmentId[ni];
|
||||
if (id < 0) continue;
|
||||
counts.set(id, (counts.get(id) || 0) + 1 + (basinField[ni] || 0) * 0.3);
|
||||
}
|
||||
let best = naturalCompartmentId[i];
|
||||
let bestScore = counts.get(best) || 0;
|
||||
for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; }
|
||||
if (best !== naturalCompartmentId[i] && bestScore >= 5.8) changes.push([i, best]);
|
||||
}
|
||||
}
|
||||
for (const [i, id] of changes) naturalCompartmentId[i] = id;
|
||||
if (!changes.length) break;
|
||||
}
|
||||
const nset = new Set();
|
||||
const rset = new Set();
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (naturalCompartmentId[i] >= 0) nset.add(naturalCompartmentId[i]);
|
||||
if (regionId[i] >= 0) rset.add(regionId[i]);
|
||||
}
|
||||
return { naturalCompartmentCount: nset.size, regionCount: rset.size };
|
||||
}
|
||||
|
||||
function traceRectFlowPath(start, ctx, fields, maxSteps = 1200) {
|
||||
const { sea, flowTo } = fields;
|
||||
let i = start;
|
||||
const path = [];
|
||||
const seen = new Set();
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
if (i < 0 || i >= ctx.size || seen.has(i)) break;
|
||||
seen.add(i);
|
||||
const x = i % ctx.width;
|
||||
const y = Math.floor(i / ctx.width);
|
||||
path.push([ctx.originX + x, ctx.originY + y]);
|
||||
if (sea[i]) break;
|
||||
const next = flowTo[i];
|
||||
if (next < 0 || next === i) break;
|
||||
i = next;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function scoreRectRiverPath(path, ctx, fields) {
|
||||
let score = 0;
|
||||
for (const [wx, wy] of path) {
|
||||
const x = wx - ctx.originX;
|
||||
const y = wy - ctx.originY;
|
||||
if (!rectInside(ctx, x, y)) continue;
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
score += (fields.flowAccum[i] || 0) + (fields.river[i] || 0) * 0.7;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function buildRectRiverPaths(ctx, fields, seed, template) {
|
||||
const { sea, flowAccum, river, erosionField } = fields;
|
||||
const candidates = [];
|
||||
const threshold = template?.terrainType === "oceanic_archipelago" ? 0.52 : template?.terrainType === "kanto_alluvial" ? 0.46 : 0.50;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (sea[i] || flowAccum[i] < threshold) continue;
|
||||
const x = i % ctx.width;
|
||||
const y = Math.floor(i / ctx.width);
|
||||
let upstream = 0;
|
||||
for (const [nx, ny] of rectNeighbors8(ctx, x, y)) {
|
||||
const ni = rectIndexOf(ctx, nx, ny);
|
||||
if (fields.flowTo[ni] === i) upstream++;
|
||||
}
|
||||
const sourceBias = hash2(ctx.originX + x, ctx.originY + y, seed ^ 0x1f123bb5);
|
||||
if (upstream <= 1 || sourceBias > 0.78) candidates.push({ i, score: flowAccum[i] + sourceBias * 0.12 });
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const accepted = [];
|
||||
const occupied = new Set();
|
||||
const desired = Math.min(72, Math.max(8, Math.floor(ctx.size / 900)));
|
||||
for (const c of candidates) {
|
||||
if (accepted.length >= desired) break;
|
||||
const path = traceRectFlowPath(c.i, ctx, fields);
|
||||
if (path.length < 8) continue;
|
||||
const keyHits = path.reduce((n, [wx, wy], k) => k % 3 === 0 && occupied.has(`${wx},${wy}`) ? n + 1 : n, 0);
|
||||
if (keyHits > Math.max(5, path.length * 0.18)) continue;
|
||||
const score = scoreRectRiverPath(path, ctx, fields);
|
||||
if (score < 4.2) continue;
|
||||
accepted.push({ path, score });
|
||||
for (const [wx, wy] of path) occupied.add(`${wx},${wy}`);
|
||||
}
|
||||
accepted.sort((a, b) => b.score - a.score);
|
||||
const mainRivers = accepted.slice(0, Math.max(1, Math.min(10, Math.round(accepted.length * 0.25)))).map((r) => r.path);
|
||||
const tributaryRivers = accepted.slice(mainRivers.length, mainRivers.length + 28).map((r) => r.path);
|
||||
const smallStreams = accepted.slice(mainRivers.length + 28, mainRivers.length + 56).map((r) => r.path);
|
||||
for (const group of [mainRivers, tributaryRivers, smallStreams]) {
|
||||
const boost = group === mainRivers ? 0.72 : group === tributaryRivers ? 0.48 : 0.28;
|
||||
for (const path of group) {
|
||||
for (const [wx, wy] of path) {
|
||||
const x = wx - ctx.originX;
|
||||
const y = wy - ctx.originY;
|
||||
if (!rectInside(ctx, x, y)) continue;
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
if (sea[i]) continue;
|
||||
river[i] = clamp(Math.max(river[i], boost + (flowAccum[i] || 0) * 0.42));
|
||||
if (erosionField) erosionField[i] = clamp((erosionField[i] || 0) + river[i] * 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { riverPaths: accepted.map((r) => r.path), mainRivers, tributaryRivers, smallStreams };
|
||||
}
|
||||
|
||||
function deriveRectTerrainFields(ctx, fields, seaLevel) {
|
||||
const {
|
||||
elevation, sea, river, flowAccum, floodplain, plain, agriculture, ridgeField, valleyField, basinField,
|
||||
coastalLowland, erosionField, depositionField, depositionalLowland, alluvialFanField, deltaField,
|
||||
naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, slope, moisture,
|
||||
} = fields;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (sea[i]) {
|
||||
river[i] = 0; plain[i] = 0; agriculture[i] = 0; naturalBarrierScore[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const low = clamp((0.48 - elevation[i]) * 2.2);
|
||||
const flat = clamp(1 - slope[i] * 2.3);
|
||||
const coast = clamp((elevation[i] - seaLevel) * 18);
|
||||
river[i] = flowAccum[i] > 0.58 ? clamp((flowAccum[i] - 0.52) * 2.1 + (0.22 - slope[i]) * 0.40) : 0;
|
||||
floodplain[i] = clamp(river[i] * 0.72 + low * flat * 0.24);
|
||||
plain[i] = clamp(flat * (low * 0.78 + basinField[i] * 0.38 + floodplain[i] * 0.35));
|
||||
agriculture[i] = clamp(plain[i] * 0.72 + moisture[i] * 0.22 - slope[i] * 0.22);
|
||||
coastalLowland[i] = clamp((1 - coast) * flat * 0.90);
|
||||
erosionField[i] = clamp(slope[i] * 0.55 + river[i] * 0.34 + ridgeField[i] * 0.22);
|
||||
depositionField[i] = clamp(floodplain[i] * 0.58 + coastalLowland[i] * 0.34 + plain[i] * 0.18);
|
||||
depositionalLowland[i] = clamp(depositionField[i] * flat);
|
||||
alluvialFanField[i] = clamp(river[i] * slope[i] * 1.8);
|
||||
deltaField[i] = clamp(river[i] * coastalLowland[i] * 1.2);
|
||||
naturalBarrierScore[i] = clamp(ridgeField[i] * 0.72 + slope[i] * 0.42 + river[i] * 0.24);
|
||||
crossingSuitability[i] = clamp(flat * (1 - river[i] * 0.65) + plain[i] * 0.24);
|
||||
passSuitability[i] = clamp((1 - ridgeField[i]) * 0.55 + valleyField[i] * 0.40 - slope[i] * 0.15);
|
||||
portSuitability[i] = clamp(coastalLowland[i] * 0.65 + plain[i] * 0.22 - slope[i] * 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTerrainRect(options = {}) {
|
||||
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : 0;
|
||||
const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
|
||||
const ctx = options.rectContext || createRectContext(options);
|
||||
const rectSeedValue = rectSeed(seed, variant, 0x5489a1f3);
|
||||
const terrainTemplate = buildTerrainTemplate(rectSeedValue, options);
|
||||
const profile = rectTerrainProfile(terrainTemplate);
|
||||
const fields = createRectTerrainFields(ctx);
|
||||
const {
|
||||
elevation, moisture, ridgeField, valleyField, basinField, coastalLowland, arcSpineField, branchRidgeField,
|
||||
visibleRavineField, surfaceTextureField,
|
||||
} = fields;
|
||||
|
||||
for (let y = 0; y < ctx.height; y++) {
|
||||
for (let x = 0; x < ctx.width; x++) {
|
||||
const i = rectIndexOf(ctx, x, y);
|
||||
const wx = ctx.originX + x;
|
||||
const wy = ctx.originY + y;
|
||||
const broad = (fbm(wx * 0.58, wy * 0.58, rectSeedValue ^ 0x9e3779b9) - 0.5) * profile.relief;
|
||||
const regional = (valueNoise(wx, wy, rectSeedValue ^ 0x85ebca6b, 58) - 0.5) * profile.relief * 0.72;
|
||||
const detail = (valueNoise(wx, wy, rectSeedValue ^ 0xc2b2ae35, 19) - 0.5) * profile.relief * 0.22;
|
||||
const ridge = periodicRidgeField(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x27d4eb2f);
|
||||
const marine = worldMarinePressure(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x165667b1);
|
||||
const basin = clamp((valueNoise(wx, wy, rectSeedValue ^ 0xd3a2646c, 120) - 0.36) * 1.65) * profile.plain;
|
||||
const valley = clamp((1 - ridge) * (valueNoise(wx, wy, rectSeedValue ^ 0xfd7046c5, 42) - 0.42) * 1.7);
|
||||
const archipelago = terrainTemplate.terrainType === "oceanic_archipelago" || terrainTemplate.coastStyle === "inland_sea"
|
||||
? clamp((fbm(wx * 0.72 + 49, wy * 0.72 - 31, rectSeedValue ^ 0x94d049bb) - 0.44) * 2.2) * profile.archipelago
|
||||
: 0;
|
||||
let e = profile.base + broad + regional + detail + ridge * profile.ridge + archipelago - marine + basin * 0.10;
|
||||
if (terrainTemplate.terrainType === "kanto_alluvial") e -= basin * 0.075;
|
||||
if (terrainTemplate.terrainType === "oceanic_archipelago") e -= marine * 0.16;
|
||||
e = softCapElevation(e, profile.capStart, profile.capMax);
|
||||
elevation[i] = clamp(e, 0.025, profile.capMax);
|
||||
ridgeField[i] = clamp(ridge * (0.62 + profile.ridge));
|
||||
branchRidgeField[i] = clamp(ridge * 0.82 + detail * 0.60);
|
||||
arcSpineField[i] = clamp(ridge * 0.90);
|
||||
valleyField[i] = clamp(valley + (1 - ridge) * marine * 0.20);
|
||||
basinField[i] = clamp(basin + valley * 0.35);
|
||||
coastalLowland[i] = clamp(marine * 0.82 + basin * 0.25);
|
||||
moisture[i] = clamp(profile.moisture + marine * 0.22 + basin * 0.15 - elevation[i] * 0.22 + (fbm(wx * 0.85, wy * 0.85, rectSeedValue ^ 0xa0761d65) - 0.5) * 0.13);
|
||||
visibleRavineField[i] = clamp(Math.abs(detail) * ridge * 1.9 + valley * 0.25);
|
||||
surfaceTextureField[i] = clamp(Math.abs(broad) * 0.55 + Math.abs(detail) * 1.3 + ridge * 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50);
|
||||
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
|
||||
recomputeRectSlope(ctx, fields);
|
||||
const filled = priorityFloodRect(ctx, fields);
|
||||
computeRectFlowAccumulation(ctx, fields, filled);
|
||||
const watershedDebug = buildRectWatershedId(ctx, fields, rectSeedValue ^ 0x51ed270b);
|
||||
deriveRectTerrainFields(ctx, fields, seaLevel);
|
||||
const riverNetwork = buildRectRiverPaths(ctx, fields, rectSeedValue ^ 0x1f123bb5, terrainTemplate);
|
||||
const naturalDebug = buildRectNaturalRegions(ctx, fields, rectSeedValue ^ 0xb5c0fbcf, terrainTemplate);
|
||||
|
||||
let landCount = 0;
|
||||
let mountainCount = 0;
|
||||
let plainCount = 0;
|
||||
for (let i = 0; i < ctx.size; i++) {
|
||||
if (fields.sea[i]) continue;
|
||||
landCount++;
|
||||
if (fields.elevation[i] > 0.56 || fields.ridgeField[i] > 0.52) mountainCount++;
|
||||
if (fields.plain[i] > 0.36) plainCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
rectContext: ctx,
|
||||
originX: ctx.originX,
|
||||
originY: ctx.originY,
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
size: ctx.size,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
...fields,
|
||||
terrainDebug: {
|
||||
terrainType: terrainTemplate.terrainType,
|
||||
terrainTypeLabel: terrainTemplate.terrainTypeLabel,
|
||||
coastStyle: terrainTemplate.coastStyle,
|
||||
rectNative: true,
|
||||
originX: ctx.originX,
|
||||
originY: ctx.originY,
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
variant,
|
||||
seaRatio: fields.sea.reduce((sum, value) => sum + value, 0) / Math.max(1, ctx.size),
|
||||
landCount,
|
||||
oceanCells,
|
||||
mountainRatio: mountainCount / Math.max(1, landCount),
|
||||
plainRatio: plainCount / Math.max(1, landCount),
|
||||
watershedCount: watershedDebug.watershedCount,
|
||||
naturalCompartmentCount: naturalDebug.naturalCompartmentCount,
|
||||
regionCount: naturalDebug.regionCount,
|
||||
mainRiverCount: riverNetwork.mainRivers.length,
|
||||
tributaryRiverCount: riverNetwork.tributaryRivers.length,
|
||||
smallStreamCount: riverNetwork.smallStreams.length,
|
||||
},
|
||||
...riverNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeRectTerrainForFixedMap(seed, terrain, options = {}) {
|
||||
if (!terrain || terrain.width !== MAP_W || terrain.height !== MAP_H || terrain.size !== SIZE) {
|
||||
throw new Error(`finalizeRectTerrainForFixedMap requires ${MAP_W}x${MAP_H} terrain, got ${terrain?.width}x${terrain?.height}`);
|
||||
}
|
||||
const {
|
||||
elevation, slope, sea, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
plain, agriculture, watershedId, landMask: existingLandMask, prefectureMask: existingPrefectureMask,
|
||||
} = terrain;
|
||||
const prefectureMask = existingPrefectureMask || makePrefectureMask(seed, sea, elevation, slope, river);
|
||||
const landMask = existingLandMask || new Uint8Array(SIZE);
|
||||
if (!existingLandMask) {
|
||||
for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1;
|
||||
}
|
||||
const zeroDensity = new Float32Array(SIZE);
|
||||
const zeroLanduse = new Int8Array(SIZE);
|
||||
const landCount = landMask.reduce((sum, value, i) => sum + (value && !sea[i] ? 1 : 0), 0);
|
||||
const natural = buildNaturalCompartments(
|
||||
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
null, plain, agriculture, zeroDensity, zeroLanduse,
|
||||
{
|
||||
seed: (seed + 17003) >>> 0,
|
||||
watershedId,
|
||||
targetCompartmentCount: clamp(Math.round(landCount / 45), 70, 360),
|
||||
}
|
||||
);
|
||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||
const terrainDebug = {
|
||||
...(terrain.terrainDebug || {}),
|
||||
rectNativeInitialTerrain: true,
|
||||
rectInitialOriginX: terrain.originX || 0,
|
||||
rectInitialOriginY: terrain.originY || 0,
|
||||
sharedNaturalCompartmentLayer: true,
|
||||
naturalCompartmentCount: natural.compartments?.filter?.((unit) => unit && unit.area > 0).length || 0,
|
||||
};
|
||||
return {
|
||||
...terrain,
|
||||
prefectureMask,
|
||||
landMask,
|
||||
prefectureBorder,
|
||||
naturalBarrierScore: natural.naturalBarrierScore || terrain.naturalBarrierScore,
|
||||
naturalCompartmentId: natural.compartmentId,
|
||||
naturalCompartments: natural.compartments,
|
||||
terrainDebug,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateInitialTerrainRect(seed, options = {}) {
|
||||
const variant = Number.isFinite(options.initialVariant) ? Math.max(0, Math.floor(options.initialVariant)) : 0;
|
||||
const terrain = generateTerrainRect({
|
||||
...options,
|
||||
seed,
|
||||
variant,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
name: "initial-full-map",
|
||||
});
|
||||
return finalizeRectTerrainForFixedMap(seed, terrain, options);
|
||||
}
|
||||
|
||||
|
||||
export function generateTerrainAndRivers(seed, options = {}) {
|
||||
const generationContext = options.generationContext || {};
|
||||
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : (Number.isFinite(generationContext.originX) ? generationContext.originX : 0));
|
||||
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : (Number.isFinite(generationContext.originY) ? generationContext.originY : 0));
|
||||
const variant = Math.max(0, Math.floor(Number.isFinite(options.variant) ? options.variant : (Number.isFinite(generationContext.variant) ? generationContext.variant : 0))) >>> 0;
|
||||
const worldNative = options.worldNative === true || generationContext.worldNative === true;
|
||||
const fields = createMapFields();
|
||||
fields.visibleRavineField = new Float32Array(SIZE);
|
||||
fields.surfaceTextureField = new Float32Array(SIZE);
|
||||
|
|
@ -1145,16 +1833,18 @@ export function generateTerrainAndRivers(seed) {
|
|||
passSuitability,
|
||||
} = fields;
|
||||
|
||||
const terrainTemplate = buildTerrainTemplate(seed);
|
||||
const terrainTemplate = buildTerrainTemplate(seed, options);
|
||||
const systems = buildMountainSystems(terrainTemplate, seed);
|
||||
const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id));
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const wx = originX + x;
|
||||
const wy = originY + y;
|
||||
const { px, py } = normalizeCoord(x, y);
|
||||
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23;
|
||||
const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||
const terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
|
||||
const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
||||
let mountainMaskMax = 0;
|
||||
|
|
@ -1179,7 +1869,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
const dx = (px - 0.5) * ASPECT;
|
||||
const dy = py - 0.5;
|
||||
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
||||
const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||
const warp = (valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
||||
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
||||
|
|
@ -1187,10 +1877,10 @@ export function generateTerrainAndRivers(seed) {
|
|||
e += lateralBranch * mountainMaskMax * 0.022;
|
||||
e -= passBreak * mountainMaskMax * 0.052;
|
||||
} else {
|
||||
macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
|
||||
macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2;
|
||||
}
|
||||
const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||
const global = (valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||
e += global * 0.020;
|
||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||
|
|
@ -1203,7 +1893,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
|
||||
// Setouchi maps should have many low hills and island backbones rather
|
||||
// than a few high alpine ridges. Add broad low relief, then cap peaks.
|
||||
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
|
||||
e += lowHillMask * 0.145;
|
||||
// Do not add the previous fine speckle uplift here: it created too many
|
||||
|
|
@ -1212,13 +1902,22 @@ export function generateTerrainAndRivers(seed) {
|
|||
const high = Math.max(0, e - 0.62);
|
||||
e -= high * 0.42;
|
||||
}
|
||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
|
||||
if (terrainTemplate.terrainType === "oceanic_archipelago") {
|
||||
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
|
||||
const islandCore = clamp((fbm(wx * 0.82 + 41, wy * 0.82 - 29, seed + 572) - 0.46) * 2.7);
|
||||
const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
|
||||
e += islandChain * 0.135;
|
||||
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
|
||||
const high = Math.max(0, e - 0.56);
|
||||
e -= high * 0.52;
|
||||
}
|
||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.62 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.84 : 1.08;
|
||||
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
|
||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16);
|
||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1280,9 +1979,20 @@ export function generateTerrainAndRivers(seed) {
|
|||
mountainRatio: mountainCount / Math.max(1, landCount),
|
||||
plainRatio: plainCount / Math.max(1, landCount),
|
||||
mountainSystemCount: systems.length,
|
||||
originX,
|
||||
originY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
variant,
|
||||
worldNative,
|
||||
};
|
||||
|
||||
return {
|
||||
originX,
|
||||
originY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
elevation,
|
||||
|
|
|
|||
1191
mapTransport.js
1191
mapTransport.js
File diff suppressed because it is too large
Load diff
145
mapTransportGraph.js
Normal file
145
mapTransportGraph.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { INF, MAP_H, MAP_W, MinHeap, indexOf, inside } from "./mapUtils.js";
|
||||
|
||||
export function buildCoarseCostGraph({ sea, costField }, options = {}) {
|
||||
const scale = options.scale ?? 4;
|
||||
const cw = Math.ceil(MAP_W / scale);
|
||||
const ch = Math.ceil(MAP_H / scale);
|
||||
const size = cw * ch;
|
||||
const cost = new Float32Array(size);
|
||||
const passable = new Uint8Array(size);
|
||||
cost.fill(INF);
|
||||
|
||||
for (let cy = 0; cy < ch; cy++) {
|
||||
for (let cx = 0; cx < cw; cx++) {
|
||||
let best = INF;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let dy = 0; dy < scale; dy++) {
|
||||
for (let dx = 0; dx < scale; dx++) {
|
||||
const x = cx * scale + dx;
|
||||
const y = cy * scale + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea?.[i] || costField?.[i] >= INF) continue;
|
||||
const v = costField[i];
|
||||
best = Math.min(best, v);
|
||||
sum += v;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
const ci = cy * cw + cx;
|
||||
if (n > 0) {
|
||||
passable[ci] = 1;
|
||||
cost[ci] = best * 0.55 + (sum / n) * 0.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { scale, cw, ch, size, cost, passable };
|
||||
}
|
||||
|
||||
export function routeCoarsePath(start, goal, graph, options = {}) {
|
||||
if (!start || !goal || !graph) return [];
|
||||
const sx = Math.floor(start.x / graph.scale);
|
||||
const sy = Math.floor(start.y / graph.scale);
|
||||
const gx = Math.floor(goal.x / graph.scale);
|
||||
const gy = Math.floor(goal.y / graph.scale);
|
||||
if (sx < 0 || sy < 0 || sx >= graph.cw || sy >= graph.ch || gx < 0 || gy < 0 || gx >= graph.cw || gy >= graph.ch) return [];
|
||||
const startCi = sy * graph.cw + sx;
|
||||
const goalCi = gy * graph.cw + gx;
|
||||
if (!graph.passable[startCi] || !graph.passable[goalCi]) return [];
|
||||
|
||||
const score = new Float32Array(graph.size);
|
||||
const cameFrom = new Int32Array(graph.size);
|
||||
const closed = new Uint8Array(graph.size);
|
||||
score.fill(INF);
|
||||
cameFrom.fill(-1);
|
||||
score[startCi] = 0;
|
||||
const heap = new MinHeap();
|
||||
heap.push({ i: startCi, f: Math.hypot(sx - gx, sy - gy) });
|
||||
const maxExpanded = options.maxExpanded ?? graph.size;
|
||||
let expanded = 0;
|
||||
let found = -1;
|
||||
|
||||
while (heap.length && expanded++ < maxExpanded) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || closed[cur.i]) continue;
|
||||
closed[cur.i] = 1;
|
||||
if (cur.i === goalCi) {
|
||||
found = cur.i;
|
||||
break;
|
||||
}
|
||||
const cx = cur.i % graph.cw;
|
||||
const cy = Math.floor(cur.i / graph.cw);
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = cx + dx;
|
||||
const ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= graph.cw || ny >= graph.ch) continue;
|
||||
const ni = ny * graph.cw + nx;
|
||||
if (closed[ni] || !graph.passable[ni]) continue;
|
||||
const nd = score[cur.i] + graph.cost[ni] * Math.hypot(dx, dy);
|
||||
if (nd < score[ni]) {
|
||||
score[ni] = nd;
|
||||
cameFrom[ni] = cur.i;
|
||||
heap.push({ i: ni, f: nd + Math.hypot(nx - gx, ny - gy) * (options.heuristicWeight ?? 0.85) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found < 0) return [];
|
||||
|
||||
const path = [];
|
||||
for (let p = found; p >= 0; p = cameFrom[p]) {
|
||||
const cx = p % graph.cw;
|
||||
const cy = Math.floor(p / graph.cw);
|
||||
path.push([
|
||||
Math.min(MAP_W - 1, Math.round(cx * graph.scale + graph.scale * 0.5)),
|
||||
Math.min(MAP_H - 1, Math.round(cy * graph.scale + graph.scale * 0.5)),
|
||||
]);
|
||||
if (p === startCi) break;
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
export function refineCoarsePath(path, costField, options = {}) {
|
||||
if (!path || path.length < 2) return [];
|
||||
const sea = options.sea || null;
|
||||
const radius = options.snapRadius ?? 1;
|
||||
const out = [];
|
||||
let lastKey = "";
|
||||
for (let k = 0; k < path.length - 1; k++) {
|
||||
const a = path[k];
|
||||
const b = path[k + 1];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||
for (let s = k === 0 ? 0 : 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const tx = Math.round(a[0] + (b[0] - a[0]) * t);
|
||||
const ty = Math.round(a[1] + (b[1] - a[1]) * t);
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = tx + dx;
|
||||
const y = ty + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea?.[i] || costField?.[i] >= INF) continue;
|
||||
const score = costField[i] + Math.hypot(dx, dy) * 0.22;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) return [];
|
||||
const key = `${best[0]},${best[1]}`;
|
||||
if (key !== lastKey) {
|
||||
out.push(best);
|
||||
lastKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.length >= 2 ? out : [];
|
||||
}
|
||||
|
|
@ -40,7 +40,9 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
transportRouteAcceptable,
|
||||
pruneParallelSameMode,
|
||||
cachedInfluenceFromPaths,
|
||||
speedTolerance = 1,
|
||||
} = ctx;
|
||||
const speedScale = clamp(speedTolerance, 0.75, 1);
|
||||
|
||||
const railways = [];
|
||||
const branchRailways = [];
|
||||
|
|
@ -220,8 +222,8 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
relaxRadius: 1,
|
||||
relaxLineWeight: branch ? 0.44 : 0.50,
|
||||
snapRadius: branch ? 2.4 : 2.8,
|
||||
searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))),
|
||||
maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36),
|
||||
searchPad: Math.ceil(Math.max(18, Math.min(56, pair.d * 0.40))),
|
||||
maxPathLength: pair.d * (branch ? 2.12 : 2.30) + (branch ? 16 : 30),
|
||||
maxSeaRun: 1,
|
||||
maxSeaShare: 0.006,
|
||||
});
|
||||
|
|
@ -260,18 +262,18 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
.filter(Boolean);
|
||||
const anchorNodes = geographicUrbanAnchors
|
||||
.filter((a) => (a.score || 0) > 0.76)
|
||||
.slice(0, 8)
|
||||
.slice(0, Math.max(5, Math.round(8 * speedScale)))
|
||||
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
|
||||
.filter(Boolean);
|
||||
|
||||
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
|
||||
.sort((a, b) => (b.population || 0) - (a.population || 0))
|
||||
.slice(0, 34);
|
||||
.slice(0, Math.max(24, Math.round(34 * speedScale)));
|
||||
if (trunkNodes.length < 2) {
|
||||
trunkNodes = dedupeNodes([
|
||||
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
|
||||
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
|
||||
], 7).slice(0, 18);
|
||||
], 7).slice(0, Math.max(14, Math.round(18 * speedScale)));
|
||||
}
|
||||
debug.nodeCounts = {
|
||||
regionalCityNodes: regionalCityNodes.length,
|
||||
|
|
@ -300,7 +302,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
|
||||
const uf = makeUnionFind(trunkNodes, keyOf);
|
||||
const penalty = new Float32Array(SIZE);
|
||||
const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1));
|
||||
const maxTrunk = Math.min(Math.max(14, Math.round(18 * speedScale)), Math.max(4, trunkNodes.length - 1));
|
||||
let connectedEdges = 0;
|
||||
for (const pair of trunkPairs) {
|
||||
if (connectedEdges >= maxTrunk) break;
|
||||
|
|
@ -322,7 +324,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
let loopAdded = 0;
|
||||
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
|
||||
for (const pair of trunkPairs) {
|
||||
if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break;
|
||||
if (loopAdded >= Math.min(Math.max(4, Math.round(7 * speedScale)), Math.max(2, Math.ceil(trunkNodes.length / 6)))) break;
|
||||
const ai = indexOf(pair.a.x, pair.a.y);
|
||||
const bi = indexOf(pair.b.x, pair.b.y);
|
||||
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
|
||||
|
|
@ -347,7 +349,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
...ports
|
||||
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
|
||||
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
|
||||
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36);
|
||||
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(26, Math.round(36 * speedScale)));
|
||||
|
||||
const trunkTargets = [];
|
||||
for (const path of railways) {
|
||||
|
|
@ -361,7 +363,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
|
||||
let branchAdded = 0;
|
||||
for (const node of branchCandidates) {
|
||||
if (branchAdded >= 14) break;
|
||||
if (branchAdded >= Math.max(10, Math.round(14 * speedScale))) break;
|
||||
const ni = indexOf(node.x, node.y);
|
||||
if ((railInfluence[ni] || 0) > 0.34) continue;
|
||||
const options = trunkTargets
|
||||
|
|
@ -374,7 +376,7 @@ export function buildUnifiedRailODNetwork(ctx) {
|
|||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.score - b.score);
|
||||
for (const pair of options.slice(0, 5)) {
|
||||
for (const pair of options.slice(0, 3)) {
|
||||
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
|
||||
const path = routeRailPair(pair, penalty, true);
|
||||
if (!path.length) continue;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,149 @@ export function pathAverageField(path, field) {
|
|||
return n ? sum / n : 0;
|
||||
}
|
||||
|
||||
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
|
||||
for (const [px, py] of path || []) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
const x = px + dx;
|
||||
const y = py + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea?.[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
||||
if (v > field[i]) field[i] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) {
|
||||
const field = new Float32Array(SIZE);
|
||||
const sea = options.sea || null;
|
||||
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea);
|
||||
return {
|
||||
field,
|
||||
add(path, strength = 1, addRadius = radius) {
|
||||
markPathInfluence(field, path, addRadius, strength, sea);
|
||||
return field;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sampledNetworkCells(paths, step = 2, sea = null) {
|
||||
const cells = [];
|
||||
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
||||
const path = paths[pathId];
|
||||
for (let k = 0; k < (path?.length || 0); k += step) {
|
||||
const [x, y] = path[k];
|
||||
if (inside(x, y) && !sea?.[indexOf(x, y)]) cells.push({ x, y, pathId });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function pathEndpoints(paths) {
|
||||
const endpoints = [];
|
||||
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
||||
const path = paths[pathId];
|
||||
if (!path || path.length < 2) continue;
|
||||
endpoints.push({ x: path[0][0], y: path[0][1], pathId });
|
||||
const end = path[path.length - 1];
|
||||
endpoints.push({ x: end[0], y: end[1], pathId });
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
export function nearestNetworkPoint(source, targets, radius, options = {}) {
|
||||
if (!source || !targets?.length) return null;
|
||||
const excludeSamePath = options.excludeSamePath !== false;
|
||||
const excludePath = options.excludePath;
|
||||
let best = null;
|
||||
let bestD = radius + 1;
|
||||
for (const target of targets) {
|
||||
if (excludePath != null && target.pathId === excludePath) continue;
|
||||
if (excludeSamePath && source.pathId != null && target.pathId === source.pathId) continue;
|
||||
const d = Math.hypot(source.x - target.x, source.y - target.y);
|
||||
if (d > 0.01 && d < bestD) {
|
||||
bestD = d;
|
||||
best = target;
|
||||
}
|
||||
}
|
||||
return best ? { target: best, d: bestD, ...best } : null;
|
||||
}
|
||||
|
||||
export function splitPathToValidCells(path, isValid, minCells = 2) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
function pushPoint(x, y) {
|
||||
if (!isValid(x, y)) {
|
||||
if (current.length >= minCells) chunks.push(current);
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
if (!current.length || current[current.length - 1][0] !== x || current[current.length - 1][1] !== y) current.push([x, y]);
|
||||
}
|
||||
for (let k = 0; k < (path?.length || 0); k++) {
|
||||
const a = path[k];
|
||||
const b = path[Math.min(k + 1, path.length - 1)];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
if (k > 0 && s === 0) continue;
|
||||
const t = s / steps;
|
||||
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
||||
}
|
||||
}
|
||||
if (current.length >= minCells) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function pathCumulativeLengths(path) {
|
||||
const cum = [0];
|
||||
for (let k = 1; k < (path?.length || 0); k++) {
|
||||
cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
||||
}
|
||||
return cum;
|
||||
}
|
||||
|
||||
export function pointAtPathDistance(path, cum, dist) {
|
||||
if (!path?.length) return null;
|
||||
if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 };
|
||||
const total = cum[cum.length - 1] || 0;
|
||||
if (dist >= total) {
|
||||
const p = path[path.length - 1];
|
||||
return { x: p[0], y: p[1], s: total };
|
||||
}
|
||||
let k = 1;
|
||||
while (k < cum.length && cum[k] < dist) k++;
|
||||
const a = path[k - 1];
|
||||
const b = path[k];
|
||||
const seg = Math.max(0.0001, cum[k] - cum[k - 1]);
|
||||
const t = clamp((dist - cum[k - 1]) / seg);
|
||||
return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist };
|
||||
}
|
||||
|
||||
export function meanFieldAround(field, x, y, radius = 8, sea = null) {
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const i = indexOf(nx, ny);
|
||||
if (sea?.[i]) continue;
|
||||
const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius);
|
||||
sum += (field?.[i] || 0) * (0.35 + w);
|
||||
n += 0.35 + w;
|
||||
}
|
||||
}
|
||||
return n ? sum / n : 0;
|
||||
}
|
||||
|
||||
export function routeQualityStats(path, fields = {}) {
|
||||
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
|
||||
const length = pathLengthCells(path);
|
||||
|
|
|
|||
8
names.js
8
names.js
|
|
@ -15,13 +15,13 @@ export const NAME_KANJI_POOLS = {
|
|||
"霞", "朝", "日", "天",
|
||||
"土", "砂", "石", "岩",
|
||||
"卯", "辰",
|
||||
"串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
|
||||
"串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "野", "野", "沢",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延",
|
||||
"峰", "嶺", "尾", "平", "坪", "延",
|
||||
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
||||
"郷", "里",
|
||||
|
|
@ -85,7 +85,7 @@ export const NAME_KANJI_POOLS = {
|
|||
],
|
||||
|
||||
archaicSuffixes: [
|
||||
"井", "羽", "江", "恵", "尾",
|
||||
"伊", "衣", "井", "羽", "江", "恵", "尾",
|
||||
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
|
||||
"多", "太", "知", "津", "豆", "土", "登",
|
||||
|
|
@ -107,7 +107,7 @@ export const NAME_KANJI_POOLS = {
|
|||
|
||||
settlementWords: [
|
||||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
|
||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"城", "館", "屋", "家", "所",
|
||||
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
|
||||
]
|
||||
|
|
|
|||
150
rectContext.js
Normal file
150
rectContext.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { clamp } from "./mapUtils.js";
|
||||
|
||||
export function createRectContext(options = {}) {
|
||||
const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0);
|
||||
const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0);
|
||||
const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : 1));
|
||||
const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : 1));
|
||||
return {
|
||||
originX,
|
||||
originY,
|
||||
width,
|
||||
height,
|
||||
size: width * height,
|
||||
name: options.name || "rect",
|
||||
};
|
||||
}
|
||||
|
||||
export function rectIndexOf(ctx, x, y) {
|
||||
return y * ctx.width + x;
|
||||
}
|
||||
|
||||
export function rectXyOf(ctx, i) {
|
||||
return [i % ctx.width, Math.floor(i / ctx.width)];
|
||||
}
|
||||
|
||||
export function rectInside(ctx, x, y) {
|
||||
return !!ctx && x >= 0 && y >= 0 && x < ctx.width && y < ctx.height;
|
||||
}
|
||||
|
||||
export function rectWorldX(ctx, x) {
|
||||
return ctx.originX + x;
|
||||
}
|
||||
|
||||
export function rectWorldY(ctx, y) {
|
||||
return ctx.originY + y;
|
||||
}
|
||||
|
||||
export function rectWorldCoord(ctx, x, y) {
|
||||
return { x: ctx.originX + x, y: ctx.originY + y };
|
||||
}
|
||||
|
||||
export function rectLocalCoord(ctx, worldX, worldY) {
|
||||
return { x: Math.round(worldX - ctx.originX), y: Math.round(worldY - ctx.originY) };
|
||||
}
|
||||
|
||||
export function rectWorldIndex(ctx, worldX, worldY) {
|
||||
const x = Math.round(worldX - ctx.originX);
|
||||
const y = Math.round(worldY - ctx.originY);
|
||||
return rectInside(ctx, x, y) ? rectIndexOf(ctx, x, y) : -1;
|
||||
}
|
||||
|
||||
export function rectFromBounds(bounds, name = "rect") {
|
||||
const x0 = Math.floor(Math.min(bounds.x0, bounds.x1));
|
||||
const y0 = Math.floor(Math.min(bounds.y0, bounds.y1));
|
||||
const x1 = Math.ceil(Math.max(bounds.x0, bounds.x1));
|
||||
const y1 = Math.ceil(Math.max(bounds.y0, bounds.y1));
|
||||
return createRectContext({ originX: x0, originY: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), name });
|
||||
}
|
||||
|
||||
export function rectBounds(ctx) {
|
||||
return { x0: ctx.originX, y0: ctx.originY, x1: ctx.originX + ctx.width, y1: ctx.originY + ctx.height };
|
||||
}
|
||||
|
||||
export function clampRectToBounds(rect, bounds) {
|
||||
const x0 = Math.max(bounds.x0 ?? 0, Math.floor(rect.x0));
|
||||
const y0 = Math.max(bounds.y0 ?? 0, Math.floor(rect.y0));
|
||||
const x1 = Math.min(bounds.x1 ?? Infinity, Math.ceil(rect.x1));
|
||||
const y1 = Math.min(bounds.y1 ?? Infinity, Math.ceil(rect.y1));
|
||||
return { x0, y0, x1: Math.max(x0, x1), y1: Math.max(y0, y1) };
|
||||
}
|
||||
|
||||
export function expandRectBounds(rect, margin, bounds = null) {
|
||||
const expanded = {
|
||||
x0: Math.floor(rect.x0) - margin,
|
||||
y0: Math.floor(rect.y0) - margin,
|
||||
x1: Math.ceil(rect.x1) + margin,
|
||||
y1: Math.ceil(rect.y1) + margin,
|
||||
};
|
||||
return bounds ? clampRectToBounds(expanded, bounds) : expanded;
|
||||
}
|
||||
|
||||
export function rectNeighbors8(ctx, x, y) {
|
||||
const out = [];
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (rectInside(ctx, nx, ny)) out.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rectDistanceToEdge(ctx, x, y) {
|
||||
return Math.min(x, y, ctx.width - 1 - x, ctx.height - 1 - y);
|
||||
}
|
||||
|
||||
export function createRectTerrainFields(ctx) {
|
||||
const size = ctx.size;
|
||||
const flowTo = new Int32Array(size);
|
||||
flowTo.fill(-1);
|
||||
return {
|
||||
elevation: new Float32Array(size),
|
||||
moisture: new Float32Array(size),
|
||||
slope: new Float32Array(size),
|
||||
sea: new Uint8Array(size),
|
||||
ocean: new Uint8Array(size),
|
||||
lake: new Uint8Array(size),
|
||||
river: new Float32Array(size),
|
||||
floodplain: new Float32Array(size),
|
||||
plain: new Float32Array(size),
|
||||
agriculture: new Float32Array(size),
|
||||
ridgeField: new Float32Array(size),
|
||||
valleyField: new Float32Array(size),
|
||||
basinField: new Float32Array(size),
|
||||
coastalLowland: new Float32Array(size),
|
||||
flowAccum: new Float32Array(size),
|
||||
erosionField: new Float32Array(size),
|
||||
depositionField: new Float32Array(size),
|
||||
arcSpineField: new Float32Array(size),
|
||||
branchRidgeField: new Float32Array(size),
|
||||
depositionalLowland: new Float32Array(size),
|
||||
alluvialFanField: new Float32Array(size),
|
||||
deltaField: new Float32Array(size),
|
||||
naturalBarrierScore: new Float32Array(size),
|
||||
flowTo,
|
||||
portSuitability: new Float32Array(size),
|
||||
crossingSuitability: new Float32Array(size),
|
||||
passSuitability: new Float32Array(size),
|
||||
visibleRavineField: new Float32Array(size),
|
||||
surfaceTextureField: new Float32Array(size),
|
||||
watershedId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||
naturalCompartmentId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||
regionId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRectCellField(value, ctx) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === ctx.size;
|
||||
}
|
||||
|
||||
export function rectQuantile(values, q) {
|
||||
const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (!arr.length) return 0;
|
||||
const p = clamp(q) * (arr.length - 1);
|
||||
const i = Math.floor(p);
|
||||
const f = p - i;
|
||||
return arr[i] + (arr[Math.min(arr.length - 1, i + 1)] - arr[i]) * f;
|
||||
}
|
||||
453
renderer.js
453
renderer.js
|
|
@ -1,12 +1,37 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
const pathVectorCache = new WeakMap();
|
||||
const coastlineCache = new WeakMap();
|
||||
const rasterBorderCache = new WeakMap();
|
||||
const baseImageCache = new WeakMap();
|
||||
const MAX_BASE_CACHE_IMAGES = 4;
|
||||
|
||||
function mapWidth(map) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
||||
}
|
||||
|
||||
function mapHeight(map) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
|
||||
}
|
||||
|
||||
function cellIndex(map, x, y) {
|
||||
return y * mapWidth(map) + x;
|
||||
}
|
||||
|
||||
function insideMap(map, x, y) {
|
||||
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
|
||||
}
|
||||
|
||||
function mapPixelWidth(map) {
|
||||
return mapWidth(map) * CELL_SIZE;
|
||||
}
|
||||
|
||||
function mapPixelHeight(map) {
|
||||
return mapHeight(map) * CELL_SIZE;
|
||||
}
|
||||
|
||||
function pointKey(p) {
|
||||
return `${p[0]},${p[1]}`;
|
||||
}
|
||||
|
|
@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
|
|||
if (cached) return cached;
|
||||
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const a = Boolean(map.sea[i]);
|
||||
if (x + 1 < MAP_W) {
|
||||
const b = Boolean(map.sea[indexOf(x + 1, y)]);
|
||||
if (x + 1 < w) {
|
||||
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
|
||||
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const b = Boolean(map.sea[indexOf(x, y + 1)]);
|
||||
if (y + 1 < h) {
|
||||
const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
|
||||
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -218,14 +245,6 @@ function vectorPath(path) {
|
|||
return simplified;
|
||||
}
|
||||
|
||||
function vectorPathMode(path, mode = "default") {
|
||||
if (mode !== "expressway") return vectorPath(path);
|
||||
if (!path || path.length < 2) return [];
|
||||
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
|
||||
const smoothIterations = path.length > 18 ? 3 : path.length > 8 ? 2 : 1;
|
||||
return simplifyRdp(chaikin(points, smoothIterations, false), CELL_SIZE * 0.18);
|
||||
}
|
||||
|
||||
function drawPolylinePoints(ctx, points) {
|
||||
if (!points || points.length < 2) return;
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
|
|
@ -252,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function sampleCellIndex(fx, fy) {
|
||||
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
|
||||
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
|
||||
return indexOf(x, y);
|
||||
function sampleCellIndex(map, fx, fy) {
|
||||
const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
|
||||
const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
|
||||
return cellIndex(map, x, y);
|
||||
}
|
||||
|
||||
function seaCoverageSample(map, fx, fy) {
|
||||
|
|
@ -269,7 +288,7 @@ function seaCoverageSample(map, fx, fy) {
|
|||
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
|
||||
];
|
||||
let sum = 0;
|
||||
for (const [ox, oy] of offsets) sum += fieldSample(map.sea, fx + ox, fy + oy);
|
||||
for (const [ox, oy] of offsets) sum += fieldSample(map, map.sea, fx + ox, fy + oy);
|
||||
return clamp(sum / offsets.length);
|
||||
}
|
||||
|
||||
|
|
@ -301,20 +320,21 @@ function blendOutside(color, isInside) {
|
|||
];
|
||||
}
|
||||
|
||||
function fieldSample(field, fx, fy) {
|
||||
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
|
||||
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
|
||||
function fieldSample(map, field, fx, fy) {
|
||||
if (!field) return 0;
|
||||
const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx));
|
||||
const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy));
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
|
||||
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
|
||||
const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
|
||||
const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
|
||||
const tx = sx - x0;
|
||||
const ty = sy - y0;
|
||||
|
||||
const a = field[indexOf(x0, y0)];
|
||||
const b = field[indexOf(x1, y0)];
|
||||
const c = field[indexOf(x0, y1)];
|
||||
const d = field[indexOf(x1, y1)];
|
||||
const a = field[cellIndex(map, x0, y0)] || 0;
|
||||
const b = field[cellIndex(map, x1, y0)] || 0;
|
||||
const c = field[cellIndex(map, x0, y1)] || 0;
|
||||
const d = field[cellIndex(map, x1, y1)] || 0;
|
||||
|
||||
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
||||
}
|
||||
|
|
@ -337,20 +357,20 @@ function interpolateColorStops(value, stops) {
|
|||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
const i = sampleCellIndex(map, fx, fy);
|
||||
const isInside = Boolean(map.prefectureMask[i]);
|
||||
|
||||
let color;
|
||||
|
||||
const waterCoverage = seaCoverageSample(map, fx, fy);
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4);
|
||||
const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
||||
|
||||
let landColor;
|
||||
if (mode === "development") {
|
||||
const dCity = distToNearest(map.modernCities, fx, fy);
|
||||
const urban = clamp(1 - dCity / 25);
|
||||
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
|
||||
const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban;
|
||||
const base = 235;
|
||||
landColor = [
|
||||
Math.round(base + density * 20),
|
||||
|
|
@ -360,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
} else {
|
||||
// 地形の基底色は標高のみに従わせる。
|
||||
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
||||
const e = fieldSample(map.elevation, fx, fy);
|
||||
const e = fieldSample(map, map.elevation, fx, fy);
|
||||
landColor = interpolateColorStops(clamp(e), [
|
||||
[0.20, [231, 236, 223]],
|
||||
[0.30, [223, 231, 214]],
|
||||
|
|
@ -386,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
const step = 0.50;
|
||||
const eC = fieldSample(map.elevation, fx, fy);
|
||||
const eL = fieldSample(map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map.elevation, fx, fy + step);
|
||||
const eC = fieldSample(map, map.elevation, fx, fy);
|
||||
const eL = fieldSample(map, map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map, map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map, map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map, map.elevation, fx, fy + step);
|
||||
|
||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||
|
|
@ -406,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) {
|
|||
const lz = 0.7071067811865476;
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
||||
|
||||
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
|
||||
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
|
||||
|
||||
|
|
@ -427,7 +447,7 @@ function terrainShadeContinuous(map, fx, fy) {
|
|||
}
|
||||
|
||||
function discreteColor(map, x, y, mode) {
|
||||
const i = indexOf(x, y);
|
||||
const i = cellIndex(map, x, y);
|
||||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
|
|
@ -459,35 +479,39 @@ function discreteColor(map, x, y, mode) {
|
|||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||
}
|
||||
|
||||
function baseCacheKey(mode, continuousTerrain) {
|
||||
function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
|
||||
const continuousModes = ["terrain", "development", "all"];
|
||||
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||
return `continuous:${mode === "all" ? "terrain" : mode}`;
|
||||
return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
||||
}
|
||||
return `discrete:${mode}`;
|
||||
return `discrete:${mode}:${scaleKey}`;
|
||||
}
|
||||
|
||||
function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
||||
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||
let cache = baseImageCache.get(map);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
baseImageCache.set(map, cache);
|
||||
}
|
||||
|
||||
const key = baseCacheKey(mode, continuousTerrain);
|
||||
let image = cache.get(key);
|
||||
if (image) return image;
|
||||
const key = baseCacheKey(mode, continuousTerrain, renderScale);
|
||||
let canvas = cache.get(key);
|
||||
if (canvas) return canvas;
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
const sourceWidth = mapPixelWidth(map);
|
||||
const sourceHeight = mapPixelHeight(map);
|
||||
const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
|
||||
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
||||
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
||||
const img = ctx.createImageData(width, height);
|
||||
const continuousModes = ["terrain", "development", "all"];
|
||||
|
||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||
for (let py = 0; py < height; py++) {
|
||||
const fy = py / CELL_SIZE;
|
||||
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
||||
for (let px = 0; px < width; px++) {
|
||||
const fx = px / CELL_SIZE;
|
||||
const fx = (px / Math.max(1, width)) * mapWidth(map);
|
||||
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
||||
const shade = terrainShadeContinuous(map, fx, fy);
|
||||
|
||||
|
|
@ -499,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b] = discreteColor(map, x, y, mode);
|
||||
for (let dy = 0; dy < CELL_SIZE; dy++) {
|
||||
for (let dx = 0; dx < CELL_SIZE; dx++) {
|
||||
|
|
@ -514,13 +540,53 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
|||
}
|
||||
}
|
||||
}
|
||||
cache.set(key, img);
|
||||
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const bctx = canvas.getContext("2d");
|
||||
bctx.putImageData(img, 0, 0);
|
||||
cache.set(key, canvas);
|
||||
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
|
||||
return img;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function drawBase(ctx, map, mode, continuousTerrain) {
|
||||
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
|
||||
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||
// putImageData ignores the current transform, so it made the terrain layer
|
||||
// appear unzoomed while vector layers scaled. Cache the raster into an
|
||||
// offscreen canvas and draw it with drawImage so wheel zoom applies to terrain.
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||
}
|
||||
|
||||
|
||||
function getRasterBorderSegments(map, fieldName) {
|
||||
const field = map?.[fieldName];
|
||||
if (!field) return [];
|
||||
let cacheByField = rasterBorderCache.get(field);
|
||||
if (cacheByField?.segments) return cacheByField.segments;
|
||||
const segments = [];
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const id = field[i];
|
||||
if (id < 0 || map.sea?.[i]) continue;
|
||||
if (x + 1 < w) {
|
||||
const ri = cellIndex(map, x + 1, y);
|
||||
const rid = field[ri];
|
||||
if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]);
|
||||
}
|
||||
if (y + 1 < h) {
|
||||
const di = cellIndex(map, x, y + 1);
|
||||
const did = field[di];
|
||||
if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rasterBorderCache.set(field, { segments });
|
||||
return segments;
|
||||
}
|
||||
|
||||
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||
|
|
@ -531,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
|||
for (let k = 0; k < path.length - 1; k++) {
|
||||
const [x1, y1] = path[k];
|
||||
const [x2, y2] = path[k + 1];
|
||||
const i1 = indexOf(x1, y1);
|
||||
const i2 = indexOf(x2, y2);
|
||||
const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
|
||||
const i2 = cellIndex(map, Math.round(x2), Math.round(y2));
|
||||
const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
|
|
@ -545,8 +611,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPath(ctx, path, color, width, dashed = false, mode = "default") {
|
||||
const points = vectorPathMode(path, mode);
|
||||
function drawPath(ctx, path, color, width, dashed = false) {
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
|
|
@ -566,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) {
|
|||
let cur = [];
|
||||
for (const p of path) {
|
||||
const [x, y] = p;
|
||||
const land = inside(x, y) && !map.sea[indexOf(x, y)];
|
||||
const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)];
|
||||
if (land) {
|
||||
cur.push(p);
|
||||
} else if (cur.length >= minCells) {
|
||||
|
|
@ -580,97 +646,8 @@ function landOnlySubpaths(map, path, minCells = 2) {
|
|||
return chunks;
|
||||
}
|
||||
|
||||
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") {
|
||||
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode);
|
||||
}
|
||||
|
||||
function specialTransportSubpaths(map, path, predicate, minCells = 1, includeShoulders = true, maxCoreCells = Infinity) {
|
||||
if (!path || path.length < 2) return [];
|
||||
const chunks = [];
|
||||
let cur = [];
|
||||
let core = 0;
|
||||
function flush(nextPoint = null) {
|
||||
if (cur.length && nextPoint && includeShoulders) cur.push(nextPoint);
|
||||
if (cur.length >= Math.max(2, minCells) && core <= maxCoreCells) chunks.push(cur);
|
||||
cur = [];
|
||||
core = 0;
|
||||
}
|
||||
for (let idx = 0; idx < path.length; idx++) {
|
||||
const [x, y] = path[idx];
|
||||
const i = inside(x, y) ? indexOf(x, y) : -1;
|
||||
const hit = i >= 0 && predicate(i, x, y);
|
||||
if (hit) {
|
||||
if (!cur.length && includeShoulders && idx > 0) cur.push(path[idx - 1]);
|
||||
cur.push(path[idx]);
|
||||
core++;
|
||||
} else if (cur.length) {
|
||||
flush(path[idx]);
|
||||
}
|
||||
}
|
||||
flush(null);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function drawOffsetPolyline(ctx, points, offsetPx) {
|
||||
if (!points || points.length < 2) return;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[Math.max(0, i - 1)];
|
||||
const cur = points[i];
|
||||
const next = points[Math.min(points.length - 1, i + 1)];
|
||||
const dx = next[0] - prev[0];
|
||||
const dy = next[1] - prev[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ox = -dy / len * offsetPx;
|
||||
const oy = dx / len * offsetPx;
|
||||
if (i === 0) ctx.moveTo(cur[0] + ox, cur[1] + oy);
|
||||
else ctx.lineTo(cur[0] + ox, cur[1] + oy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawDottedOutlinePath(ctx, path, color, width, offsetPx, mode = "default") {
|
||||
const points = vectorPathMode(path, mode);
|
||||
if (points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.setLineDash([1.8, 3.2]);
|
||||
drawOffsetPolyline(ctx, points, offsetPx);
|
||||
drawOffsetPolyline(ctx, points, -offsetPx);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBridgeOverlay(ctx, map, path, width, mode = "road") {
|
||||
const limit = mode === "expressway" ? 20 : 10;
|
||||
const bridgeChunks = specialTransportSubpaths(map, path, (i) => map.sea?.[i], 2, true, limit);
|
||||
for (const chunk of bridgeChunks) {
|
||||
const vectorMode = mode === "expressway" ? "expressway" : "default";
|
||||
drawPath(ctx, chunk, "rgba(255,255,255,0.98)", width + 2.0, false, vectorMode);
|
||||
drawPath(ctx, chunk, mode === "expressway" ? "rgba(135, 160, 135, 0.95)" : "rgba(245, 225, 130, 1)", width + 0.2, false, vectorMode);
|
||||
drawDottedOutlinePath(ctx, chunk, "rgba(55, 85, 130, 0.95)", 1.0, Math.max(1.8, width * 0.72), vectorMode);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTunnelOverlay(ctx, map, path, width, mode = "road") {
|
||||
if (mode !== "expressway") return;
|
||||
const tunnelChunks = specialTransportSubpaths(
|
||||
map,
|
||||
path,
|
||||
(i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82),
|
||||
2,
|
||||
true,
|
||||
10
|
||||
);
|
||||
for (const chunk of tunnelChunks) {
|
||||
drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway");
|
||||
}
|
||||
}
|
||||
|
||||
function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
|
||||
drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway");
|
||||
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
|
||||
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed);
|
||||
}
|
||||
|
||||
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
|
||||
|
|
@ -757,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
||||
|
||||
ctx.save();
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const areaMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (areaMask && !areaMask[i]) continue;
|
||||
const lu = map.landuse[i];
|
||||
|
|
@ -781,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
function drawDebugCells(ctx, map, field, color) {
|
||||
if (!field) return;
|
||||
ctx.save();
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const debugMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (!debugMask[i] || map.sea[i]) continue;
|
||||
const raw = field[i] || 0;
|
||||
|
|
@ -841,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) {
|
|||
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const id = ids[i];
|
||||
if (map.sea[i] || id < 0) continue;
|
||||
const [r, g, b] = prefectureRegionColor(id);
|
||||
|
|
@ -903,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) {
|
|||
const x = baseX + ox;
|
||||
const y = baseY + oy;
|
||||
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue;
|
||||
const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
|
||||
if (!overlaps.length) {
|
||||
ctx.lineJoin = "round";
|
||||
|
|
@ -946,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
|
|||
return used;
|
||||
}
|
||||
|
||||
function drawScaleBar(ctx) {
|
||||
const kmPerCell = 1;
|
||||
function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
|
||||
const kmPerCell = 0.5;
|
||||
const targetKm = 25;
|
||||
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
||||
const lengthPx = lengthCells * CELL_SIZE;
|
||||
const lengthPx = lengthCells * cellScreenSize;
|
||||
const margin = 14;
|
||||
const x = margin;
|
||||
const y = margin + 18;
|
||||
|
|
@ -989,14 +972,34 @@ export function drawMap(canvas, map, options) {
|
|||
const mode = options.mode || "all";
|
||||
const showFeatures = options.showFeatures !== false;
|
||||
const showLabels = options.showLabels !== false;
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const continuousTerrain = options.continuousTerrain !== false;
|
||||
const outputWidth = MAP_W * CELL_SIZE;
|
||||
const outputHeight = MAP_H * CELL_SIZE;
|
||||
const sourceWidth = mapPixelWidth(map);
|
||||
const sourceHeight = mapPixelHeight(map);
|
||||
const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight));
|
||||
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
||||
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
||||
const cellScreenSize = CELL_SIZE * drawScale;
|
||||
const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
|
||||
|
||||
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
||||
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
||||
ctx.clearRect(0, 0, outputWidth, outputHeight);
|
||||
ctx.save();
|
||||
ctx.translate(drawOffsetX, drawOffsetY);
|
||||
ctx.scale(drawScale, drawScale);
|
||||
ctx.__mapPixelWidth = sourceWidth;
|
||||
ctx.__mapPixelHeight = sourceHeight;
|
||||
const finish = () => {
|
||||
ctx.restore();
|
||||
drawScaleBar(ctx, cellScreenSize);
|
||||
delete ctx.__mapPixelWidth;
|
||||
delete ctx.__mapPixelHeight;
|
||||
};
|
||||
|
||||
// 1. Base Terrain & Urban
|
||||
drawBase(ctx, map, mode, true);
|
||||
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
||||
drawUrbanAreas(ctx, map, mode);
|
||||
const coastSegments = getCoastlineSegments(map);
|
||||
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
|
@ -1013,7 +1016,7 @@ export function drawMap(canvas, map, options) {
|
|||
let tailCount = 0;
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
const i = cellIndex(map, Math.round(x), Math.round(y));
|
||||
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
|
||||
peak = Math.max(peak, strength);
|
||||
if (k >= tailStart) {
|
||||
|
|
@ -1064,9 +1067,11 @@ export function drawMap(canvas, map, options) {
|
|||
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
|
||||
const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
|
||||
if (showAdmin && adminBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if (mode === "borders-debug") {
|
||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||
|
|
@ -1077,18 +1082,21 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
if (showTransportDebug) drawTransportDebug(ctx, map);
|
||||
|
||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
if (showPrefectureRegions && prefectureBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
if (!showPrefectureRegions) {
|
||||
const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
|
||||
const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : map.prefectureBorder;
|
||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
if (!showFeatures) return;
|
||||
if (!showFeatures) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
|
||||
|
|
@ -1115,8 +1123,8 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.expressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
for (const path of map.externalExpressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
|
||||
}
|
||||
|
||||
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
||||
|
|
@ -1134,11 +1142,8 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); }
|
||||
for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
|
||||
for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); }
|
||||
for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
|
||||
for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); }
|
||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||
}
|
||||
|
||||
// 6. Icons & Labels
|
||||
|
|
@ -1146,15 +1151,22 @@ export function drawMap(canvas, map, options) {
|
|||
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
||||
}
|
||||
|
||||
const settlementIconLabelPoints = ["all", "modern", "history"].includes(mode)
|
||||
? [
|
||||
...(map.markets || []).filter((p) => (p.population || 0) >= 3000),
|
||||
...(map.villages || []).filter((p) => (p.population || 0) >= 3000),
|
||||
...(mode === "history" ? (map.ports || []).filter((p) => p.portClass === "major" || p.portClass === "regional") : []),
|
||||
...(mode === "history" ? (map.castles || []) : []),
|
||||
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
||||
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.labelPriorityBase || (p.kind === "Village" ? 58 : p.kind === "Castle" ? 88 : 66) }))
|
||||
: [];
|
||||
|
||||
if (["all", "modern", "history"].includes(mode)) {
|
||||
for (const p of settlementIconLabelPoints) dot(ctx, p, p.kind === "Castle" ? 3.2 : 3.1, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.88)");
|
||||
}
|
||||
|
||||
if (showModern) {
|
||||
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||
const allLayerTowns = mode === "all"
|
||||
? [
|
||||
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
|
||||
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
||||
: [];
|
||||
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
|
||||
for (const p of map.modernCities) {
|
||||
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
|
||||
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
|
||||
|
|
@ -1175,37 +1187,28 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
|
||||
if (showLabels) {
|
||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
|
||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
const municipalLabels = (map.adminCenters || [])
|
||||
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
|
||||
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (mode === "borders-debug") {
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
drawLabels(ctx, prefectureLabels, Infinity);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
// In All mode, draw town/village dots above but suppress town/village labels.
|
||||
// The Admin/Municipal Borders view still labels municipal centers normally.
|
||||
const allLayerTowns = mode === "all"
|
||||
? [
|
||||
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
|
||||
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
|
||||
: [];
|
||||
const important = [
|
||||
...prefectureLabels,
|
||||
...map.modernCities,
|
||||
...(map.ports || []).map((p) => ({
|
||||
...p,
|
||||
labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85,
|
||||
})),
|
||||
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
|
||||
...map.ports,
|
||||
...(map.satelliteCities || []),
|
||||
...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })),
|
||||
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
|
||||
drawLabels(ctx, important, mode === "all" ? 95 : 60);
|
||||
...settlementIconLabelPoints,
|
||||
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
||||
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||
}
|
||||
drawScaleBar(ctx);
|
||||
finish();
|
||||
}
|
||||
|
|
|
|||
24
styles.css
24
styles.css
|
|
@ -1,6 +1,6 @@
|
|||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
button,input{font:inherit}
|
||||
button,input,select{font:inherit}
|
||||
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||
.app{min-height:100vh;padding:16px}
|
||||
.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto}
|
||||
|
|
@ -13,6 +13,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.sidebar{display:flex;flex-direction:column;gap:12px}
|
||||
.card{padding:14px}
|
||||
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
|
||||
.inline-label{margin-top:12px}
|
||||
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
|
||||
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
|
||||
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
|
||||
|
|
@ -60,7 +61,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.74);border:1px solid rgba(0,0,0,0.10);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.10);backdrop-filter:blur(4px);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||
.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
||||
|
||||
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
|
||||
|
|
@ -71,4 +72,21 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
||||
|
||||
.canvas-shell.panning{cursor:grabbing}
|
||||
.canvas-shell.panning .map-canvas{pointer-events:none}
|
||||
.map-selection-svg{position:absolute;inset:12px;z-index:18;display:none;pointer-events:none;overflow:visible}
|
||||
.map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}
|
||||
.map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)}
|
||||
.canvas-shell.selecting{cursor:crosshair}
|
||||
.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(26,115,232,0.86);background:rgba(26,115,232,0.16);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(26,115,232,0.20)}
|
||||
.primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed}
|
||||
.patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45}
|
||||
.patch-status.invalid{color:#b3261e;font-weight:600}
|
||||
.map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)}
|
||||
|
||||
.patch-variant-row{display:grid;grid-template-columns:1fr 92px;gap:8px;align-items:end;margin-top:12px}
|
||||
.patch-variant-label{margin-bottom:0;align-self:center}
|
||||
.patch-variant-input{padding:8px 10px;text-align:right;font-family:ui-monospace,monospace}
|
||||
.patch-button-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
|
||||
.patch-button-row .primary-button,.patch-button-row .secondary-button{margin-top:0;width:100%}
|
||||
.secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s}
|
||||
.secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)}
|
||||
.secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed}
|
||||
|
|
|
|||
30
test.js
30
test.js
|
|
@ -15,7 +15,7 @@ const result = document.getElementById("result");
|
|||
const logLines = [];
|
||||
let failed = 0;
|
||||
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([
|
||||
fetch("./names.js").then((response) => response.text()),
|
||||
fetch("./mapGenerator.js").then((response) => response.text()),
|
||||
fetch("./mapOutput.js").then((response) => response.text()),
|
||||
|
|
@ -24,6 +24,9 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende
|
|||
fetch("./app.js").then((response) => response.text()),
|
||||
fetch("./mapPipeline.js").then((response) => response.text()),
|
||||
fetch("./mapAdminStage.js").then((response) => response.text()),
|
||||
fetch("./mapPatch.js").then((response) => response.text()),
|
||||
fetch("./worldMap.js").then((response) => response.text()),
|
||||
fetch("./mapMunicipalCoherence.js").then((response) => response.text()),
|
||||
fetch("./test.js").then((response) => response.text()),
|
||||
]);
|
||||
|
||||
|
|
@ -675,6 +678,23 @@ try {
|
|||
const removedContextSuffixKey = "context" + "Suffixes";
|
||||
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
|
||||
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
|
||||
assert(mapPatchSource.includes("splitWorldPathByPatch") && mapPatchSource.includes("patchAffected"), "patch path merging is alpha-aware for lasso selections");
|
||||
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
|
||||
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
|
||||
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
|
||||
assert(mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes('patchGenerationMode = "legacy-full-pipeline"'), "patch generation remains full-pipeline simulation");
|
||||
assert(mapPatchSource.includes("patchTimings") && appSource.includes("result.patchTimings"), "patch generation returns and renders timing rows");
|
||||
assert(mapPatchSource.includes("PATCH_CANDIDATE_CACHE_LIMIT") && mapPatchSource.includes("patchCandidateCacheKey") && mapPatchSource.includes("cache.size > PATCH_CANDIDATE_CACHE_LIMIT"), "patch candidate cache is bounded and keyed");
|
||||
assert(mapPatchSource.includes("getPatchAlphaCache") && mapPatchSource.includes("getPatchSourceIndexCache"), "patch generation caches alpha and source-index grids for merge work");
|
||||
assert(mapPatchSource.includes("const searchRect = expandRect(rect, 16, world)") && mapPatchSource.includes("connectorAttempts"), "patch connector pathfinding uses bounded attempts and a shared search rect");
|
||||
assert(worldMapSource.includes("shiftSelectionShape") && worldMapSource.includes("selectionShape = shiftSelectionShape"), "world expansion shifts stored lasso patch polygons");
|
||||
assert(municipalSource.includes("reconcileMunicipalMetadata") && mapOutputSource.includes("reconcileMunicipalMetadata") && mapPatchSource.includes("reconcileMunicipalMetadata"), "municipal metadata is reconciled in output and patch repair");
|
||||
assert(appSource.includes("mappedPref === id") && !appSource.includes("return nearestNamedAdminCenter(map, cellIndex, maxDistance, null)"), "tooltip municipal fallback requires exact coherent ids");
|
||||
assert(mapPatchSource.includes("signedDist") && mapPatchSource.includes("patchBand"), "lasso patch alpha uses a feathered signed seam band");
|
||||
assert(mapPatchSource.includes("repairDiscreteSeamOwnership") && mapPatchSource.includes("chooseSeamOwnerValue"), "patch admin and prefecture seams use ownership repair");
|
||||
assert(mapPatchSource.includes("featherTerrainSeam") && mapPatchSource.includes("terrainFeatherCells"), "patch terrain transition bands are feather-smoothed");
|
||||
assert(mapPatchSource.includes("strongOnly") && mapPatchSource.includes("patchAlpha(x, y, rects, seed)"), "patch water topology avoids weak low-alpha seam flips");
|
||||
assert(!municipalSource.includes("Municipality ${id + 1}") && !municipalSource.includes("Prefecture ${id + 1}") && municipalSource.includes("自治${id + 1}") && municipalSource.includes("県域${id + 1}"), "fallback municipal and prefecture metadata avoids generic English labels");
|
||||
|
||||
assert(map.elevation.length === size, "elevation length matches map size");
|
||||
assert(map.sea.length === size, "sea length matches map size");
|
||||
|
|
@ -777,6 +797,14 @@ try {
|
|||
assert(map.externalGateways.length > 0, "external gateways exist");
|
||||
assert(map.minorRoads.length > 0, "minor roads exist");
|
||||
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
|
||||
const activeMunicipalityIds = new Set([...map.adminId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const centerIds = new Set(map.adminCenters.map((center) => center.adminId ?? center.municipalityId ?? center.adminNumericId).filter((id) => Number.isFinite(id)));
|
||||
assert(activeMunicipalityIds.size === map.adminCenters.length && [...activeMunicipalityIds].every((id) => centerIds.has(id)), "every active municipality has exactly one municipal center");
|
||||
assert(map.adminCenters.every((center) => activeMunicipalityIds.has(center.adminId ?? center.municipalityId ?? center.adminNumericId)), "municipal centers do not point to inactive municipalities");
|
||||
assert([...activeMunicipalityIds].every((id) => map.municipalityToPrefectureId?.[id] >= 0), "every active municipality maps to a prefecture");
|
||||
const activePrefectureIds = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const prefMetadataIds = new Set((map.prefectureRegions || []).map((region) => region.id));
|
||||
assert([...activePrefectureIds].every((id) => prefMetadataIds.has(id)), "every active prefecture id has metadata");
|
||||
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
|
||||
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
|
||||
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
||||
|
|
|
|||
201
worldMap.js
Normal file
201
worldMap.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||
|
||||
export const DEFAULT_WORLD_PADDING_X = MAP_W;
|
||||
export const DEFAULT_WORLD_PADDING_Y = MAP_H;
|
||||
|
||||
const NEGATIVE_ONE_FIELDS = new Set([
|
||||
"adminId",
|
||||
"prefectureRegionId",
|
||||
"regionId",
|
||||
"municipalityId",
|
||||
"naturalCompartmentId",
|
||||
"watershedId",
|
||||
]);
|
||||
|
||||
function isCellField(value) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||
}
|
||||
|
||||
function defaultForField(name, Constructor) {
|
||||
if (name === "sea") return 1;
|
||||
if (name === "elevation") return 0.08;
|
||||
if (name === "seaLevel") return undefined;
|
||||
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
||||
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : source.constructor;
|
||||
const out = new Constructor(worldWidth * worldHeight);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) out.fill(fallback);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
const srcRow = y * MAP_W;
|
||||
const dstRow = (originY + y) * worldWidth + originX;
|
||||
for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function sanitizeInitialWorldFields(fields, width, height) {
|
||||
const sea = fields.sea;
|
||||
if (!sea) return;
|
||||
const adminFields = ["adminId", "municipalityId", "prefectureRegionId"];
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const isSea = Boolean(sea[i]);
|
||||
if (fields.landMask) fields.landMask[i] = isSea ? 0 : (fields.prefectureMask?.[i] ? 1 : fields.landMask[i]);
|
||||
if (fields.humanRegionMask && isSea) fields.humanRegionMask[i] = 0;
|
||||
if (isSea) {
|
||||
for (const key of adminFields) if (fields[key]) fields[key][i] = -1;
|
||||
if (fields.landuse) fields.landuse[i] = 0;
|
||||
} else {
|
||||
if (fields.municipalityId && fields.adminId && fields.municipalityId[i] < 0 && fields.adminId[i] >= 0) fields.municipalityId[i] = fields.adminId[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorldMap(initialMap, options = {}) {
|
||||
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
|
||||
const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
|
||||
const worldWidth = MAP_W + paddingX * 2;
|
||||
const worldHeight = MAP_H + paddingY * 2;
|
||||
const originX = paddingX;
|
||||
const originY = paddingY;
|
||||
const fields = {};
|
||||
|
||||
for (const [name, value] of Object.entries(initialMap || {})) {
|
||||
if (isCellField(value)) fields[name] = makeWorldField(name, value, worldWidth, worldHeight, originX, originY);
|
||||
}
|
||||
|
||||
if (!fields.sea) {
|
||||
fields.sea = new Uint8Array(worldWidth * worldHeight);
|
||||
fields.sea.fill(1);
|
||||
}
|
||||
if (!fields.elevation) {
|
||||
fields.elevation = new Float32Array(worldWidth * worldHeight);
|
||||
fields.elevation.fill(0.08);
|
||||
}
|
||||
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
|
||||
|
||||
return {
|
||||
seed: initialMap?.seed ?? 0,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
originX,
|
||||
originY,
|
||||
sourceWidth: initialMap?.width || MAP_W,
|
||||
sourceHeight: initialMap?.height || MAP_H,
|
||||
sourceMap: initialMap,
|
||||
fields,
|
||||
invalidatedRects: [],
|
||||
humanPatchHistory: [],
|
||||
generatedRects: [{
|
||||
x0: originX,
|
||||
y0: originY,
|
||||
x1: originX + MAP_W,
|
||||
y1: originY + MAP_H,
|
||||
terrainType: initialMap?.terrainTemplate?.terrainType || initialMap?.terrainDebug?.terrainType || "auto",
|
||||
label: initialMap?.terrainTemplate?.terrainTypeLabel || initialMap?.terrainDebug?.terrainTypeLabel || "Initial generation",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialCamera(world) {
|
||||
return {
|
||||
x: world?.originX || 0,
|
||||
y: world?.originY || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
if (!camera || !world) return { x: 0, y: 0 };
|
||||
const maxX = Math.max(0, world.width - viewWidth);
|
||||
const maxY = Math.max(0, world.height - viewHeight);
|
||||
return {
|
||||
x: Math.min(Math.max(Math.round(camera.x || 0), 0), maxX),
|
||||
y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function expandRectByOffset(rect, dx, dy) {
|
||||
if (!rect) return rect;
|
||||
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
|
||||
}
|
||||
|
||||
function shiftSelectionShape(shape, dx, dy) {
|
||||
if (!shape?.polygon) return shape;
|
||||
return {
|
||||
...shape,
|
||||
x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0,
|
||||
y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0,
|
||||
x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1,
|
||||
y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1,
|
||||
polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
|
||||
};
|
||||
}
|
||||
|
||||
function shiftPatchMetadata(item, dx, dy) {
|
||||
const out = expandRectByOffset(item, dx, dy);
|
||||
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
|
||||
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
|
||||
}
|
||||
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shiftRectCollections(world, dx, dy) {
|
||||
if (!dx && !dy) return;
|
||||
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
|
||||
if (!Array.isArray(world[key])) continue;
|
||||
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
|
||||
}
|
||||
if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy);
|
||||
}
|
||||
|
||||
export function expandWorldMap(world, margins = {}) {
|
||||
if (!world) return { world, dx: 0, dy: 0, expanded: false };
|
||||
const left = Math.max(0, Math.floor(margins.left || 0));
|
||||
const right = Math.max(0, Math.floor(margins.right || 0));
|
||||
const top = Math.max(0, Math.floor(margins.top || 0));
|
||||
const bottom = Math.max(0, Math.floor(margins.bottom || 0));
|
||||
if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false };
|
||||
const oldWidth = world.width;
|
||||
const oldHeight = world.height;
|
||||
const newWidth = oldWidth + left + right;
|
||||
const newHeight = oldHeight + top + bottom;
|
||||
const newFields = {};
|
||||
for (const [name, field] of Object.entries(world.fields || {})) {
|
||||
if (!ArrayBuffer.isView(field)) continue;
|
||||
const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : field.constructor;
|
||||
const out = new Constructor(newWidth * newHeight);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) out.fill(fallback);
|
||||
for (let y = 0; y < oldHeight; y++) {
|
||||
const srcRow = y * oldWidth;
|
||||
const dstRow = (y + top) * newWidth + left;
|
||||
for (let x = 0; x < oldWidth; x++) out[dstRow + x] = field[srcRow + x];
|
||||
}
|
||||
newFields[name] = out;
|
||||
}
|
||||
world.width = newWidth;
|
||||
world.height = newHeight;
|
||||
world.originX += left;
|
||||
world.originY += top;
|
||||
world.fields = newFields;
|
||||
shiftRectCollections(world, left, top);
|
||||
return { world, dx: left, dy: top, expanded: true };
|
||||
}
|
||||
|
||||
export function ensureWorldPaddingForCamera(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, padding = Math.floor(Math.min(MAP_W, MAP_H) * 0.45)) {
|
||||
if (!world || !camera) return { dx: 0, dy: 0, expanded: false };
|
||||
const grow = Math.max(64, Math.floor(padding));
|
||||
const margins = { left: 0, right: 0, top: 0, bottom: 0 };
|
||||
if (camera.x < grow) margins.left = grow;
|
||||
if (camera.y < grow) margins.top = grow;
|
||||
if (camera.x + viewWidth > world.width - grow) margins.right = grow;
|
||||
if (camera.y + viewHeight > world.height - grow) margins.bottom = grow;
|
||||
return expandWorldMap(world, margins);
|
||||
}
|
||||
250
worldViewport.js
Normal file
250
worldViewport.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||
|
||||
const EMPTY_ARRAY_KEYS = new Set([
|
||||
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||
"mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders",
|
||||
"adminBorders", "externalGateways", "prefectureRegions",
|
||||
]);
|
||||
|
||||
const PATH_ARRAY_KEYS = new Set([
|
||||
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||
"mainRivers", "tributaryRivers", "smallStreams",
|
||||
]);
|
||||
|
||||
const SEGMENT_ARRAY_KEYS = new Set([
|
||||
"prefectureBorder", "regionalPrefectureBorders", "adminBorders",
|
||||
]);
|
||||
|
||||
const POINT_ARRAY_KEYS = new Set([
|
||||
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||
"externalGateways", "prefectureRegions",
|
||||
]);
|
||||
|
||||
const NEGATIVE_ONE_FIELDS = new Set([
|
||||
"adminId",
|
||||
"prefectureRegionId",
|
||||
"regionId",
|
||||
"municipalityId",
|
||||
"naturalCompartmentId",
|
||||
"watershedId",
|
||||
]);
|
||||
|
||||
function isCellField(value) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||
}
|
||||
|
||||
function defaultForField(name, Constructor) {
|
||||
if (name === "sea") return 1;
|
||||
if (name === "elevation") return 0.08;
|
||||
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function worldIndex(world, x, y) {
|
||||
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
||||
return y * world.width + x;
|
||||
}
|
||||
|
||||
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
||||
const Constructor = source.constructor;
|
||||
const out = new Constructor(viewWidth * viewHeight);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) out.fill(fallback);
|
||||
|
||||
const cx = Math.round(camera.x || 0);
|
||||
const cy = Math.round(camera.y || 0);
|
||||
for (let y = 0; y < viewHeight; y++) {
|
||||
for (let x = 0; x < viewWidth; x++) {
|
||||
const src = worldIndex(world, cx + x, cy + y);
|
||||
if (src >= 0) out[y * viewWidth + x] = source[src];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin;
|
||||
}
|
||||
|
||||
function transformPointObject(point, camera, originX, originY) {
|
||||
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
|
||||
return {
|
||||
...point,
|
||||
x: point.x + originX - camera.x,
|
||||
y: point.y + originY - camera.y,
|
||||
worldX: point.x + originX,
|
||||
worldY: point.y + originY,
|
||||
};
|
||||
}
|
||||
|
||||
function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
|
||||
return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight));
|
||||
}
|
||||
|
||||
function transformTuple(tuple, camera, originX, originY) {
|
||||
if (!Array.isArray(tuple) || tuple.length < 2) return null;
|
||||
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
|
||||
}
|
||||
|
||||
function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin;
|
||||
}
|
||||
|
||||
function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (const tuple of path || []) {
|
||||
const p = transformTuple(tuple, camera, originX, originY);
|
||||
const inside = tupleInside(p, margin, viewWidth, viewHeight);
|
||||
if (inside) {
|
||||
current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
} else if (current.length >= 2) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
} else {
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const out = [];
|
||||
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
|
||||
return out;
|
||||
}
|
||||
|
||||
function segmentIntersectsViewport(seg, margin = 4, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
if (!seg || seg.length < 2) return false;
|
||||
const xs = [seg[0][0], seg[1][0]];
|
||||
const ys = [seg[0][1], seg[1][1]];
|
||||
return Math.max(...xs) >= -margin && Math.min(...xs) <= viewWidth + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= viewHeight + margin;
|
||||
}
|
||||
|
||||
function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
return (segments || [])
|
||||
.map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
|
||||
.filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight));
|
||||
}
|
||||
|
||||
|
||||
function copySourceMapViewportField(source, camera, originX, originY, viewWidth, viewHeight) {
|
||||
if (!ArrayBuffer.isView(source) || typeof source.length !== "number" || source.length !== SIZE) return source;
|
||||
const out = new source.constructor(viewWidth * viewHeight);
|
||||
const cx = Math.round(camera.x || 0);
|
||||
const cy = Math.round(camera.y || 0);
|
||||
for (let y = 0; y < viewHeight; y++) {
|
||||
for (let x = 0; x < viewWidth; x++) {
|
||||
const sx = cx + x - originX;
|
||||
const sy = cy + y - originY;
|
||||
if (sx >= 0 && sy >= 0 && sx < MAP_W && sy < MAP_H) out[y * viewWidth + x] = source[sy * MAP_W + sx];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
if (!debug?.layers) return debug;
|
||||
const layers = { ...debug.layers };
|
||||
for (const [key, value] of Object.entries(layers)) {
|
||||
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, viewWidth, viewHeight);
|
||||
}
|
||||
// The original transport-debug potential layers are fixed-map arrays. Once the
|
||||
// viewport pans into patched world cells, synthesize equivalent viewport-sized
|
||||
// debug fields from the current world-backed fields so the color overlay moves
|
||||
// with the terrain instead of staying tied to the initial source map.
|
||||
if (viewport) {
|
||||
const n = viewWidth * viewHeight;
|
||||
const make = (fn) => {
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = fn(i);
|
||||
return out;
|
||||
};
|
||||
const sea = viewport.sea || new Uint8Array(n);
|
||||
const slope = viewport.slope || new Float32Array(n);
|
||||
const plain = viewport.plain || new Float32Array(n);
|
||||
const pop = viewport.populationDensity || viewport.settlementScore || new Float32Array(n);
|
||||
const road = viewport.roadInfluence || new Float32Array(n);
|
||||
const rail = viewport.railInfluence2 || viewport.stationInfluence || new Float32Array(n);
|
||||
layers.expresswayPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.72 + pop[i] * 0.42 + plain[i] * 0.22 - slope[i] * 0.52)));
|
||||
layers.nationalRoadPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.58 + pop[i] * 0.55 + plain[i] * 0.18 - slope[i] * 0.38)));
|
||||
layers.railPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, rail[i] * 0.72 + pop[i] * 0.38 + plain[i] * 0.26 - slope[i] * 0.72)));
|
||||
layers.slopeSeaPenalty = make((i) => sea[i] ? 1 : Math.max(0, Math.min(1, slope[i] * 1.35)));
|
||||
}
|
||||
if (Array.isArray(layers.components)) {
|
||||
layers.components = layers.components.map((component) => ({
|
||||
...component,
|
||||
cells: (component.cells || [])
|
||||
.map((cell) => transformTuple(cell, camera, originX, originY))
|
||||
.filter((cell) => tupleInside(cell, 0, viewWidth, viewHeight))
|
||||
.map(([x, y]) => [Math.round(x), Math.round(y)]),
|
||||
})).filter((component) => component.cells.length);
|
||||
}
|
||||
if (Array.isArray(layers.repairedSegments)) {
|
||||
layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY, viewWidth, viewHeight).map((path) => ({ ...repair, path })));
|
||||
}
|
||||
if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight);
|
||||
return { ...debug, layers };
|
||||
}
|
||||
|
||||
function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewHeight) {
|
||||
const viewport = { ...sourceMap };
|
||||
viewport.width = viewWidth;
|
||||
viewport.height = viewHeight;
|
||||
viewport.worldCamera = { x: camera.x, y: camera.y };
|
||||
viewport.worldOrigin = { x: world.originX, y: world.originY };
|
||||
viewport.generatedRects = world.generatedRects || [];
|
||||
for (const key of EMPTY_ARRAY_KEYS) if (Array.isArray(viewport[key])) viewport[key] = [];
|
||||
return viewport;
|
||||
}
|
||||
|
||||
export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) {
|
||||
const sourceMap = world?.sourceMap || {};
|
||||
const normalizedCamera = {
|
||||
x: Math.round(camera?.x || 0),
|
||||
y: Math.round(camera?.y || 0),
|
||||
};
|
||||
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
|
||||
|
||||
for (const [name, value] of Object.entries(world?.fields || {})) {
|
||||
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
|
||||
}
|
||||
|
||||
if (options.light) return viewport;
|
||||
|
||||
const originX = world?.originX || 0;
|
||||
const originY = world?.originY || 0;
|
||||
for (const key of POINT_ARRAY_KEYS) {
|
||||
if (!Array.isArray(sourceMap[key])) continue;
|
||||
viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters", viewWidth, viewHeight);
|
||||
}
|
||||
for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
|
||||
for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY, viewWidth, viewHeight);
|
||||
|
||||
if (sourceMap.adminDebug) {
|
||||
viewport.adminDebug = {
|
||||
...sourceMap.adminDebug,
|
||||
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
};
|
||||
}
|
||||
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
|
||||
if (sourceMap.neighborPrefectureDetails) {
|
||||
viewport.neighborPrefectureDetails = {
|
||||
...sourceMap.neighborPrefectureDetails,
|
||||
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||
};
|
||||
}
|
||||
|
||||
return viewport;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue