feat(wilderness): 用基于控制顶点图 (DRLGVER) 的 Catmull-Rom 样条道路取代栅格化 A* 道路 (#66)

- 实现控制顶点图算法 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
This commit is contained in:
troytt 2026-09-18 02:32:14 +00:00
parent 11e8a39e4e
commit 939ada6e4c
2 changed files with 1053 additions and 120 deletions

View File

@ -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<string, number>
readonly claimedBorderBlocks: Set<string>
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<string>()
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 })
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
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
}
}
} else {
candidates.push({ bx: currBx + stepX, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY })
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
}
} 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 })
}
// 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
const inflectionVertex: DrlgVertex = {
x: cx,
y: cy,
type: 'inflection',
label: `Inflection (${anchor.label})`,
}
allVertices.push(inflectionVertex)
controlPoints.push({ x: cx, y: cy })
}
}
currBx = chosen.bx
currBy = chosen.by
line.push({ x: currBx * TILES_PER_BLOCK + 3, y: currBy * TILES_PER_BLOCK + 3 })
}
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,
}
// 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`)
for (let x = westEnd; x <= eastGateX; x += 1) {
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) {
paintRoadTile(x, hubY + dy, true)
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)
}
}
}
}
}
}
// If openGates contains non-standard gate blocks, connect them into the highway network
// 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
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,

View File

@ -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<number>()
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)
}
}
})
})
})