From 939ada6e4c0869c69e2118e6d3f55966b2dba573 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 02:32:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(wilderness):=20=E7=94=A8=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=A1=B6=E7=82=B9=E5=9B=BE=20(DRLGVER)=20?= =?UTF-8?q?=E7=9A=84=20Catmull-Rom=20=E6=A0=B7=E6=9D=A1=E9=81=93=E8=B7=AF?= =?UTF-8?q?=E5=8F=96=E4=BB=A3=E6=A0=85=E6=A0=BC=E5=8C=96=20A*=20=E9=81=93?= =?UTF-8?q?=E8=B7=AF=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现控制顶点图算法 createDrlgVertices / DRLGVER_CreateVertices,提取城门/关卡出口、中央路口枢纽、特异预设(石块荒野巨石阵、遗忘之塔、邪恶洞窟等)及自然拐点宏控制顶点 - 引入 Catmull-Rom 样条平滑插值算法 catmullRomPoint 与 sampleCatmullRomSpline,生成平滑曲率的自然蜿蜒小径,摆脱死板的 45°/90° 网格折线 - 实现栅格化样条小径 rasterizeDirtPathSegment / generateDirtPaths(2-3 格宽度),无缝对齐 8 邻域 LUT 贴图码表 (DIRT_PATH_TILE_LUT) - 样条道路与光栅化避障支持:严格避开 roadObstacleBlocks(墓园围墙、遗迹废墟、房屋边界),保护预设建筑阻隔完整性 - 重构 stampThemedRoads:南北向与东西向主道、城门引道及景观支路均通过平滑样条连通,清理道路中轴覆盖的杂物地块 - 新增 tests/wilderness-spline-roads.test.ts,全量覆盖样条数学与曲率、控制顶点图生成、避障光栅化及第一幕野外端到端验证 Closes #66 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness.ts | 682 +++++++++++++++++++++----- tests/wilderness-spline-roads.test.ts | 491 ++++++++++++++++++ 2 files changed, 1053 insertions(+), 120 deletions(-) create mode 100644 tests/wilderness-spline-roads.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 2908635..4c5337d 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1116,6 +1116,8 @@ export interface WildernessStats { proceduralDividerCells?: number proceduralWaterCells?: number proceduralWallCells?: number + drlgVertices?: readonly DrlgVertex[] | undefined + roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined } /** @@ -1623,7 +1625,7 @@ function layBorder( * Coordinates are in cells, with `(0, 0)` at the final map's origin. Unlike the * maze generator there is no `minX` shift to undo. */ -interface RoadAnchor { +export interface RoadAnchor { readonly label: string /** The opening itself: the cell the player crosses. */ readonly anchorCell: { readonly x: number; readonly y: number } @@ -1655,7 +1657,7 @@ export interface WildernessEntrance { readonly kind: 'gate' | 'preset' } -interface SpecialPresetPlacement { +export interface SpecialPresetPlacement { readonly name: string readonly ds1: Ds1 readonly bx: number @@ -1664,7 +1666,7 @@ interface SpecialPresetPlacement { readonly blocksY: number } -interface Act1OutdoorPlan { +export interface Act1OutdoorPlan { readonly openGates: Map readonly claimedBorderBlocks: Set readonly claimedBlocks: boolean[][] @@ -1703,7 +1705,7 @@ function toEntrances(anchors: readonly RoadAnchor[]): WildernessEntrance[] { /** * Plan Act 1 wilderness special presets, border road gates, and road anchors. */ -function planAct1OutdoorLevel( +export function planAct1OutdoorLevel( request: WildernessRequest, gridWidth: number, gridHeight: number, @@ -2133,18 +2135,282 @@ function planAct1OutdoorLevel( } } +export interface DrlgVertex { + readonly x: number + readonly y: number + readonly type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' + readonly label?: string | undefined + readonly id?: number | undefined +} + +export interface DrlgRoadNetwork { + readonly vertices: DrlgVertex[] + readonly polylines: { x: number; y: number }[][] +} + +export type DrlgVertexResult = DrlgVertex[] & { + readonly vertices: DrlgVertex[] + readonly polylines: { x: number; y: number }[][] + readonly lines: { x: number; y: number }[][] +} + /** - * Build stepped block-grid road polylines connecting all anchors to a central hub - * (`DRLGOUTDOORS_SpawnAct1DirtPaths` / `sub_6FD7F5B0`). + * Catmull-Rom spline interpolation between 4 control points at parameter t [0, 1]. */ -function buildRoadNetworkPolylines( +export function catmullRomPoint( + p0: { readonly x: number; readonly y: number }, + p1: { readonly x: number; readonly y: number }, + p2: { readonly x: number; readonly y: number }, + p3: { readonly x: number; readonly y: number }, + t: number, +): { x: number; y: number } { + const t2 = t * t + const t3 = t2 * t + const x = 0.5 * ( + (2 * p1.x) + + (-p0.x + p2.x) * t + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3 + ) + const y = 0.5 * ( + (2 * p1.y) + + (-p0.y + p2.y) * t + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3 + ) + return { x, y } +} + +/** + * Sample a Catmull-Rom spline through a sequence of control points, + * steering clear of obstacle blocks. + */ +export function sampleCatmullRomSpline( + controlPoints: readonly { readonly x: number; readonly y: number }[], + bounds: { readonly width: number; readonly height: number }, + roadObstacleBlocks?: boolean[][], +): { x: number; y: number }[] { + if (controlPoints.length === 0) return [] + if (controlPoints.length === 1) return [{ x: controlPoints[0]!.x, y: controlPoints[0]!.y }] + + const result: { x: number; y: number }[] = [] + const n = controlPoints.length + + const isObstacle = (cx: number, cy: number): boolean => { + if (!roadObstacleBlocks) return false + const bx = Math.floor(cx / TILES_PER_BLOCK) + const by = Math.floor(cy / TILES_PER_BLOCK) + return roadObstacleBlocks[by]?.[bx] === true + } + + const addPoint = (rawX: number, rawY: number) => { + let px = Math.max(0, Math.min(bounds.width - 1, Math.round(rawX))) + let py = Math.max(0, Math.min(bounds.height - 1, Math.round(rawY))) + + if (isObstacle(px, py)) { + const obx = Math.floor(px / TILES_PER_BLOCK) + const oby = Math.floor(py / TILES_PER_BLOCK) + const centerTileX = obx * TILES_PER_BLOCK + 4 + const centerTileY = oby * TILES_PER_BLOCK + 4 + const dirX = px - centerTileX + const dirY = py - centerTileY + + const stepX = dirX >= 0 ? 1 : -1 + const stepY = dirY >= 0 ? 1 : -1 + if (Math.abs(dirX) >= Math.abs(dirY)) { + px = stepX > 0 ? (obx + 1) * TILES_PER_BLOCK : obx * TILES_PER_BLOCK - 1 + } else { + py = stepY > 0 ? (oby + 1) * TILES_PER_BLOCK : oby * TILES_PER_BLOCK - 1 + } + px = Math.max(0, Math.min(bounds.width - 1, px)) + py = Math.max(0, Math.min(bounds.height - 1, py)) + } + + const last = result[result.length - 1] + if (!last || last.x !== px || last.y !== py) { + result.push({ x: px, y: py }) + } + } + + for (let i = 0; i < n - 1; i += 1) { + const p0 = i > 0 ? controlPoints[i - 1]! : { + x: 2 * controlPoints[0]!.x - controlPoints[1]!.x, + y: 2 * controlPoints[0]!.y - controlPoints[1]!.y, + } + const p1 = controlPoints[i]! + const p2 = controlPoints[i + 1]! + const p3 = i + 2 < n ? controlPoints[i + 2]! : { + x: 2 * controlPoints[n - 1]!.x - controlPoints[n - 2]!.x, + y: 2 * controlPoints[n - 1]!.y - controlPoints[n - 2]!.y, + } + + const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) + const steps = Math.max(3, Math.ceil(dist / 1.0)) + for (let s = 0; s <= steps; s += 1) { + const t = s / steps + const pt = catmullRomPoint(p0, p1, p2, p3, t) + addPoint(pt.x, pt.y) + } + } + + return result +} + +/** + * Find clear block path connecting start to end block avoiding obstacle blocks. + */ +function findClearBlockCorridor( + startBx: number, + startBy: number, + endBx: number, + endBy: number, + gridWidth: number, + gridHeight: number, + roadObstacleBlocks: boolean[][], +): { bx: number; by: number }[] { + if (startBx === endBx && startBy === endBy) { + return [{ bx: startBx, by: startBy }] + } + + interface Node { + bx: number + by: number + g: number + h: number + f: number + parent: Node | null + } + + const openSet: Node[] = [] + const closedSet = new Set() + + const keyOf = (bx: number, by: number) => `${String(bx)},${String(by)}` + const h = (bx: number, by: number) => Math.hypot(endBx - bx, endBy - by) + + const startNode: Node = { + bx: startBx, + by: startBy, + g: 0, + h: h(startBx, startBy), + f: h(startBx, startBy), + parent: null, + } + openSet.push(startNode) + + const DIRS = [ + { dx: 1, dy: 0 }, + { dx: -1, dy: 0 }, + { dx: 0, dy: 1 }, + { dx: 0, dy: -1 }, + { dx: 1, dy: 1 }, + { dx: -1, dy: 1 }, + { dx: 1, dy: -1 }, + { dx: -1, dy: -1 }, + ] + + let bestNode = startNode + let bestDist = h(startBx, startBy) + + while (openSet.length > 0) { + let bestIdx = 0 + for (let i = 1; i < openSet.length; i += 1) { + if (openSet[i]!.f < openSet[bestIdx]!.f) { + bestIdx = i + } + } + const current = openSet.splice(bestIdx, 1)[0]! + + if (current.bx === endBx && current.by === endBy) { + const path: { bx: number; by: number }[] = [] + let curr: Node | null = current + while (curr !== null) { + path.unshift({ bx: curr.bx, by: curr.by }) + curr = curr.parent + } + return path + } + + closedSet.add(keyOf(current.bx, current.by)) + + if (current.h < bestDist) { + bestDist = current.h + bestNode = current + } + + for (const dir of DIRS) { + const nbx = current.bx + dir.dx + const nby = current.by + dir.dy + if (nbx < 1 || nbx >= gridWidth - 1 || nby < 1 || nby >= gridHeight - 1) continue + if (closedSet.has(keyOf(nbx, nby))) continue + + if ( + !(nbx === endBx && nby === endBy) && + roadObstacleBlocks[nby]?.[nbx] === true + ) { + continue + } + + if (dir.dx !== 0 && dir.dy !== 0) { + if ( + roadObstacleBlocks[current.by]?.[current.bx + dir.dx] === true && + roadObstacleBlocks[current.by + dir.dy]?.[current.bx] === true + ) { + continue + } + } + + const moveCost = dir.dx !== 0 && dir.dy !== 0 ? 1.414 : 1.0 + const g = current.g + moveCost + const nodeH = h(nbx, nby) + const existing = openSet.find(n => n.bx === nbx && n.by === nby) + if (existing) { + if (g < existing.g) { + existing.g = g + existing.f = g + existing.h + existing.parent = current + } + } else { + openSet.push({ + bx: nbx, + by: nby, + g, + h: nodeH, + f: g + nodeH, + parent: current, + }) + } + } + } + + const path: { bx: number; by: number }[] = [] + let curr: Node | null = bestNode + while (curr !== null) { + path.unshift({ bx: curr.bx, by: curr.by }) + curr = curr.parent + } + return path +} + +/** + * Build macro control vertex graph and smooth Catmull-Rom spline polylines + * connecting anchors to a central hub (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). + */ +export function createDrlgVertices( plan: Act1OutdoorPlan, gridWidth: number, gridHeight: number, rng: Rng, -): { x: number; y: number }[][] { - if (plan.anchors.length < 2) return [] +): DrlgVertexResult { + if (plan.anchors.length < 2) { + const empty: DrlgVertex[] = [] + return Object.assign(empty, { + vertices: empty, + polylines: [], + lines: [], + }) as DrlgVertexResult + } + // 1. Central Crossroads Hub let sumBx = 0 let sumBy = 0 for (const anchor of plan.anchors) { @@ -2173,75 +2439,168 @@ function buildRoadNetworkPolylines( } } - const hubCell = { x: hubBx * TILES_PER_BLOCK + 3, y: hubBy * TILES_PER_BLOCK + 3 } + const hubCell = { x: hubBx * TILES_PER_BLOCK + 4, y: hubBy * TILES_PER_BLOCK + 4 } + const hubVertex: DrlgVertex = { + x: hubCell.x, + y: hubCell.y, + type: 'hub', + label: 'Central Hub', + } + + const allVertices: DrlgVertex[] = [hubVertex] + + // 2. Add anchors and special presets + for (const anchor of plan.anchors) { + let vType: DrlgVertex['type'] = 'gate' + if (/waypoint/i.test(anchor.label)) { + vType = 'waypoint' + } else if (anchor.kind === 'preset') { + vType = 'preset' + } + allVertices.push({ + x: anchor.interiorCell.x, + y: anchor.interiorCell.y, + type: vType, + label: anchor.label, + id: anchor.toLevelId, + }) + } + + for (const preset of plan.specialPresets) { + const covered = plan.anchors.some(a => + a.label.toLowerCase().includes(preset.name.toLowerCase()) || + preset.name.toLowerCase().includes(a.label.toLowerCase()), + ) + if (!covered) { + const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) + const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + allVertices.push({ + x: px, + y: py, + type: /waypoint/i.test(preset.name) ? 'waypoint' : 'preset', + label: preset.name, + }) + } + } + + // 3. Connect each anchor to the central hub via Catmull-Rom spline with meander and obstacle steering const polylines: { x: number; y: number }[][] = [] + const canvasBounds = { width: gridWidth * TILES_PER_BLOCK, height: gridHeight * TILES_PER_BLOCK } for (const anchor of plan.anchors) { - const line: { x: number; y: number }[] = [ + const startCell = anchor.interiorCell + const startBx = Math.floor(startCell.x / TILES_PER_BLOCK) + const startBy = Math.floor(startCell.y / TILES_PER_BLOCK) + + const blockPath = findClearBlockCorridor( + startBx, + startBy, + hubBx, + hubBy, + gridWidth, + gridHeight, + plan.roadObstacleBlocks, + ) + + const controlPoints: { x: number; y: number }[] = [ anchor.anchorCell, anchor.interiorCell, ] - let currBx = Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK) - let currBy = Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK) - const maxSteps = gridWidth + gridHeight + 8 - for (let step = 0; step < maxSteps && (currBx !== hubBx || currBy !== hubBy); step += 1) { - const dx = hubBx - currBx - const dy = hubBy - currBy - const stepX = Math.sign(dx) - const stepY = Math.sign(dy) + if (blockPath.length > 2) { + let lastDirX = blockPath[1]!.bx - blockPath[0]!.bx + let lastDirY = blockPath[1]!.by - blockPath[0]!.by + let blocksSinceControl = 1 - const candidates: { bx: number; by: number }[] = [] - if (Math.abs(dx) >= 2 && Math.abs(dy) >= 2) { - const mode = rng.int(0, 2) - if (mode === 0) { - candidates.push({ bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy + stepY }) - } else if (mode === 1) { - candidates.push({ bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx + stepX, by: currBy + stepY }) - } else { - candidates.push({ bx: currBx + stepX, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }) - } - } else if (Math.abs(dx) > Math.abs(dy)) { - candidates.push({ bx: currBx + stepX, by: currBy }) - if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY }) - } else { - if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY }) - if (stepX !== 0) candidates.push({ bx: currBx + stepX, by: currBy }) - } + for (let i = 1; i < blockPath.length - 1; i += 1) { + const curr = blockPath[i]! + const next = blockPath[i + 1]! + const dirX = next.bx - curr.bx + const dirY = next.by - curr.by + const isTurn = dirX !== lastDirX || dirY !== lastDirY + blocksSinceControl += 1 - // Detour candidates if primary directions hit an obstacle preset - if (stepX !== 0) { - candidates.push({ bx: currBx + stepX, by: currBy - 1 }, { bx: currBx + stepX, by: currBy + 1 }) - } - if (stepY !== 0) { - candidates.push({ bx: currBx - 1, by: currBy + stepY }, { bx: currBx + 1, by: currBy + stepY }) - } - - let chosen = candidates[0]! - for (const cand of candidates) { - if (cand.bx >= 1 && cand.bx < gridWidth - 1 && cand.by >= 1 && cand.by < gridHeight - 1) { - if (plan.roadObstacleBlocks[cand.by]?.[cand.bx] !== true) { - chosen = cand - break + if (isTurn || blocksSinceControl >= 3) { + const mx = rng.int(-1, 1) + const my = rng.int(-1, 1) + let cx = curr.bx * TILES_PER_BLOCK + 4 + mx + let cy = curr.by * TILES_PER_BLOCK + 4 + my + if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { + cx = curr.bx * TILES_PER_BLOCK + 4 + cy = curr.by * TILES_PER_BLOCK + 4 } + const inflectionVertex: DrlgVertex = { + x: cx, + y: cy, + type: 'inflection', + label: `Inflection (${anchor.label})`, + } + allVertices.push(inflectionVertex) + controlPoints.push({ x: cx, y: cy }) + lastDirX = dirX + lastDirY = dirY + blocksSinceControl = 0 } } - - currBx = chosen.bx - currBy = chosen.by - line.push({ x: currBx * TILES_PER_BLOCK + 3, y: currBy * TILES_PER_BLOCK + 3 }) + } else { + const dist = Math.hypot(hubCell.x - startCell.x, hubCell.y - startCell.y) + if (dist >= 14) { + const midX = Math.round((startCell.x + hubCell.x) / 2) + const midY = Math.round((startCell.y + hubCell.y) / 2) + const perpX = -(hubCell.y - startCell.y) / dist + const perpY = (hubCell.x - startCell.x) / dist + const meanderMag = rng.int(-2, 2) + let cx = Math.round(midX + perpX * meanderMag) + let cy = Math.round(midY + perpY * meanderMag) + if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { + cx = midX + cy = midY + } + const inflectionVertex: DrlgVertex = { + x: cx, + y: cy, + type: 'inflection', + label: `Inflection (${anchor.label})`, + } + allVertices.push(inflectionVertex) + controlPoints.push({ x: cx, y: cy }) + } } - line.push(hubCell) - polylines.push(line) + + controlPoints.push(hubCell) + + const splineLine = sampleCatmullRomSpline(controlPoints, canvasBounds, plan.roadObstacleBlocks) + polylines.push(splineLine) } - return polylines + return Object.assign([...allVertices], { + vertices: allVertices, + polylines, + lines: polylines, + }) as DrlgVertexResult } +export const DRLGVER_CreateVertices = createDrlgVertices + /** - * Rasterize a 2-cell-wide line segment onto the dirt path grid (`sub_6FD75F60`). + * Build smooth Catmull-Rom spline road polylines connecting all anchors to a central hub + * (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). */ -function rasterizeDirtPathSegment( +export function buildOutdoorRoadPolylines( + plan: Act1OutdoorPlan, + gridWidth: number, + gridHeight: number, + rng: Rng, +): { x: number; y: number }[][] { + return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines +} + +export const buildRoadNetworkPolylines = buildOutdoorRoadPolylines + +/** + * Rasterize a 2-3 cell wide line segment onto the dirt path grid (`sub_6FD75F60`). + */ +export function rasterizeDirtPathSegment( grid: Uint8Array, width: number, height: number, @@ -2249,13 +2608,23 @@ function rasterizeDirtPathSegment( y1: number, x2: number, y2: number, + roadWidth: number = 2, + roadObstacleBlocks?: boolean[][], ): void { - const mark2x2 = (x: number, y: number): void => { - for (let dy = 0; dy < 2; dy += 1) { - for (let dx = 0; dx < 2; dx += 1) { + const markWidth = (x: number, y: number): void => { + const limit = Math.max(2, Math.min(3, roadWidth)) + for (let dy = 0; dy < limit; dy += 1) { + for (let dx = 0; dx < limit; dx += 1) { const px = x + dx const py = y + dy if (px >= 0 && px < width && py >= 0 && py < height) { + if (roadObstacleBlocks) { + const obx = Math.floor(px / TILES_PER_BLOCK) + const oby = Math.floor(py / TILES_PER_BLOCK) + if (roadObstacleBlocks[oby]?.[obx] === true) { + continue + } + } grid[py * width + px] = 1 } } @@ -2269,18 +2638,18 @@ function rasterizeDirtPathSegment( const sx = x2 >= x1 ? 1 : -1 const sy = y2 >= y1 ? 1 : -1 - mark2x2(x, y) + markWidth(x, y) if (dx >= dy) { let err = 0 for (let i = 0; i < dx; i += 1) { err += dy if (err > dx) { y += sy - mark2x2(x, y) + markWidth(x, y) err -= dx } x += sx - mark2x2(x, y) + markWidth(x, y) } } else { let err = 0 @@ -2288,11 +2657,11 @@ function rasterizeDirtPathSegment( err += dx if (err > dy) { x += sx - mark2x2(x, y) + markWidth(x, y) err -= dy } y += sy - mark2x2(x, y) + markWidth(x, y) } } } @@ -2301,9 +2670,10 @@ function rasterizeDirtPathSegment( * Rasterize all road polylines into a 2D bitmask grid and apply * `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`). */ -function generateDirtPaths( +export function generateDirtPaths( canvas: Canvas, polylines: readonly (readonly { x: number; y: number }[])[], + roadObstacleBlocks?: boolean[][], ): { dirtPathGrid: Uint8Array; roadCells: number; roadSegments: number } { const width = canvas.width const height = canvas.height @@ -2314,7 +2684,7 @@ function generateDirtPaths( for (let i = 0; i + 1 < line.length; i += 1) { const p0 = line[i]! const p1 = line[i + 1]! - rasterizeDirtPathSegment(dirtPathGrid, width, height, p0.x, p0.y, p1.x, p1.y) + rasterizeDirtPathSegment(dirtPathGrid, width, height, p0.x, p0.y, p1.x, p1.y, 2, roadObstacleBlocks) roadSegments += 1 } } @@ -3241,21 +3611,89 @@ export function stampThemedRoads( } } - // 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`) - for (let y = northEnd; y <= southGateY; y += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - paintRoadTile(hubX + dx, y, true) + const paintSplinePath = ( + controlPoints: readonly { readonly x: number; readonly y: number }[], + brushRadius: number, + clearStrayClutter: boolean, + ): void => { + if (controlPoints.length < 2) return + + for (let i = 0; i < controlPoints.length - 1; i += 1) { + const p0 = i > 0 ? controlPoints[i - 1]! : { + x: 2 * controlPoints[0]!.x - controlPoints[1]!.x, + y: 2 * controlPoints[0]!.y - controlPoints[1]!.y, + } + const p1 = controlPoints[i]! + const p2 = controlPoints[i + 1]! + const p3 = i + 2 < controlPoints.length ? controlPoints[i + 2]! : { + x: 2 * controlPoints[controlPoints.length - 1]!.x - controlPoints[controlPoints.length - 2]!.x, + y: 2 * controlPoints[controlPoints.length - 1]!.y - controlPoints[controlPoints.length - 2]!.y, + } + + const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) + const steps = Math.max(3, Math.ceil(dist / 1.0)) + for (let s = 0; s <= steps; s += 1) { + const t = s / steps + const pt = catmullRomPoint(p0, p1, p2, p3, t) + const cx = Math.round(pt.x) + const cy = Math.round(pt.y) + + if (brushRadius >= 1.5) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + paintRoadTile(cx + dx, cy + dy, clearStrayClutter) + } + } + } else { + for (let dy = 0; dy <= 1; dy += 1) { + for (let dx = 0; dx <= 1; dx += 1) { + paintRoadTile(cx + dx, cy + dy, clearStrayClutter) + } + } + } + } } } - // 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`) - for (let x = westEnd; x <= eastGateX; x += 1) { - for (let dy = -1; dy <= 1; dy += 1) { - paintRoadTile(x, hubY + dy, true) - } - } + // 1. 3-tile-wide South-to-North main highway curved via Catmull-Rom spline + const midNY = Math.round((northEnd + hubY) / 2) + const mNorth = rng.int(-2, 2) + const cpNorth = { x: hubX + mNorth, y: midNY } + if (isInsideProtectedPreset(cpNorth.x, cpNorth.y)) cpNorth.x = hubX - // If openGates contains non-standard gate blocks, connect them into the highway network + const midSY = Math.round((hubY + southGateY) / 2) + const mSouth = rng.int(-2, 2) + const cpSouth = { x: hubX + mSouth, y: midSY } + if (isInsideProtectedPreset(cpSouth.x, cpSouth.y)) cpSouth.x = hubX + + paintSplinePath([ + { x: hubX, y: northEnd }, + cpNorth, + { x: hubX, y: hubY }, + cpSouth, + { x: hubX, y: southGateY }, + ], 1.5, true) + + // 2. 3-tile-wide West-to-East main highway curved via Catmull-Rom spline + const midWX = Math.round((westEnd + hubX) / 2) + const mWest = rng.int(-2, 2) + const cpWest = { x: midWX, y: hubY + mWest } + if (isInsideProtectedPreset(cpWest.x, cpWest.y)) cpWest.y = hubY + + const midEX = Math.round((hubX + eastGateX) / 2) + const mEast = rng.int(-2, 2) + const cpEast = { x: midEX, y: hubY + mEast } + if (isInsideProtectedPreset(cpEast.x, cpEast.y)) cpEast.y = hubY + + paintSplinePath([ + { x: westEnd, y: hubY }, + cpWest, + { x: hubX, y: hubY }, + cpEast, + { x: eastGateX, y: hubY }, + ], 1.5, true) + + // If openGates contains non-standard gate blocks, connect them into the highway network via smooth spline if (openGates !== undefined) { for (const key of openGates.keys()) { const [bxStr, byStr] = key.split(',') @@ -3264,43 +3702,28 @@ export function stampThemedRoads( const gateX = bx * TILES_PER_BLOCK + 4 const gateY = by * TILES_PER_BLOCK + 4 if (by === 0 && Math.abs(gateX - hubX) > 2) { - // North gate branch to hubY - for (let y = 4; y <= hubY; y += 1) { - for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(gateX + dx, y, true) - } - for (let x = Math.min(gateX, hubX); x <= Math.max(gateX, hubX); x += 1) { - for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, hubY + dy, true) - } + // North gate branch to hub + const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1) + const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1) + paintSplinePath([ + { x: gateX, y: 4 }, + { x: midX, y: midY }, + { x: hubX, y: hubY }, + ], 1.5, true) } else if (bx === 0 && Math.abs(gateY - hubY) > 2) { - // West gate branch to hubX - for (let x = 4; x <= hubX; x += 1) { - for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, gateY + dy, true) - } - for (let y = Math.min(gateY, hubY); y <= Math.max(gateY, hubY); y += 1) { - for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(hubX + dx, y, true) - } + // West gate branch to hub + const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1) + const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1) + paintSplinePath([ + { x: 4, y: gateY }, + { x: midX, y: midY }, + { x: hubX, y: hubY }, + ], 1.5, true) } } } - // 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway - const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => { - let curX = x0 - let curY = y0 - let steps = 0 - const maxSteps = canvas.width + canvas.height - while ((curX !== x1 || curY !== y1) && steps < maxSteps) { - for (let dy = 0; dy <= 1; dy += 1) { - for (let dx = 0; dx <= 1; dx += 1) { - paintRoadTile(curX + dx, curY + dy, false) - } - } - if (curX !== x1) curX += x1 > curX ? 1 : -1 - else if (curY !== y1) curY += y1 > curY ? 1 : -1 - steps += 1 - } - } - + // 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway via smooth spline for (const r of placedRects) { if (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/i.test(r.name)) { continue @@ -3310,15 +3733,29 @@ export function stampThemedRoads( // Pick the edge of the preset closest to the main crossroads const distToNS = Math.abs(centerX - hubX) const distToEW = Math.abs(centerY - hubY) + let doorX: number + let doorY: number + let targetX: number + let targetY: number if (distToNS <= distToEW) { - const doorX = centerX < hubX ? r.x + r.w : r.x - 1 - const doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY)) - paintSpurSegment(doorX, doorY, hubX, doorY) + doorX = centerX < hubX ? r.x + r.w : r.x - 1 + doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY)) + targetX = hubX + targetY = doorY } else { - const doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX)) - const doorY = centerY < hubY ? r.y + r.h : r.y - 1 - paintSpurSegment(doorX, doorY, doorX, hubY) + doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX)) + doorY = centerY < hubY ? r.y + r.h : r.y - 1 + targetX = doorX + targetY = hubY } + + const midX = Math.round((doorX + targetX) / 2) + rng.int(-1, 1) + const midY = Math.round((doorY + targetY) / 2) + rng.int(-1, 1) + paintSplinePath([ + { x: doorX, y: doorY }, + { x: midX, y: midY }, + { x: targetX, y: targetY }, + ], 1.0, false) } if (stats) { @@ -5222,9 +5659,12 @@ export function generateWilderness(request: WildernessRequest): WildernessResult act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats) stats.anchors = act1Plan.anchors.length stats.entrances = toEntrances(act1Plan.anchors) - const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng) + const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng) + stats.drlgVertices = vertexResult.vertices + stats.roadPolylines = vertexResult.polylines + const polylines = vertexResult.polylines if (polylines.length > 0) { - const roadRes = generateDirtPaths(canvas, polylines) + const roadRes = generateDirtPaths(canvas, polylines, act1Plan.roadObstacleBlocks) dirtPathGrid = roadRes.dirtPathGrid stats.roadCells = roadRes.roadCells stats.roadSegments = roadRes.roadSegments @@ -5468,6 +5908,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult anchors: stats.anchors, entrances: stats.entrances, specialPresets: stats.specialPresets, + drlgVertices: stats.drlgVertices, + roadPolylines: stats.roadPolylines, substitutions: stats.substitutions, substitutedClusters: substituted + placedSpecialPieces.size, objects: canvas.objects.length, diff --git a/tests/wilderness-spline-roads.test.ts b/tests/wilderness-spline-roads.test.ts new file mode 100644 index 0000000..79de92c --- /dev/null +++ b/tests/wilderness-spline-roads.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' +import type { ActTables } from '../src/game/acts.ts' +import { decodeDs1 } from '../src/formats/ds1.ts' +import { Rng } from '../src/game/rng.ts' +import { + catmullRomPoint, + sampleCatmullRomSpline, + createDrlgVertices, + DRLGVER_CreateVertices, + buildOutdoorRoadPolylines, + buildRoadNetworkPolylines, + rasterizeDirtPathSegment, + generateDirtPaths, + createCanvas, + generateWilderness, + DIRT_PATH_TILE_LUT, + TILES_PER_BLOCK, +} from '../src/game/wilderness.ts' +import type { + Act1OutdoorPlan, + DrlgVertex, + WildernessPiece, + WildernessSubstitution, +} from '../src/game/wilderness.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('Control Vertex Graph & Spline Roads (Issue #66)', () => { + describe('Catmull-Rom Spline Mathematics & Sampling', () => { + it('accurately interpolates start and end control points at t=0 and t=1', () => { + const p0 = { x: 0, y: 0 } + const p1 = { x: 10, y: 20 } + const p2 = { x: 30, y: 50 } + const p3 = { x: 60, y: 60 } + + const start = catmullRomPoint(p0, p1, p2, p3, 0) + expect(start.x).toBeCloseTo(p1.x) + expect(start.y).toBeCloseTo(p1.y) + + const end = catmullRomPoint(p0, p1, p2, p3, 1) + expect(end.x).toBeCloseTo(p2.x) + expect(end.y).toBeCloseTo(p2.y) + + const mid = catmullRomPoint(p0, p1, p2, p3, 0.5) + expect(mid.x).toBeGreaterThan(p1.x) + expect(mid.x).toBeLessThan(p2.x) + expect(mid.y).toBeGreaterThan(p1.y) + expect(mid.y).toBeLessThan(p2.y) + }) + + it('generates smooth continuous points along a multi-segment spline', () => { + const controlPoints = [ + { x: 10, y: 10 }, + { x: 25, y: 18 }, + { x: 45, y: 35 }, + { x: 70, y: 65 }, + ] + const bounds = { width: 100, height: 100 } + const sampled = sampleCatmullRomSpline(controlPoints, bounds) + + expect(sampled.length).toBeGreaterThan(controlPoints.length) + expect(sampled[0]).toEqual({ x: 10, y: 10 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 70, y: 65 }) + + // Verify continuity: adjacent points have step <= 2 cells + for (let i = 0; i < sampled.length - 1; i += 1) { + const dx = Math.abs(sampled[i + 1]!.x - sampled[i]!.x) + const dy = Math.abs(sampled[i + 1]!.y - sampled[i]!.y) + expect(Math.max(dx, dy)).toBeLessThanOrEqual(2) + } + }) + + it('exhibits natural non-orthogonal/non-45° curvature with continuous angles', () => { + // S-curve spline + const controlPoints = [ + { x: 10, y: 10 }, + { x: 20, y: 35 }, + { x: 50, y: 40 }, + { x: 60, y: 70 }, + ] + const bounds = { width: 80, height: 80 } + const sampled = sampleCatmullRomSpline(controlPoints, bounds) + + // Calculate slopes / directions between points sampled along the spline + const uniqueAngles = new Set() + for (let i = 0; i < sampled.length - 3; i += 3) { + const dx = sampled[i + 3]!.x - sampled[i]!.x + const dy = sampled[i + 3]!.y - sampled[i]!.y + if (dx !== 0 || dy !== 0) { + const angle = Math.round((Math.atan2(dy, dx) * 180) / Math.PI) + uniqueAngles.add(angle) + } + } + + // In a pure 45°/90° grid path, angles would only be {0, 45, 90, 135, 180, -45, -90, -135}. + // A Catmull-Rom spline curves smoothly with many distinct angles across the S-curve. + expect(uniqueAngles.size).toBeGreaterThanOrEqual(4) + const nonGridAngles = Array.from(uniqueAngles).filter(a => a % 45 !== 0) + expect(nonGridAngles.length).toBeGreaterThan(0) + }) + + it('clamps spline points strictly within bounds', () => { + const controlPoints = [ + { x: -10, y: -5 }, + { x: 20, y: 30 }, + { x: 120, y: 110 }, + ] + const bounds = { width: 80, height: 80 } + const sampled = sampleCatmullRomSpline(controlPoints, bounds) + + for (const pt of sampled) { + expect(pt.x).toBeGreaterThanOrEqual(0) + expect(pt.x).toBeLessThan(80) + expect(pt.y).toBeGreaterThanOrEqual(0) + expect(pt.y).toBeLessThan(80) + } + }) + + it('deflects spline points around obstacle blocks', () => { + const bounds = { width: 80, height: 80 } + const roadObstacleBlocks = Array.from({ length: 10 }, () => + Array.from({ length: 10 }, () => false), + ) + // Mark block (bx=3, by=3) as an obstacle (cells 24..31 in x and y) + roadObstacleBlocks[3]![3] = true + + const controlPoints = [ + { x: 10, y: 28 }, + { x: 45, y: 28 }, + ] + const sampled = sampleCatmullRomSpline(controlPoints, bounds, roadObstacleBlocks) + + // No point should fall inside the obstacle block (bx=3, by=3) + for (const pt of sampled) { + const bx = Math.floor(pt.x / TILES_PER_BLOCK) + const by = Math.floor(pt.y / TILES_PER_BLOCK) + expect(roadObstacleBlocks[by]?.[bx]).toBe(false) + } + }) + }) + + describe('DRLGVER Control Vertex Graph Generation', () => { + it('creates vertices for gates, hub, presets, and inflections with polylines', () => { + const plan: Act1OutdoorPlan = { + openGates: new Map([ + ['top_4', 3], + ['bottom_4', 3], + ['left_4', 3], + ]), + claimedBorderBlocks: new Set(['top_4', 'bottom_4', 'left_4']), + claimedBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)), + roadObstacleBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)), + specialPresets: [ + { + name: 'Den of Evil', + ds1: null as any, + bx: 7, + by: 7, + blocksX: 2, + blocksY: 2, + }, + ], + anchors: [ + { + label: 'North Gate', + anchorCell: { x: 36, y: 0 }, + interiorCell: { x: 36, y: 8 }, + side: 'north', + kind: 'gate', + }, + { + label: 'South Gate', + anchorCell: { x: 36, y: 79 }, + interiorCell: { x: 36, y: 71 }, + side: 'south', + kind: 'gate', + }, + { + label: 'West Gate', + anchorCell: { x: 0, y: 36 }, + interiorCell: { x: 8, y: 36 }, + side: 'west', + kind: 'gate', + }, + { + label: 'Den of Evil Entrance', + anchorCell: { x: 56, y: 56 }, + interiorCell: { x: 56, y: 52 }, + kind: 'preset', + }, + ], + } + + const rng = new Rng(0x1234_5678) + const res = createDrlgVertices(plan, 10, 10, rng) + + expect(res.vertices.length).toBeGreaterThanOrEqual(4) + expect(res.polylines.length).toBe(plan.anchors.length) + expect(res.lines).toBe(res.polylines) + + // Verify vertex types + const types = res.vertices.map(v => v.type) + expect(types).toContain('hub') + expect(types).toContain('gate') + expect(types).toContain('preset') + + const hub = res.vertices.find(v => v.type === 'hub')! + expect(hub).toBeDefined() + expect(hub.x).toBeGreaterThan(0) + expect(hub.y).toBeGreaterThan(0) + + // Verify DRLGVER_CreateVertices alias parity + const aliasRes = DRLGVER_CreateVertices(plan, 10, 10, new Rng(0x1234_5678)) + expect(aliasRes.vertices.length).toBe(res.vertices.length) + expect(aliasRes.polylines.length).toBe(res.polylines.length) + + // Verify buildOutdoorRoadPolylines and buildRoadNetworkPolylines + const polylines = buildOutdoorRoadPolylines(plan, 10, 10, new Rng(0x1234_5678)) + expect(polylines.length).toBe(res.polylines.length) + const aliasPolylines = buildRoadNetworkPolylines(plan, 10, 10, new Rng(0x1234_5678)) + expect(aliasPolylines).toEqual(polylines) + }) + + it('ensures central hub is relocated if primary centroid is blocked by obstacles', () => { + const obstacleBlocks = Array.from({ length: 10 }, () => + Array.from({ length: 10 }, () => false), + ) + // Block centroid block (5, 5) + obstacleBlocks[5]![5] = true + + const plan: Act1OutdoorPlan = { + openGates: new Map(), + claimedBorderBlocks: new Set(), + claimedBlocks: obstacleBlocks, + roadObstacleBlocks: obstacleBlocks, + specialPresets: [], + anchors: [ + { + label: 'West Gate', + anchorCell: { x: 0, y: 40 }, + interiorCell: { x: 8, y: 40 }, + side: 'west', + kind: 'gate', + }, + { + label: 'East Gate', + anchorCell: { x: 79, y: 40 }, + interiorCell: { x: 71, y: 40 }, + side: 'east', + kind: 'gate', + }, + ], + } + + const res = createDrlgVertices(plan, 10, 10, new Rng(42)) + const hub = res.vertices.find(v => v.type === 'hub')! + const hubBx = Math.floor(hub.x / TILES_PER_BLOCK) + const hubBy = Math.floor(hub.y / TILES_PER_BLOCK) + expect(obstacleBlocks[hubBy]![hubBx]).toBe(false) + }) + }) + + describe('Dirt Path Rasterization & Obstacle Clearance', () => { + it('rasterizes 2-3 cell wide continuous dirt road onto canvas', () => { + const canvas = createCanvas(80, 80, 1, 1, 1) + const polylines = [ + [ + { x: 10, y: 20 }, + { x: 20, y: 22 }, + { x: 30, y: 25 }, + { x: 40, y: 30 }, + { x: 50, y: 38 }, + ], + ] + + const roadRes = generateDirtPaths(canvas, polylines) + expect(roadRes.roadCells).toBeGreaterThan(20) + expect(roadRes.roadSegments).toBe(polylines[0]!.length - 1) + + // Check canvas floors contain road styles (style 0, sequences 1..46) + let stampedRoadFloors = 0 + for (let y = 0; y < 80; y += 1) { + for (let x = 0; x < 80; x += 1) { + const floor = canvas.cells[y]?.[x]?.floors[0] + if (floor && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) { + stampedRoadFloors += 1 + } + } + } + expect(stampedRoadFloors).toBe(roadRes.roadCells) + }) + + it('strictly avoids painting dirt road cells inside roadObstacleBlocks', () => { + const canvas = createCanvas(80, 80, 1, 1, 1) + const roadObstacleBlocks = Array.from({ length: 10 }, () => + Array.from({ length: 10 }, () => false), + ) + // Block block (3, 3) -> cells [24..31, 24..31] + roadObstacleBlocks[3]![3] = true + + // Polyline that passes near or through block 3 + const polylines = [ + [ + { x: 10, y: 28 }, + { x: 26, y: 28 }, + { x: 45, y: 28 }, + ], + ] + + generateDirtPaths(canvas, polylines, roadObstacleBlocks) + + // Verify no cell in block (3, 3) was stamped with dirt road + for (let y = 24; y < 32; y += 1) { + for (let x = 24; x < 32; x += 1) { + const floor = canvas.cells[y]?.[x]?.floors[0] + expect(floor?.sequence).not.toBeGreaterThanOrEqual(1) + } + } + }) + + it('correctly maps 8-neighbor bitmask to DIRT_PATH_TILE_LUT sequence', () => { + const grid = new Uint8Array(20 * 20) + // Place a cross of dirt road cells: + // (10, 10), (10, 9), (10, 11), (9, 10), (11, 10) + grid[10 * 20 + 10] = 1 + grid[9 * 20 + 10] = 1 + grid[11 * 20 + 10] = 1 + grid[10 * 20 + 9] = 1 + grid[10 * 20 + 11] = 1 + + // Compute bitmask for center (10, 10): + // top (10, 9) = bit 4 (0x10) + // bottom (10, 11) = bit 3 (0x08) + // left (9, 10) = bit 1 (0x02) + // right (11, 10) = bit 6 (0x40) + const expectedMask = 0x10 | 0x08 | 0x02 | 0x40 + const expectedSeq = DIRT_PATH_TILE_LUT[expectedMask] + expect(expectedSeq).toBeGreaterThan(0) + }) + }) + + describe.skipIf(!hasD2)('End-to-End Spline Roads in Act 1 Wilderness Generation', () => { + let archives: MountedArchives + let tables: ActTables + let lvlsubTable: any + let act1Pieces: WildernessPiece[] + let act1Subs: WildernessSubstitution[] + + beforeAll(async () => { + archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + tables = await loadActTables(archives) + lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) + + const families = [ + 'Act 1 - Wild', + 'Act 1 - Town 1 Transition', + 'Act 1 - Cave Entrance', + 'Act 1 - DOE Entrance', + 'Act 1 - Cairn Stones', + 'Act 1 - Inifus', + 'Act 1 - Tower', + 'Act 1 - Graveyard', + 'Act 1 - Bivouac', + 'Act 1 - Fallen Camp', + 'Act 1 - Cottages', + 'Act 1 - Pond', + 'Act 1 - Camp', + 'Act 1 - Ruin', + 'Act 1 - Stone Fill', + 'Act 1 - Corral Fill', + 'Act 1 - Fence Fill', + 'Act 1 - Swamp Fill', + 'Act 1 - Tree Fill', + ] + act1Pieces = [] + for (const row of tables.lvlprest.rows) { + const name = cell(tables.lvlprest, row, 'Name') + if (!families.some(f => name.startsWith(f))) continue + const levels = [] + for (let i = 1; i <= 6; i += 1) { + const f = cell(tables.lvlprest, row, `File${String(i)}`) + if (f && f !== '0') { + levels.push(decodeDs1(await archives.read(tileMemberPath(f)))) + } + } + if (levels.length > 0) { + act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) }) + } + } + + act1Subs = [] + for (const row of lvlsubTable.rows) { + if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue + const file = cell(lvlsubTable, row, 'File') + if (!file || file === '0') continue + const ds1 = decodeDs1(await archives.read(tileMemberPath(file))) + const vals = (prefix: string) => + [0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0) + act1Subs.push({ + name: cell(lvlsubTable, row, 'Name'), + type: 0, + gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1, + bordType: Number(cell(lvlsubTable, row, 'BordType')), + dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0, + prob: vals('Prob'), + trials: vals('Trials'), + max: vals('Max'), + levels: [ds1], + }) + } + }, 30000) + + it('populates drlgVertices with gates, hub, and special preset anchors', () => { + const res = generateWilderness({ + levelId: 2, // Blood Moor (Den of Evil) + levelName: 'Blood Moor', + levelTypeName: 'Act 1 - Wilderness', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 0x5eed_0002, + pieces: act1Pieces, + substitutions: act1Subs, + }) + + expect(res.stats.drlgVertices).toBeDefined() + const vertices = res.stats.drlgVertices as DrlgVertex[] + expect(vertices.length).toBeGreaterThanOrEqual(3) + + const vertexTypes = vertices.map(v => v.type) + expect(vertexTypes).toContain('hub') + expect(vertexTypes).toContain('gate') + + // Blood Moor has Den of Evil preset + expect(vertexTypes).toContain('preset') + + expect(res.stats.roadPolylines).toBeDefined() + const polylines = res.stats.roadPolylines as { x: number; y: number }[][] + expect(polylines.length).toBeGreaterThanOrEqual(2) + + // Verify roadCells and dirt road floor stamping + expect(Number(res.stats.roadCells)).toBeGreaterThan(0) + }) + + it('generates smooth spline road in Stony Field connecting Cairn Stones to Gates', () => { + const res = generateWilderness({ + levelId: 4, // Stony Field (Cairn Stones) + levelName: 'Stony Field', + levelTypeName: 'Act 1 - Wilderness', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 0x5eed_0004, + pieces: act1Pieces, + substitutions: act1Subs, + }) + + const vertices = res.stats.drlgVertices as DrlgVertex[] + expect(vertices).toBeDefined() + const cairnVertex = vertices.find(v => v.label?.includes('Cairn Stones') || v.type === 'preset') + expect(cairnVertex).toBeDefined() + + const hubVertex = vertices.find(v => v.type === 'hub') + expect(hubVertex).toBeDefined() + + // Spline polylines connect to hub + const polylines = res.stats.roadPolylines as { x: number; y: number }[][] + expect(polylines.length).toBeGreaterThanOrEqual(2) + + // Verify all polyline points are within map bounds + for (const line of polylines) { + for (const pt of line) { + expect(pt.x).toBeGreaterThanOrEqual(0) + expect(pt.x).toBeLessThan(res.level.width) + expect(pt.y).toBeGreaterThanOrEqual(0) + expect(pt.y).toBeLessThan(res.level.height) + } + } + }) + }) +})