From 32398c13e8ae4801323d7f2db95daae632c7c7ba Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 20 May 2026 13:50:56 +0900 Subject: [PATCH] first commit --- README.md | 46 + app.js | 249 ++++ index.html | 98 ++ mapGenerator.js | 3024 +++++++++++++++++++++++++++++++++++++++++++++++ names.js | 34 + renderer.js | 493 ++++++++ styles.css | 11 + test.html | 18 + test.js | 152 +++ 9 files changed, 4125 insertions(+) create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 mapGenerator.js create mode 100644 names.js create mode 100644 renderer.js create mode 100644 styles.css create mode 100644 test.html create mode 100644 test.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..86da000 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Prefecture Map Generator v16 - Causal Terrain Cleanup Edition + +This version is designed to run directly in VSCode with **Live Server**. No npm setup is required. + +## Run + +1. Open this folder in VSCode. +2. Right-click `index.html`. +3. Choose **Open with Live Server**. + +## Main changes in v16 + +- River action is much stronger. `erosionField` and `depositionField` now produce clearer valleys, terraces, and alluvial lowlands. +- Neighboring prefecture regions are generated as background territory with `prefectureRegionId` and `regionalPrefectureBorders`, so the current prefecture is no longer visually isolated. +- River color now uses the same blue family as the sea; hierarchy is expressed by width and opacity. +- CBD/DID cells are sparser and restricted to dense, connected urban fabric. +- Railways, roads, and expressways are pruned for long-distance purpose; compact local tangles are rejected except for environmental ring segments. +- Expressway rings are rare. ICs are sampled at shorter intervals along expressway corridors. +- Bridge and tunnel icon rendering was removed. The legacy arrays remain empty for compatibility. +- Municipal regions are less likely to split small urban areas, while large cities claim larger municipality-like areas. + +## Existing systems retained + +- River hierarchy: main rivers, tributaries, and small streams. +- Port classes: major, regional, fishing, lake. +- Major rail corridors first, then purposeful branches. +- Expressways avoid city centers; city access is represented through IC access roads. +- Castles are restricted to terrain/historical candidate sites. +- Default generated names and manual overrides are both managed in `names.js`. +- Continuous terrain rendering is always on. + +## Editing place names + +Edit `names.js`. + +```js +export const CUSTOM_NAMES = { + "city-0": "Aohara", + "port-0": "Shirahama", + "castle-0": "Kurono" +}; +``` + +## Tests + +Open `test.html` with Live Server to run the browser-side smoke tests, or run `node --check *.js` on the source files. diff --git a/app.js b/app.js new file mode 100644 index 0000000..d4c4d06 --- /dev/null +++ b/app.js @@ -0,0 +1,249 @@ +import { generateMap } from "./mapGenerator.js"; +import { drawMap } from "./renderer.js"; + +const modes = [ + ["all", "All"], + ["terrain", "Terrain"], + ["suitability", "Suitability"], + ["history", "Premodern"], + ["modern", "Modern"], + ["roads", "Roads"], + ["development", "Development"], + ["landuse", "Land Use"], + ["admin", "Municipal Borders"], +]; + +const state = { + seedText: "114514", + mode: "all", + showFeatures: true, + showLabels: true, + map: null, +}; + +const canvas = document.getElementById("mapCanvas"); +const seedInput = document.getElementById("seed"); +const randomSeedButton = document.getElementById("randomSeed"); +const showFeaturesInput = document.getElementById("showFeatures"); +const showLabelsInput = document.getElementById("showLabels"); +const modeGrid = document.getElementById("modeGrid"); +const statsEl = document.getElementById("stats"); +const idsEl = document.getElementById("nameIds"); +const tooltipEl = document.getElementById("mapTooltip"); + +function parseSeed(seedText) { + const numeric = Number.parseInt(seedText, 10); + if (Number.isFinite(numeric)) return numeric >>> 0; + + let hash = 2166136261; + for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619); + return hash >>> 0; +} + +function insideCount(items) { + return items.filter((item) => item.insidePrefecture).length; +} + +function outsideCount(items) { + return items.length - insideCount(items); +} + +function countText(items) { + return `${insideCount(items)} / outside ${outsideCount(items)}`; +} + +function getStats(map) { + return [ + ["Villages", countText(map.villages)], + ["Market Towns", countText(map.markets)], + ["Castles", countText(map.castles)], + ["Premodern Roads", map.premodernRoads.length], + ["Minor Roads", map.minorRoads.length], + ["Prefectural Capital", map.prefecturalCapital?.name || "-"], + ["Modern Cities", countText(map.modernCities)], + ["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`], + ["Satellite Cities", countText(map.satelliteCities || [])], + ["Population", (map.totalPopulation || 0).toLocaleString()], + ["Rivers", `${map.mainRivers.length} main / ${(map.tributaryRivers || []).length} tributary / ${(map.smallStreams || []).length} hidden streams`], + ["Neighbor Prefecture Borders", (map.regionalPrefectureBorders || []).length], + ["Harbor Works", (map.harborWorks || []).length], + ["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length], + ["Industrial Zones", countText(map.industrialZones)], + ["National Roads", map.nationalRoads.length + (map.ringRoads || []).length], + ["Expressways", map.expressways.length + (map.ringExpressways || []).length + map.externalExpressways.length], + ["External Gateways", map.externalGateways.length], + ["Interchanges", countText(map.interchanges)], + ["Logistics Parks", countText(map.logisticsParks)], + ["New Towns", countText(map.newTowns)], + ["Municipalities", map.adminCenters.length], + ]; +} + +function renderStats(map) { + statsEl.innerHTML = ""; + for (const [labelText, valueText] of getStats(map)) { + const row = document.createElement("div"); + row.className = "stat-row"; + + const label = document.createElement("span"); + label.textContent = labelText; + + const value = document.createElement("strong"); + value.textContent = String(valueText); + + row.append(label, value); + statsEl.append(row); + } +} + +function renderNameIds(map) { + idsEl.innerHTML = ""; + for (const entity of map.entitiesForNames.slice(0, 120)) { + const row = document.createElement("div"); + row.className = "id-row"; + + const code = document.createElement("code"); + code.textContent = entity.id; + + const name = document.createElement("span"); + const population = entity.population ? ` / ${entity.population.toLocaleString()} people` : ""; + name.textContent = `${entity.name} / ${entity.kind}${population}`; + + row.append(code, name); + idsEl.append(row); + } +} + + +function nearestEntity(map, x, y, maxDistance = 5) { + const groups = [ + ...(map.modernCities || []), + ...(map.ports || []), + ...(map.stations || []), + ...(map.interchanges || []), + ...(map.industrialZones || []), + ...(map.logisticsParks || []), + ...(map.newTowns || []), + ...(map.castles || []), + ...(map.markets || []), + ...(map.villages || []), + ...(map.adminCenters || []), + ]; + let best = null; + let bestD = maxDistance; + for (const item of groups) { + const d = Math.hypot(item.x - x, item.y - y); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function landuseName(value) { + return { + 0: "Agriculture", + 1: "Plain", + 2: "Old urban area", + 3: "CBD / DID core", + 4: "Suburban urban area", + 5: "Industrial zone", + 6: "Logistics area", + 7: "New town", + 8: "Roadside development", + 9: "Forest / rural land", + }[value] || "Land"; +} + +function adminName(map, adminId) { + const center = (map.adminCenters || [])[adminId]; + return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-"); +} + +function updateTooltip(event) { + if (!state.map || !tooltipEl) return; + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((event.clientX - rect.left) / rect.width * state.map.width); + const y = Math.floor((event.clientY - rect.top) / rect.height * state.map.height); + if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) { + tooltipEl.classList.remove("visible"); + return; + } + const i = y * state.map.width + x; + const entity = nearestEntity(state.map, x, y); + const elevation = state.map.elevation?.[i] ?? 0; + const density = state.map.populationDensity?.[i] ?? 0; + const lines = [ + `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}`, + `Admin: ${adminName(state.map, state.map.adminId?.[i] ?? -1)}`, + `Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`, + `Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`, + `River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, + ]; + if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`); + tooltipEl.innerHTML = lines.join("
"); + tooltipEl.style.left = `${event.clientX - rect.left + 14}px`; + tooltipEl.style.top = `${event.clientY - rect.top + 14}px`; + tooltipEl.classList.add("visible"); +} + +function renderModeButtons() { modeGrid.innerHTML = ""; + for (const [key, label] of modes) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.className = key === state.mode ? "mode-button active" : "mode-button"; + button.addEventListener("click", () => { + state.mode = key; + renderModeButtons(); + redraw(); + }); + modeGrid.append(button); + } +} + +function regenerate() { + state.seedText = seedInput.value; + state.map = generateMap(parseSeed(state.seedText)); + renderStats(state.map); + renderNameIds(state.map); + redraw(); +} + +function redraw() { + if (!state.map) return; + drawMap(canvas, state.map, { + mode: state.mode, + showFeatures: state.showFeatures, + showLabels: state.showLabels, + }); +} + +function init() { + renderModeButtons(); + + seedInput.addEventListener("change", regenerate); + seedInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") regenerate(); + }); + + randomSeedButton.addEventListener("click", () => { + seedInput.value = String(Math.floor(Math.random() * 9999999)); + regenerate(); + }); + + showFeaturesInput.addEventListener("change", () => { + state.showFeatures = showFeaturesInput.checked; + redraw(); + }); + + showLabelsInput.addEventListener("change", () => { + state.showLabels = showLabelsInput.checked; + redraw(); + }); + + canvas.addEventListener("mousemove", updateTooltip); + canvas.addEventListener("mouseleave", () => tooltipEl?.classList.remove("visible")); + + regenerate(); +} + +init(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..2260ac3 --- /dev/null +++ b/index.html @@ -0,0 +1,98 @@ + + + + + + Prefecture Map Generator v16 + + + +
+
+
+
+
+

Prefecture Map Generator v16

+

+ Terrain-highlighted prefecture generation with terrain-snapped municipalities, a clear prefectural capital, + hidden small streams, city-seeking national roads, and hover tooltips. +

+
+
+ +
+ +
+
+
+ + +
+
+ + + + diff --git a/mapGenerator.js b/mapGenerator.js new file mode 100644 index 0000000..caaf54e --- /dev/null +++ b/mapGenerator.js @@ -0,0 +1,3024 @@ +import { CUSTOM_NAMES, KIND_SUFFIXES, NAME_PARTS } from "./names.js"; + +export const MAP_W = 172; +export const MAP_H = 122; +export const CELL_SIZE = 6; + +const SIZE = MAP_W * MAP_H; +const INF = 1e9; + +export function indexOf(x, y) { + return y * MAP_W + x; +} + +function xyOf(i) { + return [i % MAP_W, Math.floor(i / MAP_W)]; +} + +function inside(x, y) { + return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; +} + +function clamp(v, a = 0, b = 1) { + return Math.max(a, Math.min(b, v)); +} + +function nearMapEdge(x, y, margin = 1) { + return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin; +} + +function hash2(x, y, seed) { + let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +} + +function rand(seed, n) { + return hash2(n * 7919 + 17, n * 104729 + 31, seed); +} + +function smoothstep(t) { + t = clamp(t); + return t * t * (3 - 2 * t); +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + +function valueNoise(x, y, seed, scale) { + const sx = x / scale; + const sy = y / scale; + const x0 = Math.floor(sx); + const y0 = Math.floor(sy); + const tx = smoothstep(sx - x0); + const ty = smoothstep(sy - y0); + + const a = hash2(x0, y0, seed); + const b = hash2(x0 + 1, y0, seed); + const c = hash2(x0, y0 + 1, seed); + const d = hash2(x0 + 1, y0 + 1, seed); + + return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); +} + +function fbm(x, y, seed) { + let amp = 1; + let scale = 54; + let sum = 0; + let norm = 0; + for (let i = 0; i < 5; i++) { + sum += valueNoise(x, y, seed + i * 101, scale) * amp; + norm += amp; + amp *= 0.5; + scale *= 0.5; + } + return sum / norm; +} + +function neighbors8(x, y) { + const out = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); + } + } + return out; +} + +function distanceToNearest(points, x, y, fallback = 999) { + let best = fallback; + for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); + return best; +} + +function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) { + const sorted = candidates + .filter((p) => Number.isFinite(p.score) && p.score >= threshold) + .map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter })) + .sort((a, b) => b.score - a.score); + + const out = []; + for (const candidate of sorted) { + if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) { + out.push(candidate); + if (out.length >= max) break; + } + } + return out; +} + +class MinHeap { + constructor() { this.items = []; } + push(item) { + this.items.push(item); + let i = this.items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.items[parent].f <= item.f) break; + this.items[i] = this.items[parent]; + i = parent; + } + this.items[i] = item; + } + pop() { + if (this.items.length === 0) return null; + const root = this.items[0]; + const last = this.items.pop(); + if (this.items.length > 0) { + let i = 0; + while (true) { + const left = i * 2 + 1; + const right = left + 1; + if (left >= this.items.length) break; + const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left; + if (this.items[child].f >= last.f) break; + this.items[i] = this.items[child]; + i = child; + } + this.items[i] = last; + } + return root; + } + get length() { return this.items.length; } +} + +function aStar(start, goal, costAt) { + const startIndex = indexOf(start.x, start.y); + const goalIndex = indexOf(goal.x, goal.y); + if (startIndex === goalIndex) return [[start.x, start.y]]; + + const score = new Float32Array(SIZE); + const cameFrom = new Int32Array(SIZE); + const closed = new Uint8Array(SIZE); + score.fill(INF); + cameFrom.fill(-1); + + const heap = new MinHeap(); + score[startIndex] = 0; + heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 3) { + const current = heap.pop(); + if (!current || closed[current.i]) continue; + closed[current.i] = 1; + + if (current.i === goalIndex) { + const path = []; + let p = goalIndex; + while (p !== -1) { + const [x, y] = xyOf(p); + path.push([x, y]); + if (p === startIndex) break; + p = cameFrom[p]; + } + return path.reverse(); + } + + const [cx, cy] = xyOf(current.i); + for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) { + const nextIndex = indexOf(nx, ny); + if (closed[nextIndex]) continue; + const cost = costAt(nx, ny, cx, cy); + if (cost >= INF) continue; + const nextScore = score[current.i] + cost * stepDistance; + if (nextScore < score[nextIndex]) { + score[nextIndex] = nextScore; + cameFrom[nextIndex] = current.i; + heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 }); + } + } + } + return []; +} + +function influenceFromPaths(paths, radius) { + const grid = new Float32Array(SIZE); + for (const path of paths) { + for (const [x, y] of path) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], 1 / (1 + d)); + } + } + } + } + return grid; +} + +function pointKey(p) { + return `${p.x},${p.y}`; +} + +function getDegree(degreeMap, p) { + return degreeMap.get(pointKey(p)) || 0; +} + +function incrementDegree(degreeMap, p) { + degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1); +} + +function nearestConnectable(points, target, degreeMap, maxDegree = 3) { + if (!points.length) return null; + const sorted = points + .map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) })) + .sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7)); + return sorted.find((p) => p.degree < maxDegree) || sorted[0]; +} + +function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) { + if (!grid) return 0; + const value = grid[indexOf(x, y)]; + if (value <= 0.0001) return 0; + + const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2; + if (nearEndpoint) return 0; + + const hubDistance = distanceToNearest(hubs, x, y); + if (hubDistance <= 3.5) return 0; + if (hubDistance <= 7.5) return value * strength * 0.28; + return value * strength; +} + +function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) { + if (!points || points.length === 0) return 0; + if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0; + const d = distanceToNearest(points, x, y); + if (d >= radius) return 0; + return (radius - d) * strength; +} + +function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) { + const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null; + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + return base + + corridorPenalty(grid, x, y, hubs, endpoints, strength) + + nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength); + }; +} + +function pathLength(path) { + let total = 0; + for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + return total; +} + +function pathEndpointDistance(path) { + if (!path || path.length < 2) return 0; + const a = path[0]; + const b = path[path.length - 1]; + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +function pathCompactness(path) { + const direct = pathEndpointDistance(path); + if (direct <= 0.001) return INF; + return pathLength(path) / direct; +} + +function pathOverlapRatio(path, existingPaths, radius = 2) { + if (!path?.length || !existingPaths?.length) return 0; + const grid = influenceFromPaths(existingPaths, radius); + let overlap = 0; + for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++; + return overlap / Math.max(1, path.length); +} + +function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) { + const kept = []; + for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) { + if (pathLength(path) < minLength) continue; + if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue; + kept.push(path); + if (kept.length >= maxCount) break; + } + paths.splice(0, paths.length, ...kept); +} + +function bresenhamCells(a, b) { + const cells = []; + let x0 = a[0]; + let y0 = a[1]; + const x1 = b[0]; + const y1 = b[1]; + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + cells.push([x0, y0]); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + return cells; +} + +function smoothPathByLineOfSight(path, passable, maxSegment = 9) { + if (!path || path.length < 3) return path || []; + const out = [path[0]]; + let i = 0; + while (i < path.length - 1) { + let best = i + 1; + const limit = Math.min(path.length - 1, i + maxSegment); + for (let j = limit; j > i + 1; j--) { + const cells = bresenhamCells(path[i], path[j]); + if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; } + } + for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell); + i = best; + } + return out; +} + +function averagePathField(path, field) { + if (!path?.length) return 0; + let sum = 0; + for (const [x, y] of path) sum += field[indexOf(x, y)] || 0; + return sum / path.length; +} + +function influenceFromPoints(points, radius, weightFn = () => 1) { + const grid = new Float32Array(SIZE); + for (const p of points) { + const weight = weightFn(p); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = p.x + dx; + const ny = p.y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], weight / (1 + d)); + } + } + } + return grid; +} + +function samplePath(path, step) { + const out = []; + for (let i = step; i < path.length - step; i += step) { + const [x, y] = path[i]; + out.push({ x, y, score: 1 }); + } + return out; +} + +function smoothMask(mask, passes = 2) { + let current = new Uint8Array(mask); + for (let pass = 0; pass < passes; pass++) { + const next = new Uint8Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + let count = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (current[indexOf(x + dx, y + dy)]) count++; + } + } + if (count >= 5) next[i] = 1; + else if (count <= 3) next[i] = 0; + } + } + current = next; + } + return current; +} + +function largestConnectedMask(mask) { + const seen = new Uint8Array(SIZE); + let best = []; + const queue = []; + + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + const component = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + + if (component.length > best.length) best = component; + } + + const out = new Uint8Array(SIZE); + for (const i of best) out[i] = 1; + return out; +} + +function componentCount(mask) { + const seen = new Uint8Array(SIZE); + const queue = []; + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + count++; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const [x, y] = xyOf(queue[q]); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + return count; +} + + +function makePrefectureMask(seed, sea, elevation, slope, river) { + const candidates = []; + for (let y = 8; y < MAP_H - 8; y++) { + for (let x = 8; x < MAP_W - 8; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72; + const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08); + candidates.push({ x, y, score }); + } + } + + const regionSeeds = pickEntities(candidates, { + max: 1, + minDistance: 18, + threshold: 0.35, + seed: seed + 904, + jitter: 0.02, + }); + + const mask = new Uint8Array(SIZE); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0); + const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08)); + + for (const s of regionSeeds) { + const i = indexOf(s.x, s.y); + dist[i] = 0; + heap.push({ i, f: 0 }); + } + + let claimed = 0; + while (heap.length > 0 && claimed < target) { + const current = heap.pop(); + if (!current) continue; + const ci = current.i; + if (current.f > dist[ci] + 1e-5 || mask[ci]) continue; + const [cx, cy] = xyOf(ci); + if (sea[ci]) continue; + + mask[ci] = 1; + claimed++; + + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni] || mask[ni]) continue; + const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0; + const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2; + const slopePenalty = slope[ni] * 4.1; + const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0; + const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step; + const nd = dist[ci] + cost; + if (nd < dist[ni]) { + dist[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + + return largestConnectedMask(smoothMask(mask, 2)); +} + +function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { + const centers = []; + let sx = 0; + let sy = 0; + let sc = 0; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (anchorMask[i]) { sx += x; sy += y; sc++; } + } + } + if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" }); + + const candidates = []; + const ax = centers[0]?.x ?? MAP_W / 2; + const ay = centers[0]?.y ?? MAP_H / 2; + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i] || anchorMask[i]) continue; + const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5)); + const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H); + const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08; + const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06; + candidates.push({ x, y, score, kind: "Neighbor Prefecture" }); + } + } + centers.push(...pickEntities(candidates, { + max: 9 + Math.floor(rand(seed, 6101) * 6), + minDistance: 22, + threshold: 0.38, + seed: seed + 6102, + jitter: 0.02, + })); + + const regionId = new Int16Array(SIZE); + regionId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + centers.forEach((center, id) => { + const i = indexOf(center.x, center.y); + if (sea[i]) return; + regionId[i] = id; + dist[i] = 0; + heap.push({ i, f: 0 }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 16) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [cx, cy] = xyOf(cur.i); + const curRegion = regionId[cur.i]; + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]); + const riverBarrier = Math.max(river[ni], river[cur.i]); + const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8; + const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7; + const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0; + const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step; + const nd = dist[cur.i] + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + regionId[ni] = curRegion; + heap.push({ i: ni, f: nd }); + } + } + } + return { regionId, centers }; +} + +function extractRegionBorderSegments(regionId, sea) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i] || regionId[i] < 0) continue; + const a = regionId[i]; + if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) { + const b = regionId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) { + const b = regionId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +function extractMaskBorder(mask, sea = null) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const a = mask[i]; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +function extractAdminBorderSegments(adminId, prefectureMask) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i]) continue; + const a = adminId[i]; + if (a < 0) continue; + if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) { + const b = adminId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) { + const b = adminId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +function tagInsidePrefecture(points, prefectureMask) { + return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); +} + +function defaultName(seed, id, kind) { + const prefixKey = id.split("-")[0]; + const n = Number(id.split("-")[1] || 0); + const prefixes = NAME_PARTS.prefixes || [""]; + const infixes = NAME_PARTS.infixes || [""]; + const suffixes = NAME_PARTS.suffixes || [""]; + const prefix = prefixes[(seed + n * 7) % prefixes.length]; + const useInfix = hash2(n + prefixKey.length * 17, seed + n * 31, seed + 2777) >= 0.7; + const infix = useInfix ? infixes[(seed * 3 + n * 11) % infixes.length] : ""; + const suffixWord = suffixes[(seed * 5 + n * 13 + prefixKey.length) % suffixes.length]; + const kindSuffix = KIND_SUFFIXES[prefixKey] || kind || ""; + return `${prefix}${infix}${suffixWord}${kindSuffix}`; +} + +function attachIdsAndNames(points, prefix, seed, kindOverride = null) { + return points.map((p, i) => { + const id = `${prefix}-${i}`; + const kind = kindOverride || p.kind; + return { + ...p, + id, + name: CUSTOM_NAMES[id] || defaultName(seed + prefix.length * 1000, id, kind), + insidePrefecture: Boolean(p.insidePrefecture), + }; + }); +} + +function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + + centers.forEach((center, regionId) => { + const i = indexOf(center.x, center.y); + dist[i] = 0; + adminId[i] = regionId; + heap.push({ i, f: 0, regionId }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 12) { + const current = heap.pop(); + if (!current) continue; + const curIndex = current.i; + const curRegion = adminId[curIndex]; + if (curRegion < 0) continue; + if (current.f > dist[curIndex] + 1e-5) continue; + + const [cx, cy] = xyOf(curIndex); + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + + const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); + const riverBarrier = Math.max(river[ni], river[curIndex]); + const ridgePenalty = Math.max(0, elevation[ni] - 0.40) * 9.4 + Math.abs(elevation[ni] - elevation[curIndex]) * 8.8 + ridgeBarrier * 14.2; + const slopePenalty = slope[ni] * 7.8; + const riverPenalty = riverBarrier > 0.7 ? 13.0 : riverBarrier > 0.42 ? 8.2 : riverBarrier > 0.22 ? 3.8 : 0; + const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.95 : 0; + const valleyLocalityBonus = valleyField[ni] * 0.03; + const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty - valleyLocalityBonus - urbanContinuityBonus) * step; + const nextDist = dist[curIndex] + stepCost; + + if (nextDist < dist[ni]) { + dist[ni] = nextDist; + adminId[ni] = curRegion; + heap.push({ i: ni, f: nextDist, regionId: curRegion }); + } + } + } + + return adminId; +} + +function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { + return clamp( + ridgeField[i] * 2.75 + + river[i] * 2.05 + + slope[i] * 1.18 + + Math.max(0, elevation[i] - 0.5) * 1.05 - + valleyField[i] * 0.10 + ); +} + +function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { + let current = new Int16Array(adminId); + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!prefectureMask[i] || sea[i] || own < 0) continue; + const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; + const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); + if (barrier > 0.62 || urbanCell) continue; + + const counts = new Map(); + let ownCount = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const id = current[ni]; + if (id < 0) continue; + const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; + counts.set(id, (counts.get(id) || 0) + weight); + if (id === own) ownCount += weight; + } + let bestId = own; + let best = ownCount; + for (const [id, score] of counts) { + if (score > best) { best = score; bestId = id; } + } + if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; + } + } + current = next; + } + adminId.set(current); +} + +function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { + const seen = new Uint8Array(SIZE); + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; + if (!isUrbanStart) continue; + const component = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; + if (!isUrban) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (component.length === 0 || component.length > maxCells) continue; + const counts = new Map(); + for (const ci of component) { + const id = adminId[ci]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); + } + let bestId = -1; + let best = -1; + for (const [id, score] of counts) { + if (score > best) { best = score; bestId = id; } + } + if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; + } +} + + +function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) { + const area = new Map(); + const pop = new Map(); + const adjacency = new Map(); + const cityMunicipalities = new Set(); + for (const city of modernCities || []) { + if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]); + } + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + if (id < 0) continue; + area.set(id, (area.get(id) || 0) + 1); + pop.set(id, (pop.get(id) || 0) + populationDensity[i]); + for (const [nx, ny] of [[x+1,y],[x,y+1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other < 0 || other === id) continue; + const key = id < other ? `${id}:${other}` : `${other}:${id}`; + adjacency.set(key, (adjacency.get(key) || 0) + 1); + } + } + } + const mergeTarget = new Map(); + for (const [id, cells] of area) { + const score = cells + (pop.get(id) || 0) * 16; + if (cells >= minArea || cityMunicipalities.has(id)) continue; + let bestNeighbor = -1; + let bestScore = -1; + for (const [key, border] of adjacency) { + const [a, b] = key.split(':').map(Number); + if (a !== id && b !== id) continue; + const other = a === id ? b : a; + const otherArea = area.get(other) || 0; + const otherPop = pop.get(other) || 0; + const candidate = border * 3 + otherArea * 0.012 + otherPop * 0.24; + if (candidate > bestScore) { + bestScore = candidate; + bestNeighbor = other; + } + } + if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor); + } + if (mergeTarget.size === 0) return; + for (let i = 0; i < SIZE; i++) { + const id = adminId[i]; + if (mergeTarget.has(id)) adminId[i] = mergeTarget.get(id); + } +} + +export function generateMap(seedInput = 114514) { + const seed = Number(seedInput) >>> 0; + + let prefectureMask; + let prefectureBorder; + + const elevation = new Float32Array(SIZE); + const moisture = new Float32Array(SIZE); + const slope = new Float32Array(SIZE); + const sea = new Uint8Array(SIZE); + const river = new Float32Array(SIZE); + const floodplain = new Float32Array(SIZE); + const plain = new Float32Array(SIZE); + const agriculture = new Float32Array(SIZE); + const ridgeField = new Float32Array(SIZE); + const valleyField = new Float32Array(SIZE); + const basinField = new Float32Array(SIZE); + const coastalLowland = new Float32Array(SIZE); + const flowAccum = new Float32Array(SIZE); + const erosionField = new Float32Array(SIZE); + const depositionField = new Float32Array(SIZE); + const flowTo = new Int32Array(SIZE); + flowTo.fill(-1); + const portSuitability = new Float32Array(SIZE); + const crossingSuitability = new Float32Array(SIZE); + const passSuitability = new Float32Array(SIZE); + + const coastAngle = rand(seed, 11) * Math.PI * 2; + const coastX = Math.cos(coastAngle); + const coastY = Math.sin(coastAngle); + const coastThreshold = 0.22 + rand(seed, 12) * 0.22; + const coastStrength = 0.15 + rand(seed, 13) * 0.23; + + const seaLevel = 0.285; + + const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ + x: rand(seed, 100 + i) * MAP_W, + y: rand(seed, 200 + i) * MAP_H, + r: 10 + rand(seed, 300 + i) * 24, + h: 0.08 + rand(seed, 400 + i) * 0.16, + })); + + const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ + x: rand(seed, 1500 + i) * MAP_W, + y: rand(seed, 1600 + i) * MAP_H, + angle: rand(seed, 1700 + i) * Math.PI * 2, + width: 3 + rand(seed, 1800 + i) * 7, + length: 42 + rand(seed, 1900 + i) * 92, + h: 0.11 + rand(seed, 2000 + i) * 0.22, + })); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const i = indexOf(x, y); + + const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13; + const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13; + const wx = x + warpX; + const wy = y + warpY; + + let mountains = 0; + for (const blob of mountainBlobs) { + const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; + mountains += Math.exp(-d * d * 2.35) * blob.h; + } + + let ridges = 0; + for (const ridge of ridgeBands) { + const dx = wx - ridge.x; + const dy = wy - ridge.y; + const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); + const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); + const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); + const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; + ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; + } + + const directionalCoast = nx * coastX + ny * coastY; + const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; + const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); + // Four terrain-noise bands from continental structure to fine surface roughness. + const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710); + const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777); + const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777); + const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5); + const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035; + const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI); + const rawElevation = + 0.30 * terrainLarge + + 0.235 * terrainRegional + + 0.105 * terrainLocal + + 0.055 * terrainFine + + mountains * 0.54 + + ridges * 1.22 + + basin + + fineDissection - + coastLower * (coastStrength + 0.19) + + 0.055; + + elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); + ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); + basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7); + moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22); + } + } + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const directionalCoast = nx * coastX + ny * coastY; + const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; + const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; + if (elevation[i] < seaLevel || oceanSide) sea[i] = 1; + if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012); + } + } + + // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs + // when the directional coastline cuts through a high terrain cell. + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let nearestSea = INF; + for (let dy = -7; dy <= 7; dy++) { + for (let dx = -7; dx <= 7; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; + nearestSea = Math.min(nearestSea, Math.hypot(dx, dy)); + } + } + if (nearestSea <= 7) { + const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; + elevation[i] = Math.min(elevation[i], coastalCap); + coastalLowland[i] = clamp(1 - nearestSea / 7); + } + } + } + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + } + } + + const landOrder = []; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let low = i; + let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); + let localMean = 0; + let localMax = elevation[i]; + let localMin = elevation[i]; + let nCount = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const ev = elevation[ni]; + localMean += ev; + localMax = Math.max(localMax, ev); + localMin = Math.min(localMin, ev); + nCount++; + const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); + if (directed < best || sea[ni]) { + best = directed; + low = ni; + } + } + if (low !== i) flowTo[i] = low; + localMean /= Math.max(1, nCount); + const hollow = Math.max(0, localMean - elevation[i]); + const relief = localMax - localMin; + valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); + basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); + flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; + landOrder.push(i); + } + } + landOrder.sort((a, b) => elevation[b] - elevation[a]); + for (const i of landOrder) { + const to = flowTo[i]; + if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; + } + let maxFlowAccum = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); + if (maxFlowAccum > 0) { + for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); + } + for (let i = 0; i < SIZE; i++) { + if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); + } + + // First-order fluvial shaping: cut valley floors on steep/high-flow cells and + // deposit gently in coastal lowlands and basin floors. This gives visible + // river valleys without destroying the macro terrain structure. + const shapedElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const flow = Math.pow(flowAccum[i], 0.46); + const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; + const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); + const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); + const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); + erosionField[i] = steepValley + lateralCut; + depositionField[i] = lowSettling; + shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); + } + } + elevation.set(shapedElevation); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); + basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); + } + } + + const sourceCandidates = []; + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; + if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); + } + } + + const sources = pickEntities(sourceCandidates, { + max: 20 + Math.floor(rand(seed, 910) * 28), + minDistance: 8, + threshold: 0.53 + rand(seed, 911) * 0.11, + seed, + }); + + function nearestWaterGoal(from) { + let bestSea = null; + let bestScore = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!sea[i]) continue; + const d = Math.hypot(x - from.x, y - from.y); + const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; + if (score < bestScore) { + bestScore = score; + bestSea = { x, y }; + } + } + } + return bestSea; + } + + function riverRouteCost(x, y, cx, cy) { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.18; + const uphill = Math.max(0, elevation[i] - elevation[ci]); + const downhill = Math.max(0, elevation[ci] - elevation[i]); + if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; + return Math.max( + 0.18, + 1 + + uphill * 86 + + slope[i] * 0.38 + + elevation[i] * 0.42 - + downhill * 2.1 - + valleyField[i] * 0.92 - + flowAccum[i] * 0.72 - + moisture[i] * 0.18 - + coastalLowland[i] * 0.22 + ); + } + + function forceRiverToWater(path) { + if (!path.length) return path; + const [ex, ey] = path[path.length - 1]; + if (sea[indexOf(ex, ey)]) return path; + const goal = nearestWaterGoal({ x: ex, y: ey }); + if (!goal) return path; + const startElevation = elevation[indexOf(ex, ey)]; + const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; + return riverRouteCost(x, y, cx, cy); + }); + if (tail.length <= 2) return path; + return path.concat(tail.slice(1)); + } + + function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { + if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; + let best = 0.16; + const inLen = Math.hypot(dx, dy) || 1; + for (const [rx, ry] of neighbors8(nx, ny)) { + if (river[indexOf(rx, ry)] < 0.22) continue; + const rdx = rx - nx; + const rdy = ry - ny; + const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); + const angle = Math.acos(cos); + const shallow = angle < 0.45 ? 0.28 : 0; + best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); + } + return best; + } + + function traceRiverPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + let accum = 0; + + for (let step = 0; step < 600; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; + accum += river[i] + flowAccum[i]; + if (sea[i]) break; + + let best = null; + let bestValue = INF; + const currentElevation = elevation[i]; + const preferred = flowTo[i]; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = currentElevation - elevation[ni]; + const uphill = Math.max(0, -drop); + if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; + let surrounding = 0; + let surroundingCount = 0; + for (const [vx, vy] of neighbors8(nx, ny)) { + surrounding += elevation[indexOf(vx, vy)]; + surroundingCount++; + } + const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const straightPenalty = Math.max(0, sameDirection) * 0.075; + const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; + const sideSwing = Math.abs(dx * lastDy - dy * lastDx); + const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; + const meander = sideSwing * (0.032 + meanderPhase * 0.026); + const flowBonus = ni === preferred ? 0.62 : 0; + const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); + const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; + const value = + elevation[ni] * 1.45 + + uphill * 88 - + Math.max(0, drop) * 2.05 - + valley * 1.05 - + valleyField[ni] * 1.72 - + flowAccum[ni] * 0.94 - + moisture[ni] * 0.14 - + coastalLowland[ni] * 0.28 - + (river[ni] > 0 ? 0.22 : 0) - + flowBonus + + slope[ni] * 0.04 + + straightPenalty + + turnPenalty + + junctionPenalty * 1.35 - + meander + + noise - + (sea[ni] ? 0.6 : 0); + + if (value < bestValue) { + bestValue = value; + best = [nx, ny, dx, dy]; + } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + + const forced = forceRiverToWater(path); + if (forced.length > path.length) { + for (const [rx, ry] of forced.slice(path.length)) { + const ri = indexOf(rx, ry); + river[ri] += 0.32 + flowAccum[ri] * 0.4; + accum += river[ri] + flowAccum[ri]; + } + } + return { path: forced, accum }; + } + + function traceSmallStreamPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + for (let step = 0; step < 160; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.12 + flowAccum[i] * 0.18; + if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; + let best = null; + let bestValue = INF; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = elevation[i] - elevation[ni]; + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; + if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + return path; + } + + const riverPaths = []; + const riverScores = []; + for (const source of sources) { + const { path, accum } = traceRiverPath(source.x, source.y, 0); + if (path.length > 6) { + riverPaths.push(path); + riverScores.push(path.length + accum * 0.18); + } + } + + const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributarySources = pickEntities(sourceCandidates + .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) + .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { + max: 14 + Math.floor(rand(seed, 915) * 20), + minDistance: 6, + threshold: 0.45, + seed: seed + 916, + jitter: 0.02, + }); + for (const source of tributarySources) { + const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); + if (path.length > 8) { + riverPaths.push(path); + riverScores.push(path.length * 0.7 + accum * 0.12); + } + } + + const streamPaths = []; + const streamSources = pickEntities(sourceCandidates + .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) + .filter((p) => p.score > 0.18), { + max: 22 + Math.floor(rand(seed, 919) * 20), + minDistance: 4, + threshold: 0.18, + seed: seed + 919, + jitter: 0.015, + }); + for (const source of streamSources) { + const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); + if (path.length > 4) streamPaths.push(path); + } + + if (riverPaths.length === 0 && sourceCandidates.length > 0) { + const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; + let bestSea = null; + let bestSeaDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - fallback.x, y - fallback.y); + if (d < bestSeaDist) { + bestSeaDist = d; + bestSea = { x, y }; + } + } + } + if (bestSea) { + const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.25; + const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; + const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; + return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); + }); + if (fallbackPath.length > 6) { + let accum = 0; + for (const [x, y] of fallbackPath) { + const i = indexOf(x, y); + river[i] += 0.42; + accum += river[i]; + } + riverPaths.push(fallbackPath); + riverScores.push(fallbackPath.length + accum * 0.18); + } + } + } + + function sanitizeDownhillRiverPath(path, tolerance = 0.040) { + if (!path || path.length < 2) return path || []; + const out = [path[0]]; + for (let k = 1; k < path.length; k++) { + const [px, py] = out[out.length - 1]; + const [x, y] = path[k]; + const pi = indexOf(px, py); + const i = indexOf(x, y); + if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break; + out.push(path[k]); + if (sea[i]) break; + } + return out.length >= 2 ? out : []; + } + function trimMountainHeadwaters(path) { + if (!path || path.length < 4) return path || []; + let start = 0; + while (start < path.length - 3) { + const [x, y] = path[start]; + const i = indexOf(x, y); + if (sea[i]) break; + if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break; + start++; + } + return path.slice(start); + } + for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032); + for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); + for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026); + for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); + river.fill(0); + for (const path of riverPaths) { + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55; + } + } + for (const path of streamPaths) { + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + river[i] += 0.11 + flowAccum[i] * 0.18; + } + } + + const expandedRiver = new Float32Array(river); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (river[i] <= 0) continue; + for (const [nx, ny] of neighbors8(x, y)) { + expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); + } + } + } + river.set(expandedRiver); + + // Second fluvial pass uses the actual traced river network. Main channels cut + // visible V-shaped valleys; lower reaches accumulate alluvial deposits. + const fluvialElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i] || river[i] <= 0.02) continue; + const r = clamp(river[i] / 3.4); + const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); + const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); + const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); + erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); + depositionField[i] = clamp(depositionField[i] + alluvium); + fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); + valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); + basinField[i] = clamp(basinField[i] + alluvium * 3.2); + } + } + // Lateral valley carving around the traced river network deepens valleys and + // makes ridge/valley contrast legible at the map scale. + for (const path of riverPaths) { + for (const [rx, ry] of path) { + const ri = indexOf(rx, ry); + const r = clamp(river[ri] / 3.0); + const radius = r > 0.48 ? 2 : 1; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = rx + dx; + const ny = ry + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > radius || d === 0) continue; + const weight = (radius + 0.35 - d) / (radius + 0.35); + const highRidgeGuard = clamp((elevation[ni] - 0.68) / 0.18) * clamp(ridgeField[ni] * 1.2); + const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22) * (1 - highRidgeGuard * 0.72); + fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1); + erosionField[ni] = clamp(erosionField[ni] + carve * 3.0); + valleyField[ni] = clamp(valleyField[ni] + carve * 12.0); + } + } + } + } + + // Restore rugged summit relief after strong river incision. This prevents highlands + // from becoming unnaturally flat or visually concave while keeping valleys cut. + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const high = clamp((fluvialElevation[i] - 0.62) / 0.26); + const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8); + const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035; + const uplift = summit * (0.018 + Math.max(0, rugged)); + if (uplift > 0) { + fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1); + erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); + } + } + } + + elevation.set(fluvialElevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2); + } + } + + // Re-trim visible river paths after fluvial reshaping changes local elevation. + for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028); + for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); + for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022); + for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); + + const mainRivers = riverPaths + .map((p, i) => ({ path: p, score: riverScores[i] })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(6, riverPaths.length)) + .map((x) => x.path); + + if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); + if (mainRivers.length === 0) { + let start = null; + let startScore = -INF; + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; + if (score > startScore) { + startScore = score; + start = { x, y }; + } + } + } + if (start) { + let goal = null; + let goalDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - start.x, y - start.y); + if (d < goalDist) { + goalDist = d; + goal = { x, y }; + } + } + } + if (goal) { + const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.2; + const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; + const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; + return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); + }); + if (fallbackPath.length > 4) { + riverPaths.push(fallbackPath); + mainRivers.push(fallbackPath); + for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; + } + } + } + } + + const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); + const smallStreams = streamPaths.filter((path) => path.length >= 5); + + prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); + prefectureBorder = extractMaskBorder(prefectureMask, sea); + const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); + const prefectureRegionId = regionalPrefectures.regionId; + const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const low = 1 - clamp((elevation[i] - 0.28) / 0.4); + const flat = 1 - slope[i]; + const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55; + plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0)); + + let nearRiver = 0; + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy))); + } + } + + const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55)); + floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22); + agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); + } + } + + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let seaNear = 0; + let riverNear = 0; + let sheltered = 0; + + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d); + riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d)); + } + } + + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1; + } + } + + const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18; + const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; + portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); + } + } + + for (let y = 3; y < MAP_H - 3; y++) { + for (let x = 3; x < MAP_W - 3; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const r = river[i]; + if (r < 0.2 || r > 1.85) continue; + let bankPlain = 0; + for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)]; + crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06); + } + } + + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const e = elevation[i]; + if (e < 0.43 || e > 0.82) continue; + const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2; + const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2; + const diagLow = Math.min( + elevation[indexOf(x - 3, y - 3)], + elevation[indexOf(x + 3, y + 3)], + elevation[indexOf(x - 3, y + 3)], + elevation[indexOf(x + 3, y - 3)] + ); + passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2); + } + } + + function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { + const candidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (!predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08; + if (score >= threshold) candidates.push({ x, y, score }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + let ports = pickPoints(portSuitability, { + threshold: 0.3 + rand(seed, 1001) * 0.08, + max: 3 + Math.floor(rand(seed, 1002) * 7), + minDistance: 10, + seedOffset: 1000, + predicate: (x, y, i) => !sea[i], + }).map((p) => { + const i = indexOf(p.x, p.y); + let seaEdge = 0; + for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { + const nx = p.x + dx; + const ny = p.y + dy; + if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy)); + } + const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2; + return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" }; + }).sort((a, b) => b.harborPotential - a.harborPotential) + .map((p, n) => { + const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32; + const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing"; + const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; + return { ...p, portClass, kind, score: p.harborPotential }; + }); + if (!ports.some((p) => p.portClass === "major")) { + const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0]; + if (fallbackMajor) { + fallbackMajor.portClass = "major"; + fallbackMajor.kind = "Major Port"; + fallbackMajor.score += 0.16; + } + } + const majorPorts = ports.filter((p) => p.portClass === "major"); + const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); + + let crossings = pickPoints(crossingSuitability, { + threshold: 0.28 + rand(seed, 1011) * 0.08, + max: 8 + Math.floor(rand(seed, 1012) * 15), + minDistance: 8, + seedOffset: 1010, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "River Crossing" })); + + let passes = pickPoints(passSuitability, { + threshold: 0.16 + rand(seed, 1021) * 0.08, + max: 4 + Math.floor(rand(seed, 1022) * 10), + minDistance: 9, + seedOffset: 1020, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Pass" })); + + const settlementScore = new Float32Array(SIZE); + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let nearFeature = 0; + for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); + const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); + const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; + settlementScore[i] = clamp(agriculture[i] * 0.55 + plain[i] * 0.16 + nearFeature * 0.24 + riverPull + basinField[i] * 0.12 + mountainVillage - slope[i] * 0.42 - ridgeField[i] * 0.2 - floodplain[i] * 0.06); + } + } + + let villages = pickPoints(settlementScore, { + threshold: 0.32 + rand(seed, 1031) * 0.1, + max: 28 + Math.floor(rand(seed, 1032) * 44), + minDistance: 4 + Math.floor(rand(seed, 1033) * 4), + seedOffset: 1030, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Village" })); + + const marketScore = new Float32Array(SIZE); + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + + let villagePull = 0; + let nearbyVillages = 0; + for (const v of villages) { + const d = Math.hypot(x - v.x, y - v.y); + if (d < 24) { + villagePull += 1 / (1 + d); + nearbyVillages++; + } + } + + let featurePull = 0; + for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3)); + const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0; + marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); + } + } + + let markets = pickPoints(marketScore, { + threshold: 0.2 + rand(seed, 1041) * 0.08, + max: 6 + Math.floor(rand(seed, 1042) * 12), + minDistance: 11, + seedOffset: 1040, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Market Town" })); + + const defenseScore = new Float32Array(SIZE); + for (let y = 3; y < MAP_H - 3; y++) { + for (let x = 3; x < MAP_W - 3; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24); + let riverArms = 0; + for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++; + const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0; + const roadJunctionProxy = ( + (distanceToNearest(markets, x, y) < 7 ? 1 : 0) + + (distanceToNearest(crossings, x, y) < 6 ? 1 : 0) + + (distanceToNearest(passes, x, y) < 7 ? 1 : 0) + + (distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0) + ) >= 2 ? 0.32 : 0; + const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0; + const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0; + const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0; + defenseScore[i] = validCastleSite + ? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12) + : 0; + } + } + + let castles = pickPoints(defenseScore, { + threshold: 0.34 + rand(seed, 1051) * 0.08, + max: 2 + Math.floor(rand(seed, 1052) * 4), + minDistance: 15, + seedOffset: 1050, + predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0, + }).map((p) => ({ + ...p, + kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle", + })); + + function normalEdgePenalty(x, y) { + if (nearMapEdge(x, y, 1)) return INF; + if (nearMapEdge(x, y, 2)) return 7; + if (nearMapEdge(x, y, 4)) return 2.8; + return 0; + } + + function premodernCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0; + const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0; + const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0; + const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0; + return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16); + } + + const premodernRoads = []; + function addPremodernRoad(a, b) { + const path = aStar(a, b, premodernCost); + if (path.length > 3) premodernRoads.push(path); + } + + for (const castle of castles) { + const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 }); + for (const p of near) addPremodernRoad(castle, p); + } + for (const market of markets) { + const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 }); + for (const p of near) addPremodernRoad(market, p); + } + + let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); + const cityCandidates = [ + ...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })), + ...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })), + ...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })), + ]; + + let modernCities = pickEntities(cityCandidates, { + max: 7 + Math.floor(rand(seed, 1061) * 10), + minDistance: 9, + threshold: 0.33 + rand(seed, 1062) * 0.12, + seed: seed + 1060, + }).map((p, n) => { + const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City"; + const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y); + const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; + const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000; + const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000; + const pi = indexOf(p.x, p.y); + const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + (p.kind === "Port Town" ? 0.22 : 0)); + const population = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; + const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32); + const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9); + const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05); + return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" }; + }); + + if (modernCities.length > 0) { + modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000)); + modernCities[0] = { + ...modernCities[0], + rank: "Prefectural Capital", + kind: "Prefectural Capital", + isPrefecturalCapital: true, + population: Math.max(modernCities[0].population || 0, 620000), + urbanRadius: Math.max(modernCities[0].urbanRadius || 0, 18), + coreRadius: Math.max(modernCities[0].coreRadius || 0, 5.5), + urbanWeight: Math.max(modernCities[0].urbanWeight || 0, 2.15), + }; + for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; + } + + const capital = modernCities[0] || markets[0] || ports[0] || { x: Math.floor(MAP_W / 2), y: Math.floor(MAP_H / 2), score: 1, population: 0, urbanRadius: 12, coreRadius: 4, urbanWeight: 1, isPrefecturalCapital: true }; + + const populationDensity = new Float32Array(SIZE); + let maxPopulationDensity = 0; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let density = 0; + for (const city of modernCities) { + const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55); + const d = Math.hypot(city.x - x, city.y - y); + const urbanR = Math.max(5, city.urbanRadius || 11); + const coreR = Math.max(2.4, city.coreRadius || 4); + density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35)); + density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2)); + } + for (const market of markets) { + const d = Math.hypot(market.x - x, market.y - y); + density += 0.22 / (1 + Math.pow(d / 7.5, 2.2)); + } + for (const village of villages) { + const d = Math.hypot(village.x - x, village.y - y); + density += 0.055 / (1 + Math.pow(d / 4.2, 2)); + } + density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22); + populationDensity[i] = density; + if (density > maxPopulationDensity) maxPopulationDensity = density; + } + } + if (maxPopulationDensity > 0) { + for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity); + } + + function densityValue(x, y) { + return populationDensity[indexOf(x, y)] || 0; + } + + function midDensityAffinity(x, y) { + const d = densityValue(x, y); + return clamp(1 - Math.abs(d - 0.38) / 0.38); + } + + function nearPassPoint(x, y, radius = 5) { + return distanceToNearest(passes, x, y) <= radius; + } + + function mountainBarrierPenalty(x, y, type = "rail") { + const i = indexOf(x, y); + const e = elevation[i]; + const s = slope[i]; + const pass = nearPassPoint(x, y, type === "express" ? 7 : 5); + if (e > 0.86) return INF; + if (pass && e > 0.82 && s > 0.16) return INF; + if (!pass && e > 0.80) return INF; + if (!pass && e > 0.72 && s > 0.18) return INF; + if (!pass && e > 0.68 && s > 0.32) return INF; + if (!pass && e > 0.74) return type === "express" ? 175 : 215; + if (!pass && e > 0.66 && s > 0.22) return type === "express" ? 105 : 135; + const passDiscount = pass ? 0.18 : 1; + const mountain = Math.max(0, e - 0.50); + const steep = Math.max(0, s - 0.18); + const typeFactor = type === "express" ? 260 : type === "rail" ? 320 : 120; + return (mountain * mountain * typeFactor + steep * steep * 120) * passDiscount; + } + + function transportAccessPoint(node, mode = "road", salt = 0) { + if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node; + const minR = mode === "express" ? 10 : mode === "rail" ? 2 : 4; + const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; + let best = null; + let bestScore = -INF; + for (let dy = -maxR; dy <= maxR; dy++) { + for (let dx = -maxR; dx <= maxR; dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const x = node.x + dx; + const y = node.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : 0; + if (barrier >= INF) continue; + const targetD = (minR + maxR) * 0.5; + const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35; + const ring = -Math.abs(d - targetD) * 0.08; + const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; + const density = densityValue(x, y); + const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; + const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; + const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; + if (score > bestScore) { + bestScore = score; + best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node }; + } + } + } + return best || node; + } + + function routePoint(node, mode, salt = 0) { + return transportAccessPoint(node, mode, salt); + } + + const townAvoidNodes = [...modernCities, ...markets, ...ports]; + + const urbanCenters = modernCities.map((city, n) => { + let best = { x: city.x, y: city.y, score: city.score + 0.5 }; + let bestScore = -INF; + const searchR = Math.max(2, Math.round(city.coreRadius)); + for (let dy = -searchR; dy <= searchR; dy++) { + for (let dx = -searchR; dx <= searchR; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const d = Math.hypot(dx, dy); + const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07; + if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; } + } + } + return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) }; + }); + + function railCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "rail"); + if (barrier >= INF) return INF; + const density = densityValue(x, y); + const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier; + const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0; + return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08); + } + + const railways = []; + const branchRailways = []; + const railDegree = new Map(); + const railCore = [capital]; + const railHubs = [...modernCities, ...commercialPorts]; + + function addRailRoute(a, b, bucket = railways) { + const start = routePoint(a, "rail", a.x * 19 + a.y * 23); + const goal = routePoint(b, "rail", b.x * 19 + b.y * 23 + 11); + const existingRails = [...railways, ...branchRailways]; + const cost = makeTransportCost(railCost, existingRails, railHubs, [start, goal], 5, 10.5, townAvoidNodes, 2.4, 4.2); + const path = aStar(start, goal, cost); + const length = pathLength(path); + const direct = pathEndpointDistance(path); + const overlap = pathOverlapRatio(path, existingRails, 2); + const densityPurpose = averagePathField(path, populationDensity) + averagePathField(path, plain) * 0.28 + averagePathField(path, valleyField) * 0.2; + const isMain = bucket === railways; + if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.1 : 3.4) && overlap < (isMain ? 0.30 : 0.20) && densityPurpose > (isMain ? 0.18 : 0.12)) { + bucket.push(path); + incrementDegree(railDegree, a); + incrementDegree(railDegree, b); + return true; + } + return false; + } + + const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000); + const mainRailTargets = transportCities.filter((city) => city !== capital).slice(0, 2 + Math.floor(rand(seed, 1070) * 3)); + for (const city of mainRailTargets) { + const anchor = nearestConnectable(railCore, city, railDegree, 3) || capital; + if (addRailRoute(anchor, city, railways)) railCore.push(city); + } + for (const city of modernCities.filter((city) => city !== capital && !mainRailTargets.includes(city))) { + const anchor = nearestConnectable(railCore, city, railDegree, 2) || capital; + if (anchor && rand(seed, city.x * 10 + city.y) > 0.2) { + if (addRailRoute(city, anchor, branchRailways)) railCore.push(city); + } + } + for (const port of majorPorts.slice(0, 1 + Math.floor(rand(seed, 1071) * 2))) { + const anchor = nearestConnectable(railCore, port, railDegree, 2) || capital; + if (anchor && addRailRoute(port, anchor, branchRailways)) railCore.push(port); + } + + compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); + compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); + + const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5); + const stationCandidates = [ + ...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })), + ...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })), + ...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })), + ]; + + let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 }); + + const industrialScore = new Float32Array(SIZE); + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5); + const nearCity = distanceToNearest(modernCities, x, y); + const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0; + industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03); + } + } + + let industrialZones = pickPoints(industrialScore, { + threshold: 0.31 + rand(seed, 1091) * 0.09, + max: 4 + Math.floor(rand(seed, 1092) * 13), + minDistance: 10, + seedOffset: 1090, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Industrial Zone" })); + + function roadCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const density = densityValue(x, y); + const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; + return Math.max(0.35, 1 + slope[i] * 15.8 + Math.max(0, elevation[i] - 0.56) * 2.6 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.18 - valleyField[i] * 0.24 - coastalLowland[i] * 0.18 + ridgeField[i] * 0.90 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08); + } + + function expresswayCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const density = densityValue(x, y); + const midDensity = midDensityAffinity(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; + const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; + const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); + return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); + } + + const nationalRoads = []; + const roadDegree = new Map(); + const roadTargets = pickEntities([...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets].map((p) => ({ ...p, score: p.score + ((p.population || 0) >= 180000 ? 0.18 : 0.05) })), { + max: 8 + Math.floor(rand(seed, 1101) * 10), + minDistance: 9, + threshold: 0, + seed: seed + 1100, + }); + const roadHubs = [...modernCities, ...ports, ...markets, ...stations]; + const roadCore = [capital]; + + function addNationalRoad(a, b) { + const start = routePoint(a, "road", a.x * 31 + a.y * 37); + const goal = routePoint(b, "road", b.x * 31 + b.y * 37 + 17); + const existing = [...nationalRoads, ...railways, ...branchRailways]; + const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4)); + const direct = pathEndpointDistance(path); + const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length; + const passBonusOk = urbanPasses >= 2 || direct >= 24; + if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { + nationalRoads.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; + } + return false; + } + + for (const target of roadTargets.slice(1, 8 + Math.floor(rand(seed, 1102) * 7))) { + const anchor = nearestConnectable(roadCore, target, roadDegree, 3) || capital; + if (addNationalRoad(anchor, target)) roadCore.push(target); + } + for (let i = 1; i < roadTargets.length - 1; i++) { + const a = roadTargets[i]; + const b = pickEntities(roadTargets.filter((p) => p !== a && getDegree(roadDegree, p) < 4).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - a.x, p.y - a.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + const d = b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; + if (b && d >= 18 && d < 52 && getDegree(roadDegree, a) < 4 && rand(seed, i + 1111) > 0.24) { + addNationalRoad(a, b); + } + } + + // National roads should behave like long trunk corridors: they intentionally + // pass near as many urbanized cells/cities as possible, unlike expressways. + const trunkCities = modernCities + .filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000) + .slice() + .sort((a, b) => a.x - b.x || a.y - b.y); + for (let i = 0; i < trunkCities.length - 1; i += 2) { + const a = trunkCities[i]; + const b = trunkCities[Math.min(trunkCities.length - 1, i + 2)]; + if (a && b && Math.hypot(a.x - b.x, a.y - b.y) >= 22 && getDegree(roadDegree, a) < 5) addNationalRoad(a, b); + } + + const expressTargets = pickEntities(modernCities.filter((p) => p !== capital && (p.population || 0) >= 180000).map((p) => ({ ...p, score: p.score + Math.hypot(p.x - capital.x, p.y - capital.y) / 80 + 0.15 })).concat(majorPorts.map((p) => ({ ...p, score: p.score + 0.55 }))), { + max: 1 + Math.floor(rand(seed, 1120) * 3), + minDistance: 20, + threshold: 0.05, + seed: seed + 1120, + }); + + const expressways = []; + const expressDegree = new Map(); + const expressCore = [capital]; + + function addExpressway(a, b, bucket = expressways) { + const start = routePoint(a, "express", a.x * 41 + a.y * 43); + const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29); + const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways]; + let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0)); + path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10); + const direct = pathEndpointDistance(path); + if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { + bucket.push(path); + incrementDegree(expressDegree, a); + incrementDegree(expressDegree, b); + return true; + } + return false; + } + + for (const target of expressTargets) { + const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital; + if (addExpressway(anchor, target)) expressCore.push(target); + } + + const ringRoads = []; + const ringExpressways = []; + const ringRailways = []; + + function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) { + const anchors = []; + const minR = Math.max(5, targetRadius - 5); + const maxR = targetRadius + 7; + for (let s = 0; s < sectors; s++) { + const angle0 = (s / sectors) * Math.PI * 2; + let best = null; + let bestScore = -INF; + for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { + for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const angle = Math.atan2(dy, dx); + let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0))); + if (delta > Math.PI / sectors * 0.95) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || !prefectureMask[i]) continue; + const barrier = mode === "road" ? 0 : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail"); + if (barrier >= INF) continue; + const density = densityValue(x, y); + const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22; + const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08; + if (score > bestScore) { + bestScore = score; + best = { x, y, score, kind: `${mode} ring anchor`, parent: city }; + } + } + } + if (best) anchors.push(best); + } + return anchors; + } + + function ringCost(baseCost, city, targetRadius, mode) { + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + const d = Math.hypot(x - city.x, y - city.y); + const tooClose = Math.max(0, targetRadius * 0.46 - d); + const tooFar = Math.max(0, d - targetRadius * 1.55); + const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018; + const density = densityValue(x, y); + const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12; + return Math.max(0.36, base + bandPenalty + densityBias); + }; + } + + function softRingRailCost(x, y) { + const i = indexOf(x, y); + if (sea[i] || elevation[i] > 0.82) return INF; + const density = densityValue(x, y); + return Math.max(0.38, 1 + slope[i] * 12 + Math.max(0, elevation[i] - 0.58) * 16 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.18 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05); + } + + function softRingExpressCost(x, y) { + const i = indexOf(x, y); + if (sea[i] || elevation[i] > 0.82) return INF; + return Math.max(0.38, 1 + slope[i] * 11 + Math.max(0, elevation[i] - 0.6) * 14 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05); + } + + function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) { + const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8); + if (anchors.length < 3) return 0; + let made = 0; + const cost = ringCost(baseCost, city, targetRadius, mode); + for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) { + const a = anchors[i]; + const b = anchors[(i + 1) % anchors.length]; + if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue; + const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8)); + if (path.length >= 5 && path.length <= targetRadius * 8.0) { + bucket.push(path); + made++; + } + } + return made; + } + + function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) { + const candidates = []; + const maxR = targetRadius + 11; + const minR = Math.max(5, targetRadius * 0.45); + for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { + for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue; + const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06; + candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city }); + } + } + return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 }) + .sort((a, b) => a.angle - b.angle); + } + + function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) { + let anchors = ringAnchorCandidates(city, "road", targetRadius, 6); + if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6); + if (anchors.length < 2) return 0; + let made = 0; + for (let i = 0; i < anchors.length; i++) { + const a = anchors[i]; + const b = anchors[(i + 1) % anchors.length]; + const path = aStar(a, b, (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return INF; + const d = Math.hypot(x - city.x, y - city.y); + const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012; + return Math.max(0.3, base + band); + }); + if (path.length >= 4 && path.length <= targetRadius * 9.0) { + bucket.push(path); + made++; + } + } + return made; + } + + const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6); + for (const city of mediumRingCities) { + const radius = clamp(8 + Math.sqrt(city.population || 100000) / 170, 10, 22); + addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); + } + const largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1); + for (const city of largeRingCities) { + const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28); + const expressRadius = roadRadius + 3 + rand(seed, city.x * 71 + city.y * 73) * 3; + const railRadius = Math.max(8, roadRadius - 4); + addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); + const expressRingSegments = addEnvironmentalRing(city, "express", ringExpressways, expresswayCost, [...expressways, ...nationalRoads, ...railways, ...branchRailways], expressRadius); + const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius); + // Expressway rings should be rare; do not force a fallback ring when terrain rejects it. + if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); + } + compactPathArray(ringExpressways, { minLength: 10, maxOverlap: 0.22, maxCount: 2 }); + compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); + compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 }); + + const gatewayCandidates = []; + for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 }); } + for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 }); } + + let externalGateways = pickEntities(gatewayCandidates, { + max: 2 + Math.floor(rand(seed, 1201) * 3), + minDistance: 28, + threshold: 0.4, + seed: seed + 1201, + }).map((p) => ({ ...p, kind: "External Gateway" })); + + function externalRoadCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; + const density = densityValue(x, y); + const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; + return Math.max(0.35, 1 + slope[i] * 10 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.22 + borderPenalty + hash2(x, y, seed + 333) * 0.06); + }; + } + function externalExpresswayCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; + const density = densityValue(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; + const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; + return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); + }; + } + function externalRailCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "rail"); + if (barrier >= INF) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0; + const density = densityValue(x, y); + return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05); + }; + } + + const externalRoads = []; + const externalExpressways = []; + const externalRailways = []; + + function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) { + const sorted = pool + .filter(Boolean) + .map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) })) + .sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0))); + return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital; + } + + externalGateways.forEach((gate, idx) => { + const makeExpressLink = idx === 0 || rand(seed, 1210 + idx) > 0.4; + const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 3); + const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59); + const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; + const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate); + const roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6)); + if (roadPath.length > 6) { + if (makeExpressLink) { + externalExpressways.push(roadPath); + incrementDegree(expressDegree, roadStartRaw); + incrementDegree(expressDegree, gate); + expressCore.push(gate); + } else { + externalRoads.push(roadPath); + incrementDegree(roadDegree, roadStartRaw); + incrementDegree(roadDegree, gate); + } + } + if ((idx === 0 || rand(seed, 1220 + idx) > 0.5) && modernCities.length > 0) { + const railStartRaw = selectExternalStart([...railCore, ...modernCities, ...ports], gate, railDegree, 2); + const railStart = routePoint(railStartRaw, "rail", gate.x * 61 + gate.y * 67); + const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...expressways, ...externalExpressways]; + const railPath = aStar(railStart, gate, makeTransportCost(externalRailCost(gate), railExisting, railHubs, [railStart, gate], 4, 8.2, townAvoidNodes, 2.5, 4.4)); + if (railPath.length > 6) { + externalRailways.push(railPath); + incrementDegree(railDegree, railStartRaw); + incrementDegree(railDegree, gate); + } + } + }); + + function pruneHighMountainTransport(paths, threshold = 0.82) { + for (let i = paths.length - 1; i >= 0; i--) { + if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); + } + } + for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, ringExpressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); + + const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways, ...ringExpressways], 6); + const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways], 4); + + const icCandidates = []; + for (const path of [...expressways, ...ringExpressways, ...externalExpressways]) { + icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" }))); + for (const city of modernCities) { + let best = null; + let bestDistance = 999; + for (const [x, y] of path) { + const d = Math.hypot(x - city.x, y - city.y); + if (d < bestDistance) { bestDistance = d; best = { x, y }; } + } + if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" }); + } + } + + let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 }); + + const icAccessRoads = []; + const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8)); + for (const ic of interchanges) { + const accessTargets = [ + ...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })), + ...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })), + ...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })), + ]; + const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0]; + if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue; + const path = aStar(ic, target, roadCost); + if (path.length > 2 && path.length < 36) icAccessRoads.push(path); + } + + const logisticsScore = new Float32Array(SIZE); + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3); + const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0; + logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty); + } + } + + let logisticsParks = pickPoints(logisticsScore, { + threshold: 0.32 + rand(seed, 1141) * 0.1, + max: 3 + Math.floor(rand(seed, 1142) * 13), + minDistance: 9, + seedOffset: 1140, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Logistics Park" })); + + const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2); + const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2); + const stationInfluence = influenceFromPoints(stations, 10, () => 1); + const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6); + const satelliteScore = new Float32Array(SIZE); + const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000); + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i] || !prefectureMask[i]) continue; + let ringPull = 0; + let parent = null; + for (const city of largeCitiesForSatellites) { + const d = Math.hypot(city.x - x, city.y - y); + const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27); + const v = clamp(1 - Math.abs(d - ideal) / 9); + if (v > ringPull) { ringPull = v; parent = city; } + } + if (!parent) continue; + const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84); + const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0; + satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035); + } + } + let satelliteCities = pickPoints(satelliteScore, { + threshold: 0.43 + rand(seed, 1161) * 0.07, + max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)), + minDistance: 8, + seedOffset: 1160, + predicate: (x, y, i) => !sea[i] && prefectureMask[i], + }).map((p, n) => { + const parent = largeCitiesForSatellites.slice().sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0]; + const basePop = parent ? parent.population * (0.045 + rand(seed, 1165 + n) * 0.11) : 42000 + rand(seed, 1165 + n) * 90000; + return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 }; + }); + const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8); + const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1); + const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1); + const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1); + const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1); + const premodernInfluence = influenceFromPaths(premodernRoads, 4); + const villageInfluence = influenceFromPoints(villages, 7, () => 1); + + const newTownScore = new Float32Array(SIZE); + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const dCity = distanceToNearest(modernCities, x, y); + const ring = dCity > 8 && dCity < 22 ? 1 : 0; + const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0; + newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18); + } + } + + let newTowns = pickPoints(newTownScore, { + threshold: 0.32 + rand(seed, 1151) * 0.1, + max: 2 + Math.floor(rand(seed, 1152) * 10), + minDistance: 11, + seedOffset: 1150, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "New Town" })); + + const minorRoads = []; + const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)]; + const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3); + + function minorRoadCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + return Math.max(0.3, 1 + slope[i] * 7.2 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.22 - valleyField[i] * 0.34 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.4 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15); + } + + const connectedPairs = new Set(); + function addMinorRoad(a, b) { + const key = `${a.x},${a.y}|${b.x},${b.y}`; + if (connectedPairs.has(key)) return; + connectedPairs.add(key); + const path = aStar(a, b, minorRoadCost); + if (path.length > 2 && path.length < 90) minorRoads.push(path); + } + + for (const village of villages) { + if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { + const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target); + } + } + for (const market of markets) { + const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 3, minDistance: 1, threshold: 0 }); + for (const v of localVillages) addMinorRoad(market, v); + } + for (const pass of passes.slice(0, 8)) { + const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (target) addMinorRoad(pass, target); + } + + const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); + const landuse = new Uint8Array(SIZE); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64; + const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25); + let nearestCity = null; + let nearestCityDistance = INF; + for (const city of modernCities) { + const d = Math.hypot(city.x - x, city.y - y); + if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; } + } + const dCity = nearestCityDistance; + const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5; + const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99; + const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0; + const density = populationDensity[i]; + const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08; + const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56; + const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08); + const satelliteEnvelope = satelliteInfluence[i] * 0.54; + const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty; + const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28; + const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22; + const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02; + const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18; + const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08; + + if (mountain) landuse[i] = 9; + else if (industrialInfluence[i] > 0.44) landuse[i] = 5; + else if (logisticsInfluence[i] > 0.42) landuse[i] = 6; + else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7; + else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3; + else if (oldTownScore > 0.49) landuse[i] = 2; + else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4; + else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8; + else if (farm) landuse[i] = 1; + else if (ruralScore > 0.3) landuse[i] = 0; + else landuse[i] = 0; + } + } + + function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) { + let urban = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const lu = landuse[indexOf(nx, ny)]; + if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++; + } + } + return urban >= minUrban; + } + + function removeIsolatedUrbanPatches(maxCells = 22) { + const seen = new Uint8Array(SIZE); + const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations]; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const lu0 = landuse[i]; + if (!(lu0 >= 2 && lu0 <= 8)) continue; + const component = []; + let maxDensity = 0; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + maxDensity = Math.max(maxDensity, populationDensity[cur]); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (component.length > maxCells) continue; + let hasAnchor = false; + for (const ci of component) { + const [x, y] = xyOf(ci); + if (distanceToNearest(namedCenters, x, y) <= 5.8) { + hasAnchor = true; + break; + } + } + if (!hasAnchor) { + for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; + } + } + } + + for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36); + + // CBD is no longer a marker. It is a DID-like contiguous high-density core: + // first remove isolated core cells, then grow connected high-density cells + // from each urban center according to population scale. + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + } + } + + function growDidCore(center, city, salt) { + if (!center || !city) return 0; + const start = indexOf(center.x, center.y); + if (sea[start] || !prefectureMask[start]) return 0; + if ((city.population || 0) < 220000) return 0; + const targetCells = Math.round(clamp(2 + Math.sqrt(city.population || 80000) / 74, 4, 22)); + const maxRadius = clamp((city.coreRadius || 3) * 2.4 + Math.sqrt(city.population || 80000) / 260, 6, 16); + const selected = new Set(); + const queued = new Set([start]); + const heap = new MinHeap(); + heap.push({ i: start, f: -10 }); + let made = 0; + + while (heap.length > 0 && made < targetCells) { + const cur = heap.pop(); + if (!cur || selected.has(cur.i)) continue; + const [x, y] = xyOf(cur.i); + const i = cur.i; + const d = Math.hypot(x - center.x, y - center.y); + const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22; + if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue; + if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; + + selected.add(i); + landuse[i] = 3; + made++; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (queued.has(ni) || selected.has(ni) || sea[ni] || !prefectureMask[ni]) continue; + const nd = Math.hypot(nx - center.x, ny - center.y); + if (nd > maxRadius + 1) continue; + const score = populationDensity[ni] * 1.24 + cityInfluence[ni] * 0.22 + stationInfluence[ni] * 0.18 + plain[ni] * 0.12 - slope[ni] * 1.25 - ridgeField[ni] * 0.54 - nd / maxRadius * 0.22 + hash2(nx, ny, seed + salt) * 0.03; + queued.add(ni); + heap.push({ i: ni, f: -score }); + } + } + return made; + } + + urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17)); + for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + } + } + + const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); + const municipalityCandidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; + const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; + if (score > 0.40) municipalityCandidates.push({ x, y, score }); + } + } + const majorMunicipalSeeds = modernCities + .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) + .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); + const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { + const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); + const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); + return !nearMajor && !nearSmallUrban; + }); + const satelliteMunicipalSeeds = (satelliteCities || []) + .filter((city) => prefectureMask[indexOf(city.x, city.y)]) + .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); + let adminCentersRaw = [ + ...majorMunicipalSeeds, + ...satelliteMunicipalSeeds, + ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { + max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), + minDistance: 9 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.40, + seed: seed + 1300, + jitter: 0.025, + }), + ]; + if (adminCentersRaw.length < 12) { + const fallback = [...modernCities, ...satelliteCities, ...markets, ...newTowns, ...stations, ...villages] + .filter((p) => prefectureMask[indexOf(p.x, p.y)]) + .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); + adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); + } + if (adminCentersRaw.length < 10) { + const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); + adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); + } + const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + + function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { + if (!city || !prefectureMask[indexOf(city.x, city.y)]) return; + let bestAdmin = -1; + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + const d = Math.hypot(center.x - city.x, center.y - city.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + if (bestAdmin < 0) return; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8)); + if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin; + } + } + } + for (const city of modernCities) { + const radius = (city.population || 0) >= 500000 + ? clamp(17 + Math.sqrt(city.population) / 120, 20, 38) + : clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11); + lockUrbanClusterToMunicipality(city, radius, true); + } + // Satellite cities should remain independent municipalities, not swallowed by the parent core city. + for (const sat of satelliteCities || []) { + if (!prefectureMask[indexOf(sat.x, sat.y)]) continue; + let bestAdmin = -1; + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + const d = Math.hypot(center.x - sat.x, center.y - sat.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + if (bestAdmin >= 0) { + const r = 5; + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = sat.x + dx; + const y = sat.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || Math.hypot(dx, dy) > r) continue; + if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.18) adminId[i] = bestAdmin; + } + } + } + } + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); + const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); + function makeHarborWorks(ports) { + const out = []; + for (const port of ports) { + const parts = []; + const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const sx = port.x + dx; + const sy = port.y + dy; + if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue; + parts.push([[port.x, port.y], [sx, sy]]); + const wx = sx + dx; + const wy = sy + dy; + if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]); + if (parts.length >= limit) break; + } + if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" }); + } + return out; + } + + // Bridge and tunnel icon systems were removed from the visual model. + // Arrays remain empty for backward-compatible tests and downstream code. + const bridges = []; + const tunnels = []; + const harborWorks = makeHarborWorks(ports); + const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); + let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); + const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); + + villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed); + ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed); + crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed); + passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed); + markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed); + castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed); + castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed); + modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed); + stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed); + industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed); + interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed); + logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed); + satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed); + newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed); + castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed); + externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway"); + const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center"); + + const entitiesForNames = [ + ...modernCities, + ...ports, + ...markets, + ...castles, + ...stations, + ...industrialZones, + ...interchanges, + ...logisticsParks, + ...satelliteCities, + ...newTowns, + ...passes, + ...crossings, + ...externalGateways, + ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + + return { + width: MAP_W, + height: MAP_H, + cellSize: CELL_SIZE, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalPrefectureBorders, + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + villages, + ports, + crossings, + passes, + markets, + castles, + castleTowns, + premodernRoads, + minorRoads, + modernCities, + prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital) || modernCities[0] || null, + totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations, + industrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges, + logisticsParks, + satelliteCities, + newTowns, + bridges, + tunnels, + harborWorks, + landuse, + adminCenters, + adminId, + adminBorders, + abandonedRailways, + castleRuins, + preservedOldRoads, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + externalGateways, + entitiesForNames, + }; +} diff --git a/names.js b/names.js new file mode 100644 index 0000000..2e82d5e --- /dev/null +++ b/names.js @@ -0,0 +1,34 @@ +// Default and custom place-name resources. +// Edit only this file to change generated names or override individual IDs. + +export const NAME_PARTS = { + prefixes: ["青", "白", "黒", "赤", "東", "西", "南", "北", "中", "大", "小", "浅", "高", "川", "山", "森", "松", "原", "桜", "海", "新", "古", "上", "下"], + infixes: ["野", "原", "川", "浜", "崎", "丘", "谷", "峰", "浦", "沢", "島", "岡", "井", "瀬", "関", "橋", "口", "田", "津", "森", "台", "見"], + suffixes: ["町", "市", "郷", "台", "丘", "浦", "港", "里", "野", "沢", "原", "橋", "森", "浜", "崎", "峠"], +}; + +export const KIND_SUFFIXES = { + city: " City", + port: " Port", + market: " Town", + village: " Village", + castle: " Castle", + castleTown: " Castle Town", + castleRuin: " Castle Ruins", + station: " Station", + interchange: "IC", + industrial: " Industrial Park", + logistics: " Logistics Park", + newtown: " New Town", + satellite: " Satellite City", + pass: " Pass", + crossing: " Crossing", + gateway: " Gate", + admin: " District", +}; + +export const CUSTOM_NAMES = { + // "city-0": "Aohara", + // "port-0": "Shirahama", + // "castle-0": "Kurono", +}; diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..74f3e32 --- /dev/null +++ b/renderer.js @@ -0,0 +1,493 @@ +import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js"; + +function clamp(v, a = 0, b = 1) { + return Math.max(a, Math.min(b, v)); +} + +function distToNearest(points, x, y, fallback = 999) { + let best = fallback; + for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); + return best; +} + +function blendOutside(color, isInside) { + if (isInside) return color; + return [ + Math.round(color[0] * 0.55 + 112), + Math.round(color[1] * 0.55 + 112), + Math.round(color[2] * 0.55 + 112), + ]; +} + +function fieldSample(field, fx, fy) { + const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); + const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); + const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1)); + const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1)); + const tx = fx - x0; + const ty = fy - y0; + + const a = field[indexOf(x0, y0)]; + const b = field[indexOf(x1, y0)]; + const c = field[indexOf(x0, y1)]; + const d = field[indexOf(x1, y1)]; + + return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty); +} + +function terrainColorContinuous(map, fx, fy, mode) { + const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)))); + const isInside = Boolean(map.prefectureMask[i]); + + let color; + + if (map.sea[i]) { + // Google Map風の海の色(明るい青) + const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); + color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)]; + } else if (mode === "suitability") { + const a = fieldSample(map.agriculture, fx, fy); + const p = fieldSample(map.plain, fx, fy); + const f = fieldSample(map.floodplain, fx, fy); + color = [ + Math.round(230 - p * 25 + f * 15), + Math.round(235 + a * 15), + Math.round(210 - a * 25 + p * 20), + ]; + } else if (mode === "development") { + const x = Math.floor(fx); + const y = Math.floor(fy); + const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y))); + const dCity = distToNearest(map.modernCities, fx, fy); + const dInd = distToNearest(map.industrialZones, fx, fy); + const dLog = distToNearest(map.logisticsParks, fx, fy); + const dNew = distToNearest(map.newTowns, fx, fy); + const urban = clamp(1 - dCity / 25); + const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; + const industrial = clamp(1 - dInd / 10); + const logistics = clamp(1 - dLog / 9); + const newTown = clamp(1 - dNew / 9); + const base = 214 + map.plain[baseIndex] * 22; + color = [ + Math.round(base + density * 30 + urban * 8 + industrial * 14), + Math.round(base + density * 10 + logistics * 15 + newTown * 12), + Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22), + ]; + } else { + // 起伏の大きさが読めるよう、標高段彩をやや強める + const e = fieldSample(map.elevation, fx, fy); + const m = fieldSample(map.moisture, fx, fy); + if (e > 0.82) color = [178, 170, 160]; + else if (e > 0.68) color = [198, 188, 164]; + else if (e > 0.52) color = [205, 222 + m * 5, 184]; + else if (e > 0.34) color = [224, 238 + m * 6, 206]; + else if (e > 0.24) color = [236, 245 + m * 5, 220]; + else color = [218, 232 + m * 6, 206]; + } + + return blendOutside(color, isInside); +} + +function discreteColor(map, x, y, mode) { + const i = indexOf(x, y); + let color; + + if (map.sea[i]) { + // Google Map風の海色 + color = [170, 218, 255]; + } else if (mode === "landuse") { + const colors = { + 0: [230, 242, 220], // 農地 + 1: [235, 245, 225], // 平地 + 2: [235, 230, 220], // 旧市街 + 3: [224, 202, 190], // 中心市街地 / CBD + 4: [245, 240, 230], // 郊外 + 5: [220, 220, 225], // 工業地域 + 6: [225, 235, 225], // 物流エリア + 7: [238, 242, 248], // ニュータウン + 8: [248, 242, 230], // 沿道開発 + 9: [225, 238, 220], // その他 + }; + color = colors[map.landuse[i]] || colors[0]; + } else if (mode === "admin") { + const palette = [ + [245, 235, 230], + [235, 245, 235], + [240, 240, 250], + [250, 245, 230], + [245, 240, 248], + [230, 245, 245], + [250, 240, 240], + [240, 250, 235], + ]; + const a = map.adminId[i]; + color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; + } else { + color = terrainColorContinuous(map, x, y, "terrain"); + } + + return blendOutside(color, Boolean(map.prefectureMask[i])); +} + +function drawBase(ctx, map, mode, continuousTerrain) { + const width = MAP_W * CELL_SIZE; + const height = MAP_H * CELL_SIZE; + const img = ctx.createImageData(width, height); + const continuousModes = ["terrain", "suitability", "development", "all"]; + + if (continuousTerrain && continuousModes.includes(mode)) { + for (let py = 0; py < height; py++) { + const fy = py / CELL_SIZE; + for (let px = 0; px < width; px++) { + const fx = px / CELL_SIZE; + const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode); + + const eL = fieldSample(map.elevation, fx - 0.6, fy); + const eR = fieldSample(map.elevation, fx + 0.6, fy); + const eU = fieldSample(map.elevation, fx, fy - 0.6); + const eD = fieldSample(map.elevation, fx, fy + 0.6); + const shade = clamp(0.9 + (eL - eR) * 1.0 + (eU - eD) * 0.65, 0.72, 1.18); + const elevation = fieldSample(map.elevation, fx, fy); + const contour = Math.abs((elevation * 16) - Math.round(elevation * 16)); + const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8)); + const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))]; + const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1; + + const ii = (py * width + px) * 4; + img.data[ii] = Math.round(r * shade * contourFactor); + img.data[ii + 1] = Math.round(g * shade * contourFactor); + img.data[ii + 2] = Math.round(b * shade * contourFactor); + img.data[ii + 3] = 255; + } + } + } else { + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const [r, g, b] = discreteColor(map, x, y, mode); + for (let dy = 0; dy < CELL_SIZE; dy++) { + for (let dx = 0; dx < CELL_SIZE; dx++) { + const ii = ((y * CELL_SIZE + dy) * width + (x * CELL_SIZE + dx)) * 4; + img.data[ii] = r; + img.data[ii + 1] = g; + img.data[ii + 2] = b; + img.data[ii + 3] = 255; + } + } + } + } + } + + ctx.putImageData(img, 0, 0); +} + +function drawPath(ctx, path, color, width, dashed = false) { + if (!path || path.length < 2) return; + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; + ctx.lineWidth = width; + if (dashed) ctx.setLineDash([6, 5]); + ctx.beginPath(); + ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); + for (let k = 1; k < path.length; k++) { + ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); + } + ctx.stroke(); + ctx.restore(); +} + +function drawSegments(ctx, segments, color, width, dashed = false) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + if (dashed) ctx.setLineDash([4, 4]); + + for (const seg of segments) { + ctx.beginPath(); + ctx.moveTo(seg[0][0] * CELL_SIZE, seg[0][1] * CELL_SIZE); + ctx.lineTo(seg[1][0] * CELL_SIZE, seg[1][1] * CELL_SIZE); + ctx.stroke(); + } + ctx.restore(); +} + +function drawUrbanAreas(ctx, map, mode) { + const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"]; + if (!visibleModes.includes(mode)) return; + + // Google Map風の都市部の色 + const colors = { + 2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ + 3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill + 4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム + 5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー + 6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー + 7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白 + 8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム + }; + + ctx.save(); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!map.prefectureMask[i]) continue; + const lu = map.landuse[i]; + if (!colors[lu]) continue; + + const px = x * CELL_SIZE; + const py = y * CELL_SIZE; + ctx.fillStyle = colors[lu]; + ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + + const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0); + if (lu === 3 || lu === 2 || lu === 5 || lu === 6) { + // 建物の表現を控えめに + ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)"; + if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2); + if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2); + if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5); + } else if (lu === 4 || lu === 7) { + ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)"; + ctx.lineWidth = 0.8; + ctx.beginPath(); + if (h % 2 === 0) { + ctx.moveTo(px + 1, py + 1); + ctx.lineTo(px + CELL_SIZE - 1, py + 1); + ctx.moveTo(px + 1, py + 4); + ctx.lineTo(px + CELL_SIZE - 1, py + 4); + } else { + ctx.moveTo(px + 1, py + 1); + ctx.lineTo(px + 1, py + CELL_SIZE - 1); + ctx.moveTo(px + 4, py + 1); + ctx.lineTo(px + 4, py + CELL_SIZE - 1); + } + ctx.stroke(); + } else if (lu === 8) { + ctx.strokeStyle = "rgba(180,170,160,0.2)"; + ctx.lineWidth = 0.9; + ctx.beginPath(); + ctx.moveTo(px + 1, py + 3); + ctx.lineTo(px + CELL_SIZE - 1, py + 3); + ctx.stroke(); + } + } + } + ctx.restore(); +} + +function dot(ctx, p, radius, fill, stroke = "white") { + ctx.beginPath(); + ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = stroke; + ctx.stroke(); +} + +function squareIcon(ctx, p, size, fill, stroke = "white") { + const x = p.x * CELL_SIZE + CELL_SIZE / 2; + const y = p.y * CELL_SIZE + CELL_SIZE / 2; + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.rect(x - size / 2, y - size / 2, size, size); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +function triangleIcon(ctx, p, size, fill, stroke = "white") { + const x = p.x * CELL_SIZE + CELL_SIZE / 2; + const y = p.y * CELL_SIZE + CELL_SIZE / 2; + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x, y - size / 2); + ctx.lineTo(x + size / 2, y + size / 2); + ctx.lineTo(x - size / 2, y + size / 2); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +function railStationIcon(ctx, p) { + squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)"); +} + + +function drawHarborWorks(ctx, map) { + if (!map.harborWorks) return; + ctx.save(); + ctx.strokeStyle = "rgba(95, 120, 150, 0.9)"; + ctx.lineWidth = 2.2; + ctx.lineCap = "round"; + for (const harbor of map.harborWorks) { + for (const seg of harbor.segments || []) { + ctx.beginPath(); + ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2); + ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2); + ctx.stroke(); + } + } + ctx.restore(); +} + +function boxesOverlap(a, b, pad = 2) { + return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2); +} + +function labelWithCollision(ctx, p, occupied) { + if (!p.name) return false; + ctx.save(); + ctx.font = "11px ui-sans-serif, system-ui, sans-serif"; + const baseX = p.x * CELL_SIZE + CELL_SIZE / 2; + const baseY = p.y * CELL_SIZE + CELL_SIZE / 2; + const textW = ctx.measureText(p.name).width; + const textH = 12; + const candidates = [ + [7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12], + [-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2], + ]; + + for (const [ox, oy] of candidates) { + const x = baseX + ox; + const y = baseY + oy; + const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 }; + if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; + if (occupied.some((b) => boxesOverlap(box, b))) continue; + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.fillStyle = "rgba(40,40,40,0.95)"; + ctx.strokeText(p.name, x, y); + ctx.fillText(p.name, x, y); + occupied.push(box); + ctx.restore(); + return true; + } + ctx.restore(); + return false; +} + +function drawLabels(ctx, points) { + const occupied = []; + const prioritized = points + .filter((p) => p?.name) + .map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 1000 + (p.kind === "Major Port" ? 160 : 0) + (p.kind === "Market Town" ? 55 : 0) + (p.kind?.includes("Castle") ? 45 : 0) })) + .sort((a, b) => b.labelPriority - a.labelPriority); + for (const p of prioritized) labelWithCollision(ctx, p, occupied); +} + +export function drawMap(canvas, map, options) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const mode = options.mode || "all"; + const showFeatures = options.showFeatures !== false; + const showLabels = options.showLabels !== false; + const continuousTerrain = true; + + const width = MAP_W * CELL_SIZE; + const height = MAP_H * CELL_SIZE; + canvas.width = width; + canvas.height = height; + + drawBase(ctx, map, mode, continuousTerrain); + drawUrbanAreas(ctx, map, mode); + + // Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity. + const waterBlue = "rgba(170, 218, 255, 0.95)"; + // Small streams remain in the data model but are not drawn by default. + for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35); + for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); + drawHarborWorks(ctx, map); + + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(95,95,95,0.30)", 1.0); + drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4); + drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05); + + if (!showFeatures) return; + + const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); + const showModern = ["modern", "all", "development", "landuse"].includes(mode); + const showRoads = ["roads", "all", "development", "landuse"].includes(mode); + const showAdmin = ["admin", "all"].includes(mode); + + if (showAdmin) drawSegments(ctx, map.adminBorders, "rgba(120,120,120,0.65)", 1.3); + + if (showHistory) { + for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(150, 120, 90, 0.55)", 1.45, true); + for (const path of map.minorRoads) drawPath(ctx, path, "rgba(180, 150, 120, 0.62)", 1.05); + } + + if (showModern) { + // 鉄道 - 濃いグレー + for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8); + for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1); + for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9); + for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4); + } + + if (showRoads) { + // Google Map風の道路 - 白と黄色とオレンジ + for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2); + for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0); + for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0); + for (const path of map.ringExpressways || []) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 3.3); + for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2); + } + + if (showHistory) { + for (const p of map.villages) dot(ctx, p, 1.9, "rgba(120, 100, 80, 0.72)"); + for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)"); + for (const p of map.ports) { + const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)"; + triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color); + } + for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)"); + for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)"); + for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)"); + for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)"); + } + + if (showModern) { + for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)"); + for (const p of map.stations) railStationIcon(ctx, p); + for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)"); + for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)"); + for (const p of map.modernCities) { + const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7; + const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0; + dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)"); + if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)"); + } + } + + if (showRoads) { + for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)"); + for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)"); + for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)"); + } + + if (showLabels) { + const important = [ + ...map.modernCities, + ...map.ports, + ...map.markets.slice(0, 10), + ...map.castles.slice(0, 8), + ...(map.satelliteCities || []), + ...map.newTowns, + ...map.externalGateways, + ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + + drawLabels(ctx, important); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..92d07c0 --- /dev/null +++ b/styles.css @@ -0,0 +1,11 @@ +*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;display:flex;flex-direction:column;gap:6px}.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}@media (max-width:1100px){.layout{grid-template-columns:1fr}} + +.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent} + + + +.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff} + +.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px} + +.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)} diff --git a/test.html b/test.html new file mode 100644 index 0000000..9e0c8f7 --- /dev/null +++ b/test.html @@ -0,0 +1,18 @@ + + + + + Prefecture Map Generator v7 Tests + + + +

Tests

+
Running...
+ + + diff --git a/test.js b/test.js new file mode 100644 index 0000000..78b2dab --- /dev/null +++ b/test.js @@ -0,0 +1,152 @@ +import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js"; + +const result = document.getElementById("result"); +const logLines = []; +let failed = 0; + +function assert(condition, message) { + if (condition) logLines.push(`OK: ${message}`); + else { + failed += 1; + logLines.push(`NG: ${message}`); + } +} + +try { + const map = generateMap(12345); + const other = generateMap(54321); + const size = MAP_W * MAP_H; + const urbanCellCount = [...map.landuse].filter((value) => value >= 2 && value <= 8).length; + const cityPopulations = map.modernCities.map((city) => city.population || 0); + const maxPopulation = Math.max(...cityPopulations); + const minPopulation = Math.min(...cityPopulations); + const landElevations = [...map.elevation].filter((_, i) => !map.sea[i]); + const meanElevation = landElevations.reduce((sum, value) => sum + value, 0) / landElevations.length; + const elevationStdDev = Math.sqrt(landElevations.reduce((sum, value) => sum + (value - meanElevation) ** 2, 0) / landElevations.length); + const modernPaths = [ + ...map.railways, + ...map.branchRailways, + ...map.externalRailways, + ...(map.ringRailways || []), + ...map.nationalRoads, + ...(map.ringRoads || []), + ...map.expressways, + ...(map.ringExpressways || []), + ...map.externalRoads, + ...map.externalExpressways, + ]; + const endpointDegree = new Map(); + for (const path of modernPaths) { + if (path.length < 2) continue; + for (const point of [path[0], path[path.length - 1]]) { + const key = point.join(","); + endpointDegree.set(key, (endpointDegree.get(key) || 0) + 1); + } + } + const maxModernEndpointDegree = Math.max(0, ...endpointDegree.values()); + let maxCoastalElevationStep = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (map.sea[i]) continue; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const ni = indexOf(x + dx, y + dy); + if (map.sea[ni]) maxCoastalElevationStep = Math.max(maxCoastalElevationStep, Math.abs(map.elevation[i] - map.elevation[ni])); + } + } + } + let railExpressHighMountainCells = 0; + for (const path of [...map.railways, ...map.branchRailways, ...(map.ringRailways || []), ...map.externalRailways, ...map.expressways, ...(map.ringExpressways || []), ...map.externalExpressways]) { + for (const [x, y] of path) { + const i = indexOf(x, y); + if (map.elevation[i] > 0.82) railExpressHighMountainCells += 1; + } + } + + assert(map.elevation.length === size, "elevation length matches map size"); + assert(map.sea.length === size, "sea length matches map size"); + assert(map.river.length === size, "river length matches map size"); + assert(map.landuse.length === size, "land-use length matches map size"); + assert(map.adminId.length === size, "municipal id length matches map size"); + assert(map.prefectureMask.length === size, "prefecture mask length matches map size"); + assert(map.populationDensity.length === size, "population density length matches map size"); + assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size"); + assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size"); + assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist"); + assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); + assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); + assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); + assert(Array.isArray(map.satelliteCities), "satelliteCities is an array"); + assert(Array.isArray(map.ringRoads) && Array.isArray(map.ringExpressways) && Array.isArray(map.ringRailways), "ring transport arrays exist"); + + assert(Array.isArray(map.mainRivers), "mainRivers is an array"); + assert(Array.isArray(map.minorRoads), "minorRoads is an array"); + assert(Array.isArray(map.externalGateways), "externalGateways is an array"); + + let prefectureComponents = 0; + const seenPrefecture = new Uint8Array(size); + for (let i = 0; i < size; i++) { + if (!map.prefectureMask[i] || seenPrefecture[i]) continue; + prefectureComponents++; + const queue = [i]; + seenPrefecture[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + const ni = ny * MAP_W + nx; + if (!map.prefectureMask[ni] || seenPrefecture[ni]) continue; + seenPrefecture[ni] = 1; + queue.push(ni); + } + } + } + } + assert(prefectureComponents === 1, "prefecture area is a single connected component"); + assert(map.prefectureBorder.length > 0, "prefecture border exists"); + assert(map.adminBorders.length > 0, "municipal borders exist"); + assert(map.mainRivers.length > 0, "at least one major river exists"); + assert(map.tributaryRivers.length > 0, "tributary river network exists"); + assert(map.smallStreams.length > 0, "small stream network exists"); + assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports"); + assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); + assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); + assert(map.externalGateways.length > 0, "external gateways exist"); + assert(map.minorRoads.length > 0, "minor roads exist"); + assert(map.adminCenters.length >= 12, "municipality count is sufficiently large"); + assert(urbanCellCount > 1000, "large-city urbanized cells are broad enough"); + const cbdCells = [...map.landuse].filter((value) => value === 3).length; + assert(cbdCells > 0, "CBD is represented as land-use cells rather than markers"); + assert(map.modernCities.every((city) => Number.isFinite(city.population) && city.population > 0), "modern cities have population properties"); + assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly"); + assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations"); + assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated"); + assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast"); + assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts"); + assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells"); + assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized"); + assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); + + assert( + map.adminCenters.length !== other.adminCenters.length || + map.villages.length !== other.villages.length || + map.markets.length !== other.markets.length, + "feature counts vary between seeds" + ); + + const againA = generateMap(999); + const againB = generateMap(999); + assert(JSON.stringify(againA.modernCities) === JSON.stringify(againB.modernCities), "generation is deterministic for the same seed"); + + result.className = failed === 0 ? "ok" : "ng"; + result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`; +} catch (error) { + result.className = "ng"; + result.textContent = String(error?.stack || error); +}