feat(wilderness): 还原边界顶点链表与格子级土路算法替换 Catmull-Rom 样条 (Issue #82)

- 实现 DRLGVER_CreateVertices (D2MOO DrlgDrlgVer.cpp:15-226):
  * 构建关卡边界矩形封闭双向循环链表 (顺时针 V0(0,0), V1(w,0), V2(w,h), V3(0,h))
  * 对边界连接/门/预置插入 1-2 个顶点 (支持 span [v15, v16])
  * 设置标识: dwFlags |= 1 (连接), 预置房 dwFlags |= 2
  * 采用相对坐标 (x - originX, y - originY)
  * 消耗 0 次随机数 rolls
  * 从 UNIMPLEMENTED_PASSES 移除 DRLGVER_CreateVertices
- 实现 DRLGOUTDOORS_CalculatePathCoordinates (DrlgOutdoors.cpp:1139-1168):
  * 边界顶点 8 格吸附 (ALTDIR_WEST: +11, ALTDIR_NORTH: +11, ALTDIR_EAST: -5, ALTDIR_SOUTH: -5)
  * Math.trunc 保持 C 整数截断一致性
  * 兼容 (pLevel, pVertex1, pVertex2) 与 (vertex, origin) 调用签名
- 实现 DRLGOUTDOORS_CalculateHubCoordinates (DrlgOutdoors.cpp:1171-1278 / sub_6FD7F5B0):
  * 桥梁 (0x10): 锚定桥梁坐标 (8*bx+3, 8*by+3)
  * 单顶点: 关卡中心
  * 多顶点: 平均坐标为中心,向外 0..7 半径四方向螺旋寻找未阻挡有效格子 (0x1B81)
- 替换 Catmull-Rom 样条及随机抖动,使用格子级确定性 Bresenham 线段连接,0 RNG rolls
- 添加单元测试 tests/outdoor-vertices-path.test.ts 覆盖全部算法特性

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 11:31:48 +00:00
parent 6ead63c6c5
commit ca3512f195
3 changed files with 990 additions and 135 deletions

View File

@ -1657,7 +1657,6 @@ export function applyGroundTransitions(
* the gap is visible rather than implied.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
@ -2928,11 +2927,15 @@ export 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
x: number
y: number
type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' | 'corner'
label?: string | undefined
id?: number | undefined
direction?: number | undefined
dwFlags?: number | undefined
next?: DrlgVertex | null | undefined
prev?: DrlgVertex | null | undefined
}
export interface DrlgRoadNetwork {
@ -2944,6 +2947,8 @@ export type DrlgVertexResult = DrlgVertex[] & {
readonly vertices: DrlgVertex[]
readonly polylines: { x: number; y: number }[][]
readonly lines: { x: number; y: number }[][]
readonly cyclicVertices?: DrlgVertex[] | undefined
readonly head?: DrlgVertex | undefined
}
/**
@ -3183,89 +3188,528 @@ function findClearBlockCorridor(
return path
}
/** Direction constants in D2MOO D2AltDirections order. */
export const ALTDIR_WEST = 0
export const ALTDIR_NORTH = 1
export const ALTDIR_EAST = 2
export const ALTDIR_SOUTH = 3
/**
* Build macro control vertex graph and smooth Catmull-Rom spline polylines
* connecting anchors to a central hub (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`).
* Bresenham line algorithm connecting two points on integer grid coordinates (0 RNG rolls).
*/
export function createDrlgVertices(
export function bresenhamLine(
x0: number,
y0: number,
x1: number,
y1: number,
): { x: number; y: number }[] {
const points: { x: number; y: number }[] = []
let x = Math.round(x0)
let y = Math.round(y0)
const targetX = Math.round(x1)
const targetY = Math.round(y1)
const dx = Math.abs(targetX - x)
const dy = Math.abs(targetY - y)
const sx = x < targetX ? 1 : -1
const sy = y < targetY ? 1 : -1
let err = dx - dy
while (true) {
points.push({ x, y })
if (x === targetX && y === targetY) break
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
x += sx
}
if (e2 < dx) {
err += dx
y += sy
}
}
return points
}
/**
* Check whether an outdoor grid cell is valid for spawning / hub placement
* (`DRLGOUTDOORS_TestGridCellSpawnValid`, mask 0x1B81).
*/
export function DRLGOUTDOORS_TestGridCellSpawnValid(
x: number,
y: number,
gridOrPlan?: { readonly roadObstacleBlocks?: boolean[][]; readonly gridFlags?: number[][] } | number[][],
): boolean {
if (!gridOrPlan) return true
if (Array.isArray(gridOrPlan)) {
const val = gridOrPlan[y]?.[x] ?? 0
return (val & 0x1B81) === 0
}
if (gridOrPlan.gridFlags) {
const val = gridOrPlan.gridFlags[y]?.[x] ?? 0
if ((val & 0x1B81) !== 0) return false
}
if (gridOrPlan.roadObstacleBlocks) {
if (gridOrPlan.roadObstacleBlocks[y]?.[x] === true) return false
}
return true
}
/**
* Snap vertex to 8-cell grid based on border direction (`DRLGOUTDOORS_CalculatePathCoordinates`,
* D2Common.0x6FD7F500).
*
* Supports both TypeScript `(vertex1, origin)` and D2MOO `(level, vertex1, vertex2)` call styles.
*/
export function DRLGOUTDOORS_CalculatePathCoordinates(
arg1: any,
arg2?: any,
arg3?: any,
): { x: number; y: number } {
let origin = { x: 0, y: 0 }
let v1: any
let v2: any = null
if (arg3 !== undefined || (arg2 !== undefined && typeof arg2 === 'object' && ('direction' in arg2 || 'nDirection' in arg2))) {
const origX = arg1?.nPosX ?? arg1?.x ?? 0
const origY = arg1?.nPosY ?? arg1?.y ?? 0
origin = { x: origX, y: origY }
v1 = arg2
v2 = arg3
} else {
v1 = arg1
if (arg2 && typeof arg2 === 'object') {
origin = { x: arg2.x ?? arg2.nPosX ?? 0, y: arg2.y ?? arg2.nPosY ?? 0 }
}
}
const v1X = v1.nPosX ?? v1.x ?? 0
const v1Y = v1.nPosY ?? v1.y ?? 0
const dir = v1.nDirection ?? v1.direction ?? -1
let relX = v1X - origin.x
let relY = v1Y - origin.y
switch (dir) {
case ALTDIR_WEST:
relX = 8 * Math.trunc(relX / 8) + 11
break
case ALTDIR_NORTH:
relY = 8 * Math.trunc(relY / 8) + 11
break
case ALTDIR_EAST:
relX = 8 * Math.trunc(relX / 8) - 5
break
case ALTDIR_SOUTH:
relY = 8 * Math.trunc(relY / 8) - 5
break
default:
break
}
const result = {
x: relX + origin.x,
y: relY + origin.y,
}
if (v2 && typeof v2 === 'object') {
v2.x = result.x
v2.y = result.y
v2.nPosX = result.x
v2.nPosY = result.y
}
return result
}
/**
* Calculate central crossroads hub coordinate (`sub_6FD7F5B0`, D2Common.0x6FD7F5B0).
*
* If bridge (dwFlags & 0x10): anchor to bridge coordinates.
* Else if nVertices === 1: hub = (Math.floor(nGridWidth / 2), Math.floor(nGridHeight / 2)).
* Else:
* avgX = Math.floor(sumX / (8 * nVertices))
* avgY = Math.floor(sumY / (8 * nVertices))
* Spiral outward with radius 0..7 and 4 directions: [-1, 0], [0, 1], [0, -1], [1, 0].
* Pick first cell passing TestGridCellSpawnValid (cell & 0x1B81 === 0).
* Consumes 0 RNG rolls!
*/
export function DRLGOUTDOORS_CalculateHubCoordinates(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng: Rng,
): DrlgVertexResult {
if (plan.anchors.length < 2) {
const empty: DrlgVertex[] = []
return Object.assign(empty, {
vertices: empty,
polylines: [],
lines: [],
}) as DrlgVertexResult
origin: { x: number; y: number } = { x: 0, y: 0 },
): { x: number; y: number; bx: number; by: number } {
const dwFlags = (plan as any).dwFlags ?? 0
const hasBridge = (dwFlags & 0x10) !== 0 || (plan as any).hasBridge === true ||
plan.specialPresets.some(p => /bridge/i.test(p.name))
const bridgePreset = plan.specialPresets.find(p => /bridge/i.test(p.name))
const bridgeCoords = (plan as any).bridgeCoords ?? (bridgePreset ? { x: bridgePreset.bx, y: bridgePreset.by } : undefined)
if (hasBridge && bridgeCoords && bridgeCoords.x !== -1) {
const bx = bridgeCoords.x
const by = bridgeCoords.y
return {
x: 8 * bx + 3 + origin.x,
y: 8 * by + 3 + origin.y,
bx,
by,
}
}
// 1. Central Crossroads Hub
let sumBx = 0
let sumBy = 0
const nVertices = plan.anchors.length
if (nVertices === 1) {
const bx = Math.floor(gridWidth / 2)
const by = Math.floor(gridHeight / 2)
return {
x: bx * TILES_PER_BLOCK + 4 + origin.x,
y: by * TILES_PER_BLOCK + 4 + origin.y,
bx,
by,
}
}
let sumX = 0
let sumY = 0
for (const anchor of plan.anchors) {
sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK)
sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK)
sumX += anchor.anchorCell.x - origin.x
sumY += anchor.anchorCell.y - origin.y
}
let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1)))
let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1)))
if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) {
let found = false
for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) {
for (let dy = -r; dy <= r && !found; dy += 1) {
for (let dx = -r; dx <= r && !found; dx += 1) {
const nx = hubBx + dx
const ny = hubBy + dy
if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) {
if (plan.roadObstacleBlocks[ny]?.[nx] !== true) {
hubBx = nx
hubBy = ny
found = true
}
const count = Math.max(1, nVertices)
let avgX = Math.floor(sumX / (8 * count))
let avgY = Math.floor(sumY / (8 * count))
avgX = Math.max(0, Math.min(gridWidth - 1, avgX))
avgY = Math.max(0, Math.min(gridHeight - 1, avgY))
const DIR_OFFSETS = [
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 },
{ x: 1, y: 0 },
]
let chosenBx = avgX
let chosenBy = avgY
let found = false
for (let r = 0; r < 8 && !found; r += 1) {
for (const dir of DIR_OFFSETS) {
const testX = avgX + r * dir.x
const testY = avgY + r * dir.y
if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) {
if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) {
chosenBx = testX
chosenBy = testY
found = true
break
}
}
}
}
if (!found) {
for (let r = 8; r < Math.max(gridWidth, gridHeight) && !found; r += 1) {
for (const dir of DIR_OFFSETS) {
const testX = avgX + r * dir.x
const testY = avgY + r * dir.y
if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) {
if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) {
chosenBx = testX
chosenBy = testY
found = true
break
}
}
}
}
}
const hubCell = { x: hubBx * TILES_PER_BLOCK + 4, y: hubBy * TILES_PER_BLOCK + 4 }
return {
x: chosenBx * TILES_PER_BLOCK + 4 + origin.x,
y: chosenBy * TILES_PER_BLOCK + 4 + origin.y,
bx: chosenBx,
by: chosenBy,
}
}
/**
* Constructs level bounding box closed cyclic linked list and deterministic grid-level paths
* (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_CalculatePathCoordinates`, D2MOO DrlgDrlgVer.cpp:15-226).
*
* 1. Constructs level bounding box closed cyclic linked list (4 corner vertices: (0,0), (w,0), (w,h), (0,h)).
* 2. For each orthogonal connection, inserts 1-2 vertices into that edge at connection span endpoints (v15/v16).
* 3. Flags: inserted vertex `dwFlags |= 1` (connection); if preset room `dwFlags |= 2`.
* 4. Coordinates: subtract level origin to convert to relative coordinates.
* 5. Consumes 0 RNG rolls!
*/
export function createDrlgVertices(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng?: Rng,
origin: { x: number; y: number } = { x: 0, y: 0 },
): DrlgVertexResult {
const tileW = gridWidth <= 32 ? gridWidth * TILES_PER_BLOCK : gridWidth
const tileH = gridHeight <= 32 ? gridHeight * TILES_PER_BLOCK : gridHeight
const gridW = gridWidth <= 32 ? gridWidth : Math.floor(gridWidth / TILES_PER_BLOCK)
const gridH = gridHeight <= 32 ? gridHeight : Math.floor(gridHeight / TILES_PER_BLOCK)
// 1. Level bounding box closed cyclic linked list 4 corner vertices in clockwise order:
// V0: (0, 0)
// V1: (width, 0)
// V2: (width, height)
// V3: (0, height)
const v0: DrlgVertex = {
x: 0,
y: 0,
direction: ALTDIR_NORTH,
dwFlags: 0,
type: 'corner',
label: 'Corner V0 (NW)',
}
const v1: DrlgVertex = {
x: tileW,
y: 0,
direction: ALTDIR_EAST,
dwFlags: 0,
type: 'corner',
label: 'Corner V1 (NE)',
}
const v2: DrlgVertex = {
x: tileW,
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags: 0,
type: 'corner',
label: 'Corner V2 (SE)',
}
const v3: DrlgVertex = {
x: 0,
y: tileH,
direction: ALTDIR_WEST,
dwFlags: 0,
type: 'corner',
label: 'Corner V3 (SW)',
}
// 2. For each exterior connection / gate / orth connection on each of the 4 borders:
// Compute connection entry/exit span on that edge.
// Insert 1-2 vertices into that edge at connection span endpoints (v15/v16).
// dwFlags |= 1 (connection); if preset room dwFlags |= 2.
// Coordinates: subtract level origin to convert to relative coordinates.
const northVertices: DrlgVertex[] = []
const eastVertices: DrlgVertex[] = []
const southVertices: DrlgVertex[] = []
const westVertices: DrlgVertex[] = []
const borderConnections = (plan as any).connections ?? plan.anchors ?? []
for (const conn of borderConnections) {
const rawX = (conn.anchorCell ? conn.anchorCell.x : (conn.x ?? 0)) - origin.x
const rawY = (conn.anchorCell ? conn.anchorCell.y : (conn.y ?? 0)) - origin.y
const side = conn.side ?? (
rawY <= 0 ? 'north' :
rawX >= tileW - 1 ? 'east' :
rawY >= tileH - 1 ? 'south' :
rawX <= 0 ? 'west' : undefined
)
if (!side) continue
const isPreset = conn.kind === 'preset' || (conn as any).isPreset === true || (conn.label && /preset/i.test(conn.label))
const dwFlags = 1 | (isPreset ? 2 : 0)
const vType = isPreset ? 'preset' : 'gate'
const label = conn.label ?? (isPreset ? 'Preset Connection' : 'Gate Connection')
const id = conn.toLevelId ?? conn.id
const span = (conn as any).span ?? (
((conn as any).v15 !== undefined && (conn as any).v16 !== undefined)
? [(conn as any).v15, (conn as any).v16]
: undefined
)
if (side === 'north') {
if (span && span[0] !== span[1]) {
northVertices.push({
x: Math.max(0, Math.min(tileW, span[0] - origin.x)),
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
northVertices.push({
x: Math.max(0, Math.min(tileW, span[1] - origin.x)),
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX))
northVertices.push({
x,
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'east') {
if (span && span[0] !== span[1]) {
eastVertices.push({
x: tileW,
y: Math.max(0, Math.min(tileH, span[0] - origin.y)),
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
eastVertices.push({
x: tileW,
y: Math.max(0, Math.min(tileH, span[1] - origin.y)),
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY))
eastVertices.push({
x: tileW,
y,
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'south') {
if (span && span[0] !== span[1]) {
southVertices.push({
x: Math.max(0, Math.min(tileW, span[0] - origin.x)),
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
southVertices.push({
x: Math.max(0, Math.min(tileW, span[1] - origin.x)),
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX))
southVertices.push({
x,
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'west') {
if (span && span[0] !== span[1]) {
westVertices.push({
x: 0,
y: Math.max(0, Math.min(tileH, span[0] - origin.y)),
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
westVertices.push({
x: 0,
y: Math.max(0, Math.min(tileH, span[1] - origin.y)),
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY))
westVertices.push({
x: 0,
y,
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label,
id,
})
}
}
}
// Sort edges in clockwise order:
// North edge (V0 -> V1): X ascending
northVertices.sort((a, b) => a.x - b.x)
// East edge (V1 -> V2): Y ascending
eastVertices.sort((a, b) => a.y - b.y)
// South edge (V2 -> V3): X descending
southVertices.sort((a, b) => b.x - a.x)
// West edge (V3 -> V0): Y descending
westVertices.sort((a, b) => b.y - a.y)
// Construct cyclic doubly linked list in clockwise order:
const cyclicVertices: DrlgVertex[] = [
v0,
...northVertices,
v1,
...eastVertices,
v2,
...southVertices,
v3,
...westVertices,
]
for (let i = 0; i < cyclicVertices.length; i += 1) {
const curr = cyclicVertices[i]!
const next = cyclicVertices[(i + 1) % cyclicVertices.length]!
const prev = cyclicVertices[(i - 1 + cyclicVertices.length) % cyclicVertices.length]!
curr.next = next
curr.prev = prev
}
// 3. Central Hub calculation (0 RNG rolls)
const hubInfo = DRLGOUTDOORS_CalculateHubCoordinates(plan, gridW, gridH, origin)
const hubCell = { x: hubInfo.x, y: hubInfo.y }
const hubVertex: DrlgVertex = {
x: hubCell.x,
y: hubCell.y,
type: 'hub',
label: 'Central Hub',
direction: 4,
dwFlags: 0,
}
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,
})
}
// 4. Collect all vertices: cyclic boundary vertices + hub + interior presets
const allVertices: DrlgVertex[] = [...cyclicVertices, hubVertex]
// Add interior presets (if not already on the border)
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)
const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) + origin.x
const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + origin.y
const alreadyPresent = allVertices.some(v => v.label === preset.name || Math.hypot(v.x - px, v.y - py) < 4)
if (!alreadyPresent) {
allVertices.push({
x: px,
y: py,
@ -3275,114 +3719,111 @@ export function createDrlgVertices(
}
}
// 3. Connect each anchor to the central hub via Catmull-Rom spline with meander and obstacle steering
for (const anchor of plan.anchors) {
if (anchor.kind === 'preset') {
const px = anchor.interiorCell.x - origin.x
const py = anchor.interiorCell.y - origin.y
const alreadyPresent = allVertices.some(v => Math.hypot(v.x - px, v.y - py) < 4)
if (!alreadyPresent) {
allVertices.push({
x: px,
y: py,
type: /waypoint/i.test(anchor.label) ? 'waypoint' : 'preset',
label: anchor.label,
id: anchor.toLevelId,
})
}
}
}
// 5. Grid Path Connection (replacing sampleCatmullRomSpline, 0 RNG):
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 startCell = anchor.interiorCell
const startBx = Math.floor(startCell.x / TILES_PER_BLOCK)
const startBy = Math.floor(startCell.y / TILES_PER_BLOCK)
// Snap border vertex to 8-cell grid:
const dir = anchor.side === 'west' ? ALTDIR_WEST :
anchor.side === 'north' ? ALTDIR_NORTH :
anchor.side === 'east' ? ALTDIR_EAST :
anchor.side === 'south' ? ALTDIR_SOUTH : undefined
let snappedStart = anchor.interiorCell
if (dir !== undefined) {
snappedStart = DRLGOUTDOORS_CalculatePathCoordinates({
x: anchor.anchorCell.x,
y: anchor.anchorCell.y,
direction: dir,
}, origin)
}
const startBx = Math.floor(snappedStart.x / TILES_PER_BLOCK)
const startBy = Math.floor(snappedStart.y / TILES_PER_BLOCK)
// Deterministic A* block corridor avoiding obstacles
const blockPath = findClearBlockCorridor(
startBx,
startBy,
hubBx,
hubBy,
gridWidth,
gridHeight,
hubInfo.bx,
hubInfo.by,
gridW,
gridH,
plan.roadObstacleBlocks,
)
const controlPoints: { x: number; y: number }[] = [
const waypoints: { x: number; y: number }[] = [
anchor.anchorCell,
anchor.interiorCell,
snappedStart,
]
if (blockPath.length > 2) {
let lastDirX = blockPath[1]!.bx - blockPath[0]!.bx
let lastDirY = blockPath[1]!.by - blockPath[0]!.by
let blocksSinceControl = 1
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
}
const b = blockPath[i]!
waypoints.push({
x: b.bx * TILES_PER_BLOCK + 4 + origin.x,
y: b.by * TILES_PER_BLOCK + 4 + origin.y,
})
}
} 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
}
waypoints.push(hubCell)
// Rasterize waypoints with Bresenham lines into a continuous grid-level line (0 RNG)
const pathLine: { x: number; y: number }[] = []
for (let i = 0; i < waypoints.length - 1; i += 1) {
const p0 = waypoints[i]!
const p1 = waypoints[i + 1]!
const seg = bresenhamLine(p0.x, p0.y, p1.x, p1.y)
for (const pt of seg) {
const clampedX = Math.max(0, Math.min(tileW - 1, pt.x))
const clampedY = Math.max(0, Math.min(tileH - 1, pt.y))
const last = pathLine[pathLine.length - 1]
if (!last || last.x !== clampedX || last.y !== clampedY) {
pathLine.push({ x: clampedX, y: clampedY })
}
const inflectionVertex: DrlgVertex = {
x: cx,
y: cy,
type: 'inflection',
label: `Inflection (${anchor.label})`,
}
allVertices.push(inflectionVertex)
controlPoints.push({ x: cx, y: cy })
}
}
controlPoints.push(hubCell)
const splineLine = sampleCatmullRomSpline(controlPoints, canvasBounds, plan.roadObstacleBlocks)
polylines.push(splineLine)
polylines.push(pathLine)
}
return Object.assign([...allVertices], {
vertices: allVertices,
polylines,
lines: polylines,
cyclicVertices,
head: v0,
}) as DrlgVertexResult
}
export const DRLGVER_CreateVertices = createDrlgVertices
/**
* Build smooth Catmull-Rom spline road polylines connecting all anchors to a central hub
* Build deterministic road polylines connecting all anchors to a central hub
* (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`).
*/
export function buildOutdoorRoadPolylines(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng: Rng,
rng?: Rng,
): { x: number; y: number }[][] {
return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines
}

View File

@ -0,0 +1,414 @@
import { describe, it, expect } from 'vitest'
import { Rng } from '../src/game/rng.ts'
import {
ALTDIR_WEST,
ALTDIR_NORTH,
ALTDIR_EAST,
ALTDIR_SOUTH,
DRLGVER_CreateVertices,
DRLGOUTDOORS_CalculatePathCoordinates,
DRLGOUTDOORS_CalculateHubCoordinates,
DRLGOUTDOORS_TestGridCellSpawnValid,
bresenhamLine,
UNIMPLEMENTED_PASSES,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { Act1OutdoorPlan, DrlgVertex } from '../src/game/wilderness.ts'
describe('Outdoor Boundary Vertices & Grid Road Path (Issue #82)', () => {
function createMockPlan(overrides: Partial<Act1OutdoorPlan> = {}): Act1OutdoorPlan {
const gridW = 4
const gridH = 4
return {
openGates: new Map(),
claimedBorderBlocks: new Set(),
claimedBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)),
roadObstacleBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)),
specialPresets: [],
gateOpenings: [],
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 16 },
interiorCell: { x: 4, y: 16 },
label: 'Rogue Encampment Gate',
toLevelId: 1,
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 31, y: 16 },
interiorCell: { x: 27, y: 16 },
label: 'Cold Plains Gate',
toLevelId: 3,
kind: 'gate',
},
],
...overrides,
}
}
describe('Unimplemented Passes Audit', () => {
it('verifies DRLGVER_CreateVertices is removed from UNIMPLEMENTED_PASSES', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices')
})
})
describe('DRLGVER_CreateVertices: 0 RNG Rolls Guarantee', () => {
it('consumes exactly 0 RNG rolls during vertex and path construction', () => {
const rng = new Rng(0x12345678)
const initialSeed = rng.seed
const plan = createMockPlan()
const result = DRLGVER_CreateVertices(plan, 4, 4, rng)
expect(rng.seed).toBe(initialSeed)
expect(result.vertices.length).toBeGreaterThan(0)
expect(result.polylines.length).toBe(plan.anchors.length)
})
})
describe('DRLGVER_CreateVertices: Closed Cyclic Linked List', () => {
it('constructs 4 corner vertices in clockwise order with relative coordinates', () => {
const plan = createMockPlan({ anchors: [] })
const gridW = 4
const gridH = 4
const tileW = gridW * TILES_PER_BLOCK
const tileH = gridH * TILES_PER_BLOCK
const origin = { x: 100, y: 200 }
const result = DRLGVER_CreateVertices(plan, gridW, gridH, undefined, origin)
const cyclic = result.cyclicVertices!
expect(cyclic).toBeDefined()
expect(cyclic.length).toBe(4)
const [v0, v1, v2, v3] = cyclic
expect(v0).toMatchObject({ x: 0, y: 0, direction: ALTDIR_NORTH, dwFlags: 0, type: 'corner' })
expect(v1).toMatchObject({ x: tileW, y: 0, direction: ALTDIR_EAST, dwFlags: 0, type: 'corner' })
expect(v2).toMatchObject({ x: tileW, y: tileH, direction: ALTDIR_SOUTH, dwFlags: 0, type: 'corner' })
expect(v3).toMatchObject({ x: 0, y: tileH, direction: ALTDIR_WEST, dwFlags: 0, type: 'corner' })
// Check next pointers (clockwise)
expect(v0.next).toBe(v1)
expect(v1.next).toBe(v2)
expect(v2.next).toBe(v3)
expect(v3.next).toBe(v0)
// Check prev pointers (counter-clockwise)
expect(v0.prev).toBe(v3)
expect(v3.prev).toBe(v2)
expect(v2.prev).toBe(v1)
expect(v1.prev).toBe(v0)
})
it('inserts border connection vertices into appropriate edges with correct dwFlags and order', () => {
const plan = createMockPlan({
anchors: [
{
side: 'north',
anchorCell: { x: 16, y: 0 },
interiorCell: { x: 16, y: 4 },
label: 'North Gate',
toLevelId: 4,
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 32, y: 20 },
interiorCell: { x: 28, y: 20 },
label: 'East Preset',
toLevelId: 5,
kind: 'preset',
},
{
side: 'south',
anchorCell: { x: 24, y: 32 },
interiorCell: { x: 24, y: 28 },
label: 'South Gate',
toLevelId: 6,
kind: 'gate',
},
{
side: 'west',
anchorCell: { x: 0, y: 12 },
interiorCell: { x: 4, y: 12 },
label: 'West Gate',
toLevelId: 7,
kind: 'gate',
},
],
})
const result = DRLGVER_CreateVertices(plan, 4, 4)
const cyclic = result.cyclicVertices!
// 4 corners + 4 inserted border vertices = 8 vertices
expect(cyclic.length).toBe(8)
// Verify full cycle traversal via next
let curr = result.head!
const visitedForward: DrlgVertex[] = []
for (let i = 0; i < cyclic.length; i += 1) {
visitedForward.push(curr)
curr = curr.next!
}
expect(curr).toBe(result.head!)
expect(visitedForward.length).toBe(8)
// Verify full cycle traversal via prev
let prevNode = result.head!
const visitedBackward: DrlgVertex[] = []
for (let i = 0; i < cyclic.length; i += 1) {
visitedBackward.push(prevNode)
prevNode = prevNode.prev!
}
expect(prevNode).toBe(result.head!)
// Find inserted vertices and verify flags:
// Gate: dwFlags = 1 (connection)
// Preset: dwFlags = 3 (1 = connection | 2 = preset)
const northV = cyclic.find(v => v.direction === ALTDIR_NORTH && v.type === 'gate')
expect(northV).toBeDefined()
expect(northV!.dwFlags).toBe(1)
expect(northV!.x).toBe(16)
expect(northV!.y).toBe(0)
const eastV = cyclic.find(v => v.direction === ALTDIR_EAST && v.type === 'preset')
expect(eastV).toBeDefined()
expect(eastV!.dwFlags).toBe(3) // dwFlags |= 1 | 2
expect(eastV!.x).toBe(32)
expect(eastV!.y).toBe(20)
const southV = cyclic.find(v => v.direction === ALTDIR_SOUTH && v.type === 'gate')
expect(southV).toBeDefined()
expect(southV!.dwFlags).toBe(1)
expect(southV!.x).toBe(24)
expect(southV!.y).toBe(32)
const westV = cyclic.find(v => v.direction === ALTDIR_WEST && v.type === 'gate')
expect(westV).toBeDefined()
expect(westV!.dwFlags).toBe(1)
expect(westV!.x).toBe(0)
expect(westV!.y).toBe(12)
})
it('inserts span endpoint vertices [v15, v16] when provided for wide connections', () => {
const plan = createMockPlan({
anchors: [
{
side: 'north',
anchorCell: { x: 16, y: 0 },
interiorCell: { x: 16, y: 4 },
label: 'North River Span',
toLevelId: 4,
kind: 'preset',
span: [12, 20],
} as any,
],
})
const result = DRLGVER_CreateVertices(plan, 4, 4)
const cyclic = result.cyclicVertices!
// 4 corners + 2 span vertices = 6
expect(cyclic.length).toBe(6)
const northSpans = cyclic.filter(v => v.direction === ALTDIR_NORTH && v.type === 'preset')
expect(northSpans.length).toBe(2)
expect(northSpans[0]!.x).toBe(12)
expect(northSpans[1]!.x).toBe(20)
expect(northSpans[0]!.label).toContain('(Start)')
expect(northSpans[1]!.label).toContain('(End)')
})
})
describe('DRLGOUTDOORS_CalculatePathCoordinates', () => {
it('correctly snaps coordinates according to 8-cell grid rules for all 4 directions', () => {
const origin = { x: 0, y: 0 }
// ALTDIR_WEST (0): relX = 8 * Math.trunc(relX / 8) + 11
const westSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 0,
y: 16,
direction: ALTDIR_WEST,
}, origin)
expect(westSnapped.x).toBe(8 * Math.trunc(0 / 8) + 11) // 11
expect(westSnapped.y).toBe(16)
// ALTDIR_NORTH (1): relY = 8 * Math.trunc(relY / 8) + 11
const northSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 16,
y: 0,
direction: ALTDIR_NORTH,
}, origin)
expect(northSnapped.x).toBe(16)
expect(northSnapped.y).toBe(8 * Math.trunc(0 / 8) + 11) // 11
// ALTDIR_EAST (2): relX = 8 * Math.trunc(relX / 8) - 5
const eastSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 32,
y: 16,
direction: ALTDIR_EAST,
}, origin)
expect(eastSnapped.x).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27
expect(eastSnapped.y).toBe(16)
// ALTDIR_SOUTH (3): relY = 8 * Math.trunc(relY / 8) - 5
const southSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 16,
y: 32,
direction: ALTDIR_SOUTH,
}, origin)
expect(southSnapped.x).toBe(16)
expect(southSnapped.y).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27
})
it('supports D2MOO C++ style (pLevel, pVertex1, pVertex2) signature and populates pVertex2', () => {
const mockLevel = { nPosX: 10, nPosY: 20 }
const pVertex1 = { nPosX: 42, nPosY: 20, nDirection: ALTDIR_EAST }
const pVertex2: { nPosX?: number; nPosY?: number; x?: number; y?: number } = {}
const res = DRLGOUTDOORS_CalculatePathCoordinates(mockLevel, pVertex1, pVertex2)
// relX = 42 - 10 = 32 -> 8 * trunc(32 / 8) - 5 = 27 -> 27 + 10 = 37
// relY = 20 - 20 = 0 -> 0 + 20 = 20
expect(res.x).toBe(37)
expect(res.y).toBe(20)
expect(pVertex2.x).toBe(37)
expect(pVertex2.y).toBe(20)
expect(pVertex2.nPosX).toBe(37)
expect(pVertex2.nPosY).toBe(20)
})
})
describe('DRLGOUTDOORS_TestGridCellSpawnValid', () => {
it('validates cell bitmask 0x1B81 and road obstacle blocks', () => {
const plan = {
gridFlags: [
[0, 0x0001],
[0x0080, 0x1000],
],
roadObstacleBlocks: [
[false, false],
[false, true],
],
}
// (0,0) has 0 -> valid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 0, plan)).toBe(true)
// (1,0) has 0x0001 (bit 0 in 0x1B81) -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 0, plan)).toBe(false)
// (0,1) has 0x0080 (bit 7 in 0x1B81) -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 1, plan)).toBe(false)
// (1,1) has roadObstacleBlocks = true -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 1, plan)).toBe(false)
})
})
describe('DRLGOUTDOORS_CalculateHubCoordinates', () => {
it('returns center of level when nVertices === 1', () => {
const plan = createMockPlan({
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 16 },
interiorCell: { x: 4, y: 16 },
label: 'Single Gate',
kind: 'gate',
},
],
})
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6)
expect(hub.bx).toBe(3) // floor(6 / 2)
expect(hub.by).toBe(3) // floor(6 / 2)
expect(hub.x).toBe(3 * TILES_PER_BLOCK + 4)
expect(hub.y).toBe(3 * TILES_PER_BLOCK + 4)
})
it('anchors to bridge coordinates when bridge flag is set', () => {
const plan = createMockPlan({
dwFlags: 0x10,
specialPresets: [
{
name: 'Blood Moor Bridge',
bx: 2,
by: 3,
blocksX: 1,
blocksY: 1,
ds1: {} as any,
},
],
} as any)
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6)
expect(hub.bx).toBe(2)
expect(hub.by).toBe(3)
expect(hub.x).toBe(8 * 2 + 3) // 19
expect(hub.y).toBe(8 * 3 + 3) // 27
})
it('computes average coordinates and spirals outward radius 0..7 avoiding obstacles', () => {
// 2 anchors on west and east borders
// anchor 1: cell (0, 8) -> bx = 0, by = 1
// anchor 2: cell (32, 24) -> bx = 4, by = 3
// sumX = 0 + 32 = 32 -> avgX = floor(32 / 16) = 2
// sumY = 8 + 24 = 32 -> avgY = floor(32 / 16) = 2
// If (2, 2) is blocked by road obstacle, spiral radius 1 tests [-1,0] -> (1, 2)
const obstacleBlocks = Array.from({ length: 5 }, () => Array(5).fill(false))
obstacleBlocks[2]![2] = true // Block (2, 2)
const plan = createMockPlan({
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 8 },
interiorCell: { x: 4, y: 8 },
label: 'West',
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 32, y: 24 },
interiorCell: { x: 28, y: 24 },
label: 'East',
kind: 'gate',
},
],
roadObstacleBlocks: obstacleBlocks,
})
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 5, 5)
// Radius 1 first direction is { x: -1, y: 0 } -> testX = 2 - 1 = 1, testY = 2
expect(hub.bx).toBe(1)
expect(hub.by).toBe(2)
})
})
describe('Bresenham Grid Line Rasterization', () => {
it('produces deterministic contiguous grid line without diagonal gaps', () => {
const points = bresenhamLine(2, 3, 7, 6)
expect(points.length).toBeGreaterThan(0)
expect(points[0]).toEqual({ x: 2, y: 3 })
expect(points[points.length - 1]).toEqual({ x: 7, y: 6 })
for (let i = 1; i < points.length; i += 1) {
const pPrev = points[i - 1]!
const pCurr = points[i]!
const dx = Math.abs(pCurr.x - pPrev.x)
const dy = Math.abs(pCurr.y - pPrev.y)
expect(dx).toBeLessThanOrEqual(1)
expect(dy).toBeLessThanOrEqual(1)
expect(dx + dy).toBeGreaterThan(0)
}
})
it('guarantees deterministic identical output across runs with 0 RNG', () => {
const line1 = bresenhamLine(10, 20, 50, 80)
const line2 = bresenhamLine(10, 20, 50, 80)
expect(line1).toEqual(line2)
})
})
})

View File

@ -70,8 +70,8 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices')
expect(UNIMPLEMENTED_PASSES).toEqual([
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
])