diff --git a/app.js b/app.js index ad57d2d..0380924 100644 --- a/app.js +++ b/app.js @@ -2,7 +2,7 @@ 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 { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js"; import { getViewportMap } from "./worldViewport.js"; import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js"; @@ -30,6 +30,8 @@ const state = { viewportMap: null, hoverEntities: [], selectionRect: null, + patchVariant: 0, + zoom: 1, lastPatchResult: null, }; @@ -38,7 +40,9 @@ const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const generationTypeInput = document.getElementById("generationType"); const patchTerrainTypeInput = document.getElementById("patchTerrainType"); +const patchVariantInput = document.getElementById("patchVariant"); const generatePatchButton = document.getElementById("generatePatch"); +const alternativePatchButton = document.getElementById("alternativePatch"); const patchStatusEl = document.getElementById("patchStatus"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); @@ -71,16 +75,72 @@ function activeMap() { return state.viewportMap || state.map; } +function clampZoom(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 1; + return Math.min(Math.max(parsed, 0.55), 2.8); +} + +function zoomTransform() { + const zoom = clampZoom(state.zoom || 1); + const width = canvas.width || MAP_W * CELL_SIZE; + const height = canvas.height || MAP_H * CELL_SIZE; + return { + zoom, + width, + height, + tx: width * (1 - zoom) * 0.5, + ty: height * (1 - zoom) * 0.5, + }; +} + +function applyCanvasZoom() { + if (!canvas) return; + state.zoom = clampZoom(state.zoom || 1); + // Keep the canvas element at a stable size. Zoom is applied inside the + // renderer transform, not by resizing the scrollable shell. + canvas.style.width = `${MAP_W * CELL_SIZE}px`; + canvas.style.height = `${MAP_H * CELL_SIZE}px`; + updateSelectionOverlayFromWorldRect(); +} + +function displayedCellSize() { + return CELL_SIZE * clampZoom(state.zoom || 1); +} + +function screenPointToMapPixel(clientX, clientY) { + 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, + }; +} + +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; + return { + x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, t.width)), + y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, t.height)), + }; +} + function mapClientToCell(event) { 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; + const p = screenPointToMapPixel(event.clientX, event.clientY); + if (!p) return null; return { - x: Math.floor(relX * map.width), - y: Math.floor(relY * map.height), + x: Math.floor(p.x / CELL_SIZE), + y: Math.floor(p.y / CELL_SIZE), }; } @@ -115,6 +175,7 @@ function updateSelectionOverlay() { const validation = validatePatchRect(liveRect, state.world); selectionEl.classList.toggle("invalid", !validation.ok); if (generatePatchButton) generatePatchButton.disabled = true; + if (alternativePatchButton) alternativePatchButton.disabled = true; if (patchStatusEl) { const current = validation.rect || liveRect; patchStatusEl.textContent = validation.ok @@ -128,13 +189,14 @@ 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 p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE); + const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE); + const vx0 = p0.x - canvas.offsetLeft; + const vy0 = p0.y - canvas.offsetTop; + const vx1 = p1.x - canvas.offsetLeft; + const vy1 = p1.y - canvas.offsetTop; const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width); const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height); const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width); @@ -159,13 +221,37 @@ function formatRectSize(rect) { return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`; } +function normalizePatchVariant(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0; +} + +function setPatchVariant(value, { update = true } = {}) { + state.patchVariant = normalizePatchVariant(value); + if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) { + patchVariantInput.value = String(state.patchVariant); + } + if (update) updatePatchControls(); + return state.patchVariant; +} + +function readPatchVariant() { + return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false }); +} + +function resetPatchVariant({ update = true } = {}) { + return setPatchVariant(0, { update }); +} + function updatePatchControls() { if (!patchStatusEl && !generatePatchButton) return; const validation = validatePatchRect(state.selectionRect, state.world); + const variant = readPatchVariant(); if (generatePatchButton) generatePatchButton.disabled = !validation.ok; + if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok; if (!patchStatusEl) return; if (!state.selectionRect) { - patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`; + 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.classList.toggle("invalid", false); return; } @@ -176,9 +262,9 @@ function updatePatchControls() { } 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}` : ""}.` + ? ` 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}` : ""}.` : ""; - patchStatusEl.textContent = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; + patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; patchStatusEl.classList.toggle("invalid", false); } @@ -211,6 +297,7 @@ function hideSelectionOverlay() { dragState.selectStart = null; dragState.selectEnd = null; state.selectionRect = null; + resetPatchVariant({ update: false }); if (selectionEl) selectionEl.style.display = "none"; updatePatchControls(); } @@ -220,10 +307,18 @@ function selectionPixelsToCells(start, end) { 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 toMapPixel = (p) => { + const t = zoomTransform(); + const canvasX = p.x * (t.width / rect.width); + const canvasY = p.y * (t.height / rect.height); + return { x: (canvasX - t.tx) / t.zoom, y: (canvasY - t.ty) / t.zoom }; + }; + const a = toMapPixel(start); + const b = toMapPixel(end); + const localX0 = Math.floor(Math.min(a.x, b.x) / CELL_SIZE); + const localY0 = Math.floor(Math.min(a.y, b.y) / CELL_SIZE); + const localX1 = Math.ceil(Math.max(a.x, b.x) / CELL_SIZE); + const localY1 = Math.ceil(Math.max(a.y, b.y) / CELL_SIZE); const cameraX = Math.round(state.camera?.x || 0); const cameraY = Math.round(state.camera?.y || 0); return { @@ -264,8 +359,9 @@ function handleMapPointerMove(event) { 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 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({ x: dragState.startCameraX - dxCells, y: dragState.startCameraY - dyCells, @@ -288,6 +384,8 @@ function handleMapPointerUp(event) { const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); if (width >= 4 && height >= 4) { state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + state.lastPatchResult = null; + resetPatchVariant({ update: false }); updateSelectionOverlayFromWorldRect(); updatePatchControls(); } else { @@ -466,20 +564,61 @@ function landuseName(value) { return landuseLabel(value); } -function adminName(map, adminId) { - const center = (map.adminCenters || [])[adminId]; - return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-"); +function numericIdOf(item) { + for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) { + const value = item?.[key]; + if (Number.isFinite(value)) return value; + } + return null; } -function adminPopulation(map, adminId) { - const center = (map.adminCenters || [])[adminId]; +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; +} + +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); +} + +function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36) { + 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; + const d = Math.hypot(center.x - x, center.y - y); + if (d < bestD) { best = center; bestD = d; } + } + return best; +} + +function adminName(map, adminId, cellIndex = -1) { + const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex); + const name = center?.municipalityName || center?.name || center?.canonicalSettlementName || center?.municipalityRootName || center?.generatedMunicipalityName; + if (!looksNumericName(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; } function prefectureNameForCell(map, i) { const id = map.prefectureRegionId?.[i] ?? -1; const region = (map.prefectureRegions || []).find((p) => p.id === id); - return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-"); + if (region?.name && !looksNumericName(region.name)) return region.name; + const center = nearestNamedAdminCenter(map, i, 80); + return center?.prefectureName || center?.prefectureRegionName || (id >= 0 ? "Unnamed prefecture" : "-"); } function updateTooltip(event) { @@ -497,9 +636,9 @@ function updateTooltip(event) { const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); const elevation = map.elevation?.[i] ?? 0; - const density = map.populationDensity?.[i] ?? 0; + const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0; const hoveredAdminId = map.adminId?.[i] ?? -1; - const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId); + const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i); 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"}` @@ -507,7 +646,7 @@ function updateTooltip(event) { const lines = [ `${entityTitle}`, `Prefecture: ${prefectureNameForCell(map, i)}`, - `Admin: ${adminName(map, hoveredAdminId)}`, + `Admin: ${adminName(map, hoveredAdminId, i)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`, `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`, @@ -552,6 +691,7 @@ async function regenerate() { state.world = createWorldMap(state.map); state.camera = createInitialCamera(state.world); state.lastPatchResult = null; + resetPatchVariant({ update: false }); hideSelectionOverlay(); renderStats(state.map); redraw(); @@ -565,16 +705,41 @@ async function regenerate() { } -function derivePatchSeed(rect, terrainType) { +function derivePatchSeed(rect, terrainType, variant = 0) { let h = parseSeed(state.seedText) ^ 0x9e3779b9; h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0; h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0; h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0; h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0; + h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0; for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; return h >>> 0; } + +function handleCanvasWheel(event) { + if (!state.world || !activeMap()) return; + event.preventDefault(); + tooltipEl?.classList.remove("visible"); + const beforeCell = mapClientToCell(event); + const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null; + const oldZoom = clampZoom(state.zoom || 1); + const delta = event.deltaY < 0 ? 1.12 : 1 / 1.12; + const nextZoom = clampZoom(oldZoom * delta); + if (Math.abs(nextZoom - oldZoom) < 0.001) return; + state.zoom = nextZoom; + if (beforeWorld) { + const afterCell = mapClientToCell(event); + if (afterCell) { + state.camera = clampCameraToWorld({ + x: beforeWorld.x - afterCell.x, + y: beforeWorld.y - afterCell.y, + }, state.world, MAP_W, MAP_H); + } + } + redraw({ fastTerrain: false }); +} + async function generateSelectedPatch() { const validation = validatePatchRect(state.selectionRect, state.world); if (!validation.ok) { @@ -582,11 +747,12 @@ async function generateSelectedPatch() { return; } const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; - const seed = derivePatchSeed(validation.rect, terrainType); + const variant = readPatchVariant(); + const seed = derivePatchSeed(validation.rect, terrainType, variant); setProgressVisible(true, "Generating selected patch..."); await nextFrame(); try { - const result = generatePatch(state.world, validation.rect, { terrainType, seed }); + const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant }); if (!result.ok) { if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; updatePatchControls(); @@ -599,7 +765,7 @@ async function generateSelectedPatch() { 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}`; + if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`; renderTimingRows([]); window.setTimeout(() => setProgressVisible(false), 900); } catch (error) { @@ -608,8 +774,30 @@ async function generateSelectedPatch() { } } +async function generateAlternativePatch() { + const validation = validatePatchRect(state.selectionRect, state.world); + if (!validation.ok) { + updatePatchControls(); + return; + } + setPatchVariant(readPatchVariant() + 1, { update: false }); + await generateSelectedPatch(); +} + function redraw(options = {}) { if (!state.world) return; + const expansion = ensureWorldPaddingForCamera(state.world, state.camera, MAP_W, MAP_H); + if (expansion?.expanded) { + state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) }; + if (state.selectionRect) { + 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.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); @@ -618,7 +806,9 @@ function redraw(options = {}) { showFeatures: state.showFeatures, showLabels: state.showLabels && !options.fastTerrain, continuousTerrain: !options.fastTerrain, + zoom: state.zoom || 1, }); + applyCanvasZoom(); if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect(); } @@ -631,8 +821,20 @@ function init() { }); generationTypeInput?.addEventListener("change", regenerate); - patchTerrainTypeInput?.addEventListener("change", updatePatchControls); + patchTerrainTypeInput?.addEventListener("change", () => { + state.lastPatchResult = null; + resetPatchVariant({ update: false }); + updatePatchControls(); + }); + patchVariantInput?.addEventListener("change", () => setPatchVariant(patchVariantInput.value)); + patchVariantInput?.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + setPatchVariant(patchVariantInput.value); + generateSelectedPatch(); + } + }); generatePatchButton?.addEventListener("click", generateSelectedPatch); + alternativePatchButton?.addEventListener("click", generateAlternativePatch); randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); @@ -651,6 +853,7 @@ function init() { canvasShell?.setAttribute("tabindex", "0"); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("wheel", handleCanvasWheel, { passive: false }); canvas.addEventListener("pointerdown", handleMapPointerDown); canvas.addEventListener("pointermove", handleMapPointerMove); canvas.addEventListener("pointerup", handleMapPointerUp); diff --git a/index.html b/index.html index 3ace528..fa4f34a 100644 --- a/index.html +++ b/index.html @@ -59,7 +59,14 @@ - +
Right-drag an area to enable patch generation.
diff --git a/mapHumanPatch.js b/mapHumanPatch.js deleted file mode 100644 index f1594a2..0000000 --- a/mapHumanPatch.js +++ /dev/null @@ -1,1177 +0,0 @@ -import { clamp, hash2, MinHeap, pickEntities } from "./mapUtils.js"; -import { LANDUSE } from "./landuseCodes.js"; -import { generateEntityName } from "./names.js"; - -const POINT_LAYER_KEYS = [ - "villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations", - "interchanges", "industrialZones", "logisticsParks", "newTowns", -]; - -const PATH_LAYER_KEYS = [ - "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", - "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways", -]; - -const SEGMENT_LAYER_KEYS = ["adminBorders"]; - -const FLOAT_FIELD_KEYS = [ - "settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence", -]; - -const INT_FIELD_DEFAULTS = new Map([ - ["adminId", -1], - ["municipalityId", -1], -]); - -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 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 insideRect(x, y, rect) { - return rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1; -} - -function distanceToRectEdge(x, y, rect) { - return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y); -} - -function expandRect(rect, margin, world) { - return { - x0: Math.max(0, rect.x0 - margin), - y0: Math.max(0, rect.y0 - margin), - x1: Math.min(world.width, rect.x1 + margin), - y1: Math.min(world.height, rect.y1 + margin), - }; -} - -function worldToSourcePoint(world, x, y) { - return { x: x - world.originX, y: y - world.originY }; -} - -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 ensureSourceArray(sourceMap, key) { - if (!Array.isArray(sourceMap[key])) sourceMap[key] = []; - return sourceMap[key]; -} - -function ensureField(world, key, Constructor = Float32Array, fallback = 0) { - if (!world.fields[key] || world.fields[key].length !== world.width * world.height) { - world.fields[key] = new Constructor(world.width * world.height); - if (fallback !== 0) world.fields[key].fill(fallback); - } - return world.fields[key]; -} - -function seeded(seed, x, y, salt = 0) { - return hash2((x | 0) + salt * 8191, (y | 0) - salt * 131, seed >>> 0); -} - -function localId(rect, x, y, salt = 0) { - return `${rect.x0}:${rect.y0}:${x}:${y}:${salt}`; -} - -function makeName(seed, rect, x, y, entity, usedNames, salt = 0) { - const id = localId(rect, x, y, salt); - const name = generateEntityName(seed, id, entity, null, usedNames); - if (name) usedNames?.add(name); - return name || `隨ャ${Math.max(1, Math.floor(seeded(seed, x, y, salt) * 99))}逕コ`; -} - -function isLand(world, x, y) { - const i = worldIndex(world, x, y); - return i >= 0 && !world.fields.sea?.[i]; -} - -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 candidateScore(world, x, y, seed) { - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) return -Infinity; - const elevation = world.fields.elevation?.[i] || 0; - const slope = world.fields.slope?.[i] || 0; - const plain = world.fields.plain?.[i] || 0; - const agriculture = world.fields.agriculture?.[i] || 0; - const river = world.fields.river?.[i] || 0; - const floodplain = world.fields.floodplain?.[i] || 0; - const coast = seaNeighbors(world, x, y, 2) > 0 ? 0.26 : 0; - const lowland = Math.max(0, 1 - Math.abs(elevation - 0.33) * 2.0); - const noise = seeded(seed, x, y, 47) * 0.18; - return plain * 0.72 + agriculture * 0.72 + floodplain * 0.45 + river * 0.30 + coast + lowland * 0.38 - slope * 1.35 + noise; -} - -function collectLandCandidates(world, rect, seed, stride = 3) { - const candidates = []; - for (let y = rect.y0 + 2; y < rect.y1 - 2; y += stride) { - for (let x = rect.x0 + 2; x < rect.x1 - 2; x += stride) { - const score = candidateScore(world, x, y, seed); - if (score > 0.28) candidates.push({ x, y, score }); - } - } - return candidates; -} - -function collectPortCandidates(world, rect, seed) { - const out = []; - for (let y = rect.y0 + 2; y < rect.y1 - 2; y += 2) { - for (let x = rect.x0 + 2; x < rect.x1 - 2; x += 2) { - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) continue; - const seaN = seaNeighbors(world, x, y, 2); - if (seaN < 3) continue; - const slope = world.fields.slope?.[i] || 0; - const suit = world.fields.portSuitability?.[i] || 0; - const score = seaN * 0.09 + suit * 1.10 + (1 - slope) * 0.35 + seeded(seed, x, y, 71) * 0.20; - if (score > 0.55) out.push({ x, y, score }); - } - } - return out; -} - -function usedNameSet(sourceMap) { - const out = new Set(); - for (const key of ["villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations", "industrialZones", "logisticsParks", "newTowns", "adminCenters"]) { - for (const p of sourceMap[key] || []) if (p?.name) out.add(p.name); - } - return out; -} - -function hideAdminCentersInside(sourceMap, world, rect) { - if (!Array.isArray(sourceMap.adminCenters)) return 0; - let hidden = 0; - for (const p of sourceMap.adminCenters) { - if (!p || p.patchHidden) continue; - const wx = pointWorldX(world, p); - const wy = pointWorldY(world, p); - if (!insideRect(wx, wy, rect)) continue; - p.patchHidden = true; - p.patchHiddenAt = Date.now(); - p.x = -100000; - p.y = -100000; - hidden++; - } - return hidden; -} - -function pointLayerPreservedCount(sourceMap, world, userRect) { - let preserved = 0; - for (const key of [...POINT_LAYER_KEYS, "adminCenters"]) { - const arr = sourceMap[key]; - if (!Array.isArray(arr)) continue; - for (const p of arr) { - if (!p || p.patchHidden) continue; - const wx = pointWorldX(world, p); - const wy = pointWorldY(world, p); - if (!insideRect(wx, wy, userRect)) preserved++; - } - } - return preserved; -} - -function portStillValid(world, p, rect) { - const wx = Math.round(pointWorldX(world, p)); - const wy = Math.round(pointWorldY(world, p)); - const land = nearestLand(world, wx, wy, rect, 5); - return !!land && seaNeighbors(world, land.x, land.y, 2) >= 2; -} - -function prunePointLayers(sourceMap, world, rects) { - let removed = 0; - let invalidPortsRemoved = 0; - const userRect = rects.writeRect || rects.userRect; - const blendRect = rects.coreRect || rects.blendRect; - for (const key of POINT_LAYER_KEYS) { - const arr = sourceMap[key]; - if (!Array.isArray(arr)) continue; - const kept = []; - for (const p of arr) { - const wx = pointWorldX(world, p); - const wy = pointWorldY(world, p); - const inBlend = insideRect(wx, wy, blendRect); - const inUser = insideRect(wx, wy, userRect); - const invalidTransitionPort = key === "ports" && inUser && !inBlend && !portStillValid(world, p, userRect); - if (inBlend || invalidTransitionPort) { - removed++; - if (invalidTransitionPort) invalidPortsRemoved++; - } else { - kept.push(p); - } - } - sourceMap[key] = kept; - } - removed += hideAdminCentersInside(sourceMap, world, blendRect); - return { removedPoints: removed, invalidPortsRemoved }; -} - -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 pathLayerPreservedCount(sourceMap, world, userRect) { - let preserved = 0; - for (const key of PATH_LAYER_KEYS) { - const arr = sourceMap[key]; - if (!Array.isArray(arr)) continue; - for (const path of arr) { - if (!Array.isArray(path) || !path.length) continue; - if (path.every((tuple) => !insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), userRect))) preserved++; - } - } - return preserved; -} - -function clipPathToOutsideAndAnchors(world, path, rects, mode, layerKey) { - const outsideParts = []; - const anchors = []; - let current = []; - let removedInside = 0; - let lastOutside = null; - let lastInside = null; - - for (const tuple of path || []) { - const wx = tupleWorldX(world, tuple); - const wy = tupleWorldY(world, tuple); - const p = { x: Math.round(wx), y: Math.round(wy) }; - const inUser = insideRect(p.x, p.y, rects.userRect); - if (!inUser) { - if (lastInside) anchors.push({ ...p, kind: mode, layerKey, source: "boundary" }); - current.push(tuple); - lastOutside = p; - } else { - removedInside++; - if (lastOutside) anchors.push({ ...lastOutside, kind: mode, layerKey, source: "boundary" }); - if (current.length >= 2) outsideParts.push(current); - current = []; - lastInside = p; - } - } - if (current.length >= 2) outsideParts.push(current); - - const unique = []; - const seen = new Set(); - for (const anchor of anchors) { - const land = nearestLand(world, anchor.x, anchor.y, rects.userRect, 10); - if (!land) continue; - const key = `${land.x},${land.y},${mode}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push({ ...anchor, x: land.x, y: land.y }); - } - - return { outsideParts, anchors: unique, removedInside }; -} - -function pruneLinearLayers(sourceMap, world, rects) { - let removed = 0; - const roadAnchors = []; - const railAnchors = []; - for (const key of PATH_LAYER_KEYS) { - const arr = sourceMap[key]; - if (!Array.isArray(arr)) continue; - const kept = []; - for (const path of arr) { - const touchesUser = (path || []).some((tuple) => insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), rects.userRect)); - if (!touchesUser) { - kept.push(path); - continue; - } - const mode = key.includes("Rail") || key.includes("rail") ? "rail" : "road"; - const clipped = clipPathToOutsideAndAnchors(world, path, rects, mode, key); - kept.push(...clipped.outsideParts); - if (mode === "rail") railAnchors.push(...clipped.anchors); - else roadAnchors.push(...clipped.anchors); - removed++; - } - sourceMap[key] = kept; - } - for (const key of SEGMENT_LAYER_KEYS) { - const arr = sourceMap[key]; - if (!Array.isArray(arr)) continue; - const kept = []; - for (const seg of arr) { - if (segmentTouchesRect(world, seg, rects.writeRect || rects.userRect)) removed++; - else kept.push(seg); - } - sourceMap[key] = kept; - } - return { removedLines: removed, roadAnchors, railAnchors }; -} - -function resetHumanFields(world, rect) { - for (const key of FLOAT_FIELD_KEYS) { - const field = ensureField(world, key, Float32Array, 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) field[i] = 0; - } - } - } - const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL); - const adminId = ensureField(world, "adminId", Int32Array, -1); - const municipalityId = ensureField(world, "municipalityId", Int32Array, -1); - 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; - landuse[i] = world.fields.sea?.[i] ? LANDUSE.RURAL : ((world.fields.slope?.[i] || 0) > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL); - adminId[i] = -1; - municipalityId[i] = -1; - } - } -} - -function snapshotFields(world, rect, keys) { - const width = rectWidth(rect); - const out = { rect: { ...rect }, width, fields: {} }; - for (const key of keys) { - const field = world.fields[key]; - if (!field) continue; - const Constructor = field.constructor; - const copy = new Constructor(width * rectHeight(rect)); - for (let y = rect.y0; y < rect.y1; y++) { - for (let x = rect.x0; x < rect.x1; x++) { - const wi = worldIndex(world, x, y); - const si = (y - rect.y0) * width + (x - rect.x0); - if (wi >= 0) copy[si] = field[wi]; - } - } - out.fields[key] = copy; - } - return out; -} - -function snapshotValue(snapshot, key, x, y) { - const field = snapshot?.fields?.[key]; - const rect = snapshot?.rect; - if (!field || !insideRect(x, y, rect)) return undefined; - return field[(y - rect.y0) * snapshot.width + (x - rect.x0)]; -} - -function blendWeightFromRects(x, y, rects) { - if (insideRect(x, y, rects.blendRect)) return 1; - if (!insideRect(x, y, rects.repairRect)) return 0; - const band = Math.max(1, Math.min( - rects.blendRect.x0 - rects.repairRect.x0, - rects.blendRect.y0 - rects.repairRect.y0, - rects.repairRect.x1 - rects.blendRect.x1, - rects.repairRect.y1 - rects.blendRect.y1, - )); - return clamp(distanceToRectEdge(x, y, rects.repairRect) / band); -} - -function reconcileTransitionFields(world, rects, snapshot) { - let landUseCellsUpdated = 0; - const fields = ["settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence"]; - for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) { - for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) { - const i = worldIndex(world, x, y); - if (i < 0) continue; - const w = blendWeightFromRects(x, y, rects); - for (const key of fields) { - const oldValue = snapshotValue(snapshot, key, x, y); - if (oldValue === undefined || !world.fields[key]) continue; - world.fields[key][i] = oldValue * (1 - w) + world.fields[key][i] * w; - } - const oldLanduse = snapshotValue(snapshot, "landuse", x, y); - if (oldLanduse !== undefined && world.fields.landuse && w < 0.48) world.fields.landuse[i] = oldLanduse; - if (world.fields.landuse) landUseCellsUpdated++; - const oldAdmin = snapshotValue(snapshot, "adminId", x, y); - const oldMunicipality = snapshotValue(snapshot, "municipalityId", x, y); - if (oldAdmin !== undefined && world.fields.adminId && w < 0.35) world.fields.adminId[i] = oldAdmin; - if (oldMunicipality !== undefined && world.fields.municipalityId && w < 0.35) world.fields.municipalityId[i] = oldMunicipality; - } - } - return landUseCellsUpdated; -} - -function sourcePoint(world, p) { - const src = worldToSourcePoint(world, p.x, p.y); - return { ...p, x: src.x, y: src.y, worldX: p.x, worldY: p.y, insidePrefecture: true, patchGenerated: true }; -} - -function sourcePath(world, path) { - return path.map(([x, y]) => [x - world.originX, y - world.originY]); -} - -function addInfluence(world, point, radius, amount, fields, clipRect = null) { - const r = Math.max(1, Math.floor(radius)); - for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) { - for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) { - const d = Math.hypot(x - point.x, y - point.y); - if (d > r) continue; - if (clipRect && !insideRect(x, y, clipRect)) continue; - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) continue; - const w = (1 - d / r) ** 2 * amount; - for (const [key, mult] of fields) { - const field = ensureField(world, key, Float32Array, 0); - field[i] = clamp(field[i] + w * mult, 0, 1.8); - } - } - } -} - -function setLanduseAround(world, point, radius, landuseCode, strength = 1, clipRect = null) { - const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL); - const r = Math.max(1, Math.floor(radius)); - for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) { - for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) { - const d = Math.hypot(x - point.x, y - point.y); - if (d > r) continue; - if (clipRect && !insideRect(x, y, clipRect)) continue; - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) continue; - const p = 1 - d / r; - if (p * strength < 0.22) continue; - if (landuseCode > landuse[i] || p > 0.62) landuse[i] = landuseCode; - } - } -} - -function assignFarmlandAndForest(world, rect) { - const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL); - 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 || world.fields.sea?.[i]) continue; - if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) continue; - const slope = world.fields.slope?.[i] || 0; - const agriculture = world.fields.agriculture?.[i] || 0; - const plain = world.fields.plain?.[i] || 0; - if (slope > 0.42 || (world.fields.elevation?.[i] || 0) > 0.56) landuse[i] = LANDUSE.FOREST; - else if (agriculture > 0.28 || plain > 0.42) landuse[i] = LANDUSE.FARMLAND; - else landuse[i] = LANDUSE.RURAL; - } - } -} - -function nextAdminId(sourceMap, world) { - let maxId = -1; - const field = world.fields.adminId; - if (field) { - for (let i = 0; i < field.length; i++) if (field[i] > maxId) maxId = field[i]; - } - for (let i = 0; i < (sourceMap.adminCenters || []).length; i++) if (sourceMap.adminCenters[i]) maxId = Math.max(maxId, i); - return maxId + 1; -} - -function assignLocalAdmin(world, sourceMap, rect, centers, seed, usedNames) { - const adminIdField = ensureField(world, "adminId", Int32Array, -1); - const municipalityField = ensureField(world, "municipalityId", Int32Array, -1); - const adminCenters = ensureSourceArray(sourceMap, "adminCenters"); - let id = nextAdminId(sourceMap, world); - const centerRecords = []; - - for (const center of centers) { - const population = center.population || 3500 + Math.round(seeded(seed, center.x, center.y, 221) * 21000 / 1000) * 1000; - const base = { - x: center.x, - y: center.y, - kind: "Municipal Center", - population, - municipalityPopulation: Math.max(population, Math.round(population * (1.8 + seeded(seed, center.x, center.y, 229) * 3.2))), - }; - const name = center.name || makeName(seed, rect, center.x, center.y, { ...base, kind: "Municipal Center" }, usedNames, 230 + id); - const record = sourcePoint(world, { ...base, id, name, municipalityId: id, adminId: id, labelPriorityBase: 420 + Math.sqrt(population) }); - adminCenters[id] = record; - centerRecords.push({ ...center, id, name, population: record.population, municipalityPopulation: record.municipalityPopulation }); - id++; - } - - if (!centerRecords.length) return centerRecords; - - 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 || world.fields.sea?.[i]) continue; - let best = centerRecords[0]; - let bestD = Infinity; - for (const c of centerRecords) { - const d = Math.hypot(x - c.x, y - c.y) * (1 + (world.fields.slope?.[i] || 0) * 1.8) - seeded(seed, x, y, c.id) * 2.2; - if (d < bestD) { - bestD = d; - best = c; - } - } - adminIdField[i] = best.id; - municipalityField[i] = best.id; - } - } - return centerRecords; -} - -function collectExistingAdminCenters(world, sourceMap, rect) { - const centers = []; - for (const p of sourceMap.adminCenters || []) { - if (!p || p.patchHidden) continue; - const x = Math.round(pointWorldX(world, p)); - const y = Math.round(pointWorldY(world, p)); - if (insideRect(x, y, rect)) continue; - const id = Number.isFinite(p.adminId) ? p.adminId : Number.isFinite(p.municipalityId) ? p.municipalityId : Number.isFinite(p.id) ? p.id : null; - if (id === null || id < 0) continue; - const distance = Math.max(rect.x0 - x, x - (rect.x1 - 1), rect.y0 - y, y - (rect.y1 - 1), 0); - if (distance <= 48) centers.push({ x, y, id, name: p.name, population: p.population || p.municipalityPopulation || 3000, external: true }); - } - return centers; -} - -function reassignAdminRepair(world, sourceMap, rects, localCenters, seed) { - const adminIdField = ensureField(world, "adminId", Int32Array, -1); - const municipalityField = ensureField(world, "municipalityId", Int32Array, -1); - const externalCenters = collectExistingAdminCenters(world, sourceMap, rects.userRect); - const centers = [...externalCenters, ...(localCenters || [])].filter((p) => Number.isFinite(p?.id)); - if (!centers.length) return { adminCellsReassigned: 0, adminBoundarySmoothed: 0 }; - - let reassigned = 0; - for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) { - for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) { - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) continue; - let best = null; - let bestScore = Infinity; - for (const c of centers) { - const d = Math.hypot(x - c.x, y - c.y); - const externalBias = c.external ? -4.5 * (1 - blendWeightFromRects(x, y, rects)) : 0; - const score = d * (1 + (world.fields.slope?.[i] || 0) * 1.25) + externalBias - seeded(seed, x, y, c.id + 1300) * 1.5; - if (score < bestScore) { - bestScore = score; - best = c; - } - } - if (best && adminIdField[i] !== best.id) { - adminIdField[i] = best.id; - municipalityField[i] = best.id; - reassigned++; - } - } - } - - let smoothed = 0; - for (let pass = 0; pass < 2; pass++) { - const changes = []; - for (let y = rects.repairRect.y0 + 1; y < rects.repairRect.y1 - 1; y++) { - for (let x = rects.repairRect.x0 + 1; x < rects.repairRect.x1 - 1; x++) { - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i] || blendWeightFromRects(x, y, rects) < 0.2) continue; - const counts = new Map(); - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { - const ni = worldIndex(world, x + dx, y + dy); - const id = ni >= 0 ? adminIdField[ni] : -1; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - const current = adminIdField[i]; - const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; - if (best && best[0] !== current && best[1] >= 3) changes.push([i, best[0]]); - } - } - for (const [i, id] of changes) { - adminIdField[i] = id; - municipalityField[i] = id; - smoothed++; - } - } - return { adminCellsReassigned: reassigned, adminBoundarySmoothed: smoothed }; -} - -function buildAdminBorders(world, rect) { - const adminId = world.fields.adminId; - const sea = world.fields.sea; - if (!adminId) return []; - const segments = []; - 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 = adminId[i]; - if (id < 0) continue; - const east = worldIndex(world, x + 1, y); - if (x + 1 < rect.x1 && east >= 0 && !sea?.[east] && adminId[east] >= 0 && adminId[east] !== id) { - segments.push(sourcePath(world, [[x + 1, y], [x + 1, y + 1]])); - } - const south = worldIndex(world, x, y + 1); - if (y + 1 < rect.y1 && south >= 0 && !sea?.[south] && adminId[south] >= 0 && adminId[south] !== id) { - segments.push(sourcePath(world, [[x, y + 1], [x + 1, y + 1]])); - } - } - } - return segments; -} - -function clampToRect(x, y, rect) { - return { - x: Math.max(rect.x0, Math.min(rect.x1 - 1, Math.round(x))), - y: Math.max(rect.y0, Math.min(rect.y1 - 1, Math.round(y))), - }; -} - -function nearestLand(world, x, y, rect, maxRadius = 10) { - const start = clampToRect(x, y, rect); - if (isLand(world, start.x, start.y)) return start; - for (let r = 1; r <= maxRadius; r++) { - let best = null; - let bestD = Infinity; - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; - const p = clampToRect(start.x + dx, start.y + dy, rect); - if (!isLand(world, p.x, p.y)) continue; - const d = Math.hypot(p.x - x, p.y - y); - if (d < bestD) { - best = p; - bestD = d; - } - } - } - if (best) return best; - } - return null; -} - -function findPath(world, startInput, endInput, rect, options = {}) { - const margin = options.margin ?? 8; - const searchRect = expandRect(rect, margin, world); - const start = nearestLand(world, startInput.x, startInput.y, searchRect, 12); - const end = nearestLand(world, endInput.x, endInput.y, searchRect, 12); - if (!start || !end) return []; - if (start.x === end.x && start.y === end.y) return [[start.x, start.y]]; - - const w = rectWidth(searchRect); - const h = rectHeight(searchRect); - const localIndex = (x, y) => (y - searchRect.y0) * w + (x - searchRect.x0); - const total = w * h; - const g = new Float32Array(total); - g.fill(Infinity); - const prev = new Int32Array(total); - prev.fill(-1); - const startIdx = localIndex(start.x, start.y); - const endIdx = localIndex(end.x, end.y); - const open = new MinHeap(); - g[startIdx] = 0; - open.push({ i: startIdx, x: start.x, y: start.y, f: Math.hypot(end.x - start.x, end.y - start.y) }); - const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; - let iterations = 0; - const maxIterations = Math.min(total * 8, 90000); - - while (open.length && iterations++ < maxIterations) { - const current = open.pop(); - if (!current) break; - if (current.i === endIdx) break; - if (current.f > g[current.i] + Math.hypot(end.x - current.x, end.y - current.y) * 1.45 + 100) continue; - - for (const [dx, dy] of dirs) { - const nx = current.x + dx; - const ny = current.y + dy; - if (nx < searchRect.x0 || ny < searchRect.y0 || nx >= searchRect.x1 || ny >= searchRect.y1) continue; - const wi = worldIndex(world, nx, ny); - if (wi < 0 || world.fields.sea?.[wi]) continue; - const ni = localIndex(nx, ny); - const step = Math.hypot(dx, dy); - const slope = world.fields.slope?.[wi] || 0; - const river = world.fields.river?.[wi] || 0; - const plain = world.fields.plain?.[wi] || 0; - const roadEase = options.rail ? Math.max(0, slope - 0.10) * 9.5 : slope * 4.5; - const cost = step * (1 + roadEase - plain * 0.18 - river * 0.07); - const ng = g[current.i] + Math.max(0.2, cost); - if (ng >= g[ni]) continue; - g[ni] = ng; - prev[ni] = current.i; - const heuristic = Math.hypot(end.x - nx, end.y - ny) * (options.rail ? 1.20 : 1.05); - open.push({ i: ni, x: nx, y: ny, f: ng + heuristic }); - } - } - - if (!Number.isFinite(g[endIdx])) return straightFallback(world, start, end, searchRect); - const path = []; - let cursor = endIdx; - for (let guard = 0; cursor >= 0 && guard < total; guard++) { - const lx = cursor % w; - const ly = Math.floor(cursor / w); - path.push([searchRect.x0 + lx, searchRect.y0 + ly]); - if (cursor === startIdx) break; - cursor = prev[cursor]; - } - path.reverse(); - return simplifyPath(path); -} - -function straightFallback(world, start, end, rect) { - const steps = Math.max(2, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y))); - const path = []; - for (let k = 0; k <= steps; k++) { - const t = k / steps; - const p = nearestLand(world, start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t, rect, 5); - if (!p) continue; - if (!path.length || path[path.length - 1][0] !== p.x || path[path.length - 1][1] !== p.y) path.push([p.x, p.y]); - } - return simplifyPath(path); -} - -function simplifyPath(path) { - if (!Array.isArray(path) || path.length <= 2) return path || []; - const out = [path[0]]; - let lastDx = null; - let lastDy = null; - for (let i = 1; i < path.length - 1; i++) { - const prev = out[out.length - 1]; - const cur = path[i]; - const next = path[i + 1]; - const dx1 = Math.sign(cur[0] - prev[0]); - const dy1 = Math.sign(cur[1] - prev[1]); - const dx2 = Math.sign(next[0] - cur[0]); - const dy2 = Math.sign(next[1] - cur[1]); - if (dx1 !== dx2 || dy1 !== dy2 || i % 8 === 0) out.push(cur); - lastDx = dx1; - lastDy = dy1; - } - out.push(path[path.length - 1]); - return out; -} - -function writePathInfluence(world, path, key, radius, amount, clipRect = null) { - const field = ensureField(world, key, Float32Array, 0); - const r = Math.max(1, radius | 0); - for (const [px, py] of path || []) { - for (let y = Math.max(0, py - r); y <= Math.min(world.height - 1, py + r); y++) { - for (let x = Math.max(0, px - r); x <= Math.min(world.width - 1, px + r); x++) { - const d = Math.hypot(x - px, y - py); - if (d > r) continue; - if (clipRect && !insideRect(x, y, clipRect)) continue; - const i = worldIndex(world, x, y); - if (i < 0 || world.fields.sea?.[i]) continue; - field[i] = clamp(field[i] + (1 - d / r) * amount, 0, 1.6); - } - } - } -} - -function chooseLocalCounts(rect, landCount, coastCount) { - const area = rectArea(rect); - const landArea = Math.max(0, landCount); - const scale = Math.sqrt(Math.max(1, area) / 3000); - const landScale = Math.sqrt(Math.max(1, landArea) / 3000); - return { - admin: Math.max(1, Math.min(10, Math.round(1 + landScale * 2.2))), - modern: Math.max(0, Math.min(7, Math.round(landScale * 1.35))), - markets: Math.max(1, Math.min(10, Math.round(landScale * 2.0))), - villages: Math.max(3, Math.min(22, Math.round(landScale * 5.2))), - ports: Math.max(0, Math.min(6, Math.round(Math.sqrt(Math.max(0, coastCount)) / 8))), - castles: Math.max(0, Math.min(4, Math.round(scale * 0.8))), - industrial: Math.max(0, Math.min(4, Math.round(landScale * 0.65))), - logistics: Math.max(0, Math.min(4, Math.round(landScale * 0.70))), - newTowns: Math.max(0, Math.min(4, Math.round(landScale * 0.55))), - }; -} - -function countLandAndCoast(world, rect) { - let land = 0; - let coast = 0; - for (let y = rect.y0; y < rect.y1; y++) { - for (let x = rect.x0; x < rect.x1; x++) { - if (!isLand(world, x, y)) continue; - land++; - if (seaNeighbors(world, x, y, 1) > 0) coast++; - } - } - return { land, coast }; -} - -function topN(points, n) { - return [...points].sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, Math.max(0, n)); -} - -function buildSettlementLayers(world, sourceMap, rect, seed, usedNames) { - const landStats = countLandAndCoast(world, rect); - if (landStats.land < 24) return { counts: {}, centers: [] }; - const counts = chooseLocalCounts(rect, landStats.land, landStats.coast); - const landCandidates = collectLandCandidates(world, rect, seed, rectArea(rect) > 16000 ? 4 : 3); - const portCandidates = collectPortCandidates(world, rect, seed); - - const ports = pickEntities(portCandidates, { max: counts.ports, minDistance: 18, threshold: 0.55, seed: seed + 110, jitter: 0.08 }).map((p, idx) => { - const portClass = idx === 0 && p.score > 1.05 ? "regional" : "fishing"; - const pop = portClass === "regional" ? 9000 + Math.round(seeded(seed, p.x, p.y, 301) * 22000 / 1000) * 1000 : 1600 + Math.round(seeded(seed, p.x, p.y, 302) * 5200 / 100) * 100; - const point = { ...p, kind: portClass === "regional" ? "Regional Port" : "Fishing Port", portClass, population: pop, labelPriorityBase: portClass === "regional" ? 360 : 160 }; - return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 300 + idx) }); - }); - - const citySeeds = pickEntities(landCandidates, { max: counts.modern, minDistance: 24, threshold: 0.50, seed: seed + 120, jitter: 0.12 }); - const modernCities = citySeeds.map((p, idx) => { - const rank = idx === 0 && rectArea(rect) > 9000 ? "Regional City" : "Local City"; - const popBase = rank === "Regional City" ? 52000 : 18000; - const popSpan = rank === "Regional City" ? 140000 : 52000; - const population = popBase + Math.round(seeded(seed, p.x, p.y, 401) * popSpan / 1000) * 1000; - const point = { ...p, kind: rank, rank, population, labelPriorityBase: rank === "Regional City" ? 760 : 520, isRegionalCapital: rank === "Regional City" && idx === 0 }; - return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 400 + idx) }); - }); - - const marketSeeds = pickEntities(landCandidates, { max: counts.markets, minDistance: 14, threshold: 0.42, seed: seed + 130, jitter: 0.10 }) - .filter((p) => citySeeds.every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 9)); - const markets = marketSeeds.map((p, idx) => { - const population = 3000 + Math.round(seeded(seed, p.x, p.y, 501) * 13000 / 500) * 500; - const point = { ...p, kind: "Market Town", population, labelPriorityBase: 230 }; - return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 500 + idx) }); - }); - - const villageSeeds = pickEntities(landCandidates, { max: counts.villages, minDistance: 8, threshold: 0.31, seed: seed + 140, jitter: 0.12 }) - .filter((p) => [...citySeeds, ...marketSeeds].every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 6)); - const villages = villageSeeds.map((p, idx) => { - const population = 700 + Math.round(seeded(seed, p.x, p.y, 601) * 5200 / 100) * 100; - const point = { ...p, kind: "Village", population, labelPriorityBase: 80 }; - return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 600 + idx) }); - }); - - const castleSeeds = pickEntities(landCandidates.map((p) => ({ ...p, score: p.score + (world.fields.elevation?.[worldIndex(world, p.x, p.y)] || 0) * 0.4 })), { max: counts.castles, minDistance: 20, threshold: 0.52, seed: seed + 150, jitter: 0.15 }); - const castles = castleSeeds.map((p, idx) => { - const point = { ...p, kind: "Castle", population: 0, labelPriorityBase: 190 }; - return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 700 + idx) }); - }); - - for (const p of ports) ensureSourceArray(sourceMap, "ports").push(p); - for (const p of modernCities) ensureSourceArray(sourceMap, "modernCities").push(p); - for (const p of markets) ensureSourceArray(sourceMap, "markets").push(p); - for (const p of villages) ensureSourceArray(sourceMap, "villages").push(p); - for (const p of castles) ensureSourceArray(sourceMap, "castles").push(p); - - const centers = topN([ - ...modernCities.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 2 + (p.population || 0) / 70000 })), - ...markets.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 1 + (p.population || 0) / 28000 })), - ...ports.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.8 + (p.population || 0) / 26000 })), - ...villages.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.4 + (p.population || 0) / 16000 })), - ], counts.admin); - - return { - counts: { - ports: ports.length, - modernCities: modernCities.length, - markets: markets.length, - villages: villages.length, - castles: castles.length, - }, - centers, - localPoints: { ports, modernCities, markets, villages, castles }, - }; -} - -function buildTransportLayers(world, sourceMap, rect, seed, localPoints, connectors = {}) { - const cities = (localPoints.modernCities || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY })); - const ports = (localPoints.ports || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY })); - const markets = (localPoints.markets || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY })); - const villages = (localPoints.villages || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY })); - const roadAnchors = (connectors.roadAnchors || []).map((p) => ({ ...p, score: 1.3 })); - const railAnchors = (connectors.railAnchors || []).map((p) => ({ ...p, score: 1.2 })); - const trunkNodes = topN([...cities, ...ports, ...markets], Math.min(7, Math.max(2, cities.length + ports.length + 1))); - let nationalRoads = 0; - let minorRoads = 0; - let railways = 0; - let stations = 0; - let roadConnectorsCreated = 0; - let railwayConnectorsCreated = 0; - - for (let i = 1; i < trunkNodes.length; i++) { - const target = trunkNodes[i]; - const previous = trunkNodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0]; - const path = findPath(world, previous, target, rect, { margin: 10 }); - if (path.length >= 3) { - ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path)); - writePathInfluence(world, path, "roadInfluence", 2, 0.36); - nationalRoads++; - } - } - - const connectorTargets = trunkNodes.length ? trunkNodes : topN([...cities, ...ports, ...markets, ...villages], 4); - for (const anchor of roadAnchors.slice(0, 10)) { - if (!connectorTargets.length) break; - const nearest = connectorTargets - .filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 4) - .sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0]; - if (!nearest) continue; - const path = findPath(world, anchor, nearest, rect, { margin: 4 }); - if (path.length >= 3) { - ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path)); - writePathInfluence(world, path, "roadInfluence", 2, 0.34); - nationalRoads++; - roadConnectorsCreated++; - } - } - - for (const node of [...markets, ...villages]) { - const anchors = trunkNodes.length ? trunkNodes : cities; - if (!anchors.length) continue; - const nearest = anchors.sort((a, b) => Math.hypot(a.x - node.x, a.y - node.y) - Math.hypot(b.x - node.x, b.y - node.y))[0]; - if (!nearest || Math.hypot(nearest.x - node.x, nearest.y - node.y) < 3) continue; - const path = findPath(world, node, nearest, rect, { margin: 8 }); - if (path.length >= 3) { - ensureSourceArray(sourceMap, "minorRoads").push(sourcePath(world, path)); - writePathInfluence(world, path, "roadInfluence", 1, 0.16); - minorRoads++; - } - } - - const railNodes = topN([...cities, ...ports], Math.min(4, cities.length + ports.length)); - for (let i = 1; i < railNodes.length; i++) { - const path = findPath(world, railNodes[i - 1], railNodes[i], rect, { margin: 12, rail: true }); - if (path.length >= 6) { - ensureSourceArray(sourceMap, i === 1 ? "railways" : "branchRailways").push(sourcePath(world, path)); - writePathInfluence(world, path, "railInfluence2", 2, 0.42); - railways++; - for (let k = 0; k < path.length; k += 14) { - const [x, y] = path[k]; - if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue; - const point = sourcePoint(world, { x, y, kind: k === 0 || k >= path.length - 14 ? "Major Station" : "Station", name: `Station ${Math.max(1, stations + 1)}`, population: 0, labelPriorityBase: 120 }); - ensureSourceArray(sourceMap, "stations").push(point); - addInfluence(world, { x, y }, 5, 0.38, [["stationInfluence", 1.0], ["populationDensity", 0.25]]); - stations++; - } - } - } - - const railTargets = railNodes.length ? railNodes : topN([...cities, ...ports], 3); - for (const anchor of railAnchors.slice(0, 6)) { - if (!railTargets.length) break; - const nearest = railTargets - .filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 8) - .sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0]; - if (!nearest) continue; - const path = findPath(world, anchor, nearest, rect, { margin: 6, rail: true }); - if (path.length >= 6) { - ensureSourceArray(sourceMap, "branchRailways").push(sourcePath(world, path)); - writePathInfluence(world, path, "railInfluence2", 2, 0.38); - railways++; - railwayConnectorsCreated++; - } - } - - return { nationalRoads, minorRoads, railways, stations, roadConnectorsCreated, railwayConnectorsCreated }; -} - -function buildDevelopmentLayers(world, sourceMap, rect, seed, localPoints, usedNames) { - const bases = [ - ...(localPoints.modernCities || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.4, name: p.name })), - ...(localPoints.ports || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.1, name: p.name })), - ...(localPoints.markets || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 0.8, name: p.name })), - ]; - const landCandidates = collectLandCandidates(world, rect, seed + 900, 4) - .map((p) => ({ ...p, score: p.score + (world.fields.roadInfluence?.[worldIndex(world, p.x, p.y)] || 0) * 0.9 + (world.fields.railInfluence2?.[worldIndex(world, p.x, p.y)] || 0) * 0.8 })); - const counts = chooseLocalCounts(rect, countLandAndCoast(world, rect).land, countLandAndCoast(world, rect).coast); - const industrialSeeds = pickEntities(landCandidates, { max: counts.industrial, minDistance: 18, threshold: 0.52, seed: seed + 910, jitter: 0.12 }); - const logisticsSeeds = pickEntities(landCandidates, { max: counts.logistics, minDistance: 16, threshold: 0.50, seed: seed + 920, jitter: 0.12 }); - const newTownSeeds = pickEntities(landCandidates, { max: counts.newTowns, minDistance: 18, threshold: 0.46, seed: seed + 930, jitter: 0.12 }); - - const industrialZones = industrialSeeds.map((p, idx) => { - const point = { ...p, kind: "Industrial Zone", population: 0, labelPriorityBase: 90 }; - return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 910 + idx)} Industrial` }); - }); - const logisticsParks = logisticsSeeds.map((p, idx) => { - const point = { ...p, kind: "Logistics Park", population: 0, labelPriorityBase: 80 }; - return sourcePoint(world, { ...point, name: null, facilityLabel: "Logistics Park", labelStyle: "facility", suppressSettlementLabel: true }); - }); - const newTowns = newTownSeeds.map((p, idx) => { - const population = 6000 + Math.round(seeded(seed, p.x, p.y, 931) * 26000 / 1000) * 1000; - const point = { ...p, kind: "New Town", population, labelPriorityBase: 180 }; - return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 930 + idx)} New Town` }); - }); - - for (const p of industrialZones) { - ensureSourceArray(sourceMap, "industrialZones").push(p); - setLanduseAround(world, { x: p.worldX, y: p.worldY }, 5, LANDUSE.INDUSTRIAL, 1.0); - addInfluence(world, { x: p.worldX, y: p.worldY }, 6, 0.25, [["populationDensity", 0.2]]); - } - for (const p of logisticsParks) { - ensureSourceArray(sourceMap, "logisticsParks").push(p); - setLanduseAround(world, { x: p.worldX, y: p.worldY }, 4, LANDUSE.LOGISTICS, 1.0); - } - for (const p of newTowns) { - ensureSourceArray(sourceMap, "newTowns").push(p); - setLanduseAround(world, { x: p.worldX, y: p.worldY }, 6, LANDUSE.NEW_TOWN, 1.0); - addInfluence(world, { x: p.worldX, y: p.worldY }, 8, 0.42, [["populationDensity", 1.0], ["settlementScore", 0.5]]); - } - - return { industrialZones: industrialZones.length, logisticsParks: logisticsParks.length, newTowns: newTowns.length }; -} - -function applySettlementInfluence(world, localPoints) { - for (const p of localPoints.modernCities || []) { - const wp = { x: p.worldX, y: p.worldY }; - setLanduseAround(world, wp, 5, LANDUSE.CBD, 1.0); - setLanduseAround(world, wp, 10, LANDUSE.SUBURB, 0.74); - addInfluence(world, wp, 13, 0.85, [["populationDensity", 1.0], ["settlementScore", 0.9]]); - } - for (const p of localPoints.markets || []) { - const wp = { x: p.worldX, y: p.worldY }; - setLanduseAround(world, wp, 4, LANDUSE.OLD_URBAN, 0.86); - addInfluence(world, wp, 8, 0.52, [["populationDensity", 0.7], ["settlementScore", 0.8]]); - } - for (const p of localPoints.ports || []) { - const wp = { x: p.worldX, y: p.worldY }; - setLanduseAround(world, wp, p.portClass === "regional" ? 5 : 3, LANDUSE.OLD_URBAN, 0.8); - addInfluence(world, wp, 7, 0.44, [["populationDensity", 0.55], ["settlementScore", 0.55]]); - } - for (const p of localPoints.villages || []) { - const wp = { x: p.worldX, y: p.worldY }; - setLanduseAround(world, wp, 2, LANDUSE.FARMLAND, 0.72); - addInfluence(world, wp, 5, 0.28, [["populationDensity", 0.35], ["settlementScore", 0.45], ["villageInfluence", 1.0]]); - } -} - -function applyPreservedInfluence(world, sourceMap, rect) { - const expanded = expandRect(rect, 20, world); - let pointsApplied = 0; - const pointConfigs = [ - ["modernCities", 13, 0.72, [["populationDensity", 1.0], ["settlementScore", 0.8]], LANDUSE.SUBURB], - ["satelliteCities", 10, 0.55, [["populationDensity", 0.8], ["settlementScore", 0.65]], LANDUSE.SUBURB], - ["markets", 8, 0.42, [["populationDensity", 0.65], ["settlementScore", 0.7]], LANDUSE.OLD_URBAN], - ["ports", 7, 0.40, [["populationDensity", 0.55], ["settlementScore", 0.5]], LANDUSE.OLD_URBAN], - ["villages", 5, 0.24, [["populationDensity", 0.32], ["settlementScore", 0.38], ["villageInfluence", 0.9]], LANDUSE.FARMLAND], - ["stations", 5, 0.30, [["stationInfluence", 1.0], ["populationDensity", 0.20]], LANDUSE.ROADSIDE], - ["industrialZones", 6, 0.22, [["populationDensity", 0.18]], LANDUSE.INDUSTRIAL], - ["logisticsParks", 5, 0.18, [["roadInfluence", 0.25]], LANDUSE.LOGISTICS], - ["newTowns", 8, 0.35, [["populationDensity", 0.85], ["settlementScore", 0.45]], LANDUSE.NEW_TOWN], - ]; - for (const [key, radius, amount, fields, landuseCode] of pointConfigs) { - for (const p of sourceMap[key] || []) { - if (!p || p.patchHidden) continue; - const point = { x: Math.round(pointWorldX(world, p)), y: Math.round(pointWorldY(world, p)) }; - if (!insideRect(point.x, point.y, expanded)) continue; - addInfluence(world, point, radius, amount, fields, rect); - setLanduseAround(world, point, Math.max(2, Math.floor(radius * 0.45)), landuseCode, 0.45, rect); - pointsApplied++; - } - } - return pointsApplied; -} - -function applyPreservedPathInfluence(world, sourceMap, rect) { - let roadPaths = 0; - let railPaths = 0; - for (const key of PATH_LAYER_KEYS) { - const isRail = key.includes("Rail") || key.includes("rail"); - const influenceKey = isRail ? "railInfluence2" : "roadInfluence"; - const radius = isRail ? 2 : 2; - const amount = isRail ? 0.30 : 0.22; - for (const path of sourceMap[key] || []) { - const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); - if (!worldPath.some(([x, y]) => insideRect(x, y, expandRect(rect, 8, world)))) continue; - writePathInfluence(world, worldPath, influenceKey, radius, amount, rect); - if (isRail) railPaths++; - else roadPaths++; - } - } - return { roadPaths, railPaths }; -} - -function updatePopulationSummary(sourceMap) { - const popArrays = ["modernCities", "markets", "villages", "ports", "satelliteCities", "newTowns"]; - let total = 0; - for (const key of popArrays) { - for (const p of sourceMap[key] || []) total += Number.isFinite(p?.population) ? p.population : 0; - } - sourceMap.totalPopulation = Math.max(0, Math.round(total)); -} - -export function regenerateHumanGeographyPatch(world, rects, options = {}) { - if (!world?.sourceMap || !rects?.repairRect || !rects?.blendRect) { - return { ok: false, reason: "World/source map/patch rects are missing." }; - } - const sourceMap = world.sourceMap; - const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world.seed || 0) ^ 0xa511e9b3) >>> 0; - const userRect = rects.writeRect || rects.userRect; - const buildRect = rects.coreRect || rects.blendRect; - const repairRect = rects.repairRect; - const usedNames = usedNameSet(sourceMap); - const preservedExternalEntities = pointLayerPreservedCount(sourceMap, world, userRect) + pathLayerPreservedCount(sourceMap, world, userRect); - - for (const [key, fallback] of INT_FIELD_DEFAULTS.entries()) ensureField(world, key, Int32Array, fallback); - ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL); - for (const key of FLOAT_FIELD_KEYS) ensureField(world, key, Float32Array, 0); - - const snapshot = snapshotFields(world, repairRect, [...FLOAT_FIELD_KEYS, "landuse", "adminId", "municipalityId"]); - const pointPrune = prunePointLayers(sourceMap, world, rects); - const linePrune = pruneLinearLayers(sourceMap, world, rects); - resetHumanFields(world, repairRect); - assignFarmlandAndForest(world, repairRect); - const preservedInfluencePoints = applyPreservedInfluence(world, sourceMap, repairRect); - const preservedInfluencePaths = applyPreservedPathInfluence(world, sourceMap, repairRect); - - const settlements = buildSettlementLayers(world, sourceMap, buildRect, seed, usedNames); - const adminCenters = assignLocalAdmin(world, sourceMap, buildRect, settlements.centers || [], seed, usedNames); - const adminRepair = reassignAdminRepair(world, sourceMap, rects, adminCenters, seed); - const borders = buildAdminBorders(world, repairRect); - if (borders.length) ensureSourceArray(sourceMap, "adminBorders").push(...borders); - applySettlementInfluence(world, settlements.localPoints || {}); - const transport = buildTransportLayers(world, sourceMap, repairRect, seed, settlements.localPoints || {}, linePrune); - const development = buildDevelopmentLayers(world, sourceMap, buildRect, seed, settlements.localPoints || {}, usedNames); - const landUseCellsUpdated = reconcileTransitionFields(world, rects, snapshot); - updatePopulationSummary(sourceMap); - - const result = { - ok: true, - seed, - removedPoints: pointPrune.removedPoints, - removedLines: linePrune.removedLines, - removedLocalEntities: pointPrune.removedPoints + linePrune.removedLines, - preservedExternalEntities, - roadBoundaryAnchors: linePrune.roadAnchors.length, - railwayBoundaryAnchors: linePrune.railAnchors.length, - invalidPortsRemoved: pointPrune.invalidPortsRemoved, - logisticsLabelsFixed: true, - disconnectedRoadsRailsDetected: linePrune.removedLines, - preservedInfluencePoints, - preservedInfluenceRoads: preservedInfluencePaths.roadPaths, - preservedInfluenceRails: preservedInfluencePaths.railPaths, - landUseCellsUpdated, - ...adminRepair, - adminCenters: adminCenters.length, - adminBorders: borders.length, - ...settlements.counts, - ...transport, - ...development, - buildRect: { ...buildRect }, - repairRect: { ...repairRect }, - userRect: { ...userRect }, - }; - - world.lastHumanPatchResult = result; - world.humanPatchHistory = [...(world.humanPatchHistory || []), { ...result, createdAt: Date.now() }]; - return result; -} diff --git a/mapPatch.js b/mapPatch.js index 6254889..cabe7a4 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -1,4 +1,4 @@ -import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep } from "./mapUtils.js"; +import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js"; import { generateMap } from "./mapPipeline.js"; import { LANDUSE } from "./landuseCodes.js"; @@ -39,6 +39,11 @@ const DISCRETE_FIELD_NAMES = new Set([ "adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId", ]); + +const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]); +const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]); +const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]); + const SKIP_CELL_FIELDS = new Set(["flowTo"]); function worldIndex(world, x, y) { @@ -168,16 +173,20 @@ export function buildPatchRects(userRect, world = null) { const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource)); const writeRect = expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); + const transportReachMargin = Math.max(repairMargin + 96, Math.min(260, repairMargin + Math.max(MAP_W, MAP_H))); + const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { coreRect, writeRect, repairRect, contextRect: repairRect, + transportReachRect, blendRect: coreRect, userRect: writeRect, selectedRect: coreRect, writeMargin, repairMargin, + transportReachMargin, outerMargin: writeMargin, innerMargin: 0, }; @@ -188,8 +197,8 @@ function patchAlpha(x, y, rects, seed = 0) { 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 low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5; + const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 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 @@ -198,6 +207,19 @@ function patchAlpha(x, y, rects, seed = 0) { return clamp(base); } +function continuityReplaceThreshold(name, x, y, rects, seed = 0) { + const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5; + if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92); + if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82); + return clamp(0.46 + n * 0.20, 0.28, 0.68); +} + +function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) { + if (!rects) return true; + return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha; +} + + function sourceWindowForRects(rects) { const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2; @@ -229,9 +251,188 @@ function fieldIdOffset(name, seed) { return base + ((seed >>> 0) % 997) * 10000; } +function offsetFieldValue(name, raw, seed) { + if (!Number.isFinite(raw) || raw < 0) return raw; + const offset = fieldIdOffset(name, seed); + return offset ? raw + offset : raw; +} + +function isContinuityTransitionCell(x, y, rects, seed = 0, mode = "normal") { + if (!insideRect(x, y, rects.writeRect)) return false; + const edge = distanceToRectEdge(x, y, rects.writeRect); + const margin = Math.max(2, rects.writeMargin || 1); + const alpha = patchAlpha(x, y, rects, seed); + const edgeLimit = mode === "prefecture" ? margin * 2.15 : mode === "admin" ? margin * 1.75 : margin * 1.45; + const alphaLimit = mode === "prefecture" ? 0.995 : mode === "admin" ? 0.985 : 0.96; + return edge <= edgeLimit || alpha < alphaLimit; +} + +function addCount(bucket, key, amount = 1) { + if (!Number.isFinite(key) || key < 0) return; + bucket.set(key, (bucket.get(key) || 0) + amount); +} + +function buildContinuityIdMappings(world, candidate, rects, window, oldFields, seed = 0) { + const out = new Map(); + const debug = { continuityIdMappings: 0, continuityIdMappedCells: 0 }; + const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; + + for (const name of CONTINUITY_FIELD_NAMES) { + const source = candidate?.[name]; + const old = oldFields?.get(name) || world.fields?.[name]; + if (!source || !old || !isCellField(source)) continue; + const mode = name === "prefectureRegionId" ? "prefecture" : (name === "adminId" || name === "municipalityId") ? "admin" : "natural"; + const contacts = new Map(); + + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + if (!isContinuityTransitionCell(x, y, rects, seed, mode)) continue; + const s = sourceCoordForWorld(window, x, y); + const si = sourceIndex(s.x, s.y); + if (si < 0) continue; + const from = offsetFieldValue(name, source[si], seed); + if (!Number.isFinite(from) || from < 0) continue; + const bucket = contacts.get(from) || new Map(); + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + const wi = worldIndex(world, nx, ny); + if (wi < 0 || old[wi] < 0) continue; + const outsideWrite = !insideRect(nx, ny, rects.writeRect); + const weakPatch = insideRect(nx, ny, rects.writeRect) && patchAlpha(nx, ny, rects, seed) < (mode === "prefecture" ? 0.96 : 0.88); + const edgeWeight = outsideWrite ? 6 : weakPatch ? 3 : (dx || dy ? 1 : 2); + addCount(bucket, old[wi], edgeWeight); + } + contacts.set(from, bucket); + } + } + + const mapping = new Map(); + for (const [from, bucket] of contacts) { + let total = 0; + let best = -1; + let bestCount = 0; + for (const [to, count] of bucket) { + total += count; + if (count > bestCount) { best = to; bestCount = count; } + } + const minCount = mode === "prefecture" ? 10 : mode === "admin" ? 8 : 5; + const minShare = mode === "prefecture" ? 0.42 : mode === "admin" ? 0.48 : 0.36; + if (best >= 0 && bestCount >= minCount && bestCount / Math.max(1, total) >= minShare) { + mapping.set(from, best); + } + } + if (mapping.size) { + out.set(name, mapping); + debug.continuityIdMappings += mapping.size; + } + } + out.debug = debug; + return out; +} + +function applyContinuityMapping(idMappings, name, value) { + const map = idMappings?.get?.(name); + return map && map.has(value) ? map.get(value) : value; +} + +function pointCandidateContinuityIds(p, key, seed = 0) { + const ids = []; + if (!p) return ids; + if (key === "adminCenters") { + for (const raw of [p.id, p.adminId, p.adminNumericId, p.municipalityId]) { + if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("adminId", raw, seed)); + } + } else if (key === "prefectureRegions") { + for (const raw of [p.id, p.prefectureRegionId]) { + if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("prefectureRegionId", raw, seed)); + } + } + return ids; +} + +function cloneContinuityFields(world) { + const out = new Map(); + for (const name of CONTINUITY_FIELD_NAMES) { + const field = world?.fields?.[name]; + if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); + } + return out; +} + +function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { + let restored = 0; + let remapped = 0; + const margin = Math.max(2, rects.writeMargin || 1); + for (const name of CONTINUITY_FIELD_NAMES) { + const field = world.fields?.[name]; + const old = oldFields?.get(name); + if (!field || !old) continue; + const isPrefecture = name === "prefectureRegionId"; + const isAdmin = name === "adminId" || name === "municipalityId"; + const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74; + const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72; + + // First preserve the old IDs in the transition band. This prevents the + // writeRect edge from becoming a prefecture/municipal border. + 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 || old[i] < 0) continue; + const edge = distanceToRectEdge(x, y, rects.writeRect); + const a = patchAlpha(x, y, rects, seed); + if (edge <= preserveEdge || a < preserveAlpha) { + if (field[i] !== old[i]) { field[i] = old[i]; restored++; } + } + } + } + + // Then map candidate IDs that contact an outside ID back to that outside ID. + // This lets prefectures/municipalities cross the generated-area seam instead + // of creating a new border exactly on the seam. + const contacts = new Map(); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) { + for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || field[i] < 0 || old[i] === field[i]) continue; + const a = patchAlpha(x, y, rects, seed); + if (a < 0.98 && !isPrefecture) continue; + for (const [dx, dy] of dirs) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue; + if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) { + const key = field[i]; + const bucket = contacts.get(key) || new Map(); + bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1); + contacts.set(key, bucket); + } + } + } + } + const mapping = new Map(); + for (const [from, bucket] of contacts) { + let best = -1, bestCount = 0; + for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; } + if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best); + } + if (mapping.size) { + 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 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; } + } + } + } + } + return { continuityCellsRestored: restored, continuityCellsRemapped: remapped }; +} + function copyFullPipelineFields(world, candidate, rects, seed) { const window = sourceWindowForRects(rects); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; + const oldContinuityFields = cloneContinuityFields(world); + const continuityIdMappings = buildContinuityIdMappings(world, candidate, rects, window, oldContinuityFields, seed); + let continuityIdMappedCells = 0; let updatedCells = 0; let coastCellsChanged = 0; let terrainCellsFullyReplaced = 0; @@ -258,11 +459,15 @@ function copyFullPipelineFields(world, candidate, 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); + const threshold = continuityReplaceThreshold(name, x, y, rects, seed); if (alpha >= threshold) { const raw = source[si]; - const value = idOffset && raw >= 0 ? raw + idOffset : raw; + let value = idOffset && raw >= 0 ? raw + idOffset : raw; + if (CONTINUITY_FIELD_NAMES.has(name)) { + const mapped = applyContinuityMapping(continuityIdMappings, name, value); + if (mapped !== value) continuityIdMappedCells++; + value = mapped; + } 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++; @@ -282,6 +487,27 @@ function copyFullPipelineFields(world, candidate, rects, seed) { } } + // The legacy full pipeline uses `adminId` as the municipality raster and + // assigns municipality metadata on `adminCenters`; it does not expose a + // separate municipalityId cell field. If an old experimental field exists, + // keep it synchronized with the canonical legacy adminId instead of leaving + // stale numeric/one-municipality data in regenerated patches. + if (world.fields.adminId && !candidate?.municipalityId) { + const expected = world.width * world.height; + if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { + world.fields.municipalityId = new Int32Array(expected); + world.fields.municipalityId.fill(-1); + } + const municipalityId = world.fields.municipalityId; + const adminId = world.fields.adminId; + 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) municipalityId[wi] = adminId[wi]; + } + } + } + // Keep water fields coherent after all continuous fields have been blended. const sea = world.fields.sea; const ocean = world.fields.ocean; @@ -304,7 +530,21 @@ function copyFullPipelineFields(world, candidate, rects, seed) { } } - return { window, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated }; + const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); + + return { + window, + idMappings: continuityIdMappings, + updatedCells, + terrainCellsFullyReplaced, + coastCellsChanged, + naturalRegionsUpdated, + adminCellsReassigned, + landUseCellsUpdated, + continuityIdMappedCells, + continuityIdMappings: continuityIdMappings.debug?.continuityIdMappings || 0, + ...continuityDebug, + }; } function smoothWaterTopology(world, rect, seaLevel = 0.30) { @@ -454,6 +694,8 @@ function transformCandidatePoint(world, window, p, key, seed = 0) { 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 (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId; + if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId; } if (key === "prefectureRegions") { const offset = fieldIdOffset("prefectureRegionId", seed); @@ -619,40 +861,58 @@ function simplifyPath(path, keepEvery = 2) { return out; } -function collectInternalNetworkPoints(world, sourceMap, keys, rect) { +function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") { const points = []; + const seen = new Set(); + const add = (x, y, key, weight = 1) => { + x = Math.round(x); y = Math.round(y); + if (!insideRect(x, y, rect) || !isLand(world, x, y)) return; + const sig = `${x},${y},${key}`; + if (seen.has(sig)) return; + seen.add(sig); + points.push({ x, y, key, weight }); + }; 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 }); + for (let i = 0; i < path.length; i += 4) { + add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1); } } } + const featureKeys = mode === "rail" + ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"] + : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"]; + for (const key of featureKeys) { + for (const p of sourceMap[key] || []) { + add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25); + } + } 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); + const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); 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(); + const maxRange = mode === "rail" ? 260 : 300; for (const raw of anchors) { - const anchorLand = nearestLand(world, raw.x, raw.y, rect, 12); + const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); 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]; + .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) + .filter((p) => p.d <= maxRange) + .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))[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); + const searchRect = expandRect(rect, 8, world); + const path = localPathfind(world, anchorLand, target, searchRect, mode, 42000); if (!path || path.length < 2) { disconnected++; continue; } sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); connectors++; @@ -660,7 +920,48 @@ function connectAnchors(world, sourceMap, anchors, mode, rect) { return { connectors, disconnected }; } -function mergePointLayers(world, sourceMap, candidate, rects, window, seed) { +function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) { + let best = null; + let bestD = maxDistance; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (let i = 0; i < path.length; i += 5) { + 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)) continue; + const d = Math.hypot(point.x - x, point.y - y); + if (d < bestD) { bestD = d; best = { x, y, key }; } + } + } + } + return best; +} + +function ensureSettlementRoadCoverage(world, sourceMap, rect) { + const keys = ["nationalRoads", "minorRoads", "premodernRoads"]; + const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; + sourceMap.minorRoads ||= []; + let connectors = 0; + const seen = new Set(); + for (const key of featureKeys) { + for (const p of sourceMap[key] || []) { + const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); + if (!start || !insideRect(start.x, start.y, rect)) continue; + const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 36 : 58); + if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; + const sig = `${start.x},${start.y}:${target.x},${target.y}`; + if (seen.has(sig)) continue; + seen.add(sig); + const path = localPathfind(world, start, target, rect, "road", 28000); + if (!path || path.length < 2) continue; + sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); + connectors++; + } + } + return connectors; +} + +function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMappings = null) { let preservedExternalEntities = 0; let regeneratedInternalEntities = 0; let invalidPortsRemoved = 0; @@ -681,6 +982,13 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed) { } const generated = []; for (const p of candidate[key] || []) { + const candidateIds = pointCandidateContinuityIds(p, key, seed); + if (key === "adminCenters" && candidateIds.some((id) => applyContinuityMapping(idMappings, "adminId", id) !== id || applyContinuityMapping(idMappings, "municipalityId", id) !== id)) { + continue; + } + if (key === "prefectureRegions" && candidateIds.some((id) => applyContinuityMapping(idMappings, "prefectureRegionId", id) !== id)) { + continue; + } const q = transformCandidatePoint(world, window, p, key, seed); if (!q) continue; const wx = Math.round(pointWorldX(world, q)); @@ -722,13 +1030,15 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { } sourceMap[key] = next; } - const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", rects.writeRect); - const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", rects.writeRect); + const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; + const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect); + const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect); + const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); return { roadsClipped, railsClipped, regeneratedPaths, - roadConnectorsCreated: roadConn.connectors, + roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors, railwayConnectorsCreated: railConn.connectors, disconnectedRoadComponents: roadConn.disconnected, disconnectedRailComponents: railConn.disconnected, @@ -741,9 +1051,12 @@ function segmentTouchesRect(world, seg, rect) { || insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect); } -function buildBoundarySegmentsFromField(world, fieldName, rect) { +function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) { const field = world.fields[fieldName]; const sea = world.fields.sea; + const rects = options.rects || null; + const seed = options.seed || 0; + const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0; if (!field) return []; const out = []; for (let y = rect.y0; y < rect.y1; y++) { @@ -753,11 +1066,11 @@ function buildBoundarySegmentsFromField(world, fieldName, rect) { 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) { + if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) { 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) { + if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) { out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]); } } @@ -765,15 +1078,44 @@ function buildBoundarySegmentsFromField(world, fieldName, rect) { return out; } -function mergeSegmentLayers(world, sourceMap, rects) { +function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) { + const debug = sourceMap.adminDebug || {}; + debug.compartmentBorders ||= []; + let added = 0; + for (const seg of candidate?.adminDebug?.compartmentBorders || []) { + if (!Array.isArray(seg) || seg.length < 2) continue; + const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); + const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); + const mx = (a.x + b.x) * 0.5; + const my = (a.y + b.y) * 0.5; + if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.38) continue; + debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); + added++; + } + sourceMap.adminDebug = debug; + return added; +} + +function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { 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.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 })); sourceMap.regionalPrefectureBorders ||= []; - sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect)); + sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect, { rects, seed, minAlpha: 0.72 })); + sourceMap.prefectureBorder ||= []; + + const debug = sourceMap.adminDebug || {}; + debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect)); + debug.compartmentBorders.push(...buildBoundarySegmentsFromField(world, "naturalCompartmentId", rects.writeRect, { rects, seed, minAlpha: 0.40 })); + sourceMap.adminDebug = debug; + return { + adminBordersRebuilt: sourceMap.adminBorders.length, + prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, + compartmentBordersRebuilt: debug.compartmentBorders.length, + }; } function repairLanduseAndPopulation(world, rects) { @@ -829,16 +1171,18 @@ export function generatePatch(world, userRectInput, options = {}) { 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 variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; + const candidate = generateMap(seed, { terrainType, legacyTerrain: true, 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 pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.idMappings); const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); - mergeSegmentLayers(world, sourceMap, rects); + const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed); + const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); const landDebug = repairLanduseAndPopulation(world, rects); sanitizeExistingLogistics(sourceMap); @@ -853,8 +1197,14 @@ export function generatePatch(world, userRectInput, options = {}) { ...pointDebug, ...pathDebug, adminCellsReassigned: fieldDebug.adminCellsReassigned, + continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, + continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, + continuityIdMappings: fieldDebug.continuityIdMappings || 0, + continuityIdMappedCells: fieldDebug.continuityIdMappedCells || 0, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, logisticsLabelsMigrated, + ...segmentDebug, + candidateCompartmentSegmentsAdded, }; const record = { @@ -867,6 +1217,8 @@ export function generatePatch(world, userRectInput, options = {}) { terrainType: id, label, seed, + variant, + patchGenerationMode: "legacy-full-pipeline", updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged, @@ -887,6 +1239,8 @@ export function generatePatch(world, userRectInput, options = {}) { terrainType: id, label, seed, + variant, + patchGenerationMode: "legacy-full-pipeline", updatedCells: record.updatedCells, terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, coastCellsChanged: record.coastCellsChanged, diff --git a/mapPipeline.js b/mapPipeline.js index 4aa046a..c1fba77 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -1,5 +1,5 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; -import { generateTerrainAndRivers } from "./mapTerrain.js"; +import { generateInitialTerrainRect, generateTerrainAndRivers } from "./mapTerrain.js"; import { generateMapFeatures } from "./mapFeatures.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.js"; @@ -8,6 +8,21 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; +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); + return generateTerrainAndRivers(seed, options); +} + +function terrainStageLabel(options = {}) { + return options?.rectNativeInitial === true + ? "Rect-native terrain, rivers, and natural compartments" + : "Terrain, rivers, and natural compartments"; +} + function nowMs() { return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); } @@ -51,7 +66,7 @@ export function generateMap(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn); - const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); + const terrain = stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options)); const { elevation, slope, @@ -134,7 +149,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn); - const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); + const terrain = await stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options)); const { elevation, slope, diff --git a/mapTerrain.js b/mapTerrain.js index 3087ea6..d11fc59 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -5,6 +5,7 @@ import { neighbors8, } from "./mapGeneratorHelpers.js"; import { buildNaturalCompartments } from "./adminRegions.js"; +import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, rectNeighbors8, rectQuantile } from "./rectContext.js"; const ASPECT = MAP_W / MAP_H; const SQRT2 = Math.SQRT2; @@ -1170,6 +1171,650 @@ function enforceLandGradient(elevation, sea, seaLevel) { } } +function rectTerrainProfile(template) { + const id = String(template?.terrainType || "auto"); + if (id.includes("oceanic")) return { + base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28, + seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86, + }; + if (id.includes("setouchi") || id.includes("archipelago")) return { + base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20, + seaQuantile: Math.max(0.30, template.seaRatio ?? 0.36), plain: 0.34, moisture: 0.58, capStart: 0.72, capMax: 0.94, + }; + if (id.includes("chubu") || id.includes("mountain")) return { + base: 0.52, relief: 0.26, ridge: 0.62, ridgeWidth: 36, ridgeSpacing: 108, coast: 0.12, archipelago: 0.03, + seaQuantile: Math.min(0.22, template.seaRatio ?? 0.18), plain: 0.15, moisture: 0.48, capStart: 0.90, capMax: 1.10, + }; + if (id.includes("kanto") || id.includes("alluvial")) return { + base: 0.48, relief: 0.12, ridge: 0.18, ridgeWidth: 42, ridgeSpacing: 130, coast: 0.16, archipelago: 0.04, + seaQuantile: template.seaRatio ?? 0.13, plain: 0.66, moisture: 0.56, capStart: 0.84, capMax: 1.00, + }; + if (id.includes("tohoku") || id.includes("spine")) return { + base: 0.49, relief: 0.19, ridge: 0.46, ridgeWidth: 24, ridgeSpacing: 88, coast: 0.18, archipelago: 0.03, + seaQuantile: template.seaRatio ?? 0.22, plain: 0.26, moisture: 0.52, capStart: 0.78, capMax: 0.98, + }; + return { + base: 0.45, relief: 0.18, ridge: 0.34, ridgeWidth: 32, ridgeSpacing: 100, coast: 0.18, archipelago: 0.06, + seaQuantile: template.seaRatio ?? 0.20, plain: 0.30, moisture: 0.52, capStart: 0.86, capMax: 1.04, + }; +} + +function rectSeed(seed, variant, salt) { + let h = (seed >>> 0) ^ Math.imul((variant || 0) >>> 0, 0x9e3779b9) ^ (salt >>> 0); + h ^= h >>> 16; + h = Math.imul(h, 0x7feb352d) >>> 0; + h ^= h >>> 15; + h = Math.imul(h, 0x846ca68b) >>> 0; + return (h ^ (h >>> 16)) >>> 0; +} + +function periodicRidgeField(wx, wy, template, profile, seed) { + const angle = template.mountainAngle || 0; + const c = Math.cos(angle); + const s = Math.sin(angle); + const u = wx * c + wy * s; + const v = -wx * s + wy * c; + const spacing = Math.max(18, profile.ridgeSpacing); + const shifted = v / spacing + valueNoise(wx, wy, seed ^ 0x654f6d23, 115) * 0.70; + const nearest = Math.abs((shifted - Math.round(shifted)) * spacing); + const ridgeCore = Math.exp(-Math.pow(nearest / Math.max(4, profile.ridgeWidth), 2.0)); + const along = valueNoise(u, v, seed ^ 0x27d4eb2f, 86); + const cut = valueNoise(u, v, seed ^ 0x165667b1, 31); + return clamp(ridgeCore * (0.62 + along * 0.62) * (0.74 + cut * 0.40)); +} + +function worldMarinePressure(wx, wy, template, profile, seed) { + const angle = template.coastAngle || 0; + const c = Math.cos(angle); + const s = Math.sin(angle); + const axis = wx * c + wy * s; + const cross = -wx * s + wy * c; + const period = template.coastStyle === "oceanic_archipelago" ? 160 : template.coastStyle === "inland_sea" ? 220 : 300; + const broad = Math.sin((axis + valueNoise(wx, wy, seed ^ 0xc2b2ae35, 190) * 90) / period * Math.PI * 2); + const channel = Math.exp(-Math.pow((cross + (valueNoise(wx, wy, seed ^ 0x85ebca6b, 130) - 0.5) * 80) / (profile.ridgeSpacing * 0.85), 2.0)); + const radial = valueNoise(wx, wy, seed ^ 0x9e3779b9, 260); + let pressure = clamp((broad * 0.5 + 0.5) * profile.coast + channel * profile.coast * 0.62 + radial * profile.coast * 0.52); + if (template.coastStyle === "oceanic_archipelago") { + const gap = clamp((fbm(wx * 0.75 + 33, wy * 0.75 - 17, seed ^ 0x3c6ef372) - 0.42) * 2.2); + pressure = clamp(pressure + gap * profile.archipelago); + } + if (template.coastStyle === "open_bay") pressure = clamp(pressure + channel * 0.12); + return pressure; +} + +function classifyRectWater(ctx, fields, seaLevel) { + const { elevation, sea, ocean, lake } = fields; + sea.fill(0); ocean.fill(0); lake.fill(0); + const water = new Uint8Array(ctx.size); + for (let i = 0; i < ctx.size; i++) water[i] = elevation[i] <= seaLevel ? 1 : 0; + const seen = new Uint8Array(ctx.size); + let oceanCells = 0; + for (let i = 0; i < ctx.size; i++) { + if (!water[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + let touchesEdge = false; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const x = cur % ctx.width; + const y = Math.floor(cur / ctx.width); + if (x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) touchesEdge = true; + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + if (!water[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + const isOcean = touchesEdge || cells.length > Math.max(96, ctx.size * 0.018); + if (isOcean || cells.length >= 20) { + for (const ci of cells) { + sea[ci] = 1; + if (isOcean) ocean[ci] = 1; + else lake[ci] = 1; + } + if (isOcean) oceanCells += cells.length; + } else { + for (const ci of cells) elevation[ci] = seaLevel + 0.012; + } + } + return oceanCells; +} + +function recomputeRectSlope(ctx, fields) { + const { elevation, sea, slope } = fields; + slope.fill(0); + for (let y = 1; y < ctx.height - 1; y++) { + for (let x = 1; x < ctx.width - 1; x++) { + const i = rectIndexOf(ctx, x, y); + if (sea[i]) continue; + const gx = elevation[rectIndexOf(ctx, x + 1, y)] - elevation[rectIndexOf(ctx, x - 1, y)]; + const gy = elevation[rectIndexOf(ctx, x, y + 1)] - elevation[rectIndexOf(ctx, x, y - 1)]; + slope[i] = clamp(Math.hypot(gx, gy) * 8.2); + } + } +} + +function priorityFloodRect(ctx, fields) { + const { elevation, sea, flowTo } = fields; + const filled = new Float32Array(elevation); + const visited = new Uint8Array(ctx.size); + const heap = new MinHeap(); + let seeds = 0; + for (let i = 0; i < ctx.size; i++) { + const x = i % ctx.width; + const y = Math.floor(i / ctx.width); + if (sea[i] || x === 0 || y === 0 || x === ctx.width - 1 || y === ctx.height - 1) { + visited[i] = 1; + heap.push({ i, f: filled[i] }); + seeds++; + } + } + if (!seeds) return filled; + while (heap.length) { + const cur = heap.pop(); + if (!cur || cur.f > filled[cur.i] + 1e-5) continue; + const x = cur.i % ctx.width; + const y = Math.floor(cur.i / ctx.width); + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + if (visited[ni]) continue; + visited[ni] = 1; + if (filled[ni] < filled[cur.i] + 0.00002) filled[ni] = filled[cur.i] + 0.00002; + heap.push({ i: ni, f: filled[ni] }); + } + } + flowTo.fill(-1); + for (let y = 0; y < ctx.height; y++) { + for (let x = 0; x < ctx.width; x++) { + const i = rectIndexOf(ctx, x, y); + if (sea[i]) continue; + let best = -1; + let bestScore = filled[i]; + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + const stepPenalty = (nx !== x && ny !== y) ? 0.000015 : 0; + const score = filled[ni] + stepPenalty + hash2(ctx.originX + nx, ctx.originY + ny, 9000) * 0.000002; + if (score < bestScore - 0.000001 || sea[ni]) { + bestScore = score; + best = ni; + if (sea[ni]) break; + } + } + flowTo[i] = best; + } + } + return filled; +} + +function computeRectFlowAccumulation(ctx, fields, filled) { + const { sea, flowTo, flowAccum } = fields; + const area = new Float32Array(ctx.size); + const order = []; + for (let i = 0; i < ctx.size; i++) { + if (sea[i]) continue; + area[i] = 1; + order.push(i); + } + order.sort((a, b) => filled[b] - filled[a]); + for (const i of order) { + const to = flowTo[i]; + if (to >= 0 && !sea[to]) area[to] += area[i]; + } + let maxArea = 1; + for (let i = 0; i < ctx.size; i++) if (!sea[i]) maxArea = Math.max(maxArea, area[i]); + for (let i = 0; i < ctx.size; i++) flowAccum[i] = sea[i] ? 0 : clamp(Math.pow(area[i] / maxArea, 0.42)); +} + +function rectStableId(seed, wx, wy, salt) { + const x = Math.floor(wx) | 0; + const y = Math.floor(wy) | 0; + let h = (seed >>> 0) ^ Math.imul(x, 0x9e3779b1) ^ Math.imul(y, 0x85ebca77) ^ (salt >>> 0); + h ^= h >>> 16; + h = Math.imul(h, 0x7feb352d) >>> 0; + h ^= h >>> 15; + h = Math.imul(h, 0x846ca68b) >>> 0; + return (h ^ (h >>> 16)) & 0x7fffffff; +} + +function traceRectSink(ctx, start, fields, maxSteps = 4096) { + const { sea, flowTo } = fields; + let i = start; + let last = i; + const seen = new Set(); + for (let step = 0; step < maxSteps; step++) { + if (i < 0 || i >= ctx.size || seen.has(i)) break; + seen.add(i); + last = i; + if (sea[i]) break; + const next = flowTo[i]; + if (next < 0 || next === i) break; + i = next; + } + return last; +} + +function buildRectWatershedId(ctx, fields, seed) { + const { sea, flowAccum, watershedId } = fields; + if (!watershedId) return { watershedCount: 0 }; + watershedId.fill(-1); + const sinkToId = new Map(); + let watershedCount = 0; + for (let i = 0; i < ctx.size; i++) { + if (sea[i]) continue; + const sink = traceRectSink(ctx, i, fields); + const sx = sink % ctx.width; + const sy = Math.floor(sink / ctx.width); + const wx = ctx.originX + sx; + const wy = ctx.originY + sy; + const coarseX = Math.round(wx / 12); + const coarseY = Math.round(wy / 12); + const key = `${coarseX},${coarseY}`; + let id = sinkToId.get(key); + if (!Number.isFinite(id)) { + id = 50000000 + rectStableId(seed, coarseX, coarseY, 0x51ed270b) % 40000000; + sinkToId.set(key, id); + watershedCount++; + } + watershedId[i] = id; + } + // Merge tiny or noisy drainage islands into their strongest neighbor. + for (let pass = 0; pass < 2; pass++) { + const changes = []; + for (let y = 1; y < ctx.height - 1; y++) { + for (let x = 1; x < ctx.width - 1; x++) { + const i = rectIndexOf(ctx, x, y); + if (sea[i] || watershedId[i] < 0) continue; + const counts = new Map(); + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + const id = watershedId[ni]; + if (id < 0) continue; + counts.set(id, (counts.get(id) || 0) + 1 + (flowAccum[ni] || 0)); + } + let best = watershedId[i]; + let bestScore = counts.get(best) || 0; + for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; } + if (best !== watershedId[i] && bestScore >= 5.5) changes.push([i, best]); + } + } + for (const [i, id] of changes) watershedId[i] = id; + if (!changes.length) break; + } + return { watershedCount }; +} + +function buildRectNaturalRegions(ctx, fields, seed, template) { + const { sea, ridgeField, valleyField, basinField, flowAccum, naturalBarrierScore, watershedId, naturalCompartmentId, regionId } = fields; + if (!naturalCompartmentId || !regionId) return { naturalCompartmentCount: 0, regionCount: 0 }; + naturalCompartmentId.fill(-1); + regionId.fill(-1); + const type = String(template?.terrainType || "auto"); + const spacing = type.includes("oceanic") ? 30 : type.includes("kanto") ? 44 : type.includes("chubu") ? 34 : 38; + const coarseSpacing = spacing * 2.55; + const seeds = []; + const gx0 = Math.floor((ctx.originX - spacing) / spacing) - 1; + const gx1 = Math.ceil((ctx.originX + ctx.width + spacing) / spacing) + 1; + const gy0 = Math.floor((ctx.originY - spacing) / spacing) - 1; + const gy1 = Math.ceil((ctx.originY + ctx.height + spacing) / spacing) + 1; + for (let gy = gy0; gy <= gy1; gy++) { + for (let gx = gx0; gx <= gx1; gx++) { + const jitterX = (hash2(gx, gy, seed ^ 0x6a09e667) - 0.5) * spacing * 0.74; + const jitterY = (hash2(gx, gy, seed ^ 0xbb67ae85) - 0.5) * spacing * 0.74; + const wx = gx * spacing + spacing * 0.5 + jitterX; + const wy = gy * spacing + spacing * 0.5 + jitterY; + const lx = Math.round(wx - ctx.originX); + const ly = Math.round(wy - ctx.originY); + let viability = 0.8; + if (rectInside(ctx, lx, ly)) { + const i = rectIndexOf(ctx, lx, ly); + viability += (basinField[i] || 0) * 0.25 + (valleyField[i] || 0) * 0.16 - (ridgeField[i] || 0) * 0.14; + if (sea[i]) viability -= 1.2; + } + if (viability < 0.18 && hash2(gx, gy, seed ^ 0x3c6ef372) < 0.82) continue; + seeds.push({ + wx, + wy, + id: 40000000 + rectStableId(seed, gx, gy, 0xb5c0fbcf) % 42000000, + coarseId: 30000000 + rectStableId(seed, Math.floor((gx * spacing) / coarseSpacing), Math.floor((gy * spacing) / coarseSpacing), 0xc2b2ae35) % 42000000, + }); + } + } + if (!seeds.length) return { naturalCompartmentCount: 0, regionCount: 0 }; + for (let y = 0; y < ctx.height; y++) { + for (let x = 0; x < ctx.width; x++) { + const i = rectIndexOf(ctx, x, y); + if (sea[i]) continue; + const wx = ctx.originX + x; + const wy = ctx.originY + y; + let best = seeds[0]; + let bestScore = Infinity; + const barrier = (naturalBarrierScore[i] || 0) + (ridgeField[i] || 0) * 0.55 + (flowAccum[i] || 0) * 0.18; + const basinBonus = (basinField[i] || 0) * 0.18 + (valleyField[i] || 0) * 0.10; + for (const s of seeds) { + const dx = (wx - s.wx) * 1.05; + const dy = wy - s.wy; + const d = Math.hypot(dx, dy); + const tileNoise = (valueNoise(wx + s.wx * 0.13, wy + s.wy * 0.13, seed ^ 0xa54ff53a, 52) - 0.5) * spacing * 0.34; + const watershedPenalty = watershedId?.[i] >= 0 ? ((watershedId[i] ^ s.id) & 7) * 0.16 : 0; + const score = d + barrier * spacing * 0.42 - basinBonus * spacing * 0.32 + tileNoise + watershedPenalty; + if (score < bestScore) { bestScore = score; best = s; } + } + naturalCompartmentId[i] = best.id; + regionId[i] = best.coarseId; + } + } + for (let pass = 0; pass < 2; pass++) { + const changes = []; + for (let y = 1; y < ctx.height - 1; y++) { + for (let x = 1; x < ctx.width - 1; x++) { + const i = rectIndexOf(ctx, x, y); + if (sea[i]) continue; + if ((ridgeField[i] || 0) > 0.78) continue; + const counts = new Map(); + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + const id = naturalCompartmentId[ni]; + if (id < 0) continue; + counts.set(id, (counts.get(id) || 0) + 1 + (basinField[ni] || 0) * 0.3); + } + let best = naturalCompartmentId[i]; + let bestScore = counts.get(best) || 0; + for (const [id, score] of counts) if (score > bestScore) { best = id; bestScore = score; } + if (best !== naturalCompartmentId[i] && bestScore >= 5.8) changes.push([i, best]); + } + } + for (const [i, id] of changes) naturalCompartmentId[i] = id; + if (!changes.length) break; + } + const nset = new Set(); + const rset = new Set(); + for (let i = 0; i < ctx.size; i++) { + if (naturalCompartmentId[i] >= 0) nset.add(naturalCompartmentId[i]); + if (regionId[i] >= 0) rset.add(regionId[i]); + } + return { naturalCompartmentCount: nset.size, regionCount: rset.size }; +} + +function traceRectFlowPath(start, ctx, fields, maxSteps = 1200) { + const { sea, flowTo } = fields; + let i = start; + const path = []; + const seen = new Set(); + for (let step = 0; step < maxSteps; step++) { + if (i < 0 || i >= ctx.size || seen.has(i)) break; + seen.add(i); + const x = i % ctx.width; + const y = Math.floor(i / ctx.width); + path.push([ctx.originX + x, ctx.originY + y]); + if (sea[i]) break; + const next = flowTo[i]; + if (next < 0 || next === i) break; + i = next; + } + return path; +} + +function scoreRectRiverPath(path, ctx, fields) { + let score = 0; + for (const [wx, wy] of path) { + const x = wx - ctx.originX; + const y = wy - ctx.originY; + if (!rectInside(ctx, x, y)) continue; + const i = rectIndexOf(ctx, x, y); + score += (fields.flowAccum[i] || 0) + (fields.river[i] || 0) * 0.7; + } + return score; +} + +function buildRectRiverPaths(ctx, fields, seed, template) { + const { sea, flowAccum, river, erosionField } = fields; + const candidates = []; + const threshold = template?.terrainType === "oceanic_archipelago" ? 0.52 : template?.terrainType === "kanto_alluvial" ? 0.46 : 0.50; + for (let i = 0; i < ctx.size; i++) { + if (sea[i] || flowAccum[i] < threshold) continue; + const x = i % ctx.width; + const y = Math.floor(i / ctx.width); + let upstream = 0; + for (const [nx, ny] of rectNeighbors8(ctx, x, y)) { + const ni = rectIndexOf(ctx, nx, ny); + if (fields.flowTo[ni] === i) upstream++; + } + const sourceBias = hash2(ctx.originX + x, ctx.originY + y, seed ^ 0x1f123bb5); + if (upstream <= 1 || sourceBias > 0.78) candidates.push({ i, score: flowAccum[i] + sourceBias * 0.12 }); + } + candidates.sort((a, b) => b.score - a.score); + const accepted = []; + const occupied = new Set(); + const desired = Math.min(72, Math.max(8, Math.floor(ctx.size / 900))); + for (const c of candidates) { + if (accepted.length >= desired) break; + const path = traceRectFlowPath(c.i, ctx, fields); + if (path.length < 8) continue; + const keyHits = path.reduce((n, [wx, wy], k) => k % 3 === 0 && occupied.has(`${wx},${wy}`) ? n + 1 : n, 0); + if (keyHits > Math.max(5, path.length * 0.18)) continue; + const score = scoreRectRiverPath(path, ctx, fields); + if (score < 4.2) continue; + accepted.push({ path, score }); + for (const [wx, wy] of path) occupied.add(`${wx},${wy}`); + } + accepted.sort((a, b) => b.score - a.score); + const mainRivers = accepted.slice(0, Math.max(1, Math.min(10, Math.round(accepted.length * 0.25)))).map((r) => r.path); + const tributaryRivers = accepted.slice(mainRivers.length, mainRivers.length + 28).map((r) => r.path); + const smallStreams = accepted.slice(mainRivers.length + 28, mainRivers.length + 56).map((r) => r.path); + for (const group of [mainRivers, tributaryRivers, smallStreams]) { + const boost = group === mainRivers ? 0.72 : group === tributaryRivers ? 0.48 : 0.28; + for (const path of group) { + for (const [wx, wy] of path) { + const x = wx - ctx.originX; + const y = wy - ctx.originY; + if (!rectInside(ctx, x, y)) continue; + const i = rectIndexOf(ctx, x, y); + if (sea[i]) continue; + river[i] = clamp(Math.max(river[i], boost + (flowAccum[i] || 0) * 0.42)); + if (erosionField) erosionField[i] = clamp((erosionField[i] || 0) + river[i] * 0.18); + } + } + } + return { riverPaths: accepted.map((r) => r.path), mainRivers, tributaryRivers, smallStreams }; +} + +function deriveRectTerrainFields(ctx, fields, seaLevel) { + const { + elevation, sea, river, flowAccum, floodplain, plain, agriculture, ridgeField, valleyField, basinField, + coastalLowland, erosionField, depositionField, depositionalLowland, alluvialFanField, deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, slope, moisture, + } = fields; + for (let i = 0; i < ctx.size; i++) { + if (sea[i]) { + river[i] = 0; plain[i] = 0; agriculture[i] = 0; naturalBarrierScore[i] = 0; + continue; + } + const low = clamp((0.48 - elevation[i]) * 2.2); + const flat = clamp(1 - slope[i] * 2.3); + const coast = clamp((elevation[i] - seaLevel) * 18); + river[i] = flowAccum[i] > 0.58 ? clamp((flowAccum[i] - 0.52) * 2.1 + (0.22 - slope[i]) * 0.40) : 0; + floodplain[i] = clamp(river[i] * 0.72 + low * flat * 0.24); + plain[i] = clamp(flat * (low * 0.78 + basinField[i] * 0.38 + floodplain[i] * 0.35)); + agriculture[i] = clamp(plain[i] * 0.72 + moisture[i] * 0.22 - slope[i] * 0.22); + coastalLowland[i] = clamp((1 - coast) * flat * 0.90); + erosionField[i] = clamp(slope[i] * 0.55 + river[i] * 0.34 + ridgeField[i] * 0.22); + depositionField[i] = clamp(floodplain[i] * 0.58 + coastalLowland[i] * 0.34 + plain[i] * 0.18); + depositionalLowland[i] = clamp(depositionField[i] * flat); + alluvialFanField[i] = clamp(river[i] * slope[i] * 1.8); + deltaField[i] = clamp(river[i] * coastalLowland[i] * 1.2); + naturalBarrierScore[i] = clamp(ridgeField[i] * 0.72 + slope[i] * 0.42 + river[i] * 0.24); + crossingSuitability[i] = clamp(flat * (1 - river[i] * 0.65) + plain[i] * 0.24); + passSuitability[i] = clamp((1 - ridgeField[i]) * 0.55 + valleyField[i] * 0.40 - slope[i] * 0.15); + portSuitability[i] = clamp(coastalLowland[i] * 0.65 + plain[i] * 0.22 - slope[i] * 0.26); + } +} + +export function generateTerrainRect(options = {}) { + const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : 0; + const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; + const ctx = options.rectContext || createRectContext(options); + const rectSeedValue = rectSeed(seed, variant, 0x5489a1f3); + const terrainTemplate = buildTerrainTemplate(rectSeedValue, options); + const profile = rectTerrainProfile(terrainTemplate); + const fields = createRectTerrainFields(ctx); + const { + elevation, moisture, ridgeField, valleyField, basinField, coastalLowland, arcSpineField, branchRidgeField, + visibleRavineField, surfaceTextureField, + } = fields; + + for (let y = 0; y < ctx.height; y++) { + for (let x = 0; x < ctx.width; x++) { + const i = rectIndexOf(ctx, x, y); + const wx = ctx.originX + x; + const wy = ctx.originY + y; + const broad = (fbm(wx * 0.58, wy * 0.58, rectSeedValue ^ 0x9e3779b9) - 0.5) * profile.relief; + const regional = (valueNoise(wx, wy, rectSeedValue ^ 0x85ebca6b, 58) - 0.5) * profile.relief * 0.72; + const detail = (valueNoise(wx, wy, rectSeedValue ^ 0xc2b2ae35, 19) - 0.5) * profile.relief * 0.22; + const ridge = periodicRidgeField(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x27d4eb2f); + const marine = worldMarinePressure(wx, wy, terrainTemplate, profile, rectSeedValue ^ 0x165667b1); + const basin = clamp((valueNoise(wx, wy, rectSeedValue ^ 0xd3a2646c, 120) - 0.36) * 1.65) * profile.plain; + const valley = clamp((1 - ridge) * (valueNoise(wx, wy, rectSeedValue ^ 0xfd7046c5, 42) - 0.42) * 1.7); + const archipelago = terrainTemplate.terrainType === "oceanic_archipelago" || terrainTemplate.coastStyle === "inland_sea" + ? clamp((fbm(wx * 0.72 + 49, wy * 0.72 - 31, rectSeedValue ^ 0x94d049bb) - 0.44) * 2.2) * profile.archipelago + : 0; + let e = profile.base + broad + regional + detail + ridge * profile.ridge + archipelago - marine + basin * 0.10; + if (terrainTemplate.terrainType === "kanto_alluvial") e -= basin * 0.075; + if (terrainTemplate.terrainType === "oceanic_archipelago") e -= marine * 0.16; + e = softCapElevation(e, profile.capStart, profile.capMax); + elevation[i] = clamp(e, 0.025, profile.capMax); + ridgeField[i] = clamp(ridge * (0.62 + profile.ridge)); + branchRidgeField[i] = clamp(ridge * 0.82 + detail * 0.60); + arcSpineField[i] = clamp(ridge * 0.90); + valleyField[i] = clamp(valley + (1 - ridge) * marine * 0.20); + basinField[i] = clamp(basin + valley * 0.35); + coastalLowland[i] = clamp(marine * 0.82 + basin * 0.25); + moisture[i] = clamp(profile.moisture + marine * 0.22 + basin * 0.15 - elevation[i] * 0.22 + (fbm(wx * 0.85, wy * 0.85, rectSeedValue ^ 0xa0761d65) - 0.5) * 0.13); + visibleRavineField[i] = clamp(Math.abs(detail) * ridge * 1.9 + valley * 0.25); + surfaceTextureField[i] = clamp(Math.abs(broad) * 0.55 + Math.abs(detail) * 1.3 + ridge * 0.22); + } + } + + const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50); + const oceanCells = classifyRectWater(ctx, fields, seaLevel); + recomputeRectSlope(ctx, fields); + const filled = priorityFloodRect(ctx, fields); + computeRectFlowAccumulation(ctx, fields, filled); + const watershedDebug = buildRectWatershedId(ctx, fields, rectSeedValue ^ 0x51ed270b); + deriveRectTerrainFields(ctx, fields, seaLevel); + const riverNetwork = buildRectRiverPaths(ctx, fields, rectSeedValue ^ 0x1f123bb5, terrainTemplate); + const naturalDebug = buildRectNaturalRegions(ctx, fields, rectSeedValue ^ 0xb5c0fbcf, terrainTemplate); + + let landCount = 0; + let mountainCount = 0; + let plainCount = 0; + for (let i = 0; i < ctx.size; i++) { + if (fields.sea[i]) continue; + landCount++; + if (fields.elevation[i] > 0.56 || fields.ridgeField[i] > 0.52) mountainCount++; + if (fields.plain[i] > 0.36) plainCount++; + } + + return { + rectContext: ctx, + originX: ctx.originX, + originY: ctx.originY, + width: ctx.width, + height: ctx.height, + size: ctx.size, + terrainTemplate, + seaLevel, + ...fields, + terrainDebug: { + terrainType: terrainTemplate.terrainType, + terrainTypeLabel: terrainTemplate.terrainTypeLabel, + coastStyle: terrainTemplate.coastStyle, + rectNative: true, + originX: ctx.originX, + originY: ctx.originY, + width: ctx.width, + height: ctx.height, + variant, + seaRatio: fields.sea.reduce((sum, value) => sum + value, 0) / Math.max(1, ctx.size), + landCount, + oceanCells, + mountainRatio: mountainCount / Math.max(1, landCount), + plainRatio: plainCount / Math.max(1, landCount), + watershedCount: watershedDebug.watershedCount, + naturalCompartmentCount: naturalDebug.naturalCompartmentCount, + regionCount: naturalDebug.regionCount, + mainRiverCount: riverNetwork.mainRivers.length, + tributaryRiverCount: riverNetwork.tributaryRivers.length, + smallStreamCount: riverNetwork.smallStreams.length, + }, + ...riverNetwork, + }; +} + +export function finalizeRectTerrainForFixedMap(seed, terrain, options = {}) { + if (!terrain || terrain.width !== MAP_W || terrain.height !== MAP_H || terrain.size !== SIZE) { + throw new Error(`finalizeRectTerrainForFixedMap requires ${MAP_W}x${MAP_H} terrain, got ${terrain?.width}x${terrain?.height}`); + } + const { + elevation, slope, sea, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + plain, agriculture, watershedId, landMask: existingLandMask, prefectureMask: existingPrefectureMask, + } = terrain; + const prefectureMask = existingPrefectureMask || makePrefectureMask(seed, sea, elevation, slope, river); + const landMask = existingLandMask || new Uint8Array(SIZE); + if (!existingLandMask) { + for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1; + } + const zeroDensity = new Float32Array(SIZE); + const zeroLanduse = new Int8Array(SIZE); + const landCount = landMask.reduce((sum, value, i) => sum + (value && !sea[i] ? 1 : 0), 0); + const natural = buildNaturalCompartments( + landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + null, plain, agriculture, zeroDensity, zeroLanduse, + { + seed: (seed + 17003) >>> 0, + watershedId, + targetCompartmentCount: clamp(Math.round(landCount / 45), 70, 360), + } + ); + const prefectureBorder = extractMaskBorder(prefectureMask, sea); + const terrainDebug = { + ...(terrain.terrainDebug || {}), + rectNativeInitialTerrain: true, + rectInitialOriginX: terrain.originX || 0, + rectInitialOriginY: terrain.originY || 0, + sharedNaturalCompartmentLayer: true, + naturalCompartmentCount: natural.compartments?.filter?.((unit) => unit && unit.area > 0).length || 0, + }; + return { + ...terrain, + prefectureMask, + landMask, + prefectureBorder, + naturalBarrierScore: natural.naturalBarrierScore || terrain.naturalBarrierScore, + naturalCompartmentId: natural.compartmentId, + naturalCompartments: natural.compartments, + terrainDebug, + }; +} + +export function generateInitialTerrainRect(seed, options = {}) { + const variant = Number.isFinite(options.initialVariant) ? Math.max(0, Math.floor(options.initialVariant)) : 0; + const terrain = generateTerrainRect({ + ...options, + seed, + variant, + originX: 0, + originY: 0, + width: MAP_W, + height: MAP_H, + name: "initial-full-map", + }); + return finalizeRectTerrainForFixedMap(seed, terrain, options); +} + + export function generateTerrainAndRivers(seed, options = {}) { const fields = createMapFields(); fields.visibleRavineField = new Float32Array(SIZE); diff --git a/rectContext.js b/rectContext.js new file mode 100644 index 0000000..c282a5a --- /dev/null +++ b/rectContext.js @@ -0,0 +1,150 @@ +import { clamp } from "./mapUtils.js"; + +export function createRectContext(options = {}) { + const originX = Math.floor(Number.isFinite(options.originX) ? options.originX : 0); + const originY = Math.floor(Number.isFinite(options.originY) ? options.originY : 0); + const width = Math.max(1, Math.floor(Number.isFinite(options.width) ? options.width : 1)); + const height = Math.max(1, Math.floor(Number.isFinite(options.height) ? options.height : 1)); + return { + originX, + originY, + width, + height, + size: width * height, + name: options.name || "rect", + }; +} + +export function rectIndexOf(ctx, x, y) { + return y * ctx.width + x; +} + +export function rectXyOf(ctx, i) { + return [i % ctx.width, Math.floor(i / ctx.width)]; +} + +export function rectInside(ctx, x, y) { + return !!ctx && x >= 0 && y >= 0 && x < ctx.width && y < ctx.height; +} + +export function rectWorldX(ctx, x) { + return ctx.originX + x; +} + +export function rectWorldY(ctx, y) { + return ctx.originY + y; +} + +export function rectWorldCoord(ctx, x, y) { + return { x: ctx.originX + x, y: ctx.originY + y }; +} + +export function rectLocalCoord(ctx, worldX, worldY) { + return { x: Math.round(worldX - ctx.originX), y: Math.round(worldY - ctx.originY) }; +} + +export function rectWorldIndex(ctx, worldX, worldY) { + const x = Math.round(worldX - ctx.originX); + const y = Math.round(worldY - ctx.originY); + return rectInside(ctx, x, y) ? rectIndexOf(ctx, x, y) : -1; +} + +export function rectFromBounds(bounds, name = "rect") { + const x0 = Math.floor(Math.min(bounds.x0, bounds.x1)); + const y0 = Math.floor(Math.min(bounds.y0, bounds.y1)); + const x1 = Math.ceil(Math.max(bounds.x0, bounds.x1)); + const y1 = Math.ceil(Math.max(bounds.y0, bounds.y1)); + return createRectContext({ originX: x0, originY: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), name }); +} + +export function rectBounds(ctx) { + return { x0: ctx.originX, y0: ctx.originY, x1: ctx.originX + ctx.width, y1: ctx.originY + ctx.height }; +} + +export function clampRectToBounds(rect, bounds) { + const x0 = Math.max(bounds.x0 ?? 0, Math.floor(rect.x0)); + const y0 = Math.max(bounds.y0 ?? 0, Math.floor(rect.y0)); + const x1 = Math.min(bounds.x1 ?? Infinity, Math.ceil(rect.x1)); + const y1 = Math.min(bounds.y1 ?? Infinity, Math.ceil(rect.y1)); + return { x0, y0, x1: Math.max(x0, x1), y1: Math.max(y0, y1) }; +} + +export function expandRectBounds(rect, margin, bounds = null) { + const expanded = { + x0: Math.floor(rect.x0) - margin, + y0: Math.floor(rect.y0) - margin, + x1: Math.ceil(rect.x1) + margin, + y1: Math.ceil(rect.y1) + margin, + }; + return bounds ? clampRectToBounds(expanded, bounds) : expanded; +} + +export function rectNeighbors8(ctx, x, y) { + const out = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (rectInside(ctx, nx, ny)) out.push([nx, ny]); + } + } + return out; +} + +export function rectDistanceToEdge(ctx, x, y) { + return Math.min(x, y, ctx.width - 1 - x, ctx.height - 1 - y); +} + +export function createRectTerrainFields(ctx) { + const size = ctx.size; + const flowTo = new Int32Array(size); + flowTo.fill(-1); + return { + elevation: new Float32Array(size), + moisture: new Float32Array(size), + slope: new Float32Array(size), + sea: new Uint8Array(size), + ocean: new Uint8Array(size), + lake: new Uint8Array(size), + river: new Float32Array(size), + floodplain: new Float32Array(size), + plain: new Float32Array(size), + agriculture: new Float32Array(size), + ridgeField: new Float32Array(size), + valleyField: new Float32Array(size), + basinField: new Float32Array(size), + coastalLowland: new Float32Array(size), + flowAccum: new Float32Array(size), + erosionField: new Float32Array(size), + depositionField: new Float32Array(size), + arcSpineField: new Float32Array(size), + branchRidgeField: new Float32Array(size), + depositionalLowland: new Float32Array(size), + alluvialFanField: new Float32Array(size), + deltaField: new Float32Array(size), + naturalBarrierScore: new Float32Array(size), + flowTo, + portSuitability: new Float32Array(size), + crossingSuitability: new Float32Array(size), + passSuitability: new Float32Array(size), + visibleRavineField: new Float32Array(size), + surfaceTextureField: new Float32Array(size), + watershedId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(), + naturalCompartmentId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(), + regionId: (() => { const a = new Int32Array(size); a.fill(-1); return a; })(), + }; +} + +export function isRectCellField(value, ctx) { + return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === ctx.size; +} + +export function rectQuantile(values, q) { + const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b); + if (!arr.length) return 0; + const p = clamp(q) * (arr.length - 1); + const i = Math.floor(p); + const f = p - i; + return arr[i] + (arr[Math.min(arr.length - 1, i + 1)] - arr[i]) * f; +} diff --git a/renderer.js b/renderer.js index 055d3c9..c756360 100644 --- a/renderer.js +++ b/renderer.js @@ -893,11 +893,20 @@ 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); + ctx.save(); + ctx.translate(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5); + ctx.scale(zoom, zoom); + const finish = () => { + ctx.restore(); + drawScaleBar(ctx); + }; // 1. Base Terrain & Urban drawBase(ctx, map, mode, continuousTerrain); @@ -992,7 +1001,10 @@ export function drawMap(canvas, map, options) { drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } - if (!showFeatures) return; + if (!showFeatures) { + finish(); + return; + } // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. const localRoadCasing = "rgba(112, 112, 104, 0.58)"; @@ -1089,12 +1101,12 @@ export function drawMap(canvas, map, options) { .filter((p) => p && p.name) .map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 })); drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity); - drawScaleBar(ctx); + finish(); return; } if (mode === "borders-debug") { drawLabels(ctx, prefectureLabels, Infinity); - drawScaleBar(ctx); + finish(); return; } const important = [ @@ -1106,5 +1118,5 @@ export function drawMap(canvas, map, options) { ].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000)); drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60); } - drawScaleBar(ctx); + finish(); } diff --git a/styles.css b/styles.css index 89d04d2..4aa2bed 100644 --- a/styles.css +++ b/styles.css @@ -78,3 +78,12 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45} .patch-status.invalid{color:#b3261e;font-weight:600} .map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)} + +.patch-variant-row{display:grid;grid-template-columns:1fr 92px;gap:8px;align-items:end;margin-top:12px} +.patch-variant-label{margin-bottom:0;align-self:center} +.patch-variant-input{padding:8px 10px;text-align:right;font-family:ui-monospace,monospace} +.patch-button-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px} +.patch-button-row .primary-button,.patch-button-row .secondary-button{margin-top:0;width:100%} +.secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s} +.secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)} +.secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed} diff --git a/worldMap.js b/worldMap.js index 322a60b..379adfe 100644 --- a/worldMap.js +++ b/worldMap.js @@ -8,6 +8,8 @@ const NEGATIVE_ONE_FIELDS = new Set([ "prefectureRegionId", "regionId", "municipalityId", + "naturalCompartmentId", + "watershedId", ]); function isCellField(value) { @@ -24,7 +26,7 @@ function defaultForField(name, Constructor) { } function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) { - const Constructor = source.constructor; + const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : source.constructor; const out = new Constructor(worldWidth * worldHeight); const fallback = defaultForField(name, Constructor); if (fallback !== 0) out.fill(fallback); @@ -98,3 +100,73 @@ export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY), }; } + +function expandRectByOffset(rect, dx, dy) { + if (!rect) return rect; + return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy }; +} + +function shiftRectCollections(world, dx, dy) { + if (!dx && !dy) return; + for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) { + if (!Array.isArray(world[key])) continue; + world[key] = world[key].map((item) => { + const out = expandRectByOffset(item, dx, dy); + for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) { + if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy); + } + return out; + }); + } + if (world.lastPatchResult) { + world.lastPatchResult = { ...world.lastPatchResult }; + for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) { + if (world.lastPatchResult[sub]) world.lastPatchResult[sub] = expandRectByOffset(world.lastPatchResult[sub], dx, dy); + } + } +} + +export function expandWorldMap(world, margins = {}) { + if (!world) return { world, dx: 0, dy: 0, expanded: false }; + const left = Math.max(0, Math.floor(margins.left || 0)); + const right = Math.max(0, Math.floor(margins.right || 0)); + const top = Math.max(0, Math.floor(margins.top || 0)); + const bottom = Math.max(0, Math.floor(margins.bottom || 0)); + if (!left && !right && !top && !bottom) return { world, dx: 0, dy: 0, expanded: false }; + const oldWidth = world.width; + const oldHeight = world.height; + const newWidth = oldWidth + left + right; + const newHeight = oldHeight + top + bottom; + const newFields = {}; + for (const [name, field] of Object.entries(world.fields || {})) { + if (!ArrayBuffer.isView(field)) continue; + const Constructor = NEGATIVE_ONE_FIELDS.has(name) ? Int32Array : field.constructor; + const out = new Constructor(newWidth * newHeight); + const fallback = defaultForField(name, Constructor); + if (fallback !== 0) out.fill(fallback); + for (let y = 0; y < oldHeight; y++) { + const srcRow = y * oldWidth; + const dstRow = (y + top) * newWidth + left; + for (let x = 0; x < oldWidth; x++) out[dstRow + x] = field[srcRow + x]; + } + newFields[name] = out; + } + world.width = newWidth; + world.height = newHeight; + world.originX += left; + world.originY += top; + world.fields = newFields; + shiftRectCollections(world, left, top); + return { world, dx: left, dy: top, expanded: true }; +} + +export function ensureWorldPaddingForCamera(world, camera, viewWidth = MAP_W, viewHeight = MAP_H, padding = Math.floor(Math.min(MAP_W, MAP_H) * 0.45)) { + if (!world || !camera) return { dx: 0, dy: 0, expanded: false }; + const grow = Math.max(64, Math.floor(padding)); + const margins = { left: 0, right: 0, top: 0, bottom: 0 }; + if (camera.x < grow) margins.left = grow; + if (camera.y < grow) margins.top = grow; + if (camera.x + viewWidth > world.width - grow) margins.right = grow; + if (camera.y + viewHeight > world.height - grow) margins.bottom = grow; + return expandWorldMap(world, margins); +} diff --git a/worldViewport.js b/worldViewport.js index 8b1c001..8cb6272 100644 --- a/worldViewport.js +++ b/worldViewport.js @@ -30,6 +30,8 @@ const NEGATIVE_ONE_FIELDS = new Set([ "prefectureRegionId", "regionId", "municipalityId", + "naturalCompartmentId", + "watershedId", ]); function isCellField(value) { @@ -133,9 +135,50 @@ function transformSegments(segments, camera, originX, originY) { .filter(segmentIntersectsViewport); } -function transformTransportDebug(debug, camera, originX, originY) { + +function copySourceMapViewportField(source, camera, originX, originY, viewWidth, viewHeight) { + if (!ArrayBuffer.isView(source) || typeof source.length !== "number" || source.length !== SIZE) return source; + const out = new source.constructor(viewWidth * viewHeight); + const cx = Math.round(camera.x || 0); + const cy = Math.round(camera.y || 0); + for (let y = 0; y < viewHeight; y++) { + for (let x = 0; x < viewWidth; x++) { + const sx = cx + x - originX; + const sy = cy + y - originY; + if (sx >= 0 && sy >= 0 && sx < MAP_W && sy < MAP_H) out[y * viewWidth + x] = source[sy * MAP_W + sx]; + } + } + return out; +} + +function transformTransportDebug(debug, camera, originX, originY, viewport = null) { 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); + } + // 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 make = (fn) => { + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i); + return out; + }; + const sea = viewport.sea || new Uint8Array(n); + const slope = viewport.slope || new Float32Array(n); + const plain = viewport.plain || new Float32Array(n); + const pop = viewport.populationDensity || viewport.settlementScore || new Float32Array(n); + const road = viewport.roadInfluence || new Float32Array(n); + const rail = viewport.railInfluence2 || viewport.stationInfluence || new Float32Array(n); + layers.expresswayPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.72 + pop[i] * 0.42 + plain[i] * 0.22 - slope[i] * 0.52))); + layers.nationalRoadPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, road[i] * 0.58 + pop[i] * 0.55 + plain[i] * 0.18 - slope[i] * 0.38))); + layers.railPotential = make((i) => sea[i] ? 0 : Math.max(0, Math.min(1, rail[i] * 0.72 + pop[i] * 0.38 + plain[i] * 0.26 - slope[i] * 0.72))); + layers.slopeSeaPenalty = make((i) => sea[i] ? 1 : Math.max(0, Math.min(1, slope[i] * 1.35))); + } if (Array.isArray(layers.components)) { layers.components = layers.components.map((component) => ({ ...component, @@ -191,7 +234,7 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY), }; } - if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY); + if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY, viewport); if (sourceMap.neighborPrefectureDetails) { viewport.neighborPrefectureDetails = { ...sourceMap.neighborPrefectureDetails,