hmm
This commit is contained in:
parent
112e6bf86b
commit
f2d0306d96
13 changed files with 1434 additions and 516 deletions
388
app.js
388
app.js
|
|
@ -28,6 +28,8 @@ const state = {
|
|||
world: null,
|
||||
camera: { x: 0, y: 0 },
|
||||
viewportMap: null,
|
||||
viewWidth: MAP_W,
|
||||
viewHeight: MAP_H,
|
||||
hoverEntities: [],
|
||||
selectionRect: null,
|
||||
patchVariant: 0,
|
||||
|
|
@ -50,6 +52,7 @@ const showLabelsInput = document.getElementById("showLabels");
|
|||
const modeGrid = document.getElementById("modeGrid");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const tooltipEl = document.getElementById("mapTooltip");
|
||||
const selectionSvgEl = document.getElementById("mapSelectionSvg");
|
||||
const selectionEl = document.getElementById("mapSelection");
|
||||
const progressEl = document.getElementById("generationProgress");
|
||||
const progressStageEl = document.getElementById("generationProgressStage");
|
||||
|
|
@ -57,6 +60,8 @@ const progressTimingsEl = document.getElementById("generationProgressTimings");
|
|||
let generationStartedAt = 0;
|
||||
let generationCurrentStage = "";
|
||||
let generationTimer = null;
|
||||
let zoomRedrawRaf = null;
|
||||
let zoomSettledTimer = null;
|
||||
|
||||
const dragState = {
|
||||
mode: null,
|
||||
|
|
@ -67,6 +72,7 @@ const dragState = {
|
|||
startCameraY: 0,
|
||||
selectStart: null,
|
||||
selectEnd: null,
|
||||
selectPath: null,
|
||||
pendingCamera: null,
|
||||
panRaf: null,
|
||||
};
|
||||
|
|
@ -81,19 +87,31 @@ function clampZoom(value) {
|
|||
return Math.min(Math.max(parsed, 0.55), 2.8);
|
||||
}
|
||||
|
||||
function zoomTransform() {
|
||||
const zoom = clampZoom(state.zoom || 1);
|
||||
const width = canvas.width || MAP_W * CELL_SIZE;
|
||||
const height = canvas.height || MAP_H * CELL_SIZE;
|
||||
function viewportSizeForZoom(zoom = state.zoom) {
|
||||
const z = clampZoom(zoom || 1);
|
||||
return {
|
||||
zoom,
|
||||
width,
|
||||
height,
|
||||
tx: width * (1 - zoom) * 0.5,
|
||||
ty: height * (1 - zoom) * 0.5,
|
||||
width: Math.max(1, Math.ceil(MAP_W / z)),
|
||||
height: Math.max(1, Math.ceil(MAP_H / z)),
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!canvas) return;
|
||||
state.zoom = clampZoom(state.zoom || 1);
|
||||
|
|
@ -105,38 +123,39 @@ function applyCanvasZoom() {
|
|||
}
|
||||
|
||||
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();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const t = zoomTransform();
|
||||
const scaleX = t.width / rect.width;
|
||||
const scaleY = t.height / rect.height;
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
return {
|
||||
x: (canvasX - t.tx) / t.zoom,
|
||||
y: (canvasY - t.ty) / t.zoom,
|
||||
};
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width);
|
||||
const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height);
|
||||
const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE };
|
||||
}
|
||||
|
||||
function mapPixelToScreenPoint(px, py) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = zoomTransform();
|
||||
const canvasX = t.tx + px * t.zoom;
|
||||
const canvasY = t.ty + py * t.zoom;
|
||||
const map = activeMap();
|
||||
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
|
||||
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
|
||||
const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth);
|
||||
const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight);
|
||||
return {
|
||||
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, t.width)),
|
||||
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)),
|
||||
x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)),
|
||||
y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapClientToCell(event) {
|
||||
function mapClientToCell(event, sizeOverride = null) {
|
||||
const map = activeMap();
|
||||
if (!map) return null;
|
||||
const p = screenPointToMapPixel(event.clientX, event.clientY);
|
||||
if (!map && !sizeOverride) return null;
|
||||
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
|
||||
if (!p) return null;
|
||||
return {
|
||||
x: Math.floor(p.x / CELL_SIZE),
|
||||
|
|
@ -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() {
|
||||
if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return;
|
||||
const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x);
|
||||
const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y);
|
||||
const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x);
|
||||
const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y);
|
||||
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 (!dragState.selectPath?.length) return;
|
||||
const liveShape = selectionPathToShape(dragState.selectPath);
|
||||
const validation = validatePatchRect(liveShape, state.world);
|
||||
drawSelectionSvg(dragState.selectPath, !validation.ok);
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
if (generatePatchButton) generatePatchButton.disabled = true;
|
||||
if (alternativePatchButton) alternativePatchButton.disabled = true;
|
||||
if (patchStatusEl) {
|
||||
const current = validation.rect || liveRect;
|
||||
const current = validation.rect || liveShape;
|
||||
patchStatusEl.textContent = validation.ok
|
||||
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
|
||||
: `${validation.reason} Current: ${formatRectSize(current)}.`;
|
||||
|
|
@ -186,9 +280,16 @@ function updateSelectionOverlay() {
|
|||
}
|
||||
|
||||
function updateSelectionOverlayFromWorldRect() {
|
||||
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
|
||||
if (!state.selectionRect || !state.camera || !activeMap()) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) {
|
||||
const points = state.selectionRect.polygon.map(worldCellToOverlayPoint)
|
||||
.map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) }));
|
||||
drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok);
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const cameraX = Math.round(state.camera.x || 0);
|
||||
const cameraY = Math.round(state.camera.y || 0);
|
||||
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
|
||||
|
|
@ -205,6 +306,7 @@ function updateSelectionOverlayFromWorldRect() {
|
|||
selectionEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
hideSelectionSvg();
|
||||
selectionEl.style.display = "block";
|
||||
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
|
||||
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
|
||||
|
|
@ -218,7 +320,8 @@ function formatRectSize(rect) {
|
|||
if (!rect) return "-";
|
||||
const w = Math.max(0, rect.x1 - rect.x0);
|
||||
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) {
|
||||
|
|
@ -251,7 +354,7 @@ function updatePatchControls() {
|
|||
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
|
||||
if (!patchStatusEl) return;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
|
@ -262,7 +365,7 @@ function updatePatchControls() {
|
|||
}
|
||||
const rects = buildPatchRects(validation.rect, state.world);
|
||||
const patchText = state.lastPatchResult
|
||||
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, 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.classList.toggle("invalid", false);
|
||||
|
|
@ -296,39 +399,31 @@ function schedulePanRedraw(camera) {
|
|||
function hideSelectionOverlay() {
|
||||
dragState.selectStart = null;
|
||||
dragState.selectEnd = null;
|
||||
dragState.selectPath = null;
|
||||
state.selectionRect = null;
|
||||
resetPatchVariant({ update: false });
|
||||
hideSelectionSvg();
|
||||
if (selectionEl) selectionEl.style.display = "none";
|
||||
updatePatchControls();
|
||||
}
|
||||
|
||||
function selectionPixelsToCells(start, end) {
|
||||
const map = activeMap();
|
||||
if (!map || !start || !end) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
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);
|
||||
const a = screenPointToWorldCell(start);
|
||||
const b = screenPointToWorldCell(end);
|
||||
if (!a || !b) return null;
|
||||
return {
|
||||
x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1),
|
||||
y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1),
|
||||
x1: cameraX + Math.min(Math.max(localX1, 1), map.width),
|
||||
y1: cameraY + Math.min(Math.max(localY1, 1), map.height),
|
||||
x0: Math.min(a.x, b.x),
|
||||
y0: Math.min(a.y, b.y),
|
||||
x1: Math.max(a.x, b.x) + 1,
|
||||
y1: Math.max(a.y, b.y) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function selectionPixelsToShape(start, end, path = null) {
|
||||
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
|
||||
return selectionPixelsToCells(start, end);
|
||||
}
|
||||
|
||||
function handleMapPointerDown(event) {
|
||||
if (!state.world || !canvasShell) return;
|
||||
if (event.button !== 0 && event.button !== 2) return;
|
||||
|
|
@ -346,6 +441,7 @@ function handleMapPointerDown(event) {
|
|||
dragState.mode = "select";
|
||||
dragState.selectStart = clampCanvasPoint(event);
|
||||
dragState.selectEnd = dragState.selectStart;
|
||||
dragState.selectPath = [dragState.selectStart];
|
||||
canvasShell.classList.add("selecting");
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
|
@ -362,13 +458,16 @@ function handleMapPointerMove(event) {
|
|||
const cellSize = Math.max(1, displayedCellSize());
|
||||
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
|
||||
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
|
||||
const nextCamera = clampCameraToWorld({
|
||||
const nextCamera = clampCameraForView({
|
||||
x: dragState.startCameraX - dxCells,
|
||||
y: dragState.startCameraY - dyCells,
|
||||
}, state.world, MAP_W, MAP_H);
|
||||
}, viewportSizeForZoom(state.zoom));
|
||||
schedulePanRedraw(nextCamera);
|
||||
} else if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
|
||||
dragState.selectPath = [...(dragState.selectPath || []), dragState.selectEnd];
|
||||
}
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
||||
|
|
@ -380,10 +479,13 @@ function handleMapPointerUp(event) {
|
|||
const wasPanning = dragState.mode === "pan";
|
||||
if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
|
||||
else dragState.selectPath = [...dragState.selectPath, dragState.selectEnd];
|
||||
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
|
||||
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
|
||||
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
|
||||
if (width >= 4 && height >= 4) {
|
||||
state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
|
||||
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
|
||||
state.selectionRect = shape;
|
||||
state.lastPatchResult = null;
|
||||
resetPatchVariant({ update: false });
|
||||
updateSelectionOverlayFromWorldRect();
|
||||
|
|
@ -553,7 +655,8 @@ function buildHoverEntities(map) {
|
|||
function nearestEntity(items, x, y, maxDistance = 5) {
|
||||
let best = null;
|
||||
let bestD = maxDistance;
|
||||
for (const item of items) {
|
||||
for (const item of items || []) {
|
||||
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
|
||||
const d = Math.hypot(item.x - x, item.y - y);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
|
|
@ -564,61 +667,106 @@ function landuseName(value) {
|
|||
return landuseLabel(value);
|
||||
}
|
||||
|
||||
function numericIdOf(item) {
|
||||
for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) {
|
||||
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"];
|
||||
const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"];
|
||||
const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"];
|
||||
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"];
|
||||
const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"];
|
||||
|
||||
function numericIdOf(item, keys = ADMIN_ID_KEYS) {
|
||||
for (const key of keys) {
|
||||
const value = item?.[key];
|
||||
if (Number.isFinite(value)) return 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;
|
||||
}
|
||||
|
||||
function adminCenterForId(map, adminId) {
|
||||
if (!map || adminId == null || adminId < 0) return null;
|
||||
const centers = map.adminCenters || [];
|
||||
const direct = centers[adminId];
|
||||
if (direct && [direct.adminId, direct.municipalityId, direct.id, direct.adminNumericId].some((v) => v === adminId)) return direct;
|
||||
return centers.find((center) => [center?.adminId, center?.municipalityId, center?.id, center?.adminNumericId].some((v) => v === adminId)) || null;
|
||||
const centers = (map.adminCenters || []).filter(Boolean);
|
||||
const exact = centers.find((center) => hasNumericId(center, adminId));
|
||||
if (exact) return exact;
|
||||
// Some legacy/admin debug arrays were once addressed by array index. Keep this
|
||||
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
|
||||
const direct = map.adminCenters?.[adminId];
|
||||
return hasNumericId(direct, adminId) ? direct : null;
|
||||
}
|
||||
|
||||
function looksNumericName(name) {
|
||||
if (!name) return true;
|
||||
const text = String(name).trim();
|
||||
return !text || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture)\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;
|
||||
const x = cellIndex % map.width;
|
||||
const y = Math.floor(cellIndex / map.width);
|
||||
let best = null;
|
||||
let bestD = maxDistance;
|
||||
for (const center of map.adminCenters || []) {
|
||||
const name = center?.name || center?.municipalityName || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
|
||||
if (looksNumericName(name) || !Number.isFinite(center?.x) || !Number.isFinite(center?.y)) continue;
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue;
|
||||
if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue;
|
||||
if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue;
|
||||
const d = Math.hypot(center.x - x, center.y - y);
|
||||
if (d < bestD) { best = center; bestD = d; }
|
||||
}
|
||||
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
|
||||
return best;
|
||||
}
|
||||
|
||||
function adminName(map, adminId, cellIndex = -1) {
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex);
|
||||
const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName;
|
||||
if (!looksNumericName(name)) return name;
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
|
||||
if (name) return name;
|
||||
return adminId >= 0 ? "Unnamed municipality" : "-";
|
||||
}
|
||||
|
||||
function adminPopulation(map, adminId, cellIndex = -1) {
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex);
|
||||
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
|
||||
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
|
||||
const centerPop = firstPopulation(center);
|
||||
if (centerPop !== null) return centerPop;
|
||||
let sum = 0;
|
||||
let found = false;
|
||||
for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) {
|
||||
for (const p of map?.[key] || []) {
|
||||
if (!hasNumericId(p, adminId)) continue;
|
||||
const pop = firstPopulation(p);
|
||||
if (pop !== null) { sum += pop; found = true; }
|
||||
}
|
||||
}
|
||||
return found ? sum : null;
|
||||
}
|
||||
|
||||
function prefectureNameForCell(map, i) {
|
||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
||||
if (region?.name && !looksNumericName(region.name)) return region.name;
|
||||
const center = nearestNamedAdminCenter(map, i, 80);
|
||||
return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-");
|
||||
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
|
||||
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
|
||||
if (regionName) return regionName;
|
||||
const center = nearestNamedAdminCenter(map, i, 90);
|
||||
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
|
||||
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
|
||||
}
|
||||
|
||||
function updateTooltip(event) {
|
||||
|
|
@ -640,8 +788,9 @@ function updateTooltip(event) {
|
|||
const hoveredAdminId = map.adminId?.[i] ?? -1;
|
||||
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
|
||||
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
|
||||
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
|
||||
const entityTitle = entity
|
||||
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||
: coordinateText;
|
||||
const lines = [
|
||||
`<strong>${entityTitle}</strong>`,
|
||||
|
|
@ -721,23 +870,38 @@ function handleCanvasWheel(event) {
|
|||
if (!state.world || !activeMap()) return;
|
||||
event.preventDefault();
|
||||
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 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);
|
||||
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
|
||||
state.zoom = nextZoom;
|
||||
const nextSize = syncViewportSize();
|
||||
if (beforeWorld) {
|
||||
const afterCell = mapClientToCell(event);
|
||||
const afterCell = mapClientToCell(event, nextSize);
|
||||
if (afterCell) {
|
||||
state.camera = clampCameraToWorld({
|
||||
state.camera = clampCameraForView({
|
||||
x: beforeWorld.x - afterCell.x,
|
||||
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() {
|
||||
|
|
@ -786,26 +950,32 @@ async function generateAlternativePatch() {
|
|||
|
||||
function redraw(options = {}) {
|
||||
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) {
|
||||
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
|
||||
if (state.selectionRect) {
|
||||
const dx = expansion.dx || 0;
|
||||
const dy = expansion.dy || 0;
|
||||
state.selectionRect = {
|
||||
x0: state.selectionRect.x0 + (expansion.dx || 0),
|
||||
y0: state.selectionRect.y0 + (expansion.dy || 0),
|
||||
x1: state.selectionRect.x1 + (expansion.dx || 0),
|
||||
y1: state.selectionRect.y1 + (expansion.dy || 0),
|
||||
...state.selectionRect,
|
||||
x0: state.selectionRect.x0 + dx,
|
||||
y0: state.selectionRect.y0 + dy,
|
||||
x1: state.selectionRect.x1 + dx,
|
||||
y1: state.selectionRect.y1 + dy,
|
||||
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
|
||||
};
|
||||
}
|
||||
}
|
||||
state.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H);
|
||||
state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
|
||||
state.hoverEntities = buildHoverEntities(state.viewportMap);
|
||||
state.camera = clampCameraForView(state.camera, viewSize);
|
||||
state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
|
||||
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
|
||||
drawMap(canvas, state.viewportMap, {
|
||||
mode: state.mode,
|
||||
showFeatures: state.showFeatures,
|
||||
showFeatures: state.showFeatures && !options.fastTerrain,
|
||||
showLabels: state.showLabels && !options.fastTerrain,
|
||||
continuousTerrain: !options.fastTerrain,
|
||||
fastTerrain: !!options.fastTerrain,
|
||||
zoom: state.zoom || 1,
|
||||
});
|
||||
applyCanvasZoom();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
<div class="canvas-shell">
|
||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
|
||||
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
|
||||
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||
<div class="progress-title">Generating map...</div>
|
||||
|
|
@ -67,7 +68,7 @@
|
|||
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
||||
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
|
||||
</div>
|
||||
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p>
|
||||
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
|
@ -112,7 +113,7 @@
|
|||
|
||||
<section class="card legend">
|
||||
<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>
|
||||
</section>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1960,9 +1960,9 @@ const premodernRoads = [];
|
|||
// are through-corridors here, not automatic suburbanization generators.
|
||||
// A narrow field controls land-use attachment, while a broader field raises
|
||||
// population density around trunk roads without painting a wide suburb band.
|
||||
const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse");
|
||||
const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence");
|
||||
const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density");
|
||||
let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional");
|
||||
let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional");
|
||||
let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional");
|
||||
const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence");
|
||||
|
||||
const stations = [];
|
||||
|
|
@ -2522,6 +2522,18 @@ const premodernRoads = [];
|
|||
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
|
||||
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
|
||||
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({
|
||||
seed,
|
||||
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,9 @@ export function finishMapOutput({
|
|||
return applyOutputOptions({
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
originX: Number.isFinite(terrain?.originX) ? terrain.originX : (Number.isFinite(options?.originX) ? options.originX : 0),
|
||||
originY: Number.isFinite(terrain?.originY) ? terrain.originY : (Number.isFinite(options?.originY) ? options.originY : 0),
|
||||
generationContext: options?.generationContext || terrain?.generationContext || null,
|
||||
cellSize: CELL_SIZE,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
|
|
|
|||
852
mapPatch.js
852
mapPatch.js
File diff suppressed because it is too large
Load diff
130
mapPipeline.js
130
mapPipeline.js
|
|
@ -1,5 +1,5 @@
|
|||
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 { finishMapOutput } from "./mapOutput.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";
|
||||
|
||||
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 = {}) {
|
||||
// The original high-detail terrain system remains the default for full-map
|
||||
// generation. Rect-native terrain is available as an explicit option and is
|
||||
// used by patch generation, but the historical noise/natural-compartment
|
||||
// pipeline is still the visual baseline for ordinary maps.
|
||||
if (options?.rectNativeInitial === true) return generateInitialTerrainRect(seed, options);
|
||||
// Production generation is intentionally pinned to the legacy high-detail
|
||||
// terrain/natural-compartment pipeline. Rect-native terrain helpers may remain
|
||||
// in the codebase for experiments, but they are not reachable from generateMap.
|
||||
return generateTerrainAndRivers(seed, options);
|
||||
}
|
||||
|
||||
function terrainStageLabel(options = {}) {
|
||||
return options?.rectNativeInitial === true
|
||||
? "Rect-native terrain, rivers, and natural compartments"
|
||||
: "Terrain, rivers, and natural compartments";
|
||||
function terrainStageLabel() {
|
||||
return "Terrain, rivers, and natural compartments";
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
|
|
@ -60,7 +132,9 @@ async function timedStageAsync(timings, options, key, label, fn) {
|
|||
|
||||
|
||||
export function generateMap(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
const baseSeed = Number(seedInput) >>> 0;
|
||||
options = makeRuntimeOptions(options, baseSeed);
|
||||
const seed = options.effectiveSeed;
|
||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||
|
||||
const generationTimings = [];
|
||||
|
|
@ -86,10 +160,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
||||
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||
|
||||
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
||||
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||
const {
|
||||
settlementScore,
|
||||
villages,
|
||||
|
|
@ -109,10 +183,10 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
villageInfluence,
|
||||
} = features;
|
||||
|
||||
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
||||
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||
|
||||
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
|
|
@ -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({
|
||||
seed,
|
||||
|
|
@ -139,11 +213,16 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
}));
|
||||
output.generationTimings = generationTimings;
|
||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||
output.baseSeed = options.baseSeed;
|
||||
output.effectiveSeed = seed;
|
||||
output.generationContext = { ...options.generationContext };
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
const baseSeed = Number(seedInput) >>> 0;
|
||||
options = makeRuntimeOptions(options, baseSeed);
|
||||
const seed = options.effectiveSeed;
|
||||
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||
|
||||
const generationTimings = [];
|
||||
|
|
@ -169,10 +248,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis };
|
||||
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
|
||||
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
|
||||
|
||||
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography));
|
||||
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
|
||||
const {
|
||||
settlementScore,
|
||||
villages,
|
||||
|
|
@ -192,10 +271,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
villageInfluence,
|
||||
} = features;
|
||||
|
||||
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis));
|
||||
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
|
||||
|
||||
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
|
|
@ -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({
|
||||
seed,
|
||||
|
|
@ -222,5 +301,8 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
}));
|
||||
output.generationTimings = generationTimings;
|
||||
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||
output.baseSeed = options.baseSeed;
|
||||
output.effectiveSeed = seed;
|
||||
output.generationContext = { ...options.generationContext };
|
||||
return output;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1816,6 +1816,11 @@ export function generateInitialTerrainRect(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();
|
||||
fields.visibleRavineField = 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 x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const wx = originX + x;
|
||||
const wy = originY + y;
|
||||
const { px, py } = normalizeCoord(x, y);
|
||||
const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23;
|
||||
const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||
const terrainLarge = (fbm(wx * 0.65, wy * 0.65, seed + 1) - 0.5) * 0.23;
|
||||
const terrainRegional = (valueNoise(wx * 0.8, wy * 0.8, seed + 2, 42) - 0.5) * 0.16;
|
||||
const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed);
|
||||
let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040);
|
||||
let mountainMaskMax = 0;
|
||||
|
|
@ -1862,7 +1869,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
const dx = (px - 0.5) * ASPECT;
|
||||
const dy = py - 0.5;
|
||||
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
||||
const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||
const warp = (valueNoise(wx * 0.50, wy * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
||||
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
||||
|
|
@ -1870,10 +1877,10 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
e += lateralBranch * mountainMaskMax * 0.022;
|
||||
e -= passBreak * mountainMaskMax * 0.052;
|
||||
} else {
|
||||
macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
|
||||
macro = (fbm(wx * terrainTemplate.macroNoiseScale * 48, wy * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(wx * 2.2, wy * 2.2, seed + 502) - 0.5) * 2;
|
||||
}
|
||||
const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||
const global = (valueNoise(wx * 0.23, wy * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||
e += global * 0.020;
|
||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||
|
|
@ -1886,7 +1893,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
|
||||
// Setouchi maps should have many low hills and island backbones rather
|
||||
// than a few high alpine ridges. Add broad low relief, then cap peaks.
|
||||
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
|
||||
e += lowHillMask * 0.145;
|
||||
// Do not add the previous fine speckle uplift here: it created too many
|
||||
|
|
@ -1897,7 +1904,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
}
|
||||
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);
|
||||
e += islandChain * 0.135;
|
||||
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);
|
||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16);
|
||||
moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(wx * 1.1, wy * 1.1, seed + 503) - 0.5) * 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1972,9 +1979,20 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
mountainRatio: mountainCount / Math.max(1, landCount),
|
||||
plainRatio: plainCount / Math.max(1, landCount),
|
||||
mountainSystemCount: systems.length,
|
||||
originX,
|
||||
originY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
variant,
|
||||
worldNative,
|
||||
};
|
||||
|
||||
return {
|
||||
originX,
|
||||
originY,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
generationContext: { ...generationContext, originX, originY, width: MAP_W, height: MAP_H, variant, worldNative },
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
elevation,
|
||||
|
|
|
|||
125
mapTransport.js
125
mapTransport.js
|
|
@ -10,12 +10,15 @@ import {
|
|||
makeSpatialIndex,
|
||||
makeUnionFind,
|
||||
meanFieldAround as meanFieldAroundBase,
|
||||
nearestNetworkPoint,
|
||||
packDebugField,
|
||||
pathCumulativeLengths,
|
||||
pathAverageField,
|
||||
pathEndpoints,
|
||||
pathLengthCells,
|
||||
pointAtPathDistance,
|
||||
sampledNetworkCells as sampledNetworkCellsBase,
|
||||
splitPathToValidCells as splitPathToValidCellsBase,
|
||||
squaredDistance,
|
||||
TRANSPORT_ROUTE_POLICIES,
|
||||
} from "./mapTransportUtils.js";
|
||||
|
|
@ -1332,55 +1335,20 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
|
|||
|
||||
function stitchRasterNearContacts() {
|
||||
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) {
|
||||
let added = 0;
|
||||
const endpoints = endpointListWithIds(paths);
|
||||
const endpoints = pathEndpoints(paths);
|
||||
for (const ep of endpoints) {
|
||||
if (added >= maxAdds) break;
|
||||
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++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
const expresswayCells = sampledCells(expressways, 1);
|
||||
const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1);
|
||||
const localCells = sampledCells(minorRoads, 1);
|
||||
const expresswayCells = sampledNetworkCellsBase(expressways, 1, sea);
|
||||
const nationalCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 1, sea);
|
||||
const localCells = sampledNetworkCellsBase(minorRoads, 1, sea);
|
||||
debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62);
|
||||
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.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
|
||||
// near-contact cases not caught by endpoint-only repair.
|
||||
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));
|
||||
for (const c of candidates) {
|
||||
if (debug.broadNearMisses >= 260) break;
|
||||
const near = nearestWithin(c, allLocalTargets, 4.75, true);
|
||||
const near = nearestNetworkPoint(c, allLocalTargets, 4.75);
|
||||
if (!near) continue;
|
||||
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
|
||||
const mode = out === nationalRoads ? "national" : "local";
|
||||
|
|
@ -1410,56 +1378,21 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
|
|||
|
||||
function stitchLongLocalBranches() {
|
||||
const debug = { localToTrunk: 0, localToLocal: 0 };
|
||||
function cells(paths, step = 2) {
|
||||
const out = [];
|
||||
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)]) 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)
|
||||
const trunkCells = sampledNetworkCellsBase([...nationalRoads, ...externalRoads], 2, sea);
|
||||
const localCells = sampledNetworkCellsBase(minorRoads, 2, sea);
|
||||
const eps = pathEndpoints(minorRoads)
|
||||
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
|
||||
for (const ep of eps) {
|
||||
if (debug.localToTrunk >= 160) break;
|
||||
const hit = nearest(ep, trunkCells, 20, null);
|
||||
const hit = nearestNetworkPoint(ep, trunkCells, 20, { excludeSamePath: false });
|
||||
if (!hit) continue;
|
||||
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));
|
||||
for (const ep of eps2) {
|
||||
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 (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
|
||||
}
|
||||
|
|
@ -1929,33 +1862,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
|
|||
return transportFields.local;
|
||||
}
|
||||
function splitPathToValidCells(path, costField, minCells = 2) {
|
||||
const chunks = [];
|
||||
let cur = [];
|
||||
function valid(x, y) {
|
||||
return splitPathToValidCellsBase(path, (x, y) => {
|
||||
if (!inside(x, y)) return false;
|
||||
const i = indexOf(x, y);
|
||||
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
|
||||
}
|
||||
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;
|
||||
}, minCells);
|
||||
}
|
||||
function normalizeRoadGroups() {
|
||||
const groups = roadGroups();
|
||||
|
|
|
|||
|
|
@ -95,6 +95,61 @@ export function sampledNetworkCells(paths, step = 2, sea = null) {
|
|||
return cells;
|
||||
}
|
||||
|
||||
export function pathEndpoints(paths) {
|
||||
const endpoints = [];
|
||||
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
||||
const path = paths[pathId];
|
||||
if (!path || path.length < 2) continue;
|
||||
endpoints.push({ x: path[0][0], y: path[0][1], pathId });
|
||||
const end = path[path.length - 1];
|
||||
endpoints.push({ x: end[0], y: end[1], pathId });
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
export function nearestNetworkPoint(source, targets, radius, options = {}) {
|
||||
if (!source || !targets?.length) return null;
|
||||
const excludeSamePath = options.excludeSamePath !== false;
|
||||
const excludePath = options.excludePath;
|
||||
let best = null;
|
||||
let bestD = radius + 1;
|
||||
for (const target of targets) {
|
||||
if (excludePath != null && target.pathId === excludePath) continue;
|
||||
if (excludeSamePath && source.pathId != null && target.pathId === source.pathId) continue;
|
||||
const d = Math.hypot(source.x - target.x, source.y - target.y);
|
||||
if (d > 0.01 && d < bestD) {
|
||||
bestD = d;
|
||||
best = target;
|
||||
}
|
||||
}
|
||||
return best ? { target: best, d: bestD, ...best } : null;
|
||||
}
|
||||
|
||||
export function splitPathToValidCells(path, isValid, minCells = 2) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
function pushPoint(x, y) {
|
||||
if (!isValid(x, y)) {
|
||||
if (current.length >= minCells) chunks.push(current);
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
if (!current.length || current[current.length - 1][0] !== x || current[current.length - 1][1] !== y) current.push([x, y]);
|
||||
}
|
||||
for (let k = 0; k < (path?.length || 0); k++) {
|
||||
const a = path[k];
|
||||
const b = path[Math.min(k + 1, path.length - 1)];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
if (k > 0 && s === 0) continue;
|
||||
const t = s / steps;
|
||||
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
||||
}
|
||||
}
|
||||
if (current.length >= minCells) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function pathCumulativeLengths(path) {
|
||||
const cum = [0];
|
||||
for (let k = 1; k < (path?.length || 0); k++) {
|
||||
|
|
|
|||
274
renderer.js
274
renderer.js
|
|
@ -1,12 +1,37 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } from "./mapUtils.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
const pathVectorCache = new WeakMap();
|
||||
const coastlineCache = new WeakMap();
|
||||
const rasterBorderCache = new WeakMap();
|
||||
const baseImageCache = new WeakMap();
|
||||
const MAX_BASE_CACHE_IMAGES = 4;
|
||||
|
||||
function mapWidth(map) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
||||
}
|
||||
|
||||
function mapHeight(map) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
|
||||
}
|
||||
|
||||
function cellIndex(map, x, y) {
|
||||
return y * mapWidth(map) + x;
|
||||
}
|
||||
|
||||
function insideMap(map, x, y) {
|
||||
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
|
||||
}
|
||||
|
||||
function mapPixelWidth(map) {
|
||||
return mapWidth(map) * CELL_SIZE;
|
||||
}
|
||||
|
||||
function mapPixelHeight(map) {
|
||||
return mapHeight(map) * CELL_SIZE;
|
||||
}
|
||||
|
||||
function pointKey(p) {
|
||||
return `${p[0]},${p[1]}`;
|
||||
}
|
||||
|
|
@ -183,16 +208,18 @@ function getCoastlineSegments(map) {
|
|||
if (cached) return cached;
|
||||
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const a = Boolean(map.sea[i]);
|
||||
if (x + 1 < MAP_W) {
|
||||
const b = Boolean(map.sea[indexOf(x + 1, y)]);
|
||||
if (x + 1 < w) {
|
||||
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
|
||||
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const b = Boolean(map.sea[indexOf(x, y + 1)]);
|
||||
if (y + 1 < h) {
|
||||
const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
|
||||
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -244,10 +271,10 @@ function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorO
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function sampleCellIndex(fx, fy) {
|
||||
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
|
||||
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
|
||||
return indexOf(x, y);
|
||||
function sampleCellIndex(map, fx, fy) {
|
||||
const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
|
||||
const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
|
||||
return cellIndex(map, x, y);
|
||||
}
|
||||
|
||||
function seaCoverageSample(map, fx, fy) {
|
||||
|
|
@ -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],
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -293,20 +320,21 @@ function blendOutside(color, isInside) {
|
|||
];
|
||||
}
|
||||
|
||||
function fieldSample(field, fx, fy) {
|
||||
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
|
||||
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
|
||||
function fieldSample(map, field, fx, fy) {
|
||||
if (!field) return 0;
|
||||
const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx));
|
||||
const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy));
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
|
||||
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
|
||||
const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
|
||||
const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
|
||||
const tx = sx - x0;
|
||||
const ty = sy - y0;
|
||||
|
||||
const a = field[indexOf(x0, y0)];
|
||||
const b = field[indexOf(x1, y0)];
|
||||
const c = field[indexOf(x0, y1)];
|
||||
const d = field[indexOf(x1, y1)];
|
||||
const a = field[cellIndex(map, x0, y0)] || 0;
|
||||
const b = field[cellIndex(map, x1, y0)] || 0;
|
||||
const c = field[cellIndex(map, x0, y1)] || 0;
|
||||
const d = field[cellIndex(map, x1, y1)] || 0;
|
||||
|
||||
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
||||
}
|
||||
|
|
@ -329,20 +357,20 @@ function interpolateColorStops(value, stops) {
|
|||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
const i = sampleCellIndex(map, fx, fy);
|
||||
const isInside = Boolean(map.prefectureMask[i]);
|
||||
|
||||
let color;
|
||||
|
||||
const waterCoverage = seaCoverageSample(map, fx, fy);
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4);
|
||||
const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
||||
|
||||
let landColor;
|
||||
if (mode === "development") {
|
||||
const dCity = distToNearest(map.modernCities, fx, fy);
|
||||
const urban = clamp(1 - dCity / 25);
|
||||
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
|
||||
const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban;
|
||||
const base = 235;
|
||||
landColor = [
|
||||
Math.round(base + density * 20),
|
||||
|
|
@ -352,7 +380,7 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
} else {
|
||||
// 地形の基底色は標高のみに従わせる。
|
||||
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
||||
const e = fieldSample(map.elevation, fx, fy);
|
||||
const e = fieldSample(map, map.elevation, fx, fy);
|
||||
landColor = interpolateColorStops(clamp(e), [
|
||||
[0.20, [231, 236, 223]],
|
||||
[0.30, [223, 231, 214]],
|
||||
|
|
@ -378,11 +406,11 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
const step = 0.50;
|
||||
const eC = fieldSample(map.elevation, fx, fy);
|
||||
const eL = fieldSample(map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map.elevation, fx, fy + step);
|
||||
const eC = fieldSample(map, map.elevation, fx, fy);
|
||||
const eL = fieldSample(map, map.elevation, fx - step, fy);
|
||||
const eR = fieldSample(map, map.elevation, fx + step, fy);
|
||||
const eU = fieldSample(map, map.elevation, fx, fy - step);
|
||||
const eD = fieldSample(map, map.elevation, fx, fy + step);
|
||||
|
||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
||||
|
|
@ -398,14 +426,14 @@ function terrainShadeContinuous(map, fx, fy) {
|
|||
const lz = 0.7071067811865476;
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
||||
|
||||
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy + 0.90) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
|
||||
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
|
||||
|
||||
|
|
@ -419,7 +447,7 @@ function terrainShadeContinuous(map, fx, fy) {
|
|||
}
|
||||
|
||||
function discreteColor(map, x, y, mode) {
|
||||
const i = indexOf(x, y);
|
||||
const i = cellIndex(map, x, y);
|
||||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
|
|
@ -451,35 +479,39 @@ function discreteColor(map, x, y, mode) {
|
|||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||
}
|
||||
|
||||
function baseCacheKey(mode, continuousTerrain) {
|
||||
function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
|
||||
const continuousModes = ["terrain", "development", "all"];
|
||||
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||
return `continuous:${mode === "all" ? "terrain" : mode}`;
|
||||
return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
||||
}
|
||||
return `discrete:${mode}`;
|
||||
return `discrete:${mode}:${scaleKey}`;
|
||||
}
|
||||
|
||||
function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
||||
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||
let cache = baseImageCache.get(map);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
baseImageCache.set(map, cache);
|
||||
}
|
||||
|
||||
const key = baseCacheKey(mode, continuousTerrain);
|
||||
let image = cache.get(key);
|
||||
if (image) return image;
|
||||
const key = baseCacheKey(mode, continuousTerrain, renderScale);
|
||||
let canvas = cache.get(key);
|
||||
if (canvas) return canvas;
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
const sourceWidth = mapPixelWidth(map);
|
||||
const sourceHeight = mapPixelHeight(map);
|
||||
const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
|
||||
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
||||
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
||||
const img = ctx.createImageData(width, height);
|
||||
const continuousModes = ["terrain", "development", "all"];
|
||||
|
||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
||||
for (let py = 0; py < height; py++) {
|
||||
const fy = py / CELL_SIZE;
|
||||
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
||||
for (let px = 0; px < width; px++) {
|
||||
const fx = px / CELL_SIZE;
|
||||
const fx = (px / Math.max(1, width)) * mapWidth(map);
|
||||
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
||||
const shade = terrainShadeContinuous(map, fx, fy);
|
||||
|
||||
|
|
@ -491,8 +523,10 @@ function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b] = discreteColor(map, x, y, mode);
|
||||
for (let dy = 0; dy < CELL_SIZE; dy++) {
|
||||
for (let dx = 0; dx < CELL_SIZE; dx++) {
|
||||
|
|
@ -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);
|
||||
return img;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function drawBase(ctx, map, mode, continuousTerrain) {
|
||||
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
|
||||
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||
// putImageData ignores the current transform, so it made the terrain layer
|
||||
// appear unzoomed while vector layers scaled. Cache the raster into an
|
||||
// offscreen canvas and draw it with drawImage so wheel zoom applies to terrain.
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||
}
|
||||
|
||||
|
||||
function getRasterBorderSegments(map, fieldName) {
|
||||
const field = map?.[fieldName];
|
||||
if (!field) return [];
|
||||
let cacheByField = rasterBorderCache.get(field);
|
||||
if (cacheByField?.segments) return cacheByField.segments;
|
||||
const segments = [];
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const id = field[i];
|
||||
if (id < 0 || map.sea?.[i]) continue;
|
||||
if (x + 1 < w) {
|
||||
const ri = cellIndex(map, x + 1, y);
|
||||
const rid = field[ri];
|
||||
if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]);
|
||||
}
|
||||
if (y + 1 < h) {
|
||||
const di = cellIndex(map, x, y + 1);
|
||||
const did = field[di];
|
||||
if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rasterBorderCache.set(field, { segments });
|
||||
return segments;
|
||||
}
|
||||
|
||||
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||
|
|
@ -523,8 +597,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
|||
for (let k = 0; k < path.length - 1; k++) {
|
||||
const [x1, y1] = path[k];
|
||||
const [x2, y2] = path[k + 1];
|
||||
const i1 = indexOf(x1, y1);
|
||||
const i2 = indexOf(x2, y2);
|
||||
const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
|
||||
const i2 = cellIndex(map, Math.round(x2), Math.round(y2));
|
||||
const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
|
|
@ -558,7 +632,7 @@ function landOnlySubpaths(map, path, minCells = 2) {
|
|||
let cur = [];
|
||||
for (const p of path) {
|
||||
const [x, y] = p;
|
||||
const land = inside(x, y) && !map.sea[indexOf(x, y)];
|
||||
const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)];
|
||||
if (land) {
|
||||
cur.push(p);
|
||||
} else if (cur.length >= minCells) {
|
||||
|
|
@ -660,9 +734,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
||||
|
||||
ctx.save();
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const areaMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (areaMask && !areaMask[i]) continue;
|
||||
const lu = map.landuse[i];
|
||||
|
|
@ -684,9 +760,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
|||
function drawDebugCells(ctx, map, field, color) {
|
||||
if (!field) return;
|
||||
ctx.save();
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const debugMask = map.humanRegionMask || map.prefectureMask;
|
||||
if (!debugMask[i] || map.sea[i]) continue;
|
||||
const raw = field[i] || 0;
|
||||
|
|
@ -744,9 +822,11 @@ function drawPrefectureRegionFill(ctx, map, mode) {
|
|||
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const w = mapWidth(map);
|
||||
const h = mapHeight(map);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = cellIndex(map, x, y);
|
||||
const id = ids[i];
|
||||
if (map.sea[i] || id < 0) continue;
|
||||
const [r, g, b] = prefectureRegionColor(id);
|
||||
|
|
@ -806,7 +886,7 @@ function labelWithCollision(ctx, p, occupied) {
|
|||
const x = baseX + ox;
|
||||
const y = baseY + oy;
|
||||
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue;
|
||||
const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
|
||||
if (!overlaps.length) {
|
||||
ctx.lineJoin = "round";
|
||||
|
|
@ -849,11 +929,11 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
|
|||
return used;
|
||||
}
|
||||
|
||||
function drawScaleBar(ctx) {
|
||||
function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
|
||||
const kmPerCell = 0.5;
|
||||
const targetKm = 25;
|
||||
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
||||
const lengthPx = lengthCells * CELL_SIZE;
|
||||
const lengthPx = lengthCells * cellScreenSize;
|
||||
const margin = 14;
|
||||
const x = margin;
|
||||
const y = margin + 18;
|
||||
|
|
@ -893,23 +973,33 @@ export function drawMap(canvas, map, options) {
|
|||
const showFeatures = options.showFeatures !== false;
|
||||
const showLabels = options.showLabels !== false;
|
||||
const continuousTerrain = options.continuousTerrain !== false;
|
||||
const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8);
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const outputWidth = MAP_W * CELL_SIZE;
|
||||
const outputHeight = MAP_H * CELL_SIZE;
|
||||
const sourceWidth = mapPixelWidth(map);
|
||||
const sourceHeight = mapPixelHeight(map);
|
||||
const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight));
|
||||
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
||||
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
||||
const cellScreenSize = CELL_SIZE * drawScale;
|
||||
const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
|
||||
|
||||
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
||||
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
||||
ctx.clearRect(0, 0, outputWidth, outputHeight);
|
||||
ctx.save();
|
||||
ctx.translate(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5);
|
||||
ctx.scale(zoom, zoom);
|
||||
ctx.translate(drawOffsetX, drawOffsetY);
|
||||
ctx.scale(drawScale, drawScale);
|
||||
ctx.__mapPixelWidth = sourceWidth;
|
||||
ctx.__mapPixelHeight = sourceHeight;
|
||||
const finish = () => {
|
||||
ctx.restore();
|
||||
drawScaleBar(ctx);
|
||||
drawScaleBar(ctx, cellScreenSize);
|
||||
delete ctx.__mapPixelWidth;
|
||||
delete ctx.__mapPixelHeight;
|
||||
};
|
||||
|
||||
// 1. Base Terrain & Urban
|
||||
drawBase(ctx, map, mode, continuousTerrain);
|
||||
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
||||
drawUrbanAreas(ctx, map, mode);
|
||||
const coastSegments = getCoastlineSegments(map);
|
||||
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
|
@ -926,7 +1016,7 @@ export function drawMap(canvas, map, options) {
|
|||
let tailCount = 0;
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
const i = cellIndex(map, Math.round(x), Math.round(y));
|
||||
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
|
||||
peak = Math.max(peak, strength);
|
||||
if (k >= tailStart) {
|
||||
|
|
@ -977,9 +1067,11 @@ export function drawMap(canvas, map, options) {
|
|||
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
|
||||
const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
|
||||
if (showAdmin && adminBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if (mode === "borders-debug") {
|
||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||
|
|
@ -990,13 +1082,13 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
if (showTransportDebug) drawTransportDebug(ctx, map);
|
||||
|
||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
if (showPrefectureRegions && prefectureBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
if (!showPrefectureRegions) {
|
||||
const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
|
||||
const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : map.prefectureBorder;
|
||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
.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}
|
||||
.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}
|
||||
|
|
|
|||
19
worldMap.js
19
worldMap.js
|
|
@ -39,6 +39,24 @@ function makeWorldField(name, source, worldWidth, worldHeight, originX, originY)
|
|||
return out;
|
||||
}
|
||||
|
||||
|
||||
function sanitizeInitialWorldFields(fields, width, height) {
|
||||
const sea = fields.sea;
|
||||
if (!sea) return;
|
||||
const adminFields = ["adminId", "municipalityId", "prefectureRegionId"];
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const isSea = Boolean(sea[i]);
|
||||
if (fields.landMask) fields.landMask[i] = isSea ? 0 : (fields.prefectureMask?.[i] ? 1 : fields.landMask[i]);
|
||||
if (fields.humanRegionMask && isSea) fields.humanRegionMask[i] = 0;
|
||||
if (isSea) {
|
||||
for (const key of adminFields) if (fields[key]) fields[key][i] = -1;
|
||||
if (fields.landuse) fields.landuse[i] = 0;
|
||||
} else {
|
||||
if (fields.municipalityId && fields.adminId && fields.municipalityId[i] < 0 && fields.adminId[i] >= 0) fields.municipalityId[i] = fields.adminId[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorldMap(initialMap, options = {}) {
|
||||
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
|
||||
const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
|
||||
|
|
@ -60,6 +78,7 @@ export function createWorldMap(initialMap, options = {}) {
|
|||
fields.elevation = new Float32Array(worldWidth * worldHeight);
|
||||
fields.elevation.fill(0.08);
|
||||
}
|
||||
sanitizeInitialWorldFields(fields, worldWidth, worldHeight);
|
||||
|
||||
return {
|
||||
seed: initialMap?.seed ?? 0,
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function inViewportPoint(p, margin = 0) {
|
||||
return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin;
|
||||
function inViewportPoint(p, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
return p && p.x >= -margin && p.y >= -margin && p.x < viewWidth + margin && p.y < viewHeight + margin;
|
||||
}
|
||||
|
||||
function transformPointObject(point, camera, originX, originY) {
|
||||
|
|
@ -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));
|
||||
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) {
|
||||
|
|
@ -93,16 +93,16 @@ function transformTuple(tuple, camera, originX, originY) {
|
|||
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
|
||||
}
|
||||
|
||||
function tupleInside(tuple, margin = 0) {
|
||||
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin;
|
||||
function tupleInside(tuple, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < viewWidth + margin && tuple[1] < viewHeight + margin;
|
||||
}
|
||||
|
||||
function splitTransformedPath(path, camera, originX, originY, margin = 0) {
|
||||
function splitTransformedPath(path, camera, originX, originY, margin = 0, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (const tuple of path || []) {
|
||||
const p = transformTuple(tuple, camera, originX, originY);
|
||||
const inside = tupleInside(p, margin);
|
||||
const inside = tupleInside(p, margin, viewWidth, viewHeight);
|
||||
if (inside) {
|
||||
current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
} else if (current.length >= 2) {
|
||||
|
|
@ -116,23 +116,23 @@ function splitTransformedPath(path, camera, originX, originY, margin = 0) {
|
|||
return chunks;
|
||||
}
|
||||
|
||||
function transformPaths(paths, camera, originX, originY) {
|
||||
function transformPaths(paths, camera, originX, originY, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const out = [];
|
||||
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY));
|
||||
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY, 0, viewWidth, viewHeight));
|
||||
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;
|
||||
const xs = [seg[0][0], seg[1][0]];
|
||||
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 || [])
|
||||
.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;
|
||||
}
|
||||
|
||||
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;
|
||||
const layers = { ...debug.layers };
|
||||
for (const [key, value] of Object.entries(layers)) {
|
||||
if (ArrayBuffer.isView(value) && value.length === SIZE) layers[key] = copySourceMapViewportField(value, camera, originX, originY, 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
|
||||
// viewport pans into patched world cells, synthesize equivalent viewport-sized
|
||||
// debug fields from the current world-backed fields so the color overlay moves
|
||||
// with the terrain instead of staying tied to the initial source map.
|
||||
if (viewport) {
|
||||
const n = MAP_W * MAP_H;
|
||||
const n = viewWidth * viewHeight;
|
||||
const make = (fn) => {
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = fn(i);
|
||||
|
|
@ -184,14 +184,14 @@ function transformTransportDebug(debug, camera, originX, originY, viewport = nul
|
|||
...component,
|
||||
cells: (component.cells || [])
|
||||
.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)]),
|
||||
})).filter((component) => component.cells.length);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewH
|
|||
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 normalizedCamera = {
|
||||
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);
|
||||
}
|
||||
|
||||
if (options.light) return viewport;
|
||||
|
||||
const originX = world?.originX || 0;
|
||||
const originY = world?.originY || 0;
|
||||
for (const key of POINT_ARRAY_KEYS) {
|
||||
if (!Array.isArray(sourceMap[key])) continue;
|
||||
viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters");
|
||||
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 SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(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, viewWidth, viewHeight);
|
||||
|
||||
if (sourceMap.adminDebug) {
|
||||
viewport.adminDebug = {
|
||||
...sourceMap.adminDebug,
|
||||
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY),
|
||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY),
|
||||
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
};
|
||||
}
|
||||
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport);
|
||||
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport, viewWidth, viewHeight);
|
||||
if (sourceMap.neighborPrefectureDetails) {
|
||||
viewport.neighborPrefectureDetails = {
|
||||
...sourceMap.neighborPrefectureDetails,
|
||||
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY),
|
||||
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY),
|
||||
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY),
|
||||
cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY, 36, false, viewWidth, viewHeight),
|
||||
roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY, viewWidth, viewHeight),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue