From ea0067dc0a22a1929d258d07114795c090ea1a0b Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 19:37:28 +0900 Subject: [PATCH] infinity expantion --- app.js | 380 +++++++++++-- index.html | 23 +- mapGeneratorHelpers.js | 12 + mapHumanPatch.js | 1177 ++++++++++++++++++++++++++++++++++++++++ mapPatch.js | 898 ++++++++++++++++++++++++++++++ mapTerrain.js | 48 +- renderer.js | 10 +- styles.css | 9 +- worldMap.js | 100 ++++ worldViewport.js | 205 +++++++ 10 files changed, 2792 insertions(+), 70 deletions(-) create mode 100644 mapHumanPatch.js create mode 100644 mapPatch.js create mode 100644 worldMap.js create mode 100644 worldViewport.js diff --git a/app.js b/app.js index a5dfe93..ad57d2d 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,10 @@ import { generateMapAsync } from "./mapGenerator.js"; import { drawMap } from "./renderer.js"; import { landuseLabel } from "./landuseCodes.js"; +import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js"; +import { clampCameraToWorld, createInitialCamera, createWorldMap } from "./worldMap.js"; +import { getViewportMap } from "./worldViewport.js"; +import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js"; const modes = [ ["all", "All"], @@ -21,19 +25,28 @@ const state = { showFeatures: true, showLabels: true, map: null, + world: null, + camera: { x: 0, y: 0 }, + viewportMap: null, hoverEntities: [], + selectionRect: null, + lastPatchResult: null, }; const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const generationTypeInput = document.getElementById("generationType"); +const patchTerrainTypeInput = document.getElementById("patchTerrainType"); +const generatePatchButton = document.getElementById("generatePatch"); +const patchStatusEl = document.getElementById("patchStatus"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); const modeGrid = document.getElementById("modeGrid"); const statsEl = document.getElementById("stats"); const tooltipEl = document.getElementById("mapTooltip"); +const selectionEl = document.getElementById("mapSelection"); const progressEl = document.getElementById("generationProgress"); const progressStageEl = document.getElementById("generationProgressStage"); const progressTimingsEl = document.getElementById("generationProgressTimings"); @@ -41,66 +54,253 @@ let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; -const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 }; +const dragState = { + mode: null, + pointerId: null, + startClientX: 0, + startClientY: 0, + startCameraX: 0, + startCameraY: 0, + selectStart: null, + selectEnd: null, + pendingCamera: null, + panRaf: null, +}; + +function activeMap() { + return state.viewportMap || state.map; +} function mapClientToCell(event) { - if (!state.map) return null; + const map = activeMap(); + if (!map) return null; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const relX = (event.clientX - rect.left) / rect.width; const relY = (event.clientY - rect.top) / rect.height; return { - x: Math.floor(relX * state.map.width), - y: Math.floor(relY * state.map.height), + x: Math.floor(relX * map.width), + y: Math.floor(relY * map.height), }; } -function isEditableTarget(target) { - if (!target) return false; - const tag = target.tagName?.toLowerCase?.(); - return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; +function viewportCellToWorldCell(cell) { + if (!cell || !state.camera) return null; + return { + x: Math.round(state.camera.x || 0) + cell.x, + y: Math.round(state.camera.y || 0) + cell.y, + }; } -function panFrame(time) { - if (!canvasShell || panState.keys.size === 0) { - panState.raf = null; - panState.lastTime = 0; +function clampCanvasPoint(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), + y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), + }; +} + +function updateSelectionOverlay() { + if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return; + const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x); + const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y); + const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x); + const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y); + selectionEl.style.display = "block"; + selectionEl.style.left = `${canvas.offsetLeft + x0}px`; + selectionEl.style.top = `${canvas.offsetTop + y0}px`; + selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; + selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; + const liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + const validation = validatePatchRect(liveRect, state.world); + selectionEl.classList.toggle("invalid", !validation.ok); + if (generatePatchButton) generatePatchButton.disabled = true; + if (patchStatusEl) { + const current = validation.rect || liveRect; + patchStatusEl.textContent = validation.ok + ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` + : `${validation.reason} Current: ${formatRectSize(current)}.`; + patchStatusEl.classList.toggle("invalid", !validation.ok); + } +} + +function updateSelectionOverlayFromWorldRect() { + if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const map = activeMap(); + const cameraX = Math.round(state.camera.x || 0); + const cameraY = Math.round(state.camera.y || 0); + const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width; + const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height; + const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width; + const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height; + const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width); + const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height); + const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width); + const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height); + if (x1 - x0 < 1 || y1 - y0 < 1) { + selectionEl.style.display = "none"; return; } - const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0; - panState.lastTime = time; - let dx = 0; - let dy = 0; - if (panState.keys.has("a")) dx -= 1; - if (panState.keys.has("d")) dx += 1; - if (panState.keys.has("w")) dy -= 1; - if (panState.keys.has("s")) dy += 1; - if (dx || dy) { - const normalizer = dx && dy ? Math.SQRT1_2 : 1; - const amount = panState.speedPxPerSecond * dt; - canvasShell.scrollLeft += dx * normalizer * amount; - canvasShell.scrollTop += dy * normalizer * amount; - tooltipEl?.classList.remove("visible"); + selectionEl.style.display = "block"; + selectionEl.style.left = `${canvas.offsetLeft + x0}px`; + selectionEl.style.top = `${canvas.offsetTop + y0}px`; + selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; + selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; + const validation = validatePatchRect(state.selectionRect, state.world); + selectionEl.classList.toggle("invalid", !validation.ok); +} + +function formatRectSize(rect) { + if (!rect) return "-"; + const w = Math.max(0, rect.x1 - rect.x0); + const h = Math.max(0, rect.y1 - rect.y0); + return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`; +} + +function updatePatchControls() { + if (!patchStatusEl && !generatePatchButton) return; + const validation = validatePatchRect(state.selectionRect, state.world); + if (generatePatchButton) generatePatchButton.disabled = !validation.ok; + if (!patchStatusEl) return; + if (!state.selectionRect) { + patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`; + patchStatusEl.classList.toggle("invalid", false); + return; } - panState.raf = requestAnimationFrame(panFrame); + if (!validation.ok) { + patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`; + patchStatusEl.classList.toggle("invalid", true); + return; + } + const rects = buildPatchRects(validation.rect, state.world); + const patchText = state.lastPatchResult + ? ` Last patch: ${state.lastPatchResult.label}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` + : ""; + patchStatusEl.textContent = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; + patchStatusEl.classList.toggle("invalid", false); } -function startKeyboardPan() { - if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame); +function clearDragMode() { + dragState.mode = null; + dragState.pointerId = null; + dragState.pendingCamera = null; + if (dragState.panRaf != null) { + cancelAnimationFrame(dragState.panRaf); + dragState.panRaf = null; + } + canvasShell?.classList.remove("panning", "selecting"); } -function handlePanKeyDown(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return; - panState.keys.add(key); - startKeyboardPan(); +function schedulePanRedraw(camera) { + dragState.pendingCamera = camera; + if (dragState.panRaf != null) return; + dragState.panRaf = requestAnimationFrame(() => { + dragState.panRaf = null; + if (!dragState.pendingCamera) return; + const next = dragState.pendingCamera; + dragState.pendingCamera = null; + if (next.x === state.camera.x && next.y === state.camera.y) return; + state.camera = next; + redraw({ fastTerrain: true }); + }); +} + +function hideSelectionOverlay() { + dragState.selectStart = null; + dragState.selectEnd = null; + state.selectionRect = null; + if (selectionEl) selectionEl.style.display = "none"; + updatePatchControls(); +} + +function selectionPixelsToCells(start, end) { + const map = activeMap(); + if (!map || !start || !end) return null; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width); + const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height); + const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width); + const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height); + const cameraX = Math.round(state.camera?.x || 0); + const cameraY = Math.round(state.camera?.y || 0); + return { + x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1), + y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1), + x1: cameraX + Math.min(Math.max(localX1, 1), map.width), + y1: cameraY + Math.min(Math.max(localY1, 1), map.height), + }; +} + +function handleMapPointerDown(event) { + if (!state.world || !canvasShell) return; + if (event.button !== 0 && event.button !== 2) return; + dragState.pointerId = event.pointerId; + dragState.startClientX = event.clientX; + dragState.startClientY = event.clientY; + dragState.startCameraX = state.camera.x; + dragState.startCameraY = state.camera.y; + tooltipEl?.classList.remove("visible"); + + if (event.button === 0) { + dragState.mode = "pan"; + canvasShell.classList.add("panning"); + } else { + dragState.mode = "select"; + dragState.selectStart = clampCanvasPoint(event); + dragState.selectEnd = dragState.selectStart; + canvasShell.classList.add("selecting"); + updateSelectionOverlay(); + } + + canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); } -function handlePanKeyUp(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key)) return; - panState.keys.delete(key); +function handleMapPointerMove(event) { + if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return; + tooltipEl?.classList.remove("visible"); + + if (dragState.mode === "pan") { + const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE); + const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE); + const nextCamera = clampCameraToWorld({ + x: dragState.startCameraX - dxCells, + y: dragState.startCameraY - dyCells, + }, state.world, MAP_W, MAP_H); + schedulePanRedraw(nextCamera); + } else if (dragState.mode === "select") { + dragState.selectEnd = clampCanvasPoint(event); + updateSelectionOverlay(); + } + + event.preventDefault(); +} + +function handleMapPointerUp(event) { + if (dragState.pointerId !== event.pointerId) return; + const wasPanning = dragState.mode === "pan"; + if (dragState.mode === "select") { + dragState.selectEnd = clampCanvasPoint(event); + const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x); + const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); + if (width >= 4 && height >= 4) { + state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + updateSelectionOverlayFromWorldRect(); + updatePatchControls(); + } else { + hideSelectionOverlay(); + } + } + canvas.releasePointerCapture?.(event.pointerId); + if (wasPanning && dragState.pendingCamera) { + state.camera = dragState.pendingCamera; + dragState.pendingCamera = null; + } + clearDragMode(); + if (wasPanning) redraw({ fastTerrain: false }); event.preventDefault(); } @@ -283,29 +483,35 @@ function prefectureNameForCell(map, i) { } function updateTooltip(event) { - if (!state.map || !tooltipEl) return; + const map = activeMap(); + if (!map || !tooltipEl || dragState.mode) return; const rect = canvas.getBoundingClientRect(); const cell = mapClientToCell(event); if (!cell) return; const { x, y } = cell; - if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) { + if (x < 0 || y < 0 || x >= map.width || y >= map.height) { tooltipEl.classList.remove("visible"); return; } - const i = y * state.map.width + x; + const i = y * map.width + x; + const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); - const elevation = state.map.elevation?.[i] ?? 0; - const density = state.map.populationDensity?.[i] ?? 0; - const hoveredAdminId = state.map.adminId?.[i] ?? -1; - const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId); + const elevation = map.elevation?.[i] ?? 0; + const density = map.populationDensity?.[i] ?? 0; + const hoveredAdminId = map.adminId?.[i] ?? -1; + const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId); + const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`; + const entityTitle = entity + ? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}` + : coordinateText; const lines = [ - `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}`, - `Prefecture: ${prefectureNameForCell(state.map, i)}`, - `Admin: ${adminName(state.map, hoveredAdminId)}`, + `${entityTitle}`, + `Prefecture: ${prefectureNameForCell(map, i)}`, + `Admin: ${adminName(map, hoveredAdminId)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, - `Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`, - `Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`, - `River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, + `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`, + `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`, + `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, ]; if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`); tooltipEl.innerHTML = lines.join("
"); @@ -343,7 +549,10 @@ async function regenerate() { await nextFrame(); try { state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType }); - state.hoverEntities = buildHoverEntities(state.map); + state.world = createWorldMap(state.map); + state.camera = createInitialCamera(state.world); + state.lastPatchResult = null; + hideSelectionOverlay(); renderStats(state.map); redraw(); if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`; @@ -355,13 +564,62 @@ async function regenerate() { } } -function redraw() { - if (!state.map) return; - drawMap(canvas, state.map, { + +function derivePatchSeed(rect, terrainType) { + let h = parseSeed(state.seedText) ^ 0x9e3779b9; + h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0; + h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0; + h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0; + h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0; + for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; + return h >>> 0; +} + +async function generateSelectedPatch() { + const validation = validatePatchRect(state.selectionRect, state.world); + if (!validation.ok) { + updatePatchControls(); + return; + } + const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; + const seed = derivePatchSeed(validation.rect, terrainType); + setProgressVisible(true, "Generating selected patch..."); + await nextFrame(); + try { + const result = generatePatch(state.world, validation.rect, { terrainType, seed }); + if (!result.ok) { + if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; + updatePatchControls(); + window.setTimeout(() => setProgressVisible(false), 1200); + return; + } + state.lastPatchResult = result; + redraw(); + renderStats(state.map); + updatePatchControls(); + const human = result.humanGeography; + const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : ""; + if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`; + renderTimingRows([]); + window.setTimeout(() => setProgressVisible(false), 900); + } catch (error) { + if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`; + throw error; + } +} + +function redraw(options = {}) { + if (!state.world) return; + state.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H); + state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H); + state.hoverEntities = buildHoverEntities(state.viewportMap); + drawMap(canvas, state.viewportMap, { mode: state.mode, showFeatures: state.showFeatures, - showLabels: state.showLabels, + showLabels: state.showLabels && !options.fastTerrain, + continuousTerrain: !options.fastTerrain, }); + if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect(); } function init() { @@ -373,6 +631,8 @@ function init() { }); generationTypeInput?.addEventListener("change", regenerate); + patchTerrainTypeInput?.addEventListener("change", updatePatchControls); + generatePatchButton?.addEventListener("click", generateSelectedPatch); randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); @@ -390,13 +650,17 @@ function init() { }); canvasShell?.setAttribute("tabindex", "0"); - window.addEventListener("keydown", handlePanKeyDown); - window.addEventListener("keyup", handlePanKeyUp); + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("pointerdown", handleMapPointerDown); + canvas.addEventListener("pointermove", handleMapPointerMove); + canvas.addEventListener("pointerup", handleMapPointerUp); + canvas.addEventListener("pointercancel", handleMapPointerUp); canvas.addEventListener("mousemove", updateTooltip); canvas.addEventListener("mouseleave", () => { tooltipEl?.classList.remove("visible"); }); + updatePatchControls(); regenerate(); } diff --git a/index.html b/index.html index a60db97..3ace528 100644 --- a/index.html +++ b/index.html @@ -13,12 +13,13 @@

Prefecture Map Generator v17

-

Terrain, municipalities, transport, land use, and hover inspection in one generated map.

+

Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.

+