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:
parent
6ead63c6c5
commit
ca3512f195
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
])
|
||||
|
|
|
|||
Loading…
Reference in New Issue