infinity expantion
This commit is contained in:
parent
c523bf4380
commit
ea0067dc0a
10 changed files with 2792 additions and 70 deletions
380
app.js
380
app.js
|
|
@ -1,6 +1,10 @@
|
|||
import { generateMapAsync } from "./mapGenerator.js";
|
||||
import { drawMap } from "./renderer.js";
|
||||
import { landuseLabel } from "./landuseCodes.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
|
||||
import { clampCameraToWorld, createInitialCamera, createWorldMap } from "./worldMap.js";
|
||||
import { getViewportMap } from "./worldViewport.js";
|
||||
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
|
||||
|
||||
const modes = [
|
||||
["all", "All"],
|
||||
|
|
@ -21,19 +25,28 @@ const state = {
|
|||
showFeatures: true,
|
||||
showLabels: true,
|
||||
map: null,
|
||||
world: null,
|
||||
camera: { x: 0, y: 0 },
|
||||
viewportMap: null,
|
||||
hoverEntities: [],
|
||||
selectionRect: null,
|
||||
lastPatchResult: null,
|
||||
};
|
||||
|
||||
const canvas = document.getElementById("mapCanvas");
|
||||
const canvasShell = document.querySelector(".canvas-shell");
|
||||
const seedInput = document.getElementById("seed");
|
||||
const generationTypeInput = document.getElementById("generationType");
|
||||
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
|
||||
const generatePatchButton = document.getElementById("generatePatch");
|
||||
const patchStatusEl = document.getElementById("patchStatus");
|
||||
const randomSeedButton = document.getElementById("randomSeed");
|
||||
const showFeaturesInput = document.getElementById("showFeatures");
|
||||
const showLabelsInput = document.getElementById("showLabels");
|
||||
const modeGrid = document.getElementById("modeGrid");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const tooltipEl = document.getElementById("mapTooltip");
|
||||
const selectionEl = document.getElementById("mapSelection");
|
||||
const progressEl = document.getElementById("generationProgress");
|
||||
const progressStageEl = document.getElementById("generationProgressStage");
|
||||
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
||||
|
|
@ -41,66 +54,253 @@ let generationStartedAt = 0;
|
|||
let generationCurrentStage = "";
|
||||
let generationTimer = null;
|
||||
|
||||
const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
|
||||
const dragState = {
|
||||
mode: null,
|
||||
pointerId: null,
|
||||
startClientX: 0,
|
||||
startClientY: 0,
|
||||
startCameraX: 0,
|
||||
startCameraY: 0,
|
||||
selectStart: null,
|
||||
selectEnd: null,
|
||||
pendingCamera: null,
|
||||
panRaf: null,
|
||||
};
|
||||
|
||||
function activeMap() {
|
||||
return state.viewportMap || state.map;
|
||||
}
|
||||
|
||||
function mapClientToCell(event) {
|
||||
if (!state.map) return null;
|
||||
const map = activeMap();
|
||||
if (!map) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const relX = (event.clientX - rect.left) / rect.width;
|
||||
const relY = (event.clientY - rect.top) / rect.height;
|
||||
return {
|
||||
x: Math.floor(relX * state.map.width),
|
||||
y: Math.floor(relY * state.map.height),
|
||||
x: Math.floor(relX * map.width),
|
||||
y: Math.floor(relY * map.height),
|
||||
};
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target) return false;
|
||||
const tag = target.tagName?.toLowerCase?.();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
function viewportCellToWorldCell(cell) {
|
||||
if (!cell || !state.camera) return null;
|
||||
return {
|
||||
x: Math.round(state.camera.x || 0) + cell.x,
|
||||
y: Math.round(state.camera.y || 0) + cell.y,
|
||||
};
|
||||
}
|
||||
|
||||
function panFrame(time) {
|
||||
if (!canvasShell || panState.keys.size === 0) {
|
||||
panState.raf = null;
|
||||
panState.lastTime = 0;
|
||||
function clampCanvasPoint(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
|
||||
y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function 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 (generatePatchButton) generatePatchButton.disabled = true;
|
||||
if (patchStatusEl) {
|
||||
const current = validation.rect || liveRect;
|
||||
patchStatusEl.textContent = validation.ok
|
||||
? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
|
||||
: `${validation.reason} Current: ${formatRectSize(current)}.`;
|
||||
patchStatusEl.classList.toggle("invalid", !validation.ok);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectionOverlayFromWorldRect() {
|
||||
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const map = activeMap();
|
||||
const cameraX = Math.round(state.camera.x || 0);
|
||||
const cameraY = Math.round(state.camera.y || 0);
|
||||
const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width;
|
||||
const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height;
|
||||
const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width;
|
||||
const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height;
|
||||
const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
|
||||
const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
|
||||
const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
|
||||
const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
|
||||
if (x1 - x0 < 1 || y1 - y0 < 1) {
|
||||
selectionEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
|
||||
panState.lastTime = time;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (panState.keys.has("a")) dx -= 1;
|
||||
if (panState.keys.has("d")) dx += 1;
|
||||
if (panState.keys.has("w")) dy -= 1;
|
||||
if (panState.keys.has("s")) dy += 1;
|
||||
if (dx || dy) {
|
||||
const normalizer = dx && dy ? Math.SQRT1_2 : 1;
|
||||
const amount = panState.speedPxPerSecond * dt;
|
||||
canvasShell.scrollLeft += dx * normalizer * amount;
|
||||
canvasShell.scrollTop += dy * normalizer * amount;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
selectionEl.style.display = "block";
|
||||
selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
|
||||
selectionEl.style.top = `${canvas.offsetTop + y0}px`;
|
||||
selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
|
||||
selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
selectionEl.classList.toggle("invalid", !validation.ok);
|
||||
}
|
||||
|
||||
function 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`;
|
||||
}
|
||||
|
||||
function updatePatchControls() {
|
||||
if (!patchStatusEl && !generatePatchButton) return;
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
if (generatePatchButton) generatePatchButton.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.`;
|
||||
patchStatusEl.classList.toggle("invalid", false);
|
||||
return;
|
||||
}
|
||||
panState.raf = requestAnimationFrame(panFrame);
|
||||
if (!validation.ok) {
|
||||
patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
|
||||
patchStatusEl.classList.toggle("invalid", true);
|
||||
return;
|
||||
}
|
||||
const rects = buildPatchRects(validation.rect, state.world);
|
||||
const patchText = state.lastPatchResult
|
||||
? ` Last patch: ${state.lastPatchResult.label}, 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 = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
|
||||
patchStatusEl.classList.toggle("invalid", false);
|
||||
}
|
||||
|
||||
function startKeyboardPan() {
|
||||
if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
|
||||
function clearDragMode() {
|
||||
dragState.mode = null;
|
||||
dragState.pointerId = null;
|
||||
dragState.pendingCamera = null;
|
||||
if (dragState.panRaf != null) {
|
||||
cancelAnimationFrame(dragState.panRaf);
|
||||
dragState.panRaf = null;
|
||||
}
|
||||
canvasShell?.classList.remove("panning", "selecting");
|
||||
}
|
||||
|
||||
function handlePanKeyDown(event) {
|
||||
const key = event.key?.toLowerCase?.();
|
||||
if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
|
||||
panState.keys.add(key);
|
||||
startKeyboardPan();
|
||||
function schedulePanRedraw(camera) {
|
||||
dragState.pendingCamera = camera;
|
||||
if (dragState.panRaf != null) return;
|
||||
dragState.panRaf = requestAnimationFrame(() => {
|
||||
dragState.panRaf = null;
|
||||
if (!dragState.pendingCamera) return;
|
||||
const next = dragState.pendingCamera;
|
||||
dragState.pendingCamera = null;
|
||||
if (next.x === state.camera.x && next.y === state.camera.y) return;
|
||||
state.camera = next;
|
||||
redraw({ fastTerrain: true });
|
||||
});
|
||||
}
|
||||
|
||||
function hideSelectionOverlay() {
|
||||
dragState.selectStart = null;
|
||||
dragState.selectEnd = null;
|
||||
state.selectionRect = null;
|
||||
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 localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width);
|
||||
const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height);
|
||||
const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width);
|
||||
const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height);
|
||||
const cameraX = Math.round(state.camera?.x || 0);
|
||||
const cameraY = Math.round(state.camera?.y || 0);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMapPointerDown(event) {
|
||||
if (!state.world || !canvasShell) return;
|
||||
if (event.button !== 0 && event.button !== 2) return;
|
||||
dragState.pointerId = event.pointerId;
|
||||
dragState.startClientX = event.clientX;
|
||||
dragState.startClientY = event.clientY;
|
||||
dragState.startCameraX = state.camera.x;
|
||||
dragState.startCameraY = state.camera.y;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (event.button === 0) {
|
||||
dragState.mode = "pan";
|
||||
canvasShell.classList.add("panning");
|
||||
} else {
|
||||
dragState.mode = "select";
|
||||
dragState.selectStart = clampCanvasPoint(event);
|
||||
dragState.selectEnd = dragState.selectStart;
|
||||
canvasShell.classList.add("selecting");
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handlePanKeyUp(event) {
|
||||
const key = event.key?.toLowerCase?.();
|
||||
if (!key || !"wasd".includes(key)) return;
|
||||
panState.keys.delete(key);
|
||||
function handleMapPointerMove(event) {
|
||||
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (dragState.mode === "pan") {
|
||||
const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE);
|
||||
const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE);
|
||||
const nextCamera = clampCameraToWorld({
|
||||
x: dragState.startCameraX - dxCells,
|
||||
y: dragState.startCameraY - dyCells,
|
||||
}, state.world, MAP_W, MAP_H);
|
||||
schedulePanRedraw(nextCamera);
|
||||
} else if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
updateSelectionOverlay();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleMapPointerUp(event) {
|
||||
if (dragState.pointerId !== event.pointerId) return;
|
||||
const wasPanning = dragState.mode === "pan";
|
||||
if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
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);
|
||||
updateSelectionOverlayFromWorldRect();
|
||||
updatePatchControls();
|
||||
} else {
|
||||
hideSelectionOverlay();
|
||||
}
|
||||
}
|
||||
canvas.releasePointerCapture?.(event.pointerId);
|
||||
if (wasPanning && dragState.pendingCamera) {
|
||||
state.camera = dragState.pendingCamera;
|
||||
dragState.pendingCamera = null;
|
||||
}
|
||||
clearDragMode();
|
||||
if (wasPanning) redraw({ fastTerrain: false });
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
|
|
@ -283,29 +483,35 @@ function prefectureNameForCell(map, i) {
|
|||
}
|
||||
|
||||
function updateTooltip(event) {
|
||||
if (!state.map || !tooltipEl) return;
|
||||
const map = activeMap();
|
||||
if (!map || !tooltipEl || dragState.mode) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cell = mapClientToCell(event);
|
||||
if (!cell) return;
|
||||
const { x, y } = cell;
|
||||
if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
|
||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
|
||||
tooltipEl.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
const i = y * state.map.width + x;
|
||||
const i = y * map.width + x;
|
||||
const worldCell = viewportCellToWorldCell({ x, y });
|
||||
const entity = nearestEntity(state.hoverEntities, x, y);
|
||||
const elevation = state.map.elevation?.[i] ?? 0;
|
||||
const density = state.map.populationDensity?.[i] ?? 0;
|
||||
const hoveredAdminId = state.map.adminId?.[i] ?? -1;
|
||||
const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
|
||||
const elevation = map.elevation?.[i] ?? 0;
|
||||
const density = map.populationDensity?.[i] ?? 0;
|
||||
const hoveredAdminId = map.adminId?.[i] ?? -1;
|
||||
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId);
|
||||
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
|
||||
const entityTitle = entity
|
||||
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||
: coordinateText;
|
||||
const lines = [
|
||||
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
|
||||
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
|
||||
`Admin: ${adminName(state.map, hoveredAdminId)}`,
|
||||
`<strong>${entityTitle}</strong>`,
|
||||
`Prefecture: ${prefectureNameForCell(map, i)}`,
|
||||
`Admin: ${adminName(map, hoveredAdminId)}`,
|
||||
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
||||
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
|
||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
||||
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
|
||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||
`River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
|
||||
];
|
||||
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
|
||||
tooltipEl.innerHTML = lines.join("<br>");
|
||||
|
|
@ -343,7 +549,10 @@ async function regenerate() {
|
|||
await nextFrame();
|
||||
try {
|
||||
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
|
||||
state.hoverEntities = buildHoverEntities(state.map);
|
||||
state.world = createWorldMap(state.map);
|
||||
state.camera = createInitialCamera(state.world);
|
||||
state.lastPatchResult = null;
|
||||
hideSelectionOverlay();
|
||||
renderStats(state.map);
|
||||
redraw();
|
||||
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
||||
|
|
@ -355,13 +564,62 @@ async function regenerate() {
|
|||
}
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!state.map) return;
|
||||
drawMap(canvas, state.map, {
|
||||
|
||||
function derivePatchSeed(rect, terrainType) {
|
||||
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
|
||||
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
|
||||
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
|
||||
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
|
||||
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
|
||||
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
async function generateSelectedPatch() {
|
||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||
if (!validation.ok) {
|
||||
updatePatchControls();
|
||||
return;
|
||||
}
|
||||
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
|
||||
const seed = derivePatchSeed(validation.rect, terrainType);
|
||||
setProgressVisible(true, "Generating selected patch...");
|
||||
await nextFrame();
|
||||
try {
|
||||
const result = generatePatch(state.world, validation.rect, { terrainType, seed });
|
||||
if (!result.ok) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
|
||||
updatePatchControls();
|
||||
window.setTimeout(() => setProgressVisible(false), 1200);
|
||||
return;
|
||||
}
|
||||
state.lastPatchResult = result;
|
||||
redraw();
|
||||
renderStats(state.map);
|
||||
updatePatchControls();
|
||||
const human = result.humanGeography;
|
||||
const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
|
||||
renderTimingRows([]);
|
||||
window.setTimeout(() => setProgressVisible(false), 900);
|
||||
} catch (error) {
|
||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function redraw(options = {}) {
|
||||
if (!state.world) return;
|
||||
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);
|
||||
drawMap(canvas, state.viewportMap, {
|
||||
mode: state.mode,
|
||||
showFeatures: state.showFeatures,
|
||||
showLabels: state.showLabels,
|
||||
showLabels: state.showLabels && !options.fastTerrain,
|
||||
continuousTerrain: !options.fastTerrain,
|
||||
});
|
||||
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
|
@ -373,6 +631,8 @@ function init() {
|
|||
});
|
||||
|
||||
generationTypeInput?.addEventListener("change", regenerate);
|
||||
patchTerrainTypeInput?.addEventListener("change", updatePatchControls);
|
||||
generatePatchButton?.addEventListener("click", generateSelectedPatch);
|
||||
|
||||
randomSeedButton.addEventListener("click", () => {
|
||||
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
||||
|
|
@ -390,13 +650,17 @@ function init() {
|
|||
});
|
||||
|
||||
canvasShell?.setAttribute("tabindex", "0");
|
||||
window.addEventListener("keydown", handlePanKeyDown);
|
||||
window.addEventListener("keyup", handlePanKeyUp);
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
canvas.addEventListener("pointerdown", handleMapPointerDown);
|
||||
canvas.addEventListener("pointermove", handleMapPointerMove);
|
||||
canvas.addEventListener("pointerup", handleMapPointerUp);
|
||||
canvas.addEventListener("pointercancel", handleMapPointerUp);
|
||||
canvas.addEventListener("mousemove", updateTooltip);
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
tooltipEl?.classList.remove("visible");
|
||||
});
|
||||
|
||||
updatePatchControls();
|
||||
regenerate();
|
||||
}
|
||||
|
||||
|
|
|
|||
23
index.html
23
index.html
|
|
@ -13,12 +13,13 @@
|
|||
<header class="header">
|
||||
<div>
|
||||
<h1>Prefecture Map Generator v17</h1>
|
||||
<p>Terrain, municipalities, transport, land use, and hover inspection in one generated map.</p>
|
||||
<p>Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="canvas-shell">
|
||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
|
||||
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||
<div class="progress-title">Generating map...</div>
|
||||
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
||||
|
|
@ -38,12 +39,30 @@
|
|||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="card">
|
||||
<div class="card-title">Patch Generation</div>
|
||||
<label class="label" for="patchTerrainType">Patch Terrain Type</label>
|
||||
<select id="patchTerrainType" class="input">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
||||
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-title">Display Layers</div>
|
||||
<div id="modeGrid" class="mode-grid"></div>
|
||||
|
|
@ -86,7 +105,7 @@
|
|||
|
||||
<section class="card legend">
|
||||
<div class="card-title">Notes</div>
|
||||
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
|
||||
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag selects a 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>
|
||||
|
|
|
|||
|
|
@ -1327,6 +1327,18 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam
|
|||
return points.map((p, i) => {
|
||||
const id = `${prefix}-${i}`;
|
||||
const kind = kindOverride || p.kind;
|
||||
if (prefix === "logistics") {
|
||||
return {
|
||||
...p,
|
||||
id,
|
||||
name: null,
|
||||
facilityLabel: p.facilityLabel || "Logistics Park",
|
||||
kind,
|
||||
labelStyle: "facility",
|
||||
suppressSettlementLabel: true,
|
||||
insidePrefecture: Boolean(p.insidePrefecture),
|
||||
};
|
||||
}
|
||||
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||
if (usedNames) usedNames.add(name);
|
||||
return {
|
||||
|
|
|
|||
1177
mapHumanPatch.js
Normal file
1177
mapHumanPatch.js
Normal file
File diff suppressed because it is too large
Load diff
898
mapPatch.js
Normal file
898
mapPatch.js
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep } from "./mapUtils.js";
|
||||
import { generateMap } from "./mapPipeline.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
|
||||
export const PATCH_MIN_WIDTH = 48;
|
||||
export const PATCH_MIN_HEIGHT = 48;
|
||||
export const PATCH_MIN_AREA = 3000;
|
||||
|
||||
const POINT_LAYER_KEYS = [
|
||||
"villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
|
||||
"ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
|
||||
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||
"externalGateways", "prefectureRegions",
|
||||
];
|
||||
|
||||
const PATH_LAYER_KEYS = [
|
||||
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||
"icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
|
||||
];
|
||||
|
||||
const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]);
|
||||
const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]);
|
||||
const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
|
||||
|
||||
const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
|
||||
|
||||
const ID_FIELD_OFFSETS = new Map([
|
||||
["adminId", 100000],
|
||||
["municipalityId", 100000],
|
||||
["prefectureRegionId", 200000],
|
||||
["regionId", 300000],
|
||||
["naturalCompartmentId", 400000],
|
||||
["watershedId", 500000],
|
||||
]);
|
||||
|
||||
const DISCRETE_FIELD_NAMES = new Set([
|
||||
"sea", "ocean", "lake", "prefectureMask", "landMask", "landuse",
|
||||
"adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
|
||||
]);
|
||||
|
||||
const SKIP_CELL_FIELDS = new Set(["flowTo"]);
|
||||
|
||||
function worldIndex(world, x, y) {
|
||||
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
||||
return y * world.width + x;
|
||||
}
|
||||
|
||||
function sourceIndex(x, y) {
|
||||
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1;
|
||||
return y * MAP_W + x;
|
||||
}
|
||||
|
||||
function isCellField(value) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||
}
|
||||
|
||||
function rectWidth(rect) {
|
||||
return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
|
||||
}
|
||||
|
||||
function rectHeight(rect) {
|
||||
return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
|
||||
}
|
||||
|
||||
function rectArea(rect) {
|
||||
return rectWidth(rect) * rectHeight(rect);
|
||||
}
|
||||
|
||||
function normalizeRect(rect) {
|
||||
if (!rect) return null;
|
||||
const x0 = Math.floor(Math.min(rect.x0, rect.x1));
|
||||
const y0 = Math.floor(Math.min(rect.y0, rect.y1));
|
||||
const x1 = Math.ceil(Math.max(rect.x0, rect.x1));
|
||||
const y1 = Math.ceil(Math.max(rect.y0, rect.y1));
|
||||
return { x0, y0, x1, y1 };
|
||||
}
|
||||
|
||||
function insideRect(x, y, rect) {
|
||||
return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
|
||||
}
|
||||
|
||||
function expandRect(rect, margin, world = null) {
|
||||
return {
|
||||
x0: Math.max(0, rect.x0 - margin),
|
||||
y0: Math.max(0, rect.y0 - margin),
|
||||
x1: Math.min(world?.width ?? Infinity, rect.x1 + margin),
|
||||
y1: Math.min(world?.height ?? Infinity, rect.y1 + margin),
|
||||
};
|
||||
}
|
||||
|
||||
function distanceToRectEdge(x, y, rect) {
|
||||
return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
|
||||
}
|
||||
|
||||
function defaultForField(name, Constructor) {
|
||||
if (name === "sea" || name === "ocean") return 1;
|
||||
if (name === "elevation") return 0.08;
|
||||
if (ID_FIELD_OFFSETS.has(name)) return -1;
|
||||
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function ensureWorldField(world, name, source) {
|
||||
if (!source || !isCellField(source)) return null;
|
||||
const Constructor = source.constructor;
|
||||
const expected = world.width * world.height;
|
||||
if (!world.fields[name] || world.fields[name].length !== expected) {
|
||||
world.fields[name] = new Constructor(expected);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) world.fields[name].fill(fallback);
|
||||
}
|
||||
return world.fields[name];
|
||||
}
|
||||
|
||||
export function clipPatchRect(rect, world) {
|
||||
const normalized = normalizeRect(rect);
|
||||
if (!normalized || !world) return null;
|
||||
return {
|
||||
x0: Math.min(Math.max(normalized.x0, 0), world.width),
|
||||
y0: Math.min(Math.max(normalized.y0, 0), world.height),
|
||||
x1: Math.min(Math.max(normalized.x1, 0), world.width),
|
||||
y1: Math.min(Math.max(normalized.y1, 0), world.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePatchRect(rect, world) {
|
||||
const clipped = clipPatchRect(rect, world);
|
||||
if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
|
||||
const width = rectWidth(clipped);
|
||||
const height = rectHeight(clipped);
|
||||
const area = width * height;
|
||||
if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
|
||||
const parts = [];
|
||||
if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
|
||||
if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`);
|
||||
return {
|
||||
ok: false,
|
||||
rect: clipped,
|
||||
width,
|
||||
height,
|
||||
area,
|
||||
reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`,
|
||||
};
|
||||
}
|
||||
if (area < PATCH_MIN_AREA) {
|
||||
return {
|
||||
ok: false,
|
||||
rect: clipped,
|
||||
width,
|
||||
height,
|
||||
area,
|
||||
reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, rect: clipped, width, height, area, reason: "" };
|
||||
}
|
||||
|
||||
export function buildPatchRects(userRect, world = null) {
|
||||
const coreRect = normalizeRect(userRect);
|
||||
const width = rectWidth(coreRect);
|
||||
const height = rectHeight(coreRect);
|
||||
const shortSide = Math.max(1, Math.min(width, height));
|
||||
const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
|
||||
const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
|
||||
const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
|
||||
const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
|
||||
const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
|
||||
const writeRect = expandRect(coreRect, writeMargin, world);
|
||||
const repairRect = expandRect(coreRect, repairMargin, world);
|
||||
return {
|
||||
coreRect,
|
||||
writeRect,
|
||||
repairRect,
|
||||
contextRect: repairRect,
|
||||
blendRect: coreRect,
|
||||
userRect: writeRect,
|
||||
selectedRect: coreRect,
|
||||
writeMargin,
|
||||
repairMargin,
|
||||
outerMargin: writeMargin,
|
||||
innerMargin: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function patchAlpha(x, y, rects, seed = 0) {
|
||||
const writeRect = rects.writeRect || rects.userRect;
|
||||
if (!insideRect(x, y, writeRect)) return 0;
|
||||
const edge = distanceToRectEdge(x, y, writeRect);
|
||||
const margin = Math.max(1, rects.writeMargin || 1);
|
||||
const low = hash2(Math.floor(x / 18), Math.floor(y / 18), seed ^ 0x7153a9d1) - 0.5;
|
||||
const mid = hash2(Math.floor(x / 7), Math.floor(y / 7), seed ^ 0x9e3779b9) - 0.5;
|
||||
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
|
||||
const base = smoothstep(clamp(noisyEdge / margin));
|
||||
// Keep the expanded repair band as the actual seam. The user's selected core
|
||||
// is still dominant, but the write edge is irregular, so coastlines and land-use
|
||||
// no longer inherit the rectangular user selection as a hard boundary.
|
||||
return clamp(base);
|
||||
}
|
||||
|
||||
function sourceWindowForRects(rects) {
|
||||
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
||||
const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
|
||||
return {
|
||||
worldCenterX: cx,
|
||||
worldCenterY: cy,
|
||||
sourceCenterX: (MAP_W - 1) / 2,
|
||||
sourceCenterY: (MAP_H - 1) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceCoordForWorld(window, x, y) {
|
||||
return {
|
||||
x: Math.round(x - window.worldCenterX + window.sourceCenterX),
|
||||
y: Math.round(y - window.worldCenterY + window.sourceCenterY),
|
||||
};
|
||||
}
|
||||
|
||||
function worldCoordForSource(window, sx, sy) {
|
||||
return {
|
||||
x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
|
||||
y: Math.round(sy - window.sourceCenterY + window.worldCenterY),
|
||||
};
|
||||
}
|
||||
|
||||
function fieldIdOffset(name, seed) {
|
||||
const base = ID_FIELD_OFFSETS.get(name) || 0;
|
||||
if (!base) return 0;
|
||||
return base + ((seed >>> 0) % 997) * 10000;
|
||||
}
|
||||
|
||||
function copyFullPipelineFields(world, candidate, rects, seed) {
|
||||
const window = sourceWindowForRects(rects);
|
||||
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
||||
let updatedCells = 0;
|
||||
let coastCellsChanged = 0;
|
||||
let terrainCellsFullyReplaced = 0;
|
||||
let naturalRegionsUpdated = 0;
|
||||
let adminCellsReassigned = 0;
|
||||
let landUseCellsUpdated = 0;
|
||||
|
||||
for (const [name, source] of Object.entries(candidate || {})) {
|
||||
if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue;
|
||||
const dest = ensureWorldField(world, name, source);
|
||||
if (!dest) continue;
|
||||
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
|
||||
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
|
||||
const idOffset = fieldIdOffset(name, seed);
|
||||
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const wi = worldIndex(world, x, y);
|
||||
if (wi < 0) continue;
|
||||
const s = sourceCoordForWorld(window, x, y);
|
||||
const si = sourceIndex(s.x, s.y);
|
||||
if (si < 0) continue;
|
||||
const alpha = patchAlpha(x, y, rects, seed);
|
||||
if (alpha <= 0.005) continue;
|
||||
|
||||
if (isDiscrete) {
|
||||
const thresholdNoise = hash2(Math.floor(x / 6), Math.floor(y / 6), seed ^ 0x21f0aaad) - 0.5;
|
||||
const threshold = clamp(0.46 + thresholdNoise * 0.20, 0.28, 0.68);
|
||||
if (alpha >= threshold) {
|
||||
const raw = source[si];
|
||||
const value = idOffset && raw >= 0 ? raw + idOffset : raw;
|
||||
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
|
||||
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
||||
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
|
||||
if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
|
||||
dest[wi] = value;
|
||||
}
|
||||
} else {
|
||||
const before = dest[wi] || 0;
|
||||
dest[wi] = lerp(before, source[si] || 0, alpha);
|
||||
}
|
||||
|
||||
if (name === "elevation") {
|
||||
updatedCells++;
|
||||
if (alpha > 0.94) terrainCellsFullyReplaced++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep water fields coherent after all continuous fields have been blended.
|
||||
const sea = world.fields.sea;
|
||||
const ocean = world.fields.ocean;
|
||||
const lake = world.fields.lake;
|
||||
const landuse = world.fields.landuse;
|
||||
if (sea) {
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0) continue;
|
||||
if (sea[i]) {
|
||||
if (ocean) ocean[i] = 1;
|
||||
if (lake) lake[i] = 0;
|
||||
if (landuse) landuse[i] = LANDUSE.WATER || 0;
|
||||
} else {
|
||||
if (ocean) ocean[i] = 0;
|
||||
if (lake) lake[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { window, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated };
|
||||
}
|
||||
|
||||
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
|
||||
const sea = world.fields.sea;
|
||||
const ocean = world.fields.ocean;
|
||||
const lake = world.fields.lake;
|
||||
const elevation = world.fields.elevation;
|
||||
if (!sea || !elevation) return { coastCellsChanged: 0 };
|
||||
let changed = 0;
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const flips = [];
|
||||
for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) {
|
||||
for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0) continue;
|
||||
let seaN = 0;
|
||||
let landN = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const ni = worldIndex(world, x + dx, y + dy);
|
||||
if (ni < 0) continue;
|
||||
if (sea[ni]) seaN++; else landN++;
|
||||
}
|
||||
}
|
||||
if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
|
||||
else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
|
||||
}
|
||||
}
|
||||
for (const [i, nextSea] of flips) {
|
||||
if (sea[i] === nextSea) continue;
|
||||
sea[i] = nextSea;
|
||||
if (ocean) ocean[i] = nextSea;
|
||||
if (lake) lake[i] = 0;
|
||||
if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
|
||||
else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return { coastCellsChanged: changed };
|
||||
}
|
||||
|
||||
function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) {
|
||||
const fields = world.fields;
|
||||
const { elevation, sea } = fields;
|
||||
if (!elevation || !sea) return;
|
||||
if (!fields.slope) fields.slope = new Float32Array(world.width * world.height);
|
||||
for (let y = rect.y0; y < rect.y1; y++) {
|
||||
for (let x = rect.x0; x < rect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0) continue;
|
||||
if (sea[i]) {
|
||||
for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) {
|
||||
if (fields[key]) fields[key][i] = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) {
|
||||
const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)];
|
||||
const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)];
|
||||
fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
|
||||
}
|
||||
if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25);
|
||||
if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seaNeighbors(world, x, y, radius = 1) {
|
||||
let count = 0;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const i = worldIndex(world, x + dx, y + dy);
|
||||
if (i >= 0 && world.fields.sea?.[i]) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function isLand(world, x, y) {
|
||||
const i = worldIndex(world, x, y);
|
||||
return i >= 0 && !world.fields.sea?.[i];
|
||||
}
|
||||
|
||||
function nearestLand(world, x, y, rect, radius = 10) {
|
||||
if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
|
||||
for (let r = 1; r <= radius; r++) {
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (let yy = y - r; yy <= y + r; yy++) {
|
||||
for (let xx = x - r; xx <= x + r; xx++) {
|
||||
if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
|
||||
if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
|
||||
const i = worldIndex(world, xx, yy);
|
||||
const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
|
||||
if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pointWorldX(world, p) {
|
||||
if (Number.isFinite(p?.worldX)) return p.worldX;
|
||||
return (p?.x || 0) + (world?.originX || 0);
|
||||
}
|
||||
|
||||
function pointWorldY(world, p) {
|
||||
if (Number.isFinite(p?.worldY)) return p.worldY;
|
||||
return (p?.y || 0) + (world?.originY || 0);
|
||||
}
|
||||
|
||||
function tupleWorldX(world, tuple) {
|
||||
return (tuple?.[0] || 0) + (world?.originX || 0);
|
||||
}
|
||||
|
||||
function tupleWorldY(world, tuple) {
|
||||
return (tuple?.[1] || 0) + (world?.originY || 0);
|
||||
}
|
||||
|
||||
function sourcePointFromWorld(world, point) {
|
||||
return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
|
||||
}
|
||||
|
||||
function sourcePathFromWorld(world, path) {
|
||||
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
|
||||
}
|
||||
|
||||
function transformCandidatePoint(world, window, p, key, seed = 0) {
|
||||
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
|
||||
const w = worldCoordForSource(window, p.x, p.y);
|
||||
if (key === "ports") {
|
||||
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8);
|
||||
if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null;
|
||||
w.x = land.x; w.y = land.y;
|
||||
} else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) {
|
||||
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5);
|
||||
if (!land) return null;
|
||||
w.x = land.x; w.y = land.y;
|
||||
}
|
||||
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
|
||||
if (key === "adminCenters") {
|
||||
const offset = fieldIdOffset("adminId", seed);
|
||||
if (Number.isFinite(out.id)) out.id += offset;
|
||||
if (Number.isFinite(out.adminId)) out.adminId += offset;
|
||||
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
|
||||
if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
|
||||
}
|
||||
if (key === "prefectureRegions") {
|
||||
const offset = fieldIdOffset("prefectureRegionId", seed);
|
||||
if (Number.isFinite(out.id)) out.id += offset;
|
||||
if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
|
||||
}
|
||||
if (key === "logisticsParks") sanitizeLogisticsPark(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeLogisticsPark(p) {
|
||||
if (!p) return p;
|
||||
p.name = null;
|
||||
p.labelName = null;
|
||||
p.facilityLabel = p.facilityLabel || "Logistics Park";
|
||||
p.labelStyle = "facility";
|
||||
p.suppressSettlementLabel = true;
|
||||
p.kind = "Logistics Park";
|
||||
p.population = 0;
|
||||
return p;
|
||||
}
|
||||
|
||||
function sanitizeExistingLogistics(sourceMap) {
|
||||
let migrated = 0;
|
||||
if (!Array.isArray(sourceMap.logisticsParks)) return 0;
|
||||
for (const p of sourceMap.logisticsParks) {
|
||||
if (!p) continue;
|
||||
if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++;
|
||||
sanitizeLogisticsPark(p);
|
||||
}
|
||||
for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) {
|
||||
const arr = sourceMap[key];
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const p of arr) {
|
||||
if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue;
|
||||
p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null;
|
||||
p.labelName = p.name;
|
||||
migrated++;
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function transformCandidatePath(window, path) {
|
||||
const out = [];
|
||||
for (const tuple of path || []) {
|
||||
if (!Array.isArray(tuple) || tuple.length < 2) continue;
|
||||
const p = worldCoordForSource(window, tuple[0], tuple[1]);
|
||||
out.push([p.x, p.y]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitWorldPathByRect(path, rect, keepInside) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (const p of path || []) {
|
||||
const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect);
|
||||
if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
else {
|
||||
if (current.length >= 2) chunks.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function pruneOldPathLayer(world, paths, rect, mode) {
|
||||
const kept = [];
|
||||
const anchors = [];
|
||||
let clipped = 0;
|
||||
for (const path of paths || []) {
|
||||
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
|
||||
const touches = worldPath.some(([x, y]) => insideRect(x, y, rect));
|
||||
if (!touches) {
|
||||
kept.push(path);
|
||||
continue;
|
||||
}
|
||||
clipped++;
|
||||
let lastOutside = null;
|
||||
let wasInside = false;
|
||||
for (const [x, y] of worldPath) {
|
||||
const inside = insideRect(x, y, rect);
|
||||
if (!inside) {
|
||||
if (wasInside) anchors.push({ x, y, mode });
|
||||
lastOutside = { x, y, mode };
|
||||
} else if (lastOutside && !wasInside) {
|
||||
anchors.push(lastOutside);
|
||||
}
|
||||
wasInside = inside;
|
||||
}
|
||||
for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
|
||||
}
|
||||
return { kept, anchors, clipped };
|
||||
}
|
||||
|
||||
function pathCost(world, x, y, mode) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0 || world.fields.sea?.[i]) return Infinity;
|
||||
const slope = world.fields.slope?.[i] || 0;
|
||||
const river = world.fields.river?.[i] || 0;
|
||||
const plain = world.fields.plain?.[i] || 0;
|
||||
const roadInfluence = world.fields.roadInfluence?.[i] || 0;
|
||||
const pop = world.fields.populationDensity?.[i] || 0;
|
||||
const slopeMult = mode === "rail" ? 15 : 7;
|
||||
return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
|
||||
}
|
||||
|
||||
function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
|
||||
const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
|
||||
if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
|
||||
if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
|
||||
const w = rectWidth(rect);
|
||||
const h = rectHeight(rect);
|
||||
const n = w * h;
|
||||
const dist = new Float64Array(n); dist.fill(Infinity);
|
||||
const prev = new Int32Array(n); prev.fill(-1);
|
||||
const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
|
||||
const heap = new MinHeap();
|
||||
const startId = local(sx, sy);
|
||||
dist[startId] = 0;
|
||||
heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
|
||||
let expanded = 0;
|
||||
let found = -1;
|
||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||||
while (heap.items.length && expanded < maxExpanded) {
|
||||
const cur = heap.pop();
|
||||
if (!cur) break;
|
||||
if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
|
||||
expanded++;
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = cur.x + dx, ny = cur.y + dy;
|
||||
if (!insideRect(nx, ny, rect)) continue;
|
||||
const nid = local(nx, ny);
|
||||
const c = pathCost(world, nx, ny, mode);
|
||||
if (!Number.isFinite(c)) continue;
|
||||
const step = (dx && dy ? 1.42 : 1) * c;
|
||||
const nd = dist[cur.id] + step;
|
||||
if (nd >= dist[nid]) continue;
|
||||
dist[nid] = nd;
|
||||
prev[nid] = cur.id;
|
||||
heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
|
||||
}
|
||||
}
|
||||
if (found < 0) return null;
|
||||
const rev = [];
|
||||
let at = found;
|
||||
while (at >= 0) {
|
||||
const x = rect.x0 + (at % w);
|
||||
const y = rect.y0 + Math.floor(at / w);
|
||||
rev.push([x, y]);
|
||||
at = prev[at];
|
||||
}
|
||||
return rev.reverse();
|
||||
}
|
||||
|
||||
function simplifyPath(path, keepEvery = 2) {
|
||||
if (!path || path.length <= 2) return path || [];
|
||||
const out = [path[0]];
|
||||
for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
|
||||
out.push(path[path.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectInternalNetworkPoints(world, sourceMap, keys, rect) {
|
||||
const points = [];
|
||||
for (const key of keys) {
|
||||
for (const path of sourceMap[key] || []) {
|
||||
for (let i = 0; i < path.length; i += 6) {
|
||||
const x = Math.round(tupleWorldX(world, path[i]));
|
||||
const y = Math.round(tupleWorldY(world, path[i]));
|
||||
if (insideRect(x, y, rect) && isLand(world, x, y)) points.push({ x, y, key });
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function connectAnchors(world, sourceMap, anchors, mode, rect) {
|
||||
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
|
||||
const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect);
|
||||
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
|
||||
let connectors = 0;
|
||||
let disconnected = 0;
|
||||
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
||||
sourceMap[layer] ||= [];
|
||||
const seen = new Set();
|
||||
for (const raw of anchors) {
|
||||
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 12);
|
||||
if (!anchorLand) { disconnected++; continue; }
|
||||
const target = targets
|
||||
.filter((p) => Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) <= (mode === "rail" ? 80 : 64))
|
||||
.sort((a, b) => Math.hypot(a.x - anchorLand.x, a.y - anchorLand.y) - Math.hypot(b.x - anchorLand.x, b.y - anchorLand.y))[0];
|
||||
if (!target) { disconnected++; continue; }
|
||||
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
const path = localPathfind(world, anchorLand, target, rect, mode);
|
||||
if (!path || path.length < 2) { disconnected++; continue; }
|
||||
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
|
||||
connectors++;
|
||||
}
|
||||
return { connectors, disconnected };
|
||||
}
|
||||
|
||||
function mergePointLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||
let preservedExternalEntities = 0;
|
||||
let regeneratedInternalEntities = 0;
|
||||
let invalidPortsRemoved = 0;
|
||||
for (const key of POINT_LAYER_KEYS) {
|
||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||
const kept = [];
|
||||
for (const p of oldArr) {
|
||||
if (!p) continue;
|
||||
const wx = Math.round(pointWorldX(world, p));
|
||||
const wy = Math.round(pointWorldY(world, p));
|
||||
const inWrite = insideRect(wx, wy, rects.writeRect);
|
||||
const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
|
||||
if (!inWrite || alpha < 0.34) {
|
||||
if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; }
|
||||
kept.push(p);
|
||||
if (!inWrite) preservedExternalEntities++;
|
||||
}
|
||||
}
|
||||
const generated = [];
|
||||
for (const p of candidate[key] || []) {
|
||||
const q = transformCandidatePoint(world, window, p, key, seed);
|
||||
if (!q) continue;
|
||||
const wx = Math.round(pointWorldX(world, q));
|
||||
const wy = Math.round(pointWorldY(world, q));
|
||||
if (!insideRect(wx, wy, rects.writeRect)) continue;
|
||||
if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
|
||||
generated.push(q);
|
||||
}
|
||||
sourceMap[key] = [...kept, ...generated];
|
||||
regeneratedInternalEntities += generated.length;
|
||||
}
|
||||
return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
|
||||
}
|
||||
|
||||
function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||
let roadAnchors = [];
|
||||
let railAnchors = [];
|
||||
let roadsClipped = 0;
|
||||
let railsClipped = 0;
|
||||
let regeneratedPaths = 0;
|
||||
for (const key of PATH_LAYER_KEYS) {
|
||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||
const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
|
||||
const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode);
|
||||
if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
|
||||
else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
|
||||
const next = [...pruned.kept];
|
||||
for (const path of candidate[key] || []) {
|
||||
const worldPath = transformCandidatePath(window, path);
|
||||
const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true)
|
||||
.map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
|
||||
.filter((chunk) => chunk.length >= 2);
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
|
||||
next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
|
||||
regeneratedPaths++;
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceMap[key] = next;
|
||||
}
|
||||
const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", rects.writeRect);
|
||||
const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", rects.writeRect);
|
||||
return {
|
||||
roadsClipped,
|
||||
railsClipped,
|
||||
regeneratedPaths,
|
||||
roadConnectorsCreated: roadConn.connectors,
|
||||
railwayConnectorsCreated: railConn.connectors,
|
||||
disconnectedRoadComponents: roadConn.disconnected,
|
||||
disconnectedRailComponents: railConn.disconnected,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentTouchesRect(world, seg, rect) {
|
||||
if (!Array.isArray(seg) || seg.length < 2) return false;
|
||||
return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
|
||||
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
|
||||
}
|
||||
|
||||
function buildBoundarySegmentsFromField(world, fieldName, rect) {
|
||||
const field = world.fields[fieldName];
|
||||
const sea = world.fields.sea;
|
||||
if (!field) return [];
|
||||
const out = [];
|
||||
for (let y = rect.y0; y < rect.y1; y++) {
|
||||
for (let x = rect.x0; x < rect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0 || sea?.[i]) continue;
|
||||
const id = field[i];
|
||||
if (id < 0) continue;
|
||||
const right = worldIndex(world, x + 1, y);
|
||||
if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id) {
|
||||
out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]);
|
||||
}
|
||||
const down = worldIndex(world, x, y + 1);
|
||||
if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id) {
|
||||
out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeSegmentLayers(world, sourceMap, rects) {
|
||||
for (const key of SEGMENT_LAYER_KEYS) {
|
||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
|
||||
}
|
||||
sourceMap.adminBorders ||= [];
|
||||
sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect));
|
||||
sourceMap.regionalPrefectureBorders ||= [];
|
||||
sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect));
|
||||
}
|
||||
|
||||
function repairLanduseAndPopulation(world, rects) {
|
||||
const landuse = world.fields.landuse;
|
||||
if (!landuse) return { landUseCellsUpdated: 0 };
|
||||
let updated = 0;
|
||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0) continue;
|
||||
if (world.fields.sea?.[i]) {
|
||||
if (landuse[i] !== (LANDUSE.WATER || 0)) updated++;
|
||||
landuse[i] = LANDUSE.WATER || 0;
|
||||
continue;
|
||||
}
|
||||
if (landuse[i] === (LANDUSE.WATER || 0)) {
|
||||
const slope = world.fields.slope?.[i] || 0;
|
||||
const ag = world.fields.agriculture?.[i] || 0;
|
||||
landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { landUseCellsUpdated: updated };
|
||||
}
|
||||
|
||||
function countSea(world, rect) {
|
||||
let seaCount = 0;
|
||||
let total = 0;
|
||||
for (let y = rect.y0; y < rect.y1; y++) {
|
||||
for (let x = rect.x0; x < rect.x1; x++) {
|
||||
const i = worldIndex(world, x, y);
|
||||
if (i < 0) continue;
|
||||
total++;
|
||||
if (world.fields.sea?.[i]) seaCount++;
|
||||
}
|
||||
}
|
||||
return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
|
||||
}
|
||||
|
||||
function terrainLabel(candidate, fallback) {
|
||||
return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
|
||||
}
|
||||
|
||||
function terrainId(candidate, fallback) {
|
||||
return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
|
||||
}
|
||||
|
||||
export function generatePatch(world, userRectInput, options = {}) {
|
||||
const validation = validatePatchRect(userRectInput, world);
|
||||
if (!validation.ok) return { ok: false, ...validation };
|
||||
|
||||
const rects = buildPatchRects(validation.rect, world);
|
||||
const terrainType = options.terrainType || "auto";
|
||||
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
|
||||
const candidate = generateMap(seed, { terrainType, onProgress: () => {} });
|
||||
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
||||
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
||||
|
||||
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
|
||||
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
||||
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
||||
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||
mergeSegmentLayers(world, sourceMap, rects);
|
||||
const landDebug = repairLanduseAndPopulation(world, rects);
|
||||
sanitizeExistingLogistics(sourceMap);
|
||||
|
||||
const seaStats = countSea(world, rects.coreRect);
|
||||
const label = terrainLabel(candidate, terrainType);
|
||||
const id = terrainId(candidate, terrainType);
|
||||
const humanGeography = {
|
||||
ok: true,
|
||||
modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||
ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||
...pointDebug,
|
||||
...pathDebug,
|
||||
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
||||
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
||||
logisticsLabelsMigrated,
|
||||
};
|
||||
|
||||
const record = {
|
||||
...rects.coreRect,
|
||||
coreRect: { ...rects.coreRect },
|
||||
writeRect: { ...rects.writeRect },
|
||||
repairRect: { ...rects.repairRect },
|
||||
contextRect: { ...rects.contextRect },
|
||||
blendRect: { ...rects.blendRect },
|
||||
terrainType: id,
|
||||
label,
|
||||
seed,
|
||||
updatedCells: fieldDebug.updatedCells,
|
||||
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
||||
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
|
||||
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
|
||||
naturalRegionFragmentsMerged: 0,
|
||||
seaRatio: seaStats.seaRatio,
|
||||
humanGeography,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
world.generatedRects = [...(world.generatedRects || []), record];
|
||||
world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
|
||||
world.lastPatchResult = record;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
validation,
|
||||
rects,
|
||||
terrainType: id,
|
||||
label,
|
||||
seed,
|
||||
updatedCells: record.updatedCells,
|
||||
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
||||
coastCellsChanged: record.coastCellsChanged,
|
||||
naturalRegionsUpdated: record.naturalRegionsUpdated,
|
||||
naturalRegionFragmentsMerged: 0,
|
||||
seaRatio: seaStats.seaRatio,
|
||||
humanGeography,
|
||||
};
|
||||
}
|
||||
|
|
@ -206,6 +206,31 @@ const TERRAIN_TYPES = [
|
|||
riverRichnessRange: [0.74, 1.14],
|
||||
bigRiverChanceRange: [0.30, 0.60],
|
||||
},
|
||||
{
|
||||
id: "oceanic_archipelago",
|
||||
label: "海洋型・多島海",
|
||||
weight: 0.16,
|
||||
coastStyle: "oceanic_archipelago",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.04, 0.28],
|
||||
seaRatioRange: [0.72, 0.90],
|
||||
twoSidedChance: 1.0,
|
||||
mountainOffsetRange: [0.25, 0.55],
|
||||
baseHeightRange: [0.30, 0.62],
|
||||
primaryLengthRange: [0.20, 0.52],
|
||||
primaryWidthRange: [0.08, 0.24],
|
||||
systemCountRange: [7, 14],
|
||||
beltCountRange: [2, 4],
|
||||
angleSpread: 0.70,
|
||||
crossSpread: 0.90,
|
||||
lengthScale: 0.78,
|
||||
widthScale: 0.82,
|
||||
heightScale: 0.58,
|
||||
coastStrength: 1.55,
|
||||
plainBiasRange: [0.12, 0.34],
|
||||
riverRichnessRange: [0.18, 0.52],
|
||||
bigRiverChanceRange: [0.02, 0.12],
|
||||
},
|
||||
{
|
||||
id: "setouchi_inland_sea",
|
||||
label: "瀬戸内型・内海多島",
|
||||
|
|
@ -584,7 +609,15 @@ function computeCoastLower(px, py, template, seed) {
|
|||
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
|
||||
let pressure = 0;
|
||||
|
||||
if (template.coastStyle === "inland_sea") {
|
||||
if (template.coastStyle === "oceanic_archipelago") {
|
||||
// 海洋型: 外洋を強く取り、島列・湾・水道が点在する低標高圧を作る。
|
||||
const radial = distNorm(px, py, 0.5, 0.5);
|
||||
const outerOcean = smoothstep((radial - 0.30 + wave * 1.15 + bay * 0.85) / 0.18) * 0.96;
|
||||
const diagonalChannel = smoothstep((0.13 - Math.abs(cross + wave * 0.82 + islandNoise * 0.70)) / 0.14) * 0.70;
|
||||
const openSide = smoothstep((-axis + 0.12 + wave + bay) / 0.23) * 0.82;
|
||||
const islandGaps = clamp((valueNoise(px * 780 + 31, py * 780 - 19, seed + 303, 20) - 0.42) * 1.25) * 0.22;
|
||||
pressure = clamp(Math.max(outerOcean, diagonalChannel, openSide) + islandGaps);
|
||||
} else if (template.coastStyle === "inland_sea") {
|
||||
// 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
|
||||
// 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
|
||||
// 連続した水道形状で海を増やす。
|
||||
|
|
@ -1217,8 +1250,17 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
const high = Math.max(0, e - 0.62);
|
||||
e -= high * 0.42;
|
||||
}
|
||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
|
||||
if (terrainTemplate.terrainType === "oceanic_archipelago") {
|
||||
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
|
||||
const islandCore = clamp((fbm(x * 0.82 + 41, y * 0.82 - 29, seed + 572) - 0.46) * 2.7);
|
||||
const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
|
||||
e += islandChain * 0.135;
|
||||
e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
|
||||
const high = Math.max(0, e - 0.56);
|
||||
e -= high * 0.52;
|
||||
}
|
||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.62 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.84 : 1.08;
|
||||
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
|
||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||
|
|
|
|||
10
renderer.js
10
renderer.js
|
|
@ -892,14 +892,15 @@ export function drawMap(canvas, map, options) {
|
|||
const mode = options.mode || "all";
|
||||
const showFeatures = options.showFeatures !== false;
|
||||
const showLabels = options.showLabels !== false;
|
||||
const continuousTerrain = options.continuousTerrain !== false;
|
||||
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
|
||||
// 1. Base Terrain & Urban
|
||||
drawBase(ctx, map, mode, true);
|
||||
drawBase(ctx, map, mode, continuousTerrain);
|
||||
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 });
|
||||
|
|
@ -1100,10 +1101,9 @@ export function drawMap(canvas, map, options) {
|
|||
...prefectureLabels,
|
||||
...map.modernCities,
|
||||
...map.ports,
|
||||
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
|
||||
...(map.satelliteCities || []),
|
||||
...settlementIconLabelPoints,
|
||||
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
|
||||
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
||||
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||
}
|
||||
drawScaleBar(ctx);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
|
||||
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.74);border:1px solid rgba(0,0,0,0.10);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.10);backdrop-filter:blur(4px);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||
.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
||||
|
||||
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
|
||||
|
|
@ -72,4 +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}
|
||||
.canvas-shell.panning .map-canvas{pointer-events:none}
|
||||
.canvas-shell.selecting{cursor:crosshair}
|
||||
.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(26,115,232,0.86);background:rgba(26,115,232,0.16);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(26,115,232,0.20)}
|
||||
.primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed}
|
||||
.patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45}
|
||||
.patch-status.invalid{color:#b3261e;font-weight:600}
|
||||
.map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)}
|
||||
|
|
|
|||
100
worldMap.js
Normal file
100
worldMap.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||
|
||||
export const DEFAULT_WORLD_PADDING_X = MAP_W;
|
||||
export const DEFAULT_WORLD_PADDING_Y = MAP_H;
|
||||
|
||||
const NEGATIVE_ONE_FIELDS = new Set([
|
||||
"adminId",
|
||||
"prefectureRegionId",
|
||||
"regionId",
|
||||
"municipalityId",
|
||||
]);
|
||||
|
||||
function isCellField(value) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||
}
|
||||
|
||||
function defaultForField(name, Constructor) {
|
||||
if (name === "sea") return 1;
|
||||
if (name === "elevation") return 0.08;
|
||||
if (name === "seaLevel") return undefined;
|
||||
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
||||
const Constructor = source.constructor;
|
||||
const out = new Constructor(worldWidth * worldHeight);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) out.fill(fallback);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
const srcRow = y * MAP_W;
|
||||
const dstRow = (originY + y) * worldWidth + originX;
|
||||
for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createWorldMap(initialMap, options = {}) {
|
||||
const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
|
||||
const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
|
||||
const worldWidth = MAP_W + paddingX * 2;
|
||||
const worldHeight = MAP_H + paddingY * 2;
|
||||
const originX = paddingX;
|
||||
const originY = paddingY;
|
||||
const fields = {};
|
||||
|
||||
for (const [name, value] of Object.entries(initialMap || {})) {
|
||||
if (isCellField(value)) fields[name] = makeWorldField(name, value, worldWidth, worldHeight, originX, originY);
|
||||
}
|
||||
|
||||
if (!fields.sea) {
|
||||
fields.sea = new Uint8Array(worldWidth * worldHeight);
|
||||
fields.sea.fill(1);
|
||||
}
|
||||
if (!fields.elevation) {
|
||||
fields.elevation = new Float32Array(worldWidth * worldHeight);
|
||||
fields.elevation.fill(0.08);
|
||||
}
|
||||
|
||||
return {
|
||||
seed: initialMap?.seed ?? 0,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
originX,
|
||||
originY,
|
||||
sourceWidth: initialMap?.width || MAP_W,
|
||||
sourceHeight: initialMap?.height || MAP_H,
|
||||
sourceMap: initialMap,
|
||||
fields,
|
||||
invalidatedRects: [],
|
||||
humanPatchHistory: [],
|
||||
generatedRects: [{
|
||||
x0: originX,
|
||||
y0: originY,
|
||||
x1: originX + MAP_W,
|
||||
y1: originY + MAP_H,
|
||||
terrainType: initialMap?.terrainTemplate?.terrainType || initialMap?.terrainDebug?.terrainType || "auto",
|
||||
label: initialMap?.terrainTemplate?.terrainTypeLabel || initialMap?.terrainDebug?.terrainTypeLabel || "Initial generation",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialCamera(world) {
|
||||
return {
|
||||
x: world?.originX || 0,
|
||||
y: world?.originY || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
if (!camera || !world) return { x: 0, y: 0 };
|
||||
const maxX = Math.max(0, world.width - viewWidth);
|
||||
const maxY = Math.max(0, world.height - viewHeight);
|
||||
return {
|
||||
x: Math.min(Math.max(Math.round(camera.x || 0), 0), maxX),
|
||||
y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY),
|
||||
};
|
||||
}
|
||||
205
worldViewport.js
Normal file
205
worldViewport.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
|
||||
|
||||
const EMPTY_ARRAY_KEYS = new Set([
|
||||
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||
"mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders",
|
||||
"adminBorders", "externalGateways", "prefectureRegions",
|
||||
]);
|
||||
|
||||
const PATH_ARRAY_KEYS = new Set([
|
||||
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
||||
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
||||
"mainRivers", "tributaryRivers", "smallStreams",
|
||||
]);
|
||||
|
||||
const SEGMENT_ARRAY_KEYS = new Set([
|
||||
"prefectureBorder", "regionalPrefectureBorders", "adminBorders",
|
||||
]);
|
||||
|
||||
const POINT_ARRAY_KEYS = new Set([
|
||||
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
||||
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
|
||||
"externalGateways", "prefectureRegions",
|
||||
]);
|
||||
|
||||
const NEGATIVE_ONE_FIELDS = new Set([
|
||||
"adminId",
|
||||
"prefectureRegionId",
|
||||
"regionId",
|
||||
"municipalityId",
|
||||
]);
|
||||
|
||||
function isCellField(value) {
|
||||
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
|
||||
}
|
||||
|
||||
function defaultForField(name, Constructor) {
|
||||
if (name === "sea") return 1;
|
||||
if (name === "elevation") return 0.08;
|
||||
if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
|
||||
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function worldIndex(world, x, y) {
|
||||
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
||||
return y * world.width + x;
|
||||
}
|
||||
|
||||
function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
|
||||
const Constructor = source.constructor;
|
||||
const out = new Constructor(viewWidth * viewHeight);
|
||||
const fallback = defaultForField(name, Constructor);
|
||||
if (fallback !== 0) out.fill(fallback);
|
||||
|
||||
const cx = Math.round(camera.x || 0);
|
||||
const cy = Math.round(camera.y || 0);
|
||||
for (let y = 0; y < viewHeight; y++) {
|
||||
for (let x = 0; x < viewWidth; x++) {
|
||||
const src = worldIndex(world, cx + x, cy + y);
|
||||
if (src >= 0) out[y * viewWidth + x] = source[src];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inViewportPoint(p, margin = 0) {
|
||||
return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin;
|
||||
}
|
||||
|
||||
function transformPointObject(point, camera, originX, originY) {
|
||||
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
|
||||
return {
|
||||
...point,
|
||||
x: point.x + originX - camera.x,
|
||||
y: point.y + originY - camera.y,
|
||||
worldX: point.x + originX,
|
||||
worldY: point.y + originY,
|
||||
};
|
||||
}
|
||||
|
||||
function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false) {
|
||||
const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
|
||||
return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin));
|
||||
}
|
||||
|
||||
function transformTuple(tuple, camera, originX, originY) {
|
||||
if (!Array.isArray(tuple) || tuple.length < 2) return null;
|
||||
return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
|
||||
}
|
||||
|
||||
function tupleInside(tuple, margin = 0) {
|
||||
return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin;
|
||||
}
|
||||
|
||||
function splitTransformedPath(path, camera, originX, originY, margin = 0) {
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (const tuple of path || []) {
|
||||
const p = transformTuple(tuple, camera, originX, originY);
|
||||
const inside = tupleInside(p, margin);
|
||||
if (inside) {
|
||||
current.push([Math.round(p[0]), Math.round(p[1])]);
|
||||
} else if (current.length >= 2) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
} else {
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function transformPaths(paths, camera, originX, originY) {
|
||||
const out = [];
|
||||
for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY));
|
||||
return out;
|
||||
}
|
||||
|
||||
function segmentIntersectsViewport(seg, margin = 4) {
|
||||
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;
|
||||
}
|
||||
|
||||
function transformSegments(segments, camera, originX, originY) {
|
||||
return (segments || [])
|
||||
.map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
|
||||
.filter(segmentIntersectsViewport);
|
||||
}
|
||||
|
||||
function transformTransportDebug(debug, camera, originX, originY) {
|
||||
if (!debug?.layers) return debug;
|
||||
const layers = { ...debug.layers };
|
||||
if (Array.isArray(layers.components)) {
|
||||
layers.components = layers.components.map((component) => ({
|
||||
...component,
|
||||
cells: (component.cells || [])
|
||||
.map((cell) => transformTuple(cell, camera, originX, originY))
|
||||
.filter((cell) => tupleInside(cell, 0))
|
||||
.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 })));
|
||||
}
|
||||
if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY);
|
||||
return { ...debug, layers };
|
||||
}
|
||||
|
||||
function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewHeight) {
|
||||
const viewport = { ...sourceMap };
|
||||
viewport.width = viewWidth;
|
||||
viewport.height = viewHeight;
|
||||
viewport.worldCamera = { x: camera.x, y: camera.y };
|
||||
viewport.worldOrigin = { x: world.originX, y: world.originY };
|
||||
viewport.generatedRects = world.generatedRects || [];
|
||||
for (const key of EMPTY_ARRAY_KEYS) if (Array.isArray(viewport[key])) viewport[key] = [];
|
||||
return viewport;
|
||||
}
|
||||
|
||||
export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H) {
|
||||
const sourceMap = world?.sourceMap || {};
|
||||
const normalizedCamera = {
|
||||
x: Math.round(camera?.x || 0),
|
||||
y: Math.round(camera?.y || 0),
|
||||
};
|
||||
const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
|
||||
|
||||
for (const [name, value] of Object.entries(world?.fields || {})) {
|
||||
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
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);
|
||||
|
||||
if (sourceMap.adminDebug) {
|
||||
viewport.adminDebug = {
|
||||
...sourceMap.adminDebug,
|
||||
compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY),
|
||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY),
|
||||
};
|
||||
}
|
||||
if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
return viewport;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue