hmm?
This commit is contained in:
parent
ea0067dc0a
commit
112e6bf86b
11 changed files with 1586 additions and 1253 deletions
273
app.js
273
app.js
|
|
@ -2,7 +2,7 @@ import { generateMapAsync } from "./mapGenerator.js";
|
||||||
import { drawMap } from "./renderer.js";
|
import { drawMap } from "./renderer.js";
|
||||||
import { landuseLabel } from "./landuseCodes.js";
|
import { landuseLabel } from "./landuseCodes.js";
|
||||||
import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.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 { getViewportMap } from "./worldViewport.js";
|
||||||
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.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,
|
viewportMap: null,
|
||||||
hoverEntities: [],
|
hoverEntities: [],
|
||||||
selectionRect: null,
|
selectionRect: null,
|
||||||
|
patchVariant: 0,
|
||||||
|
zoom: 1,
|
||||||
lastPatchResult: null,
|
lastPatchResult: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,7 +40,9 @@ const canvasShell = document.querySelector(".canvas-shell");
|
||||||
const seedInput = document.getElementById("seed");
|
const seedInput = document.getElementById("seed");
|
||||||
const generationTypeInput = document.getElementById("generationType");
|
const generationTypeInput = document.getElementById("generationType");
|
||||||
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
|
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
|
||||||
|
const patchVariantInput = document.getElementById("patchVariant");
|
||||||
const generatePatchButton = document.getElementById("generatePatch");
|
const generatePatchButton = document.getElementById("generatePatch");
|
||||||
|
const alternativePatchButton = document.getElementById("alternativePatch");
|
||||||
const patchStatusEl = document.getElementById("patchStatus");
|
const patchStatusEl = document.getElementById("patchStatus");
|
||||||
const randomSeedButton = document.getElementById("randomSeed");
|
const randomSeedButton = document.getElementById("randomSeed");
|
||||||
const showFeaturesInput = document.getElementById("showFeatures");
|
const showFeaturesInput = document.getElementById("showFeatures");
|
||||||
|
|
@ -71,16 +75,72 @@ function activeMap() {
|
||||||
return state.viewportMap || state.map;
|
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) {
|
function mapClientToCell(event) {
|
||||||
const map = activeMap();
|
const map = activeMap();
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const p = screenPointToMapPixel(event.clientX, event.clientY);
|
||||||
if (!rect.width || !rect.height) return null;
|
if (!p) return null;
|
||||||
const relX = (event.clientX - rect.left) / rect.width;
|
|
||||||
const relY = (event.clientY - rect.top) / rect.height;
|
|
||||||
return {
|
return {
|
||||||
x: Math.floor(relX * map.width),
|
x: Math.floor(p.x / CELL_SIZE),
|
||||||
y: Math.floor(relY * map.height),
|
y: Math.floor(p.y / CELL_SIZE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +175,7 @@ function updateSelectionOverlay() {
|
||||||
const validation = validatePatchRect(liveRect, state.world);
|
const validation = validatePatchRect(liveRect, state.world);
|
||||||
selectionEl.classList.toggle("invalid", !validation.ok);
|
selectionEl.classList.toggle("invalid", !validation.ok);
|
||||||
if (generatePatchButton) generatePatchButton.disabled = true;
|
if (generatePatchButton) generatePatchButton.disabled = true;
|
||||||
|
if (alternativePatchButton) alternativePatchButton.disabled = true;
|
||||||
if (patchStatusEl) {
|
if (patchStatusEl) {
|
||||||
const current = validation.rect || liveRect;
|
const current = validation.rect || liveRect;
|
||||||
patchStatusEl.textContent = validation.ok
|
patchStatusEl.textContent = validation.ok
|
||||||
|
|
@ -128,13 +189,14 @@ function updateSelectionOverlayFromWorldRect() {
|
||||||
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
|
if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (!rect.width || !rect.height) return;
|
if (!rect.width || !rect.height) return;
|
||||||
const map = activeMap();
|
|
||||||
const cameraX = Math.round(state.camera.x || 0);
|
const cameraX = Math.round(state.camera.x || 0);
|
||||||
const cameraY = Math.round(state.camera.y || 0);
|
const cameraY = Math.round(state.camera.y || 0);
|
||||||
const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width;
|
const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE);
|
||||||
const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height;
|
const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE);
|
||||||
const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width;
|
const vx0 = p0.x - canvas.offsetLeft;
|
||||||
const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height;
|
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 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 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 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`;
|
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() {
|
function updatePatchControls() {
|
||||||
if (!patchStatusEl && !generatePatchButton) return;
|
if (!patchStatusEl && !generatePatchButton) return;
|
||||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
|
const variant = readPatchVariant();
|
||||||
if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
|
if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
|
||||||
|
if (alternativePatchButton) alternativePatchButton.disabled = !validation.ok;
|
||||||
if (!patchStatusEl) return;
|
if (!patchStatusEl) return;
|
||||||
if (!state.selectionRect) {
|
if (!state.selectionRect) {
|
||||||
patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`;
|
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);
|
patchStatusEl.classList.toggle("invalid", false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -176,9 +262,9 @@ function updatePatchControls() {
|
||||||
}
|
}
|
||||||
const rects = buildPatchRects(validation.rect, state.world);
|
const rects = buildPatchRects(validation.rect, state.world);
|
||||||
const patchText = state.lastPatchResult
|
const patchText = state.lastPatchResult
|
||||||
? ` Last patch: ${state.lastPatchResult.label}, 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);
|
patchStatusEl.classList.toggle("invalid", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +297,7 @@ function hideSelectionOverlay() {
|
||||||
dragState.selectStart = null;
|
dragState.selectStart = null;
|
||||||
dragState.selectEnd = null;
|
dragState.selectEnd = null;
|
||||||
state.selectionRect = null;
|
state.selectionRect = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
if (selectionEl) selectionEl.style.display = "none";
|
if (selectionEl) selectionEl.style.display = "none";
|
||||||
updatePatchControls();
|
updatePatchControls();
|
||||||
}
|
}
|
||||||
|
|
@ -220,10 +307,18 @@ function selectionPixelsToCells(start, end) {
|
||||||
if (!map || !start || !end) return null;
|
if (!map || !start || !end) return null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (!rect.width || !rect.height) return null;
|
if (!rect.width || !rect.height) return null;
|
||||||
const localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width);
|
const toMapPixel = (p) => {
|
||||||
const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height);
|
const t = zoomTransform();
|
||||||
const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width);
|
const canvasX = p.x * (t.width / rect.width);
|
||||||
const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height);
|
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 cameraX = Math.round(state.camera?.x || 0);
|
||||||
const cameraY = Math.round(state.camera?.y || 0);
|
const cameraY = Math.round(state.camera?.y || 0);
|
||||||
return {
|
return {
|
||||||
|
|
@ -264,8 +359,9 @@ function handleMapPointerMove(event) {
|
||||||
tooltipEl?.classList.remove("visible");
|
tooltipEl?.classList.remove("visible");
|
||||||
|
|
||||||
if (dragState.mode === "pan") {
|
if (dragState.mode === "pan") {
|
||||||
const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE);
|
const cellSize = Math.max(1, displayedCellSize());
|
||||||
const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE);
|
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
|
||||||
|
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
|
||||||
const nextCamera = clampCameraToWorld({
|
const nextCamera = clampCameraToWorld({
|
||||||
x: dragState.startCameraX - dxCells,
|
x: dragState.startCameraX - dxCells,
|
||||||
y: dragState.startCameraY - dyCells,
|
y: dragState.startCameraY - dyCells,
|
||||||
|
|
@ -288,6 +384,8 @@ function handleMapPointerUp(event) {
|
||||||
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
|
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
|
||||||
if (width >= 4 && height >= 4) {
|
if (width >= 4 && height >= 4) {
|
||||||
state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
|
state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
|
||||||
|
state.lastPatchResult = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
updateSelectionOverlayFromWorldRect();
|
updateSelectionOverlayFromWorldRect();
|
||||||
updatePatchControls();
|
updatePatchControls();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -466,20 +564,61 @@ function landuseName(value) {
|
||||||
return landuseLabel(value);
|
return landuseLabel(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminName(map, adminId) {
|
function numericIdOf(item) {
|
||||||
const center = (map.adminCenters || [])[adminId];
|
for (const key of ["adminId", "municipalityId", "id", "adminNumericId"]) {
|
||||||
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
|
const value = item?.[key];
|
||||||
|
if (Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminPopulation(map, adminId) {
|
function adminCenterForId(map, adminId) {
|
||||||
const center = (map.adminCenters || [])[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;
|
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefectureNameForCell(map, i) {
|
function prefectureNameForCell(map, i) {
|
||||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||||
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
const region = (map.prefectureRegions || []).find((p) => 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) {
|
function updateTooltip(event) {
|
||||||
|
|
@ -497,9 +636,9 @@ function updateTooltip(event) {
|
||||||
const worldCell = viewportCellToWorldCell({ x, y });
|
const worldCell = viewportCellToWorldCell({ x, y });
|
||||||
const entity = nearestEntity(state.hoverEntities, x, y);
|
const entity = nearestEntity(state.hoverEntities, x, y);
|
||||||
const elevation = map.elevation?.[i] ?? 0;
|
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 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 coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
|
||||||
const entityTitle = entity
|
const entityTitle = entity
|
||||||
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
|
||||||
|
|
@ -507,7 +646,7 @@ function updateTooltip(event) {
|
||||||
const lines = [
|
const lines = [
|
||||||
`<strong>${entityTitle}</strong>`,
|
`<strong>${entityTitle}</strong>`,
|
||||||
`Prefecture: ${prefectureNameForCell(map, i)}`,
|
`Prefecture: ${prefectureNameForCell(map, i)}`,
|
||||||
`Admin: ${adminName(map, hoveredAdminId)}`,
|
`Admin: ${adminName(map, hoveredAdminId, i)}`,
|
||||||
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
||||||
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
|
`Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
|
||||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
|
`Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||||
|
|
@ -552,6 +691,7 @@ async function regenerate() {
|
||||||
state.world = createWorldMap(state.map);
|
state.world = createWorldMap(state.map);
|
||||||
state.camera = createInitialCamera(state.world);
|
state.camera = createInitialCamera(state.world);
|
||||||
state.lastPatchResult = null;
|
state.lastPatchResult = null;
|
||||||
|
resetPatchVariant({ update: false });
|
||||||
hideSelectionOverlay();
|
hideSelectionOverlay();
|
||||||
renderStats(state.map);
|
renderStats(state.map);
|
||||||
redraw();
|
redraw();
|
||||||
|
|
@ -565,16 +705,41 @@ async function regenerate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function derivePatchSeed(rect, terrainType) {
|
function derivePatchSeed(rect, terrainType, variant = 0) {
|
||||||
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
|
let h = parseSeed(state.seedText) ^ 0x9e3779b9;
|
||||||
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
|
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
|
||||||
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
|
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
|
||||||
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
|
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
|
||||||
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 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;
|
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
||||||
return h >>> 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() {
|
async function generateSelectedPatch() {
|
||||||
const validation = validatePatchRect(state.selectionRect, state.world);
|
const validation = validatePatchRect(state.selectionRect, state.world);
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
|
|
@ -582,11 +747,12 @@ async function generateSelectedPatch() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
|
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...");
|
setProgressVisible(true, "Generating selected patch...");
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
try {
|
try {
|
||||||
const result = generatePatch(state.world, validation.rect, { terrainType, seed });
|
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
|
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
|
||||||
updatePatchControls();
|
updatePatchControls();
|
||||||
|
|
@ -599,7 +765,7 @@ async function generateSelectedPatch() {
|
||||||
updatePatchControls();
|
updatePatchControls();
|
||||||
const human = result.humanGeography;
|
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}` : "";
|
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([]);
|
renderTimingRows([]);
|
||||||
window.setTimeout(() => setProgressVisible(false), 900);
|
window.setTimeout(() => setProgressVisible(false), 900);
|
||||||
} catch (error) {
|
} 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 = {}) {
|
function redraw(options = {}) {
|
||||||
if (!state.world) return;
|
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.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H);
|
||||||
state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
|
state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
|
||||||
state.hoverEntities = buildHoverEntities(state.viewportMap);
|
state.hoverEntities = buildHoverEntities(state.viewportMap);
|
||||||
|
|
@ -618,7 +806,9 @@ function redraw(options = {}) {
|
||||||
showFeatures: state.showFeatures,
|
showFeatures: state.showFeatures,
|
||||||
showLabels: state.showLabels && !options.fastTerrain,
|
showLabels: state.showLabels && !options.fastTerrain,
|
||||||
continuousTerrain: !options.fastTerrain,
|
continuousTerrain: !options.fastTerrain,
|
||||||
|
zoom: state.zoom || 1,
|
||||||
});
|
});
|
||||||
|
applyCanvasZoom();
|
||||||
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
|
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -631,8 +821,20 @@ function init() {
|
||||||
});
|
});
|
||||||
|
|
||||||
generationTypeInput?.addEventListener("change", regenerate);
|
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);
|
generatePatchButton?.addEventListener("click", generateSelectedPatch);
|
||||||
|
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
|
||||||
|
|
||||||
randomSeedButton.addEventListener("click", () => {
|
randomSeedButton.addEventListener("click", () => {
|
||||||
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
seedInput.value = String(Math.floor(Math.random() * 9999999));
|
||||||
|
|
@ -651,6 +853,7 @@ function init() {
|
||||||
|
|
||||||
canvasShell?.setAttribute("tabindex", "0");
|
canvasShell?.setAttribute("tabindex", "0");
|
||||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||||
|
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
|
||||||
canvas.addEventListener("pointerdown", handleMapPointerDown);
|
canvas.addEventListener("pointerdown", handleMapPointerDown);
|
||||||
canvas.addEventListener("pointermove", handleMapPointerMove);
|
canvas.addEventListener("pointermove", handleMapPointerMove);
|
||||||
canvas.addEventListener("pointerup", handleMapPointerUp);
|
canvas.addEventListener("pointerup", handleMapPointerUp);
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,14 @@
|
||||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
<div class="patch-variant-row">
|
||||||
|
<label class="label patch-variant-label" for="patchVariant">Patch Variant</label>
|
||||||
|
<input id="patchVariant" class="input patch-variant-input" type="number" min="0" step="1" value="0" />
|
||||||
|
</div>
|
||||||
|
<div class="patch-button-row">
|
||||||
|
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
|
||||||
|
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
|
||||||
|
</div>
|
||||||
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p>
|
<p id="patchStatus" class="patch-status">Right-drag an area to enable patch generation.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
1177
mapHumanPatch.js
1177
mapHumanPatch.js
File diff suppressed because it is too large
Load diff
414
mapPatch.js
414
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 { generateMap } from "./mapPipeline.js";
|
||||||
import { LANDUSE } from "./landuseCodes.js";
|
import { LANDUSE } from "./landuseCodes.js";
|
||||||
|
|
||||||
|
|
@ -39,6 +39,11 @@ const DISCRETE_FIELD_NAMES = new Set([
|
||||||
"adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
|
"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"]);
|
const SKIP_CELL_FIELDS = new Set(["flowTo"]);
|
||||||
|
|
||||||
function worldIndex(world, x, y) {
|
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 repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
|
||||||
const writeRect = expandRect(coreRect, writeMargin, world);
|
const writeRect = expandRect(coreRect, writeMargin, world);
|
||||||
const repairRect = expandRect(coreRect, repairMargin, 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 {
|
return {
|
||||||
coreRect,
|
coreRect,
|
||||||
writeRect,
|
writeRect,
|
||||||
repairRect,
|
repairRect,
|
||||||
contextRect: repairRect,
|
contextRect: repairRect,
|
||||||
|
transportReachRect,
|
||||||
blendRect: coreRect,
|
blendRect: coreRect,
|
||||||
userRect: writeRect,
|
userRect: writeRect,
|
||||||
selectedRect: coreRect,
|
selectedRect: coreRect,
|
||||||
writeMargin,
|
writeMargin,
|
||||||
repairMargin,
|
repairMargin,
|
||||||
|
transportReachMargin,
|
||||||
outerMargin: writeMargin,
|
outerMargin: writeMargin,
|
||||||
innerMargin: 0,
|
innerMargin: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -188,8 +197,8 @@ function patchAlpha(x, y, rects, seed = 0) {
|
||||||
if (!insideRect(x, y, writeRect)) return 0;
|
if (!insideRect(x, y, writeRect)) return 0;
|
||||||
const edge = distanceToRectEdge(x, y, writeRect);
|
const edge = distanceToRectEdge(x, y, writeRect);
|
||||||
const margin = Math.max(1, rects.writeMargin || 1);
|
const margin = Math.max(1, rects.writeMargin || 1);
|
||||||
const low = hash2(Math.floor(x / 18), Math.floor(y / 18), seed ^ 0x7153a9d1) - 0.5;
|
const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5;
|
||||||
const mid = hash2(Math.floor(x / 7), Math.floor(y / 7), seed ^ 0x9e3779b9) - 0.5;
|
const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5;
|
||||||
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
|
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
|
||||||
const base = smoothstep(clamp(noisyEdge / margin));
|
const base = smoothstep(clamp(noisyEdge / margin));
|
||||||
// Keep the expanded repair band as the actual seam. The user's selected core
|
// 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);
|
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) {
|
function sourceWindowForRects(rects) {
|
||||||
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
|
||||||
const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 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;
|
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) {
|
function copyFullPipelineFields(world, candidate, rects, seed) {
|
||||||
const window = sourceWindowForRects(rects);
|
const window = sourceWindowForRects(rects);
|
||||||
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
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 updatedCells = 0;
|
||||||
let coastCellsChanged = 0;
|
let coastCellsChanged = 0;
|
||||||
let terrainCellsFullyReplaced = 0;
|
let terrainCellsFullyReplaced = 0;
|
||||||
|
|
@ -258,11 +459,15 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
|
||||||
if (alpha <= 0.005) continue;
|
if (alpha <= 0.005) continue;
|
||||||
|
|
||||||
if (isDiscrete) {
|
if (isDiscrete) {
|
||||||
const thresholdNoise = hash2(Math.floor(x / 6), Math.floor(y / 6), seed ^ 0x21f0aaad) - 0.5;
|
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
|
||||||
const threshold = clamp(0.46 + thresholdNoise * 0.20, 0.28, 0.68);
|
|
||||||
if (alpha >= threshold) {
|
if (alpha >= threshold) {
|
||||||
const raw = source[si];
|
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 === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
|
||||||
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
||||||
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
|
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.
|
// Keep water fields coherent after all continuous fields have been blended.
|
||||||
const sea = world.fields.sea;
|
const sea = world.fields.sea;
|
||||||
const ocean = world.fields.ocean;
|
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) {
|
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.adminId)) out.adminId += offset;
|
||||||
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
|
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
|
||||||
if (Number.isFinite(out.municipalityId)) out.municipalityId += 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") {
|
if (key === "prefectureRegions") {
|
||||||
const offset = fieldIdOffset("prefectureRegionId", seed);
|
const offset = fieldIdOffset("prefectureRegionId", seed);
|
||||||
|
|
@ -619,40 +861,58 @@ function simplifyPath(path, keepEvery = 2) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectInternalNetworkPoints(world, sourceMap, keys, rect) {
|
function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") {
|
||||||
const points = [];
|
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 key of keys) {
|
||||||
for (const path of sourceMap[key] || []) {
|
for (const path of sourceMap[key] || []) {
|
||||||
for (let i = 0; i < path.length; i += 6) {
|
for (let i = 0; i < path.length; i += 4) {
|
||||||
const x = Math.round(tupleWorldX(world, path[i]));
|
add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1);
|
||||||
const y = Math.round(tupleWorldY(world, path[i]));
|
|
||||||
if (insideRect(x, y, rect) && isLand(world, x, y)) points.push({ x, y, key });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectAnchors(world, sourceMap, anchors, mode, rect) {
|
function connectAnchors(world, sourceMap, anchors, mode, rect) {
|
||||||
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
|
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 };
|
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
|
||||||
let connectors = 0;
|
let connectors = 0;
|
||||||
let disconnected = 0;
|
let disconnected = 0;
|
||||||
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
|
||||||
sourceMap[layer] ||= [];
|
sourceMap[layer] ||= [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
const maxRange = mode === "rail" ? 260 : 300;
|
||||||
for (const raw of anchors) {
|
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; }
|
if (!anchorLand) { disconnected++; continue; }
|
||||||
const target = targets
|
const target = targets
|
||||||
.filter((p) => Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) <= (mode === "rail" ? 80 : 64))
|
.map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
|
||||||
.sort((a, b) => Math.hypot(a.x - anchorLand.x, a.y - anchorLand.y) - Math.hypot(b.x - anchorLand.x, b.y - anchorLand.y))[0];
|
.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; }
|
if (!target) { disconnected++; continue; }
|
||||||
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
|
||||||
if (seen.has(sig)) continue;
|
if (seen.has(sig)) continue;
|
||||||
seen.add(sig);
|
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; }
|
if (!path || path.length < 2) { disconnected++; continue; }
|
||||||
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
|
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
|
||||||
connectors++;
|
connectors++;
|
||||||
|
|
@ -660,7 +920,48 @@ function connectAnchors(world, sourceMap, anchors, mode, rect) {
|
||||||
return { connectors, disconnected };
|
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 preservedExternalEntities = 0;
|
||||||
let regeneratedInternalEntities = 0;
|
let regeneratedInternalEntities = 0;
|
||||||
let invalidPortsRemoved = 0;
|
let invalidPortsRemoved = 0;
|
||||||
|
|
@ -681,6 +982,13 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||||
}
|
}
|
||||||
const generated = [];
|
const generated = [];
|
||||||
for (const p of candidate[key] || []) {
|
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);
|
const q = transformCandidatePoint(world, window, p, key, seed);
|
||||||
if (!q) continue;
|
if (!q) continue;
|
||||||
const wx = Math.round(pointWorldX(world, q));
|
const wx = Math.round(pointWorldX(world, q));
|
||||||
|
|
@ -722,13 +1030,15 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||||
}
|
}
|
||||||
sourceMap[key] = next;
|
sourceMap[key] = next;
|
||||||
}
|
}
|
||||||
const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", rects.writeRect);
|
const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
|
||||||
const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", 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 {
|
return {
|
||||||
roadsClipped,
|
roadsClipped,
|
||||||
railsClipped,
|
railsClipped,
|
||||||
regeneratedPaths,
|
regeneratedPaths,
|
||||||
roadConnectorsCreated: roadConn.connectors,
|
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors,
|
||||||
railwayConnectorsCreated: railConn.connectors,
|
railwayConnectorsCreated: railConn.connectors,
|
||||||
disconnectedRoadComponents: roadConn.disconnected,
|
disconnectedRoadComponents: roadConn.disconnected,
|
||||||
disconnectedRailComponents: railConn.disconnected,
|
disconnectedRailComponents: railConn.disconnected,
|
||||||
|
|
@ -741,9 +1051,12 @@ function segmentTouchesRect(world, seg, rect) {
|
||||||
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), 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 field = world.fields[fieldName];
|
||||||
const sea = world.fields.sea;
|
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 [];
|
if (!field) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let y = rect.y0; y < rect.y1; y++) {
|
for (let y = rect.y0; y < rect.y1; y++) {
|
||||||
|
|
@ -753,11 +1066,11 @@ function buildBoundarySegmentsFromField(world, fieldName, rect) {
|
||||||
const id = field[i];
|
const id = field[i];
|
||||||
if (id < 0) continue;
|
if (id < 0) continue;
|
||||||
const right = worldIndex(world, x + 1, y);
|
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]]);
|
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);
|
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]]);
|
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;
|
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) {
|
for (const key of SEGMENT_LAYER_KEYS) {
|
||||||
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
|
||||||
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
|
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
|
||||||
}
|
}
|
||||||
sourceMap.adminBorders ||= [];
|
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 ||= [];
|
||||||
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) {
|
function repairLanduseAndPopulation(world, rects) {
|
||||||
|
|
@ -829,16 +1171,18 @@ export function generatePatch(world, userRectInput, options = {}) {
|
||||||
const rects = buildPatchRects(validation.rect, world);
|
const rects = buildPatchRects(validation.rect, world);
|
||||||
const terrainType = options.terrainType || "auto";
|
const terrainType = options.terrainType || "auto";
|
||||||
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
|
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 sourceMap = world.sourceMap || (world.sourceMap = {});
|
||||||
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
||||||
|
|
||||||
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
|
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
|
||||||
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
|
||||||
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, 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);
|
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);
|
const landDebug = repairLanduseAndPopulation(world, rects);
|
||||||
sanitizeExistingLogistics(sourceMap);
|
sanitizeExistingLogistics(sourceMap);
|
||||||
|
|
||||||
|
|
@ -853,8 +1197,14 @@ export function generatePatch(world, userRectInput, options = {}) {
|
||||||
...pointDebug,
|
...pointDebug,
|
||||||
...pathDebug,
|
...pathDebug,
|
||||||
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
adminCellsReassigned: fieldDebug.adminCellsReassigned,
|
||||||
|
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
|
||||||
|
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
|
||||||
|
continuityIdMappings: fieldDebug.continuityIdMappings || 0,
|
||||||
|
continuityIdMappedCells: fieldDebug.continuityIdMappedCells || 0,
|
||||||
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
|
||||||
logisticsLabelsMigrated,
|
logisticsLabelsMigrated,
|
||||||
|
...segmentDebug,
|
||||||
|
candidateCompartmentSegmentsAdded,
|
||||||
};
|
};
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
|
|
@ -867,6 +1217,8 @@ export function generatePatch(world, userRectInput, options = {}) {
|
||||||
terrainType: id,
|
terrainType: id,
|
||||||
label,
|
label,
|
||||||
seed,
|
seed,
|
||||||
|
variant,
|
||||||
|
patchGenerationMode: "legacy-full-pipeline",
|
||||||
updatedCells: fieldDebug.updatedCells,
|
updatedCells: fieldDebug.updatedCells,
|
||||||
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
|
||||||
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
|
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
|
||||||
|
|
@ -887,6 +1239,8 @@ export function generatePatch(world, userRectInput, options = {}) {
|
||||||
terrainType: id,
|
terrainType: id,
|
||||||
label,
|
label,
|
||||||
seed,
|
seed,
|
||||||
|
variant,
|
||||||
|
patchGenerationMode: "legacy-full-pipeline",
|
||||||
updatedCells: record.updatedCells,
|
updatedCells: record.updatedCells,
|
||||||
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
|
||||||
coastCellsChanged: record.coastCellsChanged,
|
coastCellsChanged: record.coastCellsChanged,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
import { generateInitialTerrainRect, generateTerrainAndRivers } from "./mapTerrain.js";
|
||||||
import { generateMapFeatures } from "./mapFeatures.js";
|
import { generateMapFeatures } from "./mapFeatures.js";
|
||||||
import { finishMapOutput } from "./mapOutput.js";
|
import { finishMapOutput } from "./mapOutput.js";
|
||||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||||
|
|
@ -8,6 +8,21 @@ import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
|
||||||
|
|
||||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||||
|
|
||||||
|
function 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() {
|
function nowMs() {
|
||||||
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +66,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
const generationTimings = [];
|
const generationTimings = [];
|
||||||
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
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 {
|
const {
|
||||||
elevation,
|
elevation,
|
||||||
slope,
|
slope,
|
||||||
|
|
@ -134,7 +149,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
||||||
const generationTimings = [];
|
const generationTimings = [];
|
||||||
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
|
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 {
|
const {
|
||||||
elevation,
|
elevation,
|
||||||
slope,
|
slope,
|
||||||
|
|
|
||||||
645
mapTerrain.js
645
mapTerrain.js
|
|
@ -5,6 +5,7 @@ import {
|
||||||
neighbors8,
|
neighbors8,
|
||||||
} from "./mapGeneratorHelpers.js";
|
} from "./mapGeneratorHelpers.js";
|
||||||
import { buildNaturalCompartments } from "./adminRegions.js";
|
import { buildNaturalCompartments } from "./adminRegions.js";
|
||||||
|
import { createRectContext, createRectTerrainFields, rectIndexOf, rectInside, rectNeighbors8, rectQuantile } from "./rectContext.js";
|
||||||
|
|
||||||
const ASPECT = MAP_W / MAP_H;
|
const ASPECT = MAP_W / MAP_H;
|
||||||
const SQRT2 = Math.SQRT2;
|
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 = {}) {
|
export function generateTerrainAndRivers(seed, options = {}) {
|
||||||
const fields = createMapFields();
|
const fields = createMapFields();
|
||||||
fields.visibleRavineField = new Float32Array(SIZE);
|
fields.visibleRavineField = new Float32Array(SIZE);
|
||||||
|
|
|
||||||
150
rectContext.js
Normal file
150
rectContext.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
20
renderer.js
20
renderer.js
|
|
@ -893,11 +893,20 @@ export function drawMap(canvas, map, options) {
|
||||||
const showFeatures = options.showFeatures !== false;
|
const showFeatures = options.showFeatures !== false;
|
||||||
const showLabels = options.showLabels !== false;
|
const showLabels = options.showLabels !== false;
|
||||||
const continuousTerrain = options.continuousTerrain !== false;
|
const continuousTerrain = options.continuousTerrain !== false;
|
||||||
|
const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8);
|
||||||
|
|
||||||
const width = MAP_W * CELL_SIZE;
|
const width = MAP_W * CELL_SIZE;
|
||||||
const height = MAP_H * CELL_SIZE;
|
const height = MAP_H * CELL_SIZE;
|
||||||
if (canvas.width !== width) canvas.width = width;
|
if (canvas.width !== width) canvas.width = width;
|
||||||
if (canvas.height !== height) canvas.height = height;
|
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
|
// 1. Base Terrain & Urban
|
||||||
drawBase(ctx, map, mode, continuousTerrain);
|
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 });
|
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.
|
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||||
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
|
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
|
||||||
|
|
@ -1089,12 +1101,12 @@ export function drawMap(canvas, map, options) {
|
||||||
.filter((p) => p && p.name)
|
.filter((p) => p && p.name)
|
||||||
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||||
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||||
drawScaleBar(ctx);
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "borders-debug") {
|
if (mode === "borders-debug") {
|
||||||
drawLabels(ctx, prefectureLabels, Infinity);
|
drawLabels(ctx, prefectureLabels, Infinity);
|
||||||
drawScaleBar(ctx);
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const important = [
|
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));
|
].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);
|
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||||
}
|
}
|
||||||
drawScaleBar(ctx);
|
finish();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45}
|
||||||
.patch-status.invalid{color:#b3261e;font-weight:600}
|
.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)}
|
.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}
|
||||||
|
|
|
||||||
74
worldMap.js
74
worldMap.js
|
|
@ -8,6 +8,8 @@ const NEGATIVE_ONE_FIELDS = new Set([
|
||||||
"prefectureRegionId",
|
"prefectureRegionId",
|
||||||
"regionId",
|
"regionId",
|
||||||
"municipalityId",
|
"municipalityId",
|
||||||
|
"naturalCompartmentId",
|
||||||
|
"watershedId",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isCellField(value) {
|
function isCellField(value) {
|
||||||
|
|
@ -24,7 +26,7 @@ function defaultForField(name, Constructor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
|
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 out = new Constructor(worldWidth * worldHeight);
|
||||||
const fallback = defaultForField(name, Constructor);
|
const fallback = defaultForField(name, Constructor);
|
||||||
if (fallback !== 0) out.fill(fallback);
|
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),
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ const NEGATIVE_ONE_FIELDS = new Set([
|
||||||
"prefectureRegionId",
|
"prefectureRegionId",
|
||||||
"regionId",
|
"regionId",
|
||||||
"municipalityId",
|
"municipalityId",
|
||||||
|
"naturalCompartmentId",
|
||||||
|
"watershedId",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isCellField(value) {
|
function isCellField(value) {
|
||||||
|
|
@ -133,9 +135,50 @@ function transformSegments(segments, camera, originX, originY) {
|
||||||
.filter(segmentIntersectsViewport);
|
.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;
|
if (!debug?.layers) return debug;
|
||||||
const layers = { ...debug.layers };
|
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)) {
|
if (Array.isArray(layers.components)) {
|
||||||
layers.components = layers.components.map((component) => ({
|
layers.components = layers.components.map((component) => ({
|
||||||
...component,
|
...component,
|
||||||
|
|
@ -191,7 +234,7 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
|
||||||
lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY),
|
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) {
|
if (sourceMap.neighborPrefectureDetails) {
|
||||||
viewport.neighborPrefectureDetails = {
|
viewport.neighborPrefectureDetails = {
|
||||||
...sourceMap.neighborPrefectureDetails,
|
...sourceMap.neighborPrefectureDetails,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue