diff --git a/mapOutput.js b/mapOutput.js index 8972a3e..e35b6cc 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -6,6 +6,7 @@ export function finishMapOutput({ seed, options, terrainTemplate, + seaLevel, cityPopulationCap, stationInfluence, roadInfluence, @@ -207,6 +208,7 @@ export function finishMapOutput({ height: MAP_H, cellSize: CELL_SIZE, terrainTemplate, + seaLevel, prefectureMask, prefectureBorder, prefectureRegionId, diff --git a/mapPipeline.js b/mapPipeline.js index 0c18ed2..519e435 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -12,6 +12,7 @@ export function generateMap(seedInput = 114514, options = {}) { const terrain = generateTerrainAndRivers(seed); const { terrainTemplate, + seaLevel, elevation, moisture, slope, @@ -63,7 +64,7 @@ export function generateMap(seedInput = 114514, options = {}) { }); return finishMapOutput({ - seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, diff --git a/mapTerrain.js b/mapTerrain.js index ce0e763..5b47d9c 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1288,6 +1288,7 @@ export function generateTerrainAndRivers(seed) { return { terrainTemplate, + seaLevel, elevation, moisture, slope, diff --git a/names.js b/names.js index abf2e29..399aa87 100644 --- a/names.js +++ b/names.js @@ -34,7 +34,7 @@ export const NAME_KANJI_POOLS = { ], coastalTerrain: [ - "津", "浦", "津", "崎", + "津", "浦", "ヶ浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", diff --git a/renderer.js b/renderer.js index 60f5fc7..9c50c01 100644 --- a/renderer.js +++ b/renderer.js @@ -1,5 +1,259 @@ import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; + +const segmentVectorCache = new WeakMap(); +const pathVectorCache = new WeakMap(); +const coastlineCache = new WeakMap(); + +function pointKey(p) { + return `${p[0]},${p[1]}`; +} + +function parsePointKey(key) { + return key.split(",").map(Number); +} + +function samePoint(a, b, eps = 1e-6) { + return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps; +} + +function perpendicularDistance(p, a, b) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]); + const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1); + const x = a[0] + dx * t; + const y = a[1] + dy * t; + return Math.hypot(p[0] - x, p[1] - y); +} + +function simplifyRdp(points, tolerance = 0.05) { + if (!points || points.length <= 2) return points || []; + let bestIndex = -1; + let bestDistance = -1; + const first = points[0]; + const last = points[points.length - 1]; + for (let i = 1; i < points.length - 1; i++) { + const d = perpendicularDistance(points[i], first, last); + if (d > bestDistance) { + bestDistance = d; + bestIndex = i; + } + } + if (bestDistance <= tolerance) return [first, last]; + const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance); + const right = simplifyRdp(points.slice(bestIndex), tolerance); + return left.slice(0, -1).concat(right); +} + +function removeCollinear(points) { + if (!points || points.length <= 2) return points || []; + const closed = samePoint(points[0], points[points.length - 1]); + const core = closed ? points.slice(0, -1) : points.slice(); + if (core.length <= 2) return points; + const out = []; + const count = core.length; + for (let i = 0; i < count; i++) { + const prev = core[(i - 1 + count) % count]; + const cur = core[i]; + const next = core[(i + 1) % count]; + if (!closed && (i === 0 || i === count - 1)) { + out.push(cur); + continue; + } + const ax = cur[0] - prev[0]; + const ay = cur[1] - prev[1]; + const bx = next[0] - cur[0]; + const by = next[1] - cur[1]; + if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur); + } + if (closed && out.length) out.push(out[0]); + return out; +} + +function chaikin(points, iterations = 1, closed = false) { + if (!points || points.length < 3 || iterations <= 0) return points || []; + let result = points.slice(); + for (let iter = 0; iter < iterations; iter++) { + const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result; + if (src.length < 3) break; + const next = []; + if (!closed) next.push(src[0]); + const limit = closed ? src.length : src.length - 1; + for (let i = 0; i < limit; i++) { + const a = src[i]; + const b = src[(i + 1) % src.length]; + next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + if (!closed) next.push(src[src.length - 1]); + if (closed && next.length) next.push(next[0]); + result = next; + } + return result; +} + +function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) { + const line = [parsePointKey(startKey)]; + let currentKey = startKey; + let nextEdgeIndex = firstEdgeIndex; + + while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) { + used[nextEdgeIndex] = 1; + const edge = edges[nextEdgeIndex]; + const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey; + line.push(parsePointKey(toKey)); + currentKey = toKey; + + if (currentKey === startKey) break; + const degree = adjacency.get(currentKey)?.length || 0; + if (degree !== 2) break; + nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]); + } + + return line; +} + +function segmentChains(segments) { + if (!segments?.length) return []; + const edges = []; + const adjacency = new Map(); + + for (const seg of segments) { + const aKey = pointKey(seg[0]); + const bKey = pointKey(seg[1]); + if (aKey === bKey) continue; + const edgeIndex = edges.length; + edges.push({ aKey, bKey }); + if (!adjacency.has(aKey)) adjacency.set(aKey, []); + if (!adjacency.has(bKey)) adjacency.set(bKey, []); + adjacency.get(aKey).push(edgeIndex); + adjacency.get(bKey).push(edgeIndex); + } + + const used = new Uint8Array(edges.length); + const chains = []; + + for (let i = 0; i < edges.length; i++) { + if (used[i]) continue; + const edge = edges[i]; + const aDegree = adjacency.get(edge.aKey)?.length || 0; + const bDegree = adjacency.get(edge.bKey)?.length || 0; + if (aDegree === 2 && bDegree === 2) continue; + const startKey = aDegree !== 2 ? edge.aKey : edge.bKey; + chains.push(traceSegmentChain(edges, adjacency, used, startKey, i)); + } + + for (let i = 0; i < edges.length; i++) { + if (used[i]) continue; + chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i)); + } + + return chains.filter((line) => line.length >= 2); +} + +function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) { + if (!segments?.length) return []; + const cacheKey = `${iterations}:${tolerance}`; + let cachedByOption = segmentVectorCache.get(segments); + if (!cachedByOption) { + cachedByOption = new Map(); + segmentVectorCache.set(segments, cachedByOption); + } + if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey); + + const polylines = segmentChains(segments).map((line) => { + const cleaned = removeCollinear(line); + const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]); + const smoothed = chaikin(cleaned, iterations, closed); + if (closed) return smoothed; + return simplifyRdp(smoothed, tolerance); + }).filter((line) => line.length >= 2); + + cachedByOption.set(cacheKey, polylines); + return polylines; +} + +function getCoastlineSegments(map) { + if (!map?.sea) return []; + const cached = coastlineCache.get(map.sea); + if (cached) return cached; + + 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 = Boolean(map.sea[i]); + if (x + 1 < MAP_W) { + const b = Boolean(map.sea[indexOf(x + 1, y)]); + if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const b = Boolean(map.sea[indexOf(x, y + 1)]); + if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + + coastlineCache.set(map.sea, segments); + return segments; +} + +function vectorPath(path) { + if (!path || path.length < 2) return []; + const cached = pathVectorCache.get(path); + if (cached) return cached; + + const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); + const simplified = simplifyRdp(points, CELL_SIZE * 0.34); + const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false); + pathVectorCache.set(path, smoothed); + return smoothed; +} + +function drawPolylinePoints(ctx, points) { + if (!points || points.length < 2) return; + ctx.moveTo(points[0][0], points[0][1]); + for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]); +} + +function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) { + const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {}; + const polylines = vectorizeSegments(segments, shapeOptions); + if (!polylines.length) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (dashed) ctx.setLineDash([6, 5]); + + for (const line of polylines) { + ctx.beginPath(); + ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE); + for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE); + ctx.stroke(); + } + ctx.restore(); +} + +function sampleCellIndex(fx, fy) { + const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); + const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); + return indexOf(x, y); +} + +function isWaterSample(map, fx, fy) { + // The generated terrain arrays are cell-centered, while pixels are drawn across + // each cell. Water/land classification must therefore follow the discrete sea + // mask, not the interpolated elevation value. Interpolating elevation near a + // coast makes the right/bottom side of land cells inherit sea values and leaves + // visible unpainted strips inside the smoothed coastline. + return Boolean(map.sea[sampleCellIndex(fx, fy)]); +} + + 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)); @@ -16,12 +270,14 @@ function blendOutside(color, isInside) { } 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 sx = Math.max(0, Math.min(MAP_W - 1, fx)); + const sy = Math.max(0, Math.min(MAP_H - 1, fy)); + const x0 = Math.floor(sx); + const y0 = Math.floor(sy); 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 tx = sx - x0; + const ty = sy - y0; const a = field[indexOf(x0, y0)]; const b = field[indexOf(x1, y0)]; @@ -32,13 +288,13 @@ function fieldSample(field, fx, fy) { } 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 i = sampleCellIndex(fx, fy); const isInside = Boolean(map.prefectureMask[i]); let color; - if (map.sea[i]) { - const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); + if (isWaterSample(map, fx, fy)) { + const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)]; } else if (mode === "suitability") { const a = fieldSample(map.agriculture, fx, fy); @@ -152,7 +408,8 @@ function drawBase(ctx, map, mode, continuousTerrain) { } function drawPath(ctx, path, color, width, dashed = false) { - if (!path || path.length < 2) return; + const points = vectorPath(path); + if (points.length < 2) return; ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; @@ -160,17 +417,15 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.lineWidth = width; if (dashed) ctx.setLineDash([8, 6]); 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); - } + drawPolylinePoints(ctx, points); ctx.stroke(); ctx.restore(); } // 魚の骨(私鉄記号)スタイルを描画するための専用関数 function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { - if (!path || path.length < 2) return; + const points = vectorPath(path); + if (points.length < 2) return; ctx.save(); ctx.lineCap = "butt"; ctx.lineJoin = "round"; @@ -179,33 +434,27 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { // 中心の実線を描画 ctx.lineWidth = lineWidth; 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); - } + drawPolylinePoints(ctx, points); ctx.stroke(); // 棘(クロスハッチ)を描画 ctx.lineWidth = 1.0; ctx.beginPath(); - let leftover = 0; - for (let k = 0; k < path.length - 1; k++) { - const x1 = path[k][0] * CELL_SIZE + CELL_SIZE / 2; - const y1 = path[k][1] * CELL_SIZE + CELL_SIZE / 2; - const x2 = path[k+1][0] * CELL_SIZE + CELL_SIZE / 2; - const y2 = path[k+1][1] * CELL_SIZE + CELL_SIZE / 2; + let carry = spacing * 0.5; + for (let k = 0; k < points.length - 1; k++) { + const [x1, y1] = points[k]; + const [x2, y2] = points[k + 1]; const dx = x2 - x1; const dy = y2 - y1; const dist = Math.hypot(dx, dy); - if (dist === 0) continue; - - // 法線(直角)ベクトル + if (dist <= 0.001) continue; + const nx = dx / dist; const ny = dy / dist; const px = -ny * (tickLen / 2); const py = nx * (tickLen / 2); - let d = (spacing / 2) + leftover; + let d = carry; while (d < dist) { const cx = x1 + nx * d; const cy = y1 + ny * d; @@ -213,7 +462,7 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { ctx.lineTo(cx - px, cy - py); d += spacing; } - leftover = d - dist; + carry = d - dist; } ctx.stroke(); ctx.restore(); @@ -357,6 +606,9 @@ export function drawMap(canvas, map, options) { // 1. Base Terrain & Urban drawBase(ctx, map, mode, true); drawUrbanAreas(ctx, map, mode); + const coastSegments = getCoastlineSegments(map); + drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); // 2. Rivers const waterBlue = "rgba(160, 205, 240, 1)"; @@ -371,18 +623,18 @@ export function drawMap(canvas, map, options) { // 3. Borders if (showAdmin && map.adminBorders) { - drawSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false); - drawSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true); + drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } if (mode === "admin-debug" || mode === "borders-debug") { drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); + if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } - drawSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false); - drawSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true); + drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); if (!showFeatures) return;