This commit is contained in:
33333-33333 2026-05-29 14:31:42 +09:00
commit f2d0306d96
13 changed files with 1434 additions and 516 deletions

388
app.js
View file

@ -28,6 +28,8 @@ const state = {
world: null, world: null,
camera: { x: 0, y: 0 }, camera: { x: 0, y: 0 },
viewportMap: null, viewportMap: null,
viewWidth: MAP_W,
viewHeight: MAP_H,
hoverEntities: [], hoverEntities: [],
selectionRect: null, selectionRect: null,
patchVariant: 0, patchVariant: 0,
@ -50,6 +52,7 @@ const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid"); const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats"); const statsEl = document.getElementById("stats");
const tooltipEl = document.getElementById("mapTooltip"); const tooltipEl = document.getElementById("mapTooltip");
const selectionSvgEl = document.getElementById("mapSelectionSvg");
const selectionEl = document.getElementById("mapSelection"); const selectionEl = document.getElementById("mapSelection");
const progressEl = document.getElementById("generationProgress"); const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage"); const progressStageEl = document.getElementById("generationProgressStage");
@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0; let generationStartedAt = 0;
let generationCurrentStage = ""; let generationCurrentStage = "";
let generationTimer = null; let generationTimer = null;
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
const dragState = { const dragState = {
mode: null, mode: null,
@ -67,6 +72,7 @@ const dragState = {
startCameraY: 0, startCameraY: 0,
selectStart: null, selectStart: null,
selectEnd: null, selectEnd: null,
selectPath: null,
pendingCamera: null, pendingCamera: null,
panRaf: null, panRaf: null,
}; };
@ -81,19 +87,31 @@ function clampZoom(value) {
return Math.min(Math.max(parsed, 0.55), 2.8); return Math.min(Math.max(parsed, 0.55), 2.8);
} }
function zoomTransform() { function viewportSizeForZoom(zoom = state.zoom) {
const zoom = clampZoom(state.zoom || 1); const z = clampZoom(zoom || 1);
const width = canvas.width || MAP_W * CELL_SIZE;
const height = canvas.height || MAP_H * CELL_SIZE;
return { return {
zoom, width: Math.max(1, Math.ceil(MAP_W / z)),
width, height: Math.max(1, Math.ceil(MAP_H / z)),
height,
tx: width * (1 - zoom) * 0.5,
ty: height * (1 - zoom) * 0.5,
}; };
} }
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
}
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() { function applyCanvasZoom() {
if (!canvas) return; if (!canvas) return;
state.zoom = clampZoom(state.zoom || 1); state.zoom = clampZoom(state.zoom || 1);
@ -105,38 +123,39 @@ function applyCanvasZoom() {
} }
function displayedCellSize() { function displayedCellSize() {
return CELL_SIZE * clampZoom(state.zoom || 1); return mapCellScreenSize();
} }
function screenPointToMapPixel(clientX, clientY) { function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null; if (!rect.width || !rect.height) return null;
const t = zoomTransform(); const map = activeMap();
const scaleX = t.width / rect.width; const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
const scaleY = t.height / rect.height; const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
const canvasX = (clientX - rect.left) * scaleX; const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
const canvasY = (clientY - rect.top) * scaleY; const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
return { const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
x: (canvasX - t.tx) / t.zoom, const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
y: (canvasY - t.ty) / t.zoom, return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
};
} }
function mapPixelToScreenPoint(px, py) { function mapPixelToScreenPoint(px, py) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const t = zoomTransform(); const map = activeMap();
const canvasX = t.tx + px * t.zoom; const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
const canvasY = t.ty + py * t.zoom; 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 { return {
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, t.width)), x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)), y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
}; };
} }
function mapClientToCell(event) { function mapClientToCell(event, sizeOverride = null) {
const map = activeMap(); const map = activeMap();
if (!map) return null; if (!map && !sizeOverride) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY); const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
if (!p) return null; if (!p) return null;
return { return {
x: Math.floor(p.x / CELL_SIZE), x: Math.floor(p.x / CELL_SIZE),
@ -160,24 +179,99 @@ function clampCanvasPoint(event) {
}; };
} }
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 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() { function updateSelectionOverlay() {
if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return; if (!dragState.selectPath?.length) return;
const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x); const liveShape = selectionPathToShape(dragState.selectPath);
const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y); const validation = validatePatchRect(liveShape, state.world);
const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x); drawSelectionSvg(dragState.selectPath, !validation.ok);
const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y); if (selectionEl) selectionEl.style.display = "none";
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 liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
const validation = validatePatchRect(liveRect, state.world);
selectionEl.classList.toggle("invalid", !validation.ok);
if (generatePatchButton) generatePatchButton.disabled = true; if (generatePatchButton) generatePatchButton.disabled = true;
if (alternativePatchButton) alternativePatchButton.disabled = true; if (alternativePatchButton) alternativePatchButton.disabled = true;
if (patchStatusEl) { if (patchStatusEl) {
const current = validation.rect || liveRect; const current = validation.rect || liveShape;
patchStatusEl.textContent = validation.ok patchStatusEl.textContent = validation.ok
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
: `${validation.reason} Current: ${formatRectSize(current)}.`; : `${validation.reason} Current: ${formatRectSize(current)}.`;
@ -186,9 +280,16 @@ function updateSelectionOverlay() {
} }
function updateSelectionOverlayFromWorldRect() { function updateSelectionOverlayFromWorldRect() {
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return; if (!state.selectionRect || !state.camera || !activeMap()) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return; 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 cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 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 p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
@ -205,6 +306,7 @@ function updateSelectionOverlayFromWorldRect() {
selectionEl.style.display = "none"; selectionEl.style.display = "none";
return; return;
} }
hideSelectionSvg();
selectionEl.style.display = "block"; selectionEl.style.display = "block";
selectionEl.style.left = `${canvas.offsetLeft + x0}px`; selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
selectionEl.style.top = `${canvas.offsetTop + y0}px`; selectionEl.style.top = `${canvas.offsetTop + y0}px`;
@ -218,7 +320,8 @@ function formatRectSize(rect) {
if (!rect) return "-"; if (!rect) return "-";
const w = Math.max(0, rect.x1 - rect.x0); const w = Math.max(0, rect.x1 - rect.x0);
const h = Math.max(0, rect.y1 - rect.y0); const h = Math.max(0, rect.y1 - rect.y0);
return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`; const area = Math.max(0, rect.areaCells || (w * h));
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
} }
function normalizePatchVariant(value) { function normalizePatchVariant(value) {
@ -251,7 +354,7 @@ function updatePatchControls() {
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok; if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
if (!patchStatusEl) return; if (!patchStatusEl) return;
if (!state.selectionRect) { if (!state.selectionRect) {
patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells. Current variant: ${variant}.`; 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); patchStatusEl.classList.toggle("invalid", false);
return; return;
} }
@ -262,7 +365,7 @@ function updatePatchControls() {
} }
const rects = buildPatchRects(validation.rect, state.world); const rects = buildPatchRects(validation.rect, state.world);
const patchText = state.lastPatchResult 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}, idmap ${state.lastPatchResult.humanGeography.continuityIdMappings || 0}/${state.lastPatchResult.humanGeography.continuityIdMappedCells || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` ? ` 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.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); patchStatusEl.classList.toggle("invalid", false);
@ -296,39 +399,31 @@ function schedulePanRedraw(camera) {
function hideSelectionOverlay() { function hideSelectionOverlay() {
dragState.selectStart = null; dragState.selectStart = null;
dragState.selectEnd = null; dragState.selectEnd = null;
dragState.selectPath = null;
state.selectionRect = null; state.selectionRect = null;
resetPatchVariant({ update: false }); resetPatchVariant({ update: false });
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none"; if (selectionEl) selectionEl.style.display = "none";
updatePatchControls(); updatePatchControls();
} }
function selectionPixelsToCells(start, end) { function selectionPixelsToCells(start, end) {
const map = activeMap(); const a = screenPointToWorldCell(start);
if (!map || !start || !end) return null; const b = screenPointToWorldCell(end);
const rect = canvas.getBoundingClientRect(); if (!a || !b) return null;
if (!rect.width || !rect.height) return null;
const toMapPixel = (p) => {
const t = zoomTransform();
const canvasX = p.x * (t.width / rect.width);
const canvasY = p.y * (t.height / rect.height);
return { x: (canvasX - t.tx) / t.zoom, y: (canvasY - t.ty) / t.zoom };
};
const a = toMapPixel(start);
const b = toMapPixel(end);
const localX0 = Math.floor(Math.min(a.x, b.x) / CELL_SIZE);
const localY0 = Math.floor(Math.min(a.y, b.y) / CELL_SIZE);
const localX1 = Math.ceil(Math.max(a.x, b.x) / CELL_SIZE);
const localY1 = Math.ceil(Math.max(a.y, b.y) / CELL_SIZE);
const cameraX = Math.round(state.camera?.x || 0);
const cameraY = Math.round(state.camera?.y || 0);
return { return {
x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1), x0: Math.min(a.x, b.x),
y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1), y0: Math.min(a.y, b.y),
x1: cameraX + Math.min(Math.max(localX1, 1), map.width), x1: Math.max(a.x, b.x) + 1,
y1: cameraY + Math.min(Math.max(localY1, 1), map.height), 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) { function handleMapPointerDown(event) {
if (!state.world || !canvasShell) return; if (!state.world || !canvasShell) return;
if (event.button !== 0 && event.button !== 2) return; if (event.button !== 0 && event.button !== 2) return;
@ -346,6 +441,7 @@ function handleMapPointerDown(event) {
dragState.mode = "select"; dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event); dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart; dragState.selectEnd = dragState.selectStart;
dragState.selectPath = [dragState.selectStart];
canvasShell.classList.add("selecting"); canvasShell.classList.add("selecting");
updateSelectionOverlay(); updateSelectionOverlay();
} }
@ -362,13 +458,16 @@ function handleMapPointerMove(event) {
const cellSize = Math.max(1, displayedCellSize()); const cellSize = Math.max(1, displayedCellSize());
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize); const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize); const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
const nextCamera = clampCameraToWorld({ const nextCamera = clampCameraForView({
x: dragState.startCameraX - dxCells, x: dragState.startCameraX - dxCells,
y: dragState.startCameraY - dyCells, y: dragState.startCameraY - dyCells,
}, state.world, MAP_W, MAP_H); }, viewportSizeForZoom(state.zoom));
schedulePanRedraw(nextCamera); schedulePanRedraw(nextCamera);
} else if (dragState.mode === "select") { } else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event); 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(); updateSelectionOverlay();
} }
@ -380,10 +479,13 @@ function handleMapPointerUp(event) {
const wasPanning = dragState.mode === "pan"; const wasPanning = dragState.mode === "pan";
if (dragState.mode === "select") { if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event); 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 width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
if (width >= 4 && height >= 4) { if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); state.selectionRect = shape;
state.lastPatchResult = null; state.lastPatchResult = null;
resetPatchVariant({ update: false }); resetPatchVariant({ update: false });
updateSelectionOverlayFromWorldRect(); updateSelectionOverlayFromWorldRect();
@ -553,7 +655,8 @@ function buildHoverEntities(map) {
function nearestEntity(items, x, y, maxDistance = 5) { function nearestEntity(items, x, y, maxDistance = 5) {
let best = null; let best = null;
let bestD = maxDistance; 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); const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; } if (d < bestD) { best = item; bestD = d; }
} }
@ -564,61 +667,106 @@ function landuseName(value) {
return landuseLabel(value); return landuseLabel(value);
} }
function numericIdOf(item) { const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) { 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]; const value = item?.[key];
if (Number.isFinite(value)) return value; if (Number.isFinite(value)) return Math.floor(value);
}
return 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; return null;
} }
function adminCenterForId(map, adminId) { function adminCenterForId(map, adminId) {
if (!map || adminId == null || adminId < 0) return null; if (!map || adminId == null || adminId < 0) return null;
const centers = map.adminCenters || []; const centers = (map.adminCenters || []).filter(Boolean);
const direct = centers[adminId]; const exact = centers.find((center) => hasNumericId(center, adminId));
if (direct && [direct.adminId, direct.municipalityId, direct.id, direct.adminNumericId].some((v) => v === adminId)) return direct; if (exact) return exact;
return centers.find((center) => [center?.adminId, center?.municipalityId, center?.id, center?.adminNumericId].some((v) => v === adminId)) || null; // 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) { function looksNumericName(name) {
if (!name) return true; if (!name) return true;
const text = String(name).trim(); const text = String(name).trim();
return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture)\s+-?\d+/i.test(text); return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
} }
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36) { function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
if (!map || cellIndex < 0) return null; if (!map || cellIndex < 0) return null;
const x = cellIndex % map.width; const x = cellIndex % map.width;
const y = Math.floor(cellIndex / map.width); const y = Math.floor(cellIndex / map.width);
let best = null; let best = null;
let bestD = maxDistance; let bestD = maxDistance;
for (const center of map.adminCenters || []) { for (const center of map.adminCenters || []) {
const name = center?.name || center?.municipalityName || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName; if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
if (looksNumericName(name) || !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); const d = Math.hypot(center.x - x, center.y - y);
if (d < bestD) { best = center; bestD = d; } if (d < bestD) { best = center; bestD = d; }
} }
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
return best; return best;
} }
function adminName(map, adminId, cellIndex = -1) { function adminName(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex); const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName; const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
if (!looksNumericName(name)) return name; if (name) return name;
return adminId >= 0 ? "Unnamed municipality" : "-"; return adminId >= 0 ? "Unnamed municipality" : "-";
} }
function adminPopulation(map, adminId, cellIndex = -1) { function adminPopulation(map, adminId, cellIndex = -1) {
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex); const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null; 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) { function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1; const id = map.prefectureRegionId?.[i] ?? -1;
const region = (map.prefectureRegions || []).find((p) => p.id === id); const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
if (region?.name && !looksNumericName(region.name)) return region.name; const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
const center = nearestNamedAdminCenter(map, i, 80); if (regionName) return regionName;
return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-"); const center = nearestNamedAdminCenter(map, i, 90);
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
} }
function updateTooltip(event) { function updateTooltip(event) {
@ -640,8 +788,9 @@ function updateTooltip(event) {
const hoveredAdminId = map.adminId?.[i] ?? -1; const hoveredAdminId = map.adminId?.[i] ?? -1;
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i); const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`; 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 const entityTitle = entity
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}` ? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
: coordinateText; : coordinateText;
const lines = [ const lines = [
`<strong>${entityTitle}</strong>`, `<strong>${entityTitle}</strong>`,
@ -721,23 +870,38 @@ function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return; if (!state.world || !activeMap()) return;
event.preventDefault(); event.preventDefault();
tooltipEl?.classList.remove("visible"); tooltipEl?.classList.remove("visible");
const beforeCell = mapClientToCell(event); const beforeSize = viewportSizeForZoom(state.zoom);
const beforeCell = mapClientToCell(event, beforeSize);
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null; const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
const oldZoom = clampZoom(state.zoom || 1); const oldZoom = clampZoom(state.zoom || 1);
const delta = event.deltaY < 0 ? 1.12 : 1 / 1.12; const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
const nextZoom = clampZoom(oldZoom * delta); const nextZoom = clampZoom(oldZoom * delta);
if (Math.abs(nextZoom - oldZoom) < 0.001) return; if (Math.abs(nextZoom - oldZoom) < 0.001) return;
state.zoom = nextZoom; state.zoom = nextZoom;
const nextSize = syncViewportSize();
if (beforeWorld) { if (beforeWorld) {
const afterCell = mapClientToCell(event); const afterCell = mapClientToCell(event, nextSize);
if (afterCell) { if (afterCell) {
state.camera = clampCameraToWorld({ state.camera = clampCameraForView({
x: beforeWorld.x - afterCell.x, x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y, y: beforeWorld.y - afterCell.y,
}, state.world, MAP_W, MAP_H); }, nextSize);
} }
} }
redraw({ fastTerrain: false });
// 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() { async function generateSelectedPatch() {
@ -786,26 +950,32 @@ async function generateAlternativePatch() {
function redraw(options = {}) { function redraw(options = {}) {
if (!state.world) return; if (!state.world) return;
const expansion = ensureWorldPaddingForCamera(state.world, state.camera, MAP_W, MAP_H); const viewSize = syncViewportSize();
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
if (expansion?.expanded) { if (expansion?.expanded) {
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) }; state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
if (state.selectionRect) { if (state.selectionRect) {
const dx = expansion.dx || 0;
const dy = expansion.dy || 0;
state.selectionRect = { state.selectionRect = {
x0: state.selectionRect.x0 + (expansion.dx || 0), ...state.selectionRect,
y0: state.selectionRect.y0 + (expansion.dy || 0), x0: state.selectionRect.x0 + dx,
x1: state.selectionRect.x1 + (expansion.dx || 0), y0: state.selectionRect.y0 + dy,
y1: state.selectionRect.y1 + (expansion.dy || 0), 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 = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H); state.camera = clampCameraForView(state.camera, viewSize);
state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H); state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
state.hoverEntities = buildHoverEntities(state.viewportMap); state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
drawMap(canvas, state.viewportMap, { drawMap(canvas, state.viewportMap, {
mode: state.mode, mode: state.mode,
showFeatures: state.showFeatures, showFeatures: state.showFeatures && !options.fastTerrain,
showLabels: state.showLabels && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain,
continuousTerrain: !options.fastTerrain, continuousTerrain: !options.fastTerrain,
fastTerrain: !!options.fastTerrain,
zoom: state.zoom || 1, zoom: state.zoom || 1,
}); });
applyCanvasZoom(); applyCanvasZoom();

View file

@ -19,6 +19,7 @@
<div class="canvas-shell"> <div class="canvas-shell">
<canvas id="mapCanvas" class="map-canvas"></canvas> <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="mapSelection" class="map-selection" aria-hidden="true"></div>
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite"> <div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
<div class="progress-title">Generating map...</div> <div class="progress-title">Generating map...</div>
@ -67,7 +68,7 @@
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button> <button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button> <button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
</div> </div>
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p> <p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
</section> </section>
<section class="card"> <section class="card">
@ -112,7 +113,7 @@
<section class="card legend"> <section class="card legend">
<div class="card-title">Notes</div> <div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag selects a regeneration area; use Patch Generation to write terrain into that area.</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> <p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
</section> </section>
</aside> </aside>

View file

@ -1960,9 +1960,9 @@ const premodernRoads = [];
// are through-corridors here, not automatic suburbanization generators. // are through-corridors here, not automatic suburbanization generators.
// A narrow field controls land-use attachment, while a broader field raises // A narrow field controls land-use attachment, while a broader field raises
// population density around trunk roads without painting a wide suburb band. // population density around trunk roads without painting a wide suburb band.
const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse"); let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional");
const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence"); let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional");
const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density"); let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional");
const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence"); const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence");
const stations = []; const stations = [];
@ -2522,6 +2522,18 @@ const premodernRoads = [];
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
markFeatureTiming("post-connectivity-guarantees"); markFeatureTiming("post-connectivity-guarantees");
const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
roadDensityInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 9.0, "road:density:final");
transportDebugLayers.finalRoadInfluenceRefresh = {
roadPaths: finalRoadInfluencePaths.length,
nationalRoads: nationalRoads.length,
externalRoads: externalRoads.length,
ringRoads: ringRoads.length,
};
const { landuse, populationDensity } = buildFeatureLanduse({ const { landuse, populationDensity } = buildFeatureLanduse({
seed, seed,
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,

View file

@ -1223,6 +1223,9 @@ export function finishMapOutput({
return applyOutputOptions({ return applyOutputOptions({
width: MAP_W, width: MAP_W,
height: MAP_H, 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, cellSize: CELL_SIZE,
terrainTemplate, terrainTemplate,
seaLevel, seaLevel,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateInitialTerrainRect, generateTerrainAndRivers } from "./mapTerrain.js"; import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js"; import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js"; import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js"; import { generateAdminLayout } from "./mapAdminStage.js";
@ -8,19 +8,91 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.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 = {}) { function generateInitialTerrain(seed, options = {}) {
// The original high-detail terrain system remains the default for full-map // Production generation is intentionally pinned to the legacy high-detail
// generation. Rect-native terrain is available as an explicit option and is // terrain/natural-compartment pipeline. Rect-native terrain helpers may remain
// used by patch generation, but the historical noise/natural-compartment // in the codebase for experiments, but they are not reachable from generateMap.
// pipeline is still the visual baseline for ordinary maps.
if (options?.rectNativeInitial === true) return generateInitialTerrainRect(seed, options);
return generateTerrainAndRivers(seed, options); return generateTerrainAndRivers(seed, options);
} }
function terrainStageLabel(options = {}) { function terrainStageLabel() {
return options?.rectNativeInitial === true return "Terrain, rivers, and natural compartments";
? "Rect-native terrain, rivers, and natural compartments"
: "Terrain, rivers, and natural compartments";
} }
function nowMs() { function nowMs() {
@ -60,7 +132,9 @@ async function timedStageAsync(timings, options, key, label, fn) {
export function generateMap(seedInput = 114514, options = {}) { 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: () => {} }; if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = []; const generationTimings = [];
@ -86,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) {
naturalCompartments, naturalCompartments,
} = terrain; } = terrain;
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const terrainWithGeography = { ...terrain, geography: geographyBasis }; 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 { const {
settlementScore, settlementScore,
villages, villages,
@ -109,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) {
villageInfluence, villageInfluence,
} = features; } = 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({ 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, 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, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
adminProgress: (event) => options?.onProgress?.({ adminProgress: (event) => options?.onProgress?.({
@ -127,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({ const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed, seed,
@ -139,11 +213,16 @@ export function generateMap(seedInput = 114514, options = {}) {
})); }));
output.generationTimings = generationTimings; output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
output.baseSeed = options.baseSeed;
output.effectiveSeed = seed;
output.generationContext = { ...options.generationContext };
return output; return output;
} }
export async function generateMapAsync(seedInput = 114514, options = {}) { 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: () => {} }; if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = []; const generationTimings = [];
@ -169,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
naturalCompartments, naturalCompartments,
} = terrain; } = terrain;
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain)); const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const terrainWithGeography = { ...terrain, geography: geographyBasis }; 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 { const {
settlementScore, settlementScore,
villages, villages,
@ -192,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
villageInfluence, villageInfluence,
} = features; } = 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({ 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, 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, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
adminProgress: (event) => options?.onProgress?.({ adminProgress: (event) => options?.onProgress?.({
@ -210,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({ const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed, seed,
@ -222,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
})); }));
output.generationTimings = generationTimings; output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
output.baseSeed = options.baseSeed;
output.effectiveSeed = seed;
output.generationContext = { ...options.generationContext };
return output; return output;
} }

View file

@ -1816,6 +1816,11 @@ export function generateInitialTerrainRect(seed, options = {}) {
export function generateTerrainAndRivers(seed, 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(); const fields = createMapFields();
fields.visibleRavineField = new Float32Array(SIZE); fields.visibleRavineField = new Float32Array(SIZE);
fields.surfaceTextureField = new Float32Array(SIZE); fields.surfaceTextureField = new Float32Array(SIZE);
@ -1835,9 +1840,11 @@ export function generateTerrainAndRivers(seed, options = {}) {
for (let y = 0; y < MAP_H; y++) { for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) { for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y); const i = indexOf(x, y);
const wx = originX + x;
const wy = originY + y;
const { px, py } = normalizeCoord(x, y); const { px, py } = normalizeCoord(x, y);
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; const terrainLarge = (fbm(wx * 0.65, wy * 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 terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); 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 e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
let mountainMaskMax = 0; let mountainMaskMax = 0;
@ -1862,7 +1869,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
const dx = (px - 0.5) * ASPECT; const dx = (px - 0.5) * ASPECT;
const dy = py - 0.5; const dy = py - 0.5;
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle); 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; 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; 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); const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
@ -1870,10 +1877,10 @@ export function generateTerrainAndRivers(seed, options = {}) {
e += lateralBranch * mountainMaskMax * 0.022; e += lateralBranch * mountainMaskMax * 0.022;
e -= passBreak * mountainMaskMax * 0.052; e -= passBreak * mountainMaskMax * 0.052;
} else { } else {
macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2; macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 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 += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
e += global * 0.020; e += global * 0.020;
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
@ -1886,7 +1893,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
if (terrainTemplate.terrainType === "setouchi_inland_sea") { if (terrainTemplate.terrainType === "setouchi_inland_sea") {
// Setouchi maps should have many low hills and island backbones rather // Setouchi maps should have many low hills and island backbones rather
// than a few high alpine ridges. Add broad low relief, then cap peaks. // 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); const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
e += lowHillMask * 0.145; e += lowHillMask * 0.145;
// Do not add the previous fine speckle uplift here: it created too many // Do not add the previous fine speckle uplift here: it created too many
@ -1897,7 +1904,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
} }
if (terrainTemplate.terrainType === "oceanic_archipelago") { if (terrainTemplate.terrainType === "oceanic_archipelago") {
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。 // 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
const islandCore = clamp((fbm(x * 0.82 + 41, y * 0.82 - 29, seed + 572) - 0.46) * 2.7); 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); const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
e += islandChain * 0.135; e += islandChain * 0.135;
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040; e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
@ -1910,7 +1917,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); 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); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); 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);
} }
} }
@ -1972,9 +1979,20 @@ export function generateTerrainAndRivers(seed, options = {}) {
mountainRatio: mountainCount / Math.max(1, landCount), mountainRatio: mountainCount / Math.max(1, landCount),
plainRatio: plainCount / Math.max(1, landCount), plainRatio: plainCount / Math.max(1, landCount),
mountainSystemCount: systems.length, mountainSystemCount: systems.length,
originX,
originY,
width: MAP_W,
height: MAP_H,
variant,
worldNative,
}; };
return { return {
originX,
originY,
width: MAP_W,
height: MAP_H,
generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
terrainTemplate, terrainTemplate,
seaLevel, seaLevel,
elevation, elevation,

View file

@ -10,12 +10,15 @@ import {
makeSpatialIndex, makeSpatialIndex,
makeUnionFind, makeUnionFind,
meanFieldAround as meanFieldAroundBase, meanFieldAround as meanFieldAroundBase,
nearestNetworkPoint,
packDebugField, packDebugField,
pathCumulativeLengths, pathCumulativeLengths,
pathAverageField, pathAverageField,
pathEndpoints,
pathLengthCells, pathLengthCells,
pointAtPathDistance, pointAtPathDistance,
sampledNetworkCells as sampledNetworkCellsBase, sampledNetworkCells as sampledNetworkCellsBase,
splitPathToValidCells as splitPathToValidCellsBase,
squaredDistance, squaredDistance,
TRANSPORT_ROUTE_POLICIES, TRANSPORT_ROUTE_POLICIES,
} from "./mapTransportUtils.js"; } from "./mapTransportUtils.js";
@ -1332,55 +1335,20 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchRasterNearContacts() { function stitchRasterNearContacts() {
const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 }; const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 };
function sampledCells(paths, step = 1) {
const cells = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const path = paths[pathId];
for (let k = 0; k < path.length; k += step) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
}
}
return cells;
}
function endpointListWithIds(paths) {
const out = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const p = paths[pathId];
if (!p || p.length < 2) continue;
out.push({ x: p[0][0], y: p[0][1], pathId });
const q = p[p.length - 1];
out.push({ x: q[0], y: q[1], pathId });
}
return out;
}
function nearestWithin(source, targets, radius, excludeSamePath = true) {
let best = null;
let bestD = radius + 1;
for (const t of targets) {
if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue;
const d = Math.hypot(source.x - t.x, source.y - t.y);
if (d > 0.01 && d < bestD) {
bestD = d;
best = t;
}
}
return best ? { target: best, d: bestD } : null;
}
function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) { function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) {
let added = 0; let added = 0;
const endpoints = endpointListWithIds(paths); const endpoints = pathEndpoints(paths);
for (const ep of endpoints) { for (const ep of endpoints) {
if (added >= maxAdds) break; if (added >= maxAdds) break;
if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue; if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue;
const near = nearestWithin(ep, targets, radius, true); const near = nearestNetworkPoint(ep, targets, radius);
if (near && addShortConnector(paths, ep, near.target, mode)) added++; if (near && addShortConnector(paths, ep, near.target, mode)) added++;
} }
return added; return added;
} }
const expresswayCells = sampledCells(expressways, 1); const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea);
const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1); const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea);
const localCells = sampledCells(minorRoads, 1); const localCells = sampledNetworkCellsBase(minorRoads, 1, sea);
debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62); debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62);
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0); debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0);
debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0); debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0);
@ -1390,11 +1358,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
// not share a raster cell. This addresses line simplification and diagonal // not share a raster cell. This addresses line simplification and diagonal
// near-contact cases not caught by endpoint-only repair. // near-contact cases not caught by endpoint-only repair.
const allLocalTargets = [...nationalCells, ...localCells]; const allLocalTargets = [...nationalCells, ...localCells];
const candidates = sampledCells([...nationalRoads, ...minorRoads], 3) const candidates = sampledNetworkCellsBase([...nationalRoads, ...minorRoads], 3, sea)
.sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377)); .sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377));
for (const c of candidates) { for (const c of candidates) {
if (debug.broadNearMisses >= 260) break; if (debug.broadNearMisses >= 260) break;
const near = nearestWithin(c, allLocalTargets, 4.75, true); const near = nearestNetworkPoint(c, allLocalTargets, 4.75);
if (!near) continue; if (!near) continue;
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads; const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
const mode = out === nationalRoads ? "national" : "local"; const mode = out === nationalRoads ? "national" : "local";
@ -1410,56 +1378,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function stitchLongLocalBranches() { function stitchLongLocalBranches() {
const debug = { localToTrunk: 0, localToLocal: 0 }; const debug = { localToTrunk: 0, localToLocal: 0 };
function cells(paths, step = 2) { const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea);
const out = []; const localCells = sampledNetworkCellsBase(minorRoads, 2, sea);
for (let pathId = 0; pathId < (paths || []).length; pathId++) { const eps = pathEndpoints(minorRoads)
const path = paths[pathId];
for (let k = 0; k < path.length; k += step) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId });
}
}
return out;
}
function endpoints(paths) {
const out = [];
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
const p = paths[pathId];
if (!p || p.length < 2) continue;
out.push({ x: p[0][0], y: p[0][1], pathId });
const q = p[p.length - 1];
out.push({ x: q[0], y: q[1], pathId });
}
return out;
}
function nearest(source, targets, radius, excludePath = null) {
let best = null;
let bestD = radius + 1;
for (const t of targets) {
if (excludePath != null && t.pathId === excludePath) continue;
const d = Math.hypot(source.x - t.x, source.y - t.y);
if (d > 0.01 && d < bestD) {
bestD = d;
best = t;
}
}
return best ? { ...best, d: bestD } : null;
}
const trunkCells = cells([...nationalRoads, ...externalRoads], 2);
const localCells = cells(minorRoads, 2);
const eps = endpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491)); .sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
for (const ep of eps) { for (const ep of eps) {
if (debug.localToTrunk >= 160) break; if (debug.localToTrunk >= 160) break;
const hit = nearest(ep, trunkCells, 20, null); const hit = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false });
if (!hit) continue; if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++; if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++;
} }
const eps2 = endpoints(minorRoads) const eps2 = pathEndpoints(minorRoads)
.sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493)); .sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493));
for (const ep of eps2) { for (const ep of eps2) {
if (debug.localToLocal >= 150) break; if (debug.localToLocal >= 150) break;
const hit = nearest(ep, localCells, 17, ep.pathId); const hit = nearestNetworkPoint(ep, localCells, 17, { excludePath: ep.pathId });
if (!hit) continue; if (!hit) continue;
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++; if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
} }
@ -1929,33 +1862,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return transportFields.local; return transportFields.local;
} }
function splitPathToValidCells(path, costField, minCells = 2) { function splitPathToValidCells(path, costField, minCells = 2) {
const chunks = []; return splitPathToValidCellsBase(path, (x, y) => {
let cur = [];
function valid(x, y) {
if (!inside(x, y)) return false; if (!inside(x, y)) return false;
const i = indexOf(x, y); const i = indexOf(x, y);
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF; return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
} }, minCells);
function pushPoint(x, y) {
if (!valid(x, y)) {
if (cur.length >= minCells) chunks.push(cur);
cur = [];
return;
}
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.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 (cur.length >= minCells) chunks.push(cur);
return chunks;
} }
function normalizeRoadGroups() { function normalizeRoadGroups() {
const groups = roadGroups(); const groups = roadGroups();

View file

@ -95,6 +95,61 @@ export function sampledNetworkCells(paths, step = 2, sea = null) {
return cells; 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) { export function pathCumulativeLengths(path) {
const cum = [0]; const cum = [0];
for (let k = 1; k < (path?.length || 0); k++) { for (let k = 1; k < (path?.length || 0); k++) {

View file

@ -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 segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap(); const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap(); const coastlineCache = new WeakMap();
const rasterBorderCache = new WeakMap();
const baseImageCache = new WeakMap(); const baseImageCache = new WeakMap();
const MAX_BASE_CACHE_IMAGES = 4; 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) { function pointKey(p) {
return `${p[0]},${p[1]}`; return `${p[0]},${p[1]}`;
} }
@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
if (cached) return cached; if (cached) return cached;
const segments = []; const segments = [];
for (let y = 0; y < MAP_H; y++) { const w = mapWidth(map);
for (let x = 0; x < MAP_W; x++) { const h = mapHeight(map);
const i = indexOf(x, y); 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]); const a = Boolean(map.sea[i]);
if (x + 1 < MAP_W) { if (x + 1 < w) {
const b = Boolean(map.sea[indexOf(x + 1, y)]); const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
} }
if (y + 1 < MAP_H) { if (y + 1 < h) {
const b = Boolean(map.sea[indexOf(x, y + 1)]); const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
} }
} }
@ -244,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO
ctx.restore(); ctx.restore();
} }
function sampleCellIndex(fx, fy) { function sampleCellIndex(map, fx, fy) {
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
return indexOf(x, y); return cellIndex(map, x, y);
} }
function seaCoverageSample(map, fx, fy) { function seaCoverageSample(map, fx, fy) {
@ -261,7 +288,7 @@ function seaCoverageSample(map, fx, fy) {
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26], [-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
]; ];
let sum = 0; 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); return clamp(sum / offsets.length);
} }
@ -293,20 +320,21 @@ function blendOutside(color, isInside) {
]; ];
} }
function fieldSample(field, fx, fy) { function fieldSample(map, field, fx, fy) {
const sx = Math.max(0, Math.min(MAP_W - 1, fx)); if (!field) return 0;
const sy = Math.max(0, Math.min(MAP_H - 1, fy)); 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 x0 = Math.floor(sx);
const y0 = Math.floor(sy); const y0 = Math.floor(sy);
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1)); const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1)); const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
const tx = sx - x0; const tx = sx - x0;
const ty = sy - y0; const ty = sy - y0;
const a = field[indexOf(x0, y0)]; const a = field[cellIndex(map, x0, y0)] || 0;
const b = field[indexOf(x1, y0)]; const b = field[cellIndex(map, x1, y0)] || 0;
const c = field[indexOf(x0, y1)]; const c = field[cellIndex(map, x0, y1)] || 0;
const d = field[indexOf(x1, y1)]; const d = field[cellIndex(map, x1, y1)] || 0;
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty); return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
} }
@ -329,20 +357,20 @@ function interpolateColorStops(value, stops) {
} }
function terrainColorContinuous(map, fx, fy, mode) { function terrainColorContinuous(map, fx, fy, mode) {
const i = sampleCellIndex(fx, fy); const i = sampleCellIndex(map, fx, fy);
const isInside = Boolean(map.prefectureMask[i]); const isInside = Boolean(map.prefectureMask[i]);
let color; let color;
const waterCoverage = seaCoverageSample(map, fx, fy); 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)]; const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
let landColor; let landColor;
if (mode === "development") { if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy); const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25); 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; const base = 235;
landColor = [ landColor = [
Math.round(base + density * 20), Math.round(base + density * 20),
@ -352,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
} else { } else {
// 地形の基底色は標高のみに従わせる。 // 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
const e = fieldSample(map.elevation, fx, fy); const e = fieldSample(map, map.elevation, fx, fy);
landColor = interpolateColorStops(clamp(e), [ landColor = interpolateColorStops(clamp(e), [
[0.20, [231, 236, 223]], [0.20, [231, 236, 223]],
[0.30, [223, 231, 214]], [0.30, [223, 231, 214]],
@ -378,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) {
function terrainShadeContinuous(map, fx, fy) { function terrainShadeContinuous(map, fx, fy) {
const step = 0.50; const step = 0.50;
const eC = fieldSample(map.elevation, fx, fy); const eC = fieldSample(map, map.elevation, fx, fy);
const eL = fieldSample(map.elevation, fx - step, fy); const eL = fieldSample(map, map.elevation, fx - step, fy);
const eR = fieldSample(map.elevation, fx + step, fy); const eR = fieldSample(map, map.elevation, fx + step, fy);
const eU = fieldSample(map.elevation, fx, fy - step); const eU = fieldSample(map, map.elevation, fx, fy - step);
const eD = fieldSample(map.elevation, fx, fy + step); const eD = fieldSample(map, map.elevation, fx, fy + step);
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。 // x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。 // 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
@ -398,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) {
const lz = 0.7071067811865476; const lz = 0.7071067811865476;
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48); 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 slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0; const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0; const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0; const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0; const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
const rvR = map.visibleRavineField ? fieldSample(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.visibleRavineField, fx, fy - 0.90) : 0; const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
const rvD = map.visibleRavineField ? fieldSample(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 ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18); const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
@ -419,7 +447,7 @@ function terrainShadeContinuous(map, fx, fy) {
} }
function discreteColor(map, x, y, mode) { function discreteColor(map, x, y, mode) {
const i = indexOf(x, y); const i = cellIndex(map, x, y);
let color; let color;
if (map.sea[i]) { if (map.sea[i]) {
@ -451,35 +479,39 @@ function discreteColor(map, x, y, mode) {
return blendOutside(color, Boolean(map.prefectureMask[i])); return blendOutside(color, Boolean(map.prefectureMask[i]));
} }
function baseCacheKey(mode, continuousTerrain) { function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
const continuousModes = ["terrain", "development", "all"]; const continuousModes = ["terrain", "development", "all"];
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
if (continuousTerrain && continuousModes.includes(mode)) { 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); let cache = baseImageCache.get(map);
if (!cache) { if (!cache) {
cache = new Map(); cache = new Map();
baseImageCache.set(map, cache); baseImageCache.set(map, cache);
} }
const key = baseCacheKey(mode, continuousTerrain); const key = baseCacheKey(mode, continuousTerrain, renderScale);
let image = cache.get(key); let canvas = cache.get(key);
if (image) return image; if (canvas) return canvas;
const width = MAP_W * CELL_SIZE; const sourceWidth = mapPixelWidth(map);
const height = MAP_H * CELL_SIZE; 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 img = ctx.createImageData(width, height);
const continuousModes = ["terrain", "development", "all"]; const continuousModes = ["terrain", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) { if (continuousTerrain && continuousModes.includes(mode)) {
for (let py = 0; py < height; py++) { 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++) { 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 [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
const shade = terrainShadeContinuous(map, fx, fy); const shade = terrainShadeContinuous(map, fx, fy);
@ -491,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
} }
} }
} else { } else {
for (let y = 0; y < MAP_H; y++) { const w = mapWidth(map);
for (let x = 0; x < MAP_W; x++) { 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); const [r, g, b] = discreteColor(map, x, y, mode);
for (let dy = 0; dy < CELL_SIZE; dy++) { for (let dy = 0; dy < CELL_SIZE; dy++) {
for (let dx = 0; dx < CELL_SIZE; dx++) { for (let dx = 0; dx < CELL_SIZE; dx++) {
@ -506,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); if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
return img; return canvas;
} }
function drawBase(ctx, map, mode, continuousTerrain) { function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0); // 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) { function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
@ -523,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
for (let k = 0; k < path.length - 1; k++) { for (let k = 0; k < path.length - 1; k++) {
const [x1, y1] = path[k]; const [x1, y1] = path[k];
const [x2, y2] = path[k + 1]; const [x2, y2] = path[k + 1];
const i1 = indexOf(x1, y1); const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
const i2 = indexOf(x2, y2); 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); 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.strokeStyle = color;
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
@ -558,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) {
let cur = []; let cur = [];
for (const p of path) { for (const p of path) {
const [x, y] = p; 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) { if (land) {
cur.push(p); cur.push(p);
} else if (cur.length >= minCells) { } else if (cur.length >= minCells) {
@ -660,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) {
const cbdColor = "rgba(215, 175, 172, 0.84)"; const cbdColor = "rgba(215, 175, 172, 0.84)";
ctx.save(); ctx.save();
for (let y = 0; y < MAP_H; y++) { const w = mapWidth(map);
for (let x = 0; x < MAP_W; x++) { const h = mapHeight(map);
const i = indexOf(x, y); 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; const areaMask = map.humanRegionMask || map.prefectureMask;
if (areaMask && !areaMask[i]) continue; if (areaMask && !areaMask[i]) continue;
const lu = map.landuse[i]; const lu = map.landuse[i];
@ -684,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) {
function drawDebugCells(ctx, map, field, color) { function drawDebugCells(ctx, map, field, color) {
if (!field) return; if (!field) return;
ctx.save(); ctx.save();
for (let y = 0; y < MAP_H; y++) { const w = mapWidth(map);
for (let x = 0; x < MAP_W; x++) { const h = mapHeight(map);
const i = indexOf(x, y); 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; const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue; if (!debugMask[i] || map.sea[i]) continue;
const raw = field[i] || 0; const raw = field[i] || 0;
@ -744,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) {
const alpha = mode === "borders-debug" ? 0.34 : 0.18; const alpha = mode === "borders-debug" ? 0.34 : 0.18;
ctx.save(); ctx.save();
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
for (let y = 0; y < MAP_H; y++) { const w = mapWidth(map);
for (let x = 0; x < MAP_W; x++) { const h = mapHeight(map);
const i = indexOf(x, y); for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const id = ids[i]; const id = ids[i];
if (map.sea[i] || id < 0) continue; if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id); const [r, g, b] = prefectureRegionColor(id);
@ -806,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) {
const x = baseX + ox; const x = baseX + ox;
const y = baseY + oy; const y = baseY + oy;
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 }; 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)); const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
if (!overlaps.length) { if (!overlaps.length) {
ctx.lineJoin = "round"; ctx.lineJoin = "round";
@ -849,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
return used; return used;
} }
function drawScaleBar(ctx) { function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
const kmPerCell = 0.5; const kmPerCell = 0.5;
const targetKm = 25; const targetKm = 25;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE; const lengthPx = lengthCells * cellScreenSize;
const margin = 14; const margin = 14;
const x = margin; const x = margin;
const y = margin + 18; const y = margin + 18;
@ -893,23 +973,33 @@ export function drawMap(canvas, map, options) {
const showFeatures = options.showFeatures !== false; const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false; const showLabels = options.showLabels !== false;
const continuousTerrain = options.continuousTerrain !== false; const continuousTerrain = options.continuousTerrain !== false;
const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8); 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;
const width = MAP_W * CELL_SIZE; if (canvas.width !== outputWidth) canvas.width = outputWidth;
const height = MAP_H * CELL_SIZE; if (canvas.height !== outputHeight) canvas.height = outputHeight;
if (canvas.width !== width) canvas.width = width; ctx.clearRect(0, 0, outputWidth, outputHeight);
if (canvas.height !== height) canvas.height = height;
ctx.clearRect(0, 0, width, height);
ctx.save(); ctx.save();
ctx.translate(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5); ctx.translate(drawOffsetX, drawOffsetY);
ctx.scale(zoom, zoom); ctx.scale(drawScale, drawScale);
ctx.__mapPixelWidth = sourceWidth;
ctx.__mapPixelHeight = sourceHeight;
const finish = () => { const finish = () => {
ctx.restore(); ctx.restore();
drawScaleBar(ctx); drawScaleBar(ctx, cellScreenSize);
delete ctx.__mapPixelWidth;
delete ctx.__mapPixelHeight;
}; };
// 1. Base Terrain & Urban // 1. Base Terrain & Urban
drawBase(ctx, map, mode, continuousTerrain); drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
drawUrbanAreas(ctx, map, mode); drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map); 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 }); drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
@ -926,7 +1016,7 @@ export function drawMap(canvas, map, options) {
let tailCount = 0; let tailCount = 0;
for (let k = 0; k < path.length; k++) { for (let k = 0; k < path.length; k++) {
const [x, y] = path[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; const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
peak = Math.max(peak, strength); peak = Math.max(peak, strength);
if (k >= tailStart) { if (k >= tailStart) {
@ -977,9 +1067,11 @@ export function drawMap(canvas, map, options) {
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode); const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode); if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
if (showAdmin && map.adminBorders) { const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : 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 }); const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); if (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") { if (mode === "borders-debug") {
// Keep the natural barrier heatmap subtle. A dense cell fill can look like // Keep the natural barrier heatmap subtle. A dense cell fill can look like
@ -990,13 +1082,13 @@ export function drawMap(canvas, map, options) {
} }
if (showTransportDebug) drawTransportDebug(ctx, map); if (showTransportDebug) drawTransportDebug(ctx, map);
if (showPrefectureRegions && map.regionalPrefectureBorders) { if (showPrefectureRegions && prefectureBorderSegments?.length) {
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, prefectureBorderSegments, "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 }); 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) { 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(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 }); drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
} }

View file

@ -72,6 +72,9 @@ 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} .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{cursor:grabbing}
.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} .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)} .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} .primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed}

View file

@ -39,6 +39,24 @@ function makeWorldField(name, source, worldWidth, worldHeight, originX, originY)
return out; 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 = {}) { export function createWorldMap(initialMap, options = {}) {
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X; 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 paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
@ -60,6 +78,7 @@ export function createWorldMap(initialMap, options = {}) {
fields.elevation = new Float32Array(worldWidth * worldHeight); fields.elevation = new Float32Array(worldWidth * worldHeight);
fields.elevation.fill(0.08); fields.elevation.fill(0.08);
} }
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
return { return {
seed: initialMap?.seed ?? 0, seed: initialMap?.seed ?? 0,

View file

@ -68,8 +68,8 @@ function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
return out; return out;
} }
function inViewportPoint(p, margin = 0) { function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin; return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin;
} }
function transformPointObject(point, camera, originX, originY) { function transformPointObject(point, camera, originX, originY) {
@ -83,9 +83,9 @@ function transformPointObject(point, camera, originX, originY) {
}; };
} }
function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false) { 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)); const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin)); return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin, viewWidth, viewHeight));
} }
function transformTuple(tuple, camera, originX, originY) { function transformTuple(tuple, camera, originX, originY) {
@ -93,16 +93,16 @@ function transformTuple(tuple, camera, originX, originY) {
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y]; return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
} }
function tupleInside(tuple, margin = 0) { function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin; return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin;
} }
function splitTransformedPath(path, camera, originX, originY, margin = 0) { function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
const chunks = []; const chunks = [];
let current = []; let current = [];
for (const tuple of path || []) { for (const tuple of path || []) {
const p = transformTuple(tuple, camera, originX, originY); const p = transformTuple(tuple, camera, originX, originY);
const inside = tupleInside(p, margin); const inside = tupleInside(p, margin, viewWidth, viewHeight);
if (inside) { if (inside) {
current.push([Math.round(p[0]), Math.round(p[1])]); current.push([Math.round(p[0]), Math.round(p[1])]);
} else if (current.length >= 2) { } else if (current.length >= 2) {
@ -116,23 +116,23 @@ function splitTransformedPath(path, camera, originX, originY, margin = 0) {
return chunks; return chunks;
} }
function transformPaths(paths, camera, originX, originY) { function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
const out = []; const out = [];
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY)); for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
return out; return out;
} }
function segmentIntersectsViewport(seg, margin = 4) { function segmentIntersectsViewport(seg, margin = 4, viewWidth = MAP_W, viewHeight = MAP_H) {
if (!seg || seg.length < 2) return false; if (!seg || seg.length < 2) return false;
const xs = [seg[0][0], seg[1][0]]; const xs = [seg[0][0], seg[1][0]];
const ys = [seg[0][1], seg[1][1]]; const ys = [seg[0][1], seg[1][1]];
return Math.max(...xs) >= -margin && Math.min(...xs) <= MAP_W + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= MAP_H + margin; 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) { function transformSegments(segments, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
return (segments || []) return (segments || [])
.map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)]) .map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
.filter(segmentIntersectsViewport); .filter((seg) => segmentIntersectsViewport(seg, 4, viewWidth, viewHeight));
} }
@ -151,18 +151,18 @@ function copySourceMapViewportField(source, camera, originX, originY, viewWidth,
return out; return out;
} }
function transformTransportDebug(debug, camera, originX, originY, viewport = null) { function transformTransportDebug(debug, camera, originX, originY, viewport = null, viewWidth = MAP_W, viewHeight = MAP_H) {
if (!debug?.layers) return debug; if (!debug?.layers) return debug;
const layers = { ...debug.layers }; const layers = { ...debug.layers };
for (const [key, value] of Object.entries(layers)) { for (const [key, value] of Object.entries(layers)) {
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, MAP_W, MAP_H); 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 // The original transport-debug potential layers are fixed-map arrays. Once the
// viewport pans into patched world cells, synthesize equivalent viewport-sized // viewport pans into patched world cells, synthesize equivalent viewport-sized
// debug fields from the current world-backed fields so the color overlay moves // 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. // with the terrain instead of staying tied to the initial source map.
if (viewport) { if (viewport) {
const n = MAP_W * MAP_H; const n = viewWidth * viewHeight;
const make = (fn) => { const make = (fn) => {
const out = new Float32Array(n); const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = fn(i); for (let i = 0; i < n; i++) out[i] = fn(i);
@ -184,14 +184,14 @@ function transformTransportDebug(debug, camera, originX, originY, viewport = nul
...component, ...component,
cells: (component.cells || []) cells: (component.cells || [])
.map((cell) => transformTuple(cell, camera, originX, originY)) .map((cell) => transformTuple(cell, camera, originX, originY))
.filter((cell) => tupleInside(cell, 0)) .filter((cell) => tupleInside(cell, 0, viewWidth, viewHeight))
.map(([x, y]) => [Math.round(x), Math.round(y)]), .map(([x, y]) => [Math.round(x), Math.round(y)]),
})).filter((component) => component.cells.length); })).filter((component) => component.cells.length);
} }
if (Array.isArray(layers.repairedSegments)) { if (Array.isArray(layers.repairedSegments)) {
layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY).map((path) => ({ ...repair, path }))); 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); if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY, 36, false, viewWidth, viewHeight);
return { ...debug, layers }; return { ...debug, layers };
} }
@ -206,7 +206,7 @@ function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewH
return viewport; return viewport;
} }
export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H) { export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, options = {}) {
const sourceMap = world?.sourceMap || {}; const sourceMap = world?.sourceMap || {};
const normalizedCamera = { const normalizedCamera = {
x: Math.round(camera?.x || 0), x: Math.round(camera?.x || 0),
@ -218,29 +218,31 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight); viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
} }
if (options.light) return viewport;
const originX = world?.originX || 0; const originX = world?.originX || 0;
const originY = world?.originY || 0; const originY = world?.originY || 0;
for (const key of POINT_ARRAY_KEYS) { for (const key of POINT_ARRAY_KEYS) {
if (!Array.isArray(sourceMap[key])) continue; if (!Array.isArray(sourceMap[key])) continue;
viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters"); 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); 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); 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) { if (sourceMap.adminDebug) {
viewport.adminDebug = { viewport.adminDebug = {
...sourceMap.adminDebug, ...sourceMap.adminDebug,
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY), compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY), lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
}; };
} }
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport); if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
if (sourceMap.neighborPrefectureDetails) { if (sourceMap.neighborPrefectureDetails) {
viewport.neighborPrefectureDetails = { viewport.neighborPrefectureDetails = {
...sourceMap.neighborPrefectureDetails, ...sourceMap.neighborPrefectureDetails,
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY), cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY), adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY), roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
}; };
} }