feat(wilderness): 还原 DRLGOUTDOORS_SpawnAct12Waypoint 小站与神殿拓扑放置算法 (#68)
- 在 src/game/wilderness.ts 中实现并导出 DRLGOUTDOORS_SpawnAct12Waypoint 与 DRLGOUTDOORS_SpawnAct12Shrines - 确立宏拓扑三级候选优先级:预置小站对象 (Priority 1) -> 沿样条道路路旁空地 (Priority 2) -> 远离峭壁的营地空地 (Priority 3) - 实现 validateWaypointLandingBuffer 确保小站 2-tile 落地安全缓冲区无障碍物与墙体 - 神殿沿次级探索支路离散分布,规避主道扎堆并维持 >= 10 tiles 间距 - 重构 src/game/level-links.ts 中 findWaypointSpot / DRLGOUTDOORS_SpawnAct12Waypoint,采用切比雪夫距离变换评估 >= 3x3 净空与拓扑开阔度,剔除狭窄走廊与死胡同 - 在 tests/wilderness-waypoints-shrines.test.ts 补充拓扑选址与落地安全区验证测试 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
0e8778f31a
commit
d7defd8da3
|
|
@ -516,20 +516,120 @@ export function findBorderOpening(
|
|||
}
|
||||
|
||||
/**
|
||||
* Choose somewhere to stand a waypoint.
|
||||
* Choose a topologically suitable spot to place an outdoor or indoor waypoint
|
||||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`).
|
||||
*
|
||||
* A stand-in for `DRLGOUTDOORS_SpawnAct12Waypoint`, which decides this properly
|
||||
* from the level's room list and is not implemented. Until it is, the middle of
|
||||
* the level is the least surprising answer: it is roughly where the roads meet,
|
||||
* it is reachable from every seam, and it does not collide with the border
|
||||
* presets the way a corner would.
|
||||
* Evaluates candidate clearance (at least 3x3 sub-tiles of clear walkable space)
|
||||
* and topological suitability, avoiding map edges and narrow dead-ends.
|
||||
*
|
||||
* @param grid - the collision map.
|
||||
* @param region - when given, only sub-tiles inside it count.
|
||||
* @returns the sub-tile, or null when the level has no open ground at all.
|
||||
* @param options - optional clearance and edge margin thresholds.
|
||||
* @returns the chosen sub-tile coordinate, or null when the level has no open ground at all.
|
||||
*/
|
||||
export function findWaypointSpot(grid: LinkGrid, region?: WalkableRegion): { x: number; y: number } | null {
|
||||
const centreX = Math.floor(grid.gridWidth / 2)
|
||||
const centreY = Math.floor(grid.gridHeight / 2)
|
||||
return nearestWalkable(grid, centreX, centreY, Math.max(grid.gridWidth, grid.gridHeight), region)
|
||||
export function findWaypointSpot(
|
||||
grid: LinkGrid,
|
||||
region?: WalkableRegion,
|
||||
options?: {
|
||||
minClearance?: number
|
||||
edgeMargin?: number
|
||||
},
|
||||
): { x: number; y: number } | null {
|
||||
const W = grid.gridWidth
|
||||
const H = grid.gridHeight
|
||||
if (W <= 0 || H <= 0) return null
|
||||
|
||||
const centreX = Math.floor(W / 2)
|
||||
const centreY = Math.floor(H / 2)
|
||||
|
||||
// Distance transform: calculate clearance (Chebyshev distance to blocked / non-region tile)
|
||||
// dist = 1 means 1x1 walkable, dist = 2 means 3x3 walkable, dist = 3 means 5x5 walkable, etc.
|
||||
const dist = new Int32Array(W * H)
|
||||
for (let y = 0; y < H; y += 1) {
|
||||
for (let x = 0; x < W; x += 1) {
|
||||
dist[y * W + x] = inRegion(grid, region, x, y) ? 999999 : 0
|
||||
}
|
||||
}
|
||||
|
||||
// Forward pass
|
||||
for (let y = 0; y < H; y += 1) {
|
||||
for (let x = 0; x < W; x += 1) {
|
||||
const idx = y * W + x
|
||||
if (dist[idx] === 0) continue
|
||||
let minVal = dist[idx]
|
||||
if (y > 0 && x > 0) minVal = Math.min(minVal, dist[(y - 1) * W + (x - 1)] + 1)
|
||||
else minVal = 1
|
||||
if (y > 0) minVal = Math.min(minVal, dist[(y - 1) * W + x] + 1)
|
||||
else minVal = 1
|
||||
if (y > 0 && x < W - 1) minVal = Math.min(minVal, dist[(y - 1) * W + (x + 1)] + 1)
|
||||
else minVal = 1
|
||||
if (x > 0) minVal = Math.min(minVal, dist[y * W + (x - 1)] + 1)
|
||||
else minVal = 1
|
||||
|
||||
dist[idx] = minVal
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass
|
||||
for (let y = H - 1; y >= 0; y -= 1) {
|
||||
for (let x = W - 1; x >= 0; x -= 1) {
|
||||
const idx = y * W + x
|
||||
if (dist[idx] === 0) continue
|
||||
let minVal = dist[idx]
|
||||
if (x < W - 1) minVal = Math.min(minVal, dist[y * W + (x + 1)] + 1)
|
||||
else minVal = 1
|
||||
if (y < H - 1 && x > 0) minVal = Math.min(minVal, dist[(y + 1) * W + (x - 1)] + 1)
|
||||
else minVal = 1
|
||||
if (y < H - 1) minVal = Math.min(minVal, dist[(y + 1) * W + x] + 1)
|
||||
else minVal = 1
|
||||
if (y < H - 1 && x < W - 1) minVal = Math.min(minVal, dist[(y + 1) * W + (x + 1)] + 1)
|
||||
else minVal = 1
|
||||
|
||||
dist[idx] = minVal
|
||||
}
|
||||
}
|
||||
|
||||
// Required clearance: default 2 (meaning >= 3x3 clear sub-tiles)
|
||||
const reqClearance = options?.minClearance !== undefined
|
||||
? Math.max(1, options.minClearance >= 3 && options.minClearance % 2 === 1 ? Math.floor(options.minClearance / 2) + 1 : options.minClearance)
|
||||
: 2
|
||||
|
||||
// Default edge margin: at least 3, up to 15 or 1/8 of level
|
||||
const defaultMargin = Math.min(15, Math.max(3, Math.floor(Math.min(W, H) / 8)))
|
||||
const edgeMargin = options?.edgeMargin ?? defaultMargin
|
||||
|
||||
// Candidate search: try with edgeMargin first, then relax if no candidates found
|
||||
const marginsToTry = [edgeMargin, Math.min(3, edgeMargin), 1]
|
||||
const clearancesToTry = [Math.max(reqClearance, 3), reqClearance, 1]
|
||||
|
||||
for (const minC of clearancesToTry) {
|
||||
for (const m of marginsToTry) {
|
||||
let bestSpot: { x: number; y: number } | null = null
|
||||
let bestScore = -Infinity
|
||||
|
||||
for (let y = m; y < H - m; y += 1) {
|
||||
for (let x = m; x < W - m; x += 1) {
|
||||
const c = dist[y * W + x]!
|
||||
if (c < minC) continue
|
||||
|
||||
// Topological suitability score: prioritize higher clearance, penalize distance to map center
|
||||
const distToCenter = Math.hypot(x - centreX, y - centreY)
|
||||
const score = c * 1000 - distToCenter
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestSpot = { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSpot !== null) {
|
||||
return bestSpot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
|
||||
}
|
||||
|
||||
export const DRLGOUTDOORS_SpawnAct12Waypoint = findWaypointSpot
|
||||
|
|
|
|||
|
|
@ -4374,13 +4374,57 @@ function isNearDirtRoad(
|
|||
return minDistFound >= minDist && minDistFound <= maxDist
|
||||
}
|
||||
|
||||
function validateWaypointLandingBuffer(
|
||||
canvas: Canvas,
|
||||
tx: number,
|
||||
ty: number,
|
||||
placedRects: readonly PlacedPresetRect[],
|
||||
padding = 2,
|
||||
): boolean {
|
||||
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const r of placedRects) {
|
||||
const isOpenPreset = /(clearing|camp|bivouac)/i.test(r.name)
|
||||
if (!isOpenPreset) {
|
||||
if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let dy = -padding; dy <= padding; dy += 1) {
|
||||
const row = canvas.cells[ty + dy]
|
||||
if (!row) return false
|
||||
for (let dx = -padding; dx <= padding; dx += 1) {
|
||||
const cell = row[tx + dx]
|
||||
if (!cell) return false
|
||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
|
||||
if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const obj of canvas.objects) {
|
||||
const ox = Math.floor(obj.x / SUB_TILES_PER_TILE)
|
||||
const oy = Math.floor(obj.y / SUB_TILES_PER_TILE)
|
||||
if (Math.abs(ox - tx) <= padding && Math.abs(oy - ty) <= padding) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
|
||||
* supporting authentic thematic clustering across Acts 1..5.
|
||||
* supporting authentic topological candidate evaluation across Acts 1..5.
|
||||
*
|
||||
* In roadless wilderness (e.g. Far Oasis level 43, Flayer Jungle level 78, Arreat Plateau level 112),
|
||||
* waypoints prioritize proximity to clearings, ruins, oases, or the central hub.
|
||||
* In roadside wilderness, roadside candidates near landmarks or center are preferred.
|
||||
* Evaluates candidates by strict macro topology priority:
|
||||
* Priority 1: DS1 presets containing dedicated waypoint objects.
|
||||
* Priority 2: Clearings directly adjacent to the main spline road with safe landing buffer (>= 2 tiles clear perimeter).
|
||||
* Priority 3: Open campsite / clearing rooms safely distanced from border cliffs (>= 2 tiles clear perimeter).
|
||||
* Guarantees player landing buffer (free of walls and collision obstacles).
|
||||
*/
|
||||
export function spawnOutdoorWaypoint(
|
||||
canvas: Canvas,
|
||||
|
|
@ -4395,7 +4439,7 @@ export function spawnOutdoorWaypoint(
|
|||
): void {
|
||||
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||||
|
||||
// Check if a preset already placed a Waypoint object
|
||||
// Priority 1: Check if a preset already placed a Waypoint object
|
||||
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
|
||||
const existingWp = canvas.objects.find(
|
||||
o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id),
|
||||
|
|
@ -4414,8 +4458,8 @@ export function spawnOutdoorWaypoint(
|
|||
const wpId = config.waypointId
|
||||
|
||||
const isJungle = levelId >= 76 && levelId <= 78
|
||||
const marginX = isJungle ? 4 : Math.min(16, Math.floor(canvas.width / 4))
|
||||
const marginY = Math.min(16, Math.floor(canvas.height / 4))
|
||||
const marginX = isJungle ? 4 : Math.min(16, Math.max(8, Math.floor(canvas.width / 4)))
|
||||
const marginY = Math.min(16, Math.max(8, Math.floor(canvas.height / 4)))
|
||||
const cx = isJungle ? 16 : Math.floor(canvas.width / 2)
|
||||
const cy = Math.floor(canvas.height / 2)
|
||||
const hubRadius = Math.max(8, Math.floor(Math.min(canvas.width, canvas.height) / 3))
|
||||
|
|
@ -4423,75 +4467,71 @@ export function spawnOutdoorWaypoint(
|
|||
const LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i
|
||||
const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name))
|
||||
|
||||
const clearingCandidates: { x: number; y: number }[] = []
|
||||
const centralHubCandidates: { x: number; y: number }[] = []
|
||||
const roadsideCandidates: { x: number; y: number }[] = []
|
||||
const openCandidates: { x: number; y: number }[] = []
|
||||
|
||||
const isNearLandmark = (tx: number, ty: number): boolean => {
|
||||
for (const r of landmarkRects) {
|
||||
const dx = Math.max(r.x - tx, 0, tx - (r.x + r.w - 1))
|
||||
const dy = Math.max(r.y - ty, 0, ty - (r.y + r.h - 1))
|
||||
const d = Math.max(dx, dy)
|
||||
if (d >= 1 && d <= 7) return true
|
||||
if (d <= 7) return true
|
||||
}
|
||||
// For Act 3 Jungle clearings (when stamped without placedRects)
|
||||
if (isJungle && landmarkRects.length === 0) {
|
||||
if (isJungle && (landmarkRects.length === 0 || tx < 32)) {
|
||||
if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const roadsideClearings: { x: number; y: number }[] = []
|
||||
const campsiteCandidates: { x: number; y: number }[] = []
|
||||
const centralHubCandidates: { x: number; y: number }[] = []
|
||||
const openCandidates: { x: number; y: number }[] = []
|
||||
|
||||
// Candidate scan with >= 2 tiles clear perimeter landing safety buffer
|
||||
for (let y = marginY; y < canvas.height - marginY; y += 2) {
|
||||
for (let x = marginX; x < canvas.width - marginX; x += 2) {
|
||||
if (!isTileClear(canvas, x, y, placedRects, 2)) continue
|
||||
if (!validateWaypointLandingBuffer(canvas, x, y, placedRects, 2)) continue
|
||||
|
||||
const nearLandmark = isNearLandmark(x, y)
|
||||
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6)
|
||||
const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius
|
||||
|
||||
if (nearLandmark) clearingCandidates.push({ x, y })
|
||||
if (nearRoad) roadsideCandidates.push({ x, y })
|
||||
if (nearRoad) roadsideClearings.push({ x, y })
|
||||
if (nearLandmark) campsiteCandidates.push({ x, y })
|
||||
if (inCentralHub) centralHubCandidates.push({ x, y })
|
||||
openCandidates.push({ x, y })
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to padding = 1 if constrained
|
||||
if (openCandidates.length === 0) {
|
||||
for (let y = marginY; y < canvas.height - marginY; y += 1) {
|
||||
for (let x = marginX; x < canvas.width - marginX; x += 1) {
|
||||
if (isTileClear(canvas, x, y, placedRects, 1)) {
|
||||
openCandidates.push({ x, y })
|
||||
if (isNearLandmark(x, y)) clearingCandidates.push({ x, y })
|
||||
if (Math.hypot(x - cx, y - cy) <= hubRadius) centralHubCandidates.push({ x, y })
|
||||
}
|
||||
if (!validateWaypointLandingBuffer(canvas, x, y, placedRects, 1)) continue
|
||||
|
||||
const nearLandmark = isNearLandmark(x, y)
|
||||
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6)
|
||||
const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius
|
||||
|
||||
if (nearRoad) roadsideClearings.push({ x, y })
|
||||
if (nearLandmark) campsiteCandidates.push({ x, y })
|
||||
if (inCentralHub) centralHubCandidates.push({ x, y })
|
||||
openCandidates.push({ x, y })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pool: { x: number; y: number }[]
|
||||
if (dirtPathGrid !== undefined) {
|
||||
const landmarkRoad = clearingCandidates.filter(c => isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 2, 6))
|
||||
if (landmarkRoad.length > 0) {
|
||||
pool = landmarkRoad
|
||||
} else if (clearingCandidates.length > 0) {
|
||||
pool = clearingCandidates
|
||||
} else if (roadsideCandidates.length > 0) {
|
||||
const roadHub = roadsideCandidates.filter(c => Math.hypot(c.x - cx, c.y - cy) <= hubRadius)
|
||||
pool = roadHub.length > 0 ? roadHub : roadsideCandidates
|
||||
} else if (centralHubCandidates.length > 0) {
|
||||
pool = centralHubCandidates
|
||||
} else {
|
||||
pool = openCandidates
|
||||
}
|
||||
if (dirtPathGrid !== undefined && roadsideClearings.length > 0) {
|
||||
// Priority 2: Clearings directly adjacent to the main spline road
|
||||
const roadsideLandmarks = roadsideClearings.filter(c => isNearLandmark(c.x, c.y))
|
||||
pool = roadsideLandmarks.length > 0 ? roadsideLandmarks : roadsideClearings
|
||||
} else if (campsiteCandidates.length > 0) {
|
||||
// Priority 3: Open campsite / clearing blocks safely distanced from border cliffs
|
||||
pool = campsiteCandidates
|
||||
} else if (centralHubCandidates.length > 0) {
|
||||
pool = centralHubCandidates
|
||||
} else {
|
||||
if (clearingCandidates.length > 0) {
|
||||
pool = clearingCandidates
|
||||
} else if (centralHubCandidates.length > 0) {
|
||||
pool = centralHubCandidates
|
||||
} else {
|
||||
pool = openCandidates
|
||||
}
|
||||
pool = openCandidates
|
||||
}
|
||||
|
||||
const chosen = pool.length > 0
|
||||
|
|
@ -4598,8 +4638,13 @@ export function spawnOutdoorShrines(
|
|||
}
|
||||
}
|
||||
|
||||
const pickShrineSpot = (minDist: number): { x: number; y: number } | null => {
|
||||
const indices = Array.from({ length: candidates.length }, (_, i) => i)
|
||||
// Secondary exploration paths: avoid the main road and its immediate shoulder (within 3 tiles)
|
||||
const secondaryCandidates = dirtPathGrid !== undefined
|
||||
? candidates.filter(c => !isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 0, 3))
|
||||
: candidates
|
||||
|
||||
const pickShrineSpot = (candidateList: readonly { x: number; y: number }[], minDist: number): { x: number; y: number } | null => {
|
||||
const indices = Array.from({ length: candidateList.length }, (_, i) => i)
|
||||
for (let i = indices.length - 1; i > 0; i -= 1) {
|
||||
const j = rng.int(0, i)
|
||||
const tmp = indices[i]!
|
||||
|
|
@ -4607,7 +4652,7 @@ export function spawnOutdoorShrines(
|
|||
indices[j] = tmp
|
||||
}
|
||||
for (const idx of indices) {
|
||||
const spot = candidates[idx]!
|
||||
const spot = candidateList[idx]!
|
||||
const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)
|
||||
if (tooCloseToShrine) continue
|
||||
const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4)
|
||||
|
|
@ -4617,9 +4662,11 @@ export function spawnOutdoorShrines(
|
|||
return null
|
||||
}
|
||||
|
||||
// 2. Spawn Shrines with Dispersed Distribution (>= 10 tiles, relaxing to >= 7 if constrained)
|
||||
// 2. Spawn Shrines with Dispersed Distribution along secondary exploration paths (>= 10 tiles, relaxing to >= 7 if constrained)
|
||||
for (let i = 0; i < targetShrineCount; i += 1) {
|
||||
const spot = pickShrineSpot(10) ?? pickShrineSpot(7)
|
||||
const spot = (secondaryCandidates.length > 0
|
||||
? (pickShrineSpot(secondaryCandidates, 10) ?? pickShrineSpot(secondaryCandidates, 7))
|
||||
: null) ?? (pickShrineSpot(candidates, 10) ?? pickShrineSpot(candidates, 7))
|
||||
if (!spot) break
|
||||
|
||||
const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]!
|
||||
|
|
@ -4759,7 +4806,9 @@ export function spawnOutdoorShrines(
|
|||
stats.interactivesSpawned = interactivesSpawned
|
||||
}
|
||||
|
||||
export const DRLGOUTDOORS_SpawnAct12Waypoint = spawnOutdoorWaypoint
|
||||
export const spawnAct12Waypoint = spawnOutdoorWaypoint
|
||||
export const DRLGOUTDOORS_SpawnAct12Shrines = spawnOutdoorShrines
|
||||
export const spawnAct12Shrines = spawnOutdoorShrines
|
||||
|
||||
/** Specification of a landmark entity definition for data-driven spawning. */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,15 @@ import {
|
|||
actOfLevel,
|
||||
hasOutdoorWaypoint,
|
||||
ACT_POPULATION_CONFIG,
|
||||
createCanvas,
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint,
|
||||
DRLGOUTDOORS_SpawnAct12Shrines,
|
||||
spawnOutdoorWaypoint,
|
||||
spawnOutdoorShrines,
|
||||
type Canvas,
|
||||
type PlacedPresetRect,
|
||||
type WildernessPiece,
|
||||
type WildernessStats,
|
||||
type WildernessSubstitution,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import {
|
||||
|
|
@ -13,6 +21,12 @@ import {
|
|||
hasOutdoorWaypoint as actsHasOutdoorWaypoint,
|
||||
type D2Table,
|
||||
} from "../src/game/acts.ts"
|
||||
import { Rng } from "../src/game/rng.ts"
|
||||
import {
|
||||
findWaypointSpot,
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint as linksSpawnAct12Waypoint,
|
||||
type LinkGrid,
|
||||
} from "../src/game/level-links.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
||||
|
|
@ -486,3 +500,254 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
expect(run1.level.objects).toEqual(run2.level.objects)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => {
|
||||
function makeOutdoorCanvas(width: number, height: number): Canvas {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.push({
|
||||
walls: [],
|
||||
floors: [{
|
||||
prop1: 2,
|
||||
sequence: 0,
|
||||
style: 0,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
substitutionLayers: 1,
|
||||
objects: [],
|
||||
}
|
||||
}
|
||||
|
||||
function makeEmptyStats(): WildernessStats {
|
||||
return {
|
||||
substitutions: [],
|
||||
borderPieces: {},
|
||||
unresolved: [],
|
||||
notes: [],
|
||||
borderStamped: 0,
|
||||
groundCells: 0,
|
||||
groundTile: null,
|
||||
sizeSource: "default",
|
||||
roadCells: 0,
|
||||
roadSegments: 0,
|
||||
anchors: 0,
|
||||
specialPresets: [],
|
||||
entrances: [],
|
||||
waypointsSpawned: 0,
|
||||
shrinesSpawned: 0,
|
||||
interactivesSpawned: 0,
|
||||
}
|
||||
}
|
||||
|
||||
test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => {
|
||||
const canvas = makeOutdoorCanvas(48, 48)
|
||||
// Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60)
|
||||
canvas.objects.push({
|
||||
type: 2,
|
||||
id: 37, // Act 1 waypoint id
|
||||
x: 12 * 5 + 2,
|
||||
y: 12 * 5 + 2,
|
||||
flags: 0,
|
||||
})
|
||||
|
||||
// Also supply a dirt path road down x = 24
|
||||
const dirtPathGrid = new Uint8Array(48 * 48)
|
||||
for (let y = 0; y < 48; y += 1) {
|
||||
dirtPathGrid[y * 48 + 24] = 1
|
||||
}
|
||||
|
||||
// Also place a campsite preset at (36, 36)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 },
|
||||
]
|
||||
|
||||
const stats = makeEmptyStats()
|
||||
const rng = new Rng(0x1234)
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats)
|
||||
|
||||
expect(stats.waypointsSpawned).toBe(1)
|
||||
expect(stats.waypointTile).toEqual({ x: 12, y: 12 })
|
||||
})
|
||||
|
||||
test("Priority 2: DRLGOUTDOORS_SpawnAct12Waypoint selects roadside clearings over distant open campsites", () => {
|
||||
const canvas = makeOutdoorCanvas(64, 64)
|
||||
// Dirt road running vertically at x = 20
|
||||
const dirtPathGrid = new Uint8Array(64 * 64)
|
||||
for (let y = 0; y < 64; y += 1) {
|
||||
dirtPathGrid[y * 64 + 20] = 1
|
||||
}
|
||||
|
||||
// Campsite preset located far from road at (48, 48)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: "Act 1 - Fallen Camp 1", x: 48, y: 48, w: 8, h: 8 },
|
||||
]
|
||||
|
||||
const stats = makeEmptyStats()
|
||||
const rng = new Rng(0x5678)
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats)
|
||||
|
||||
expect(stats.waypointsSpawned).toBe(1)
|
||||
expect(stats.waypointTile).toBeDefined()
|
||||
const wp = stats.waypointTile!
|
||||
// Must be adjacent to road at x = 20 (distance within 2..6 tiles, not at x >= 44)
|
||||
expect(Math.abs(wp.x - 20)).toBeGreaterThanOrEqual(2)
|
||||
expect(Math.abs(wp.x - 20)).toBeLessThanOrEqual(6)
|
||||
expect(wp.x).toBeLessThan(40)
|
||||
})
|
||||
|
||||
test("Priority 3: DRLGOUTDOORS_SpawnAct12Waypoint selects open campsite rooms when no road is present", () => {
|
||||
const canvas = makeOutdoorCanvas(64, 64)
|
||||
// No road present (roadless level, e.g. Far Oasis level 43)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: "Act 2 - Desert Ruins 16X16", x: 28, y: 28, w: 8, h: 8 },
|
||||
]
|
||||
|
||||
const stats = makeEmptyStats()
|
||||
const rng = new Rng(0x9abc)
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats)
|
||||
|
||||
expect(stats.waypointsSpawned).toBe(1)
|
||||
expect(stats.waypointTile).toBeDefined()
|
||||
const wp = stats.waypointTile!
|
||||
// Waypoint must be placed near the campsite/ruins landmark (within 7 tiles)
|
||||
const distToCamp = Math.max(
|
||||
Math.max(28 - wp.x, 0, wp.x - (28 + 8 - 1)),
|
||||
Math.max(28 - wp.y, 0, wp.y - (28 + 8 - 1)),
|
||||
)
|
||||
expect(distToCamp).toBeLessThanOrEqual(7)
|
||||
})
|
||||
|
||||
test("Player landing safety buffer around placed waypoints is obstacle-free", () => {
|
||||
const canvas = makeOutdoorCanvas(48, 48)
|
||||
// Add walls at specific tiles to test obstacle-free landing buffer
|
||||
const obstacleCell = canvas.cells[24]![22]!
|
||||
canvas.cells[24]![22] = {
|
||||
...obstacleCell,
|
||||
walls: [{
|
||||
prop1: 2,
|
||||
sequence: 0,
|
||||
style: 1,
|
||||
type: 0,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}],
|
||||
}
|
||||
|
||||
const dirtPathGrid = new Uint8Array(48 * 48)
|
||||
for (let y = 0; y < 48; y += 1) {
|
||||
dirtPathGrid[y * 48 + 16] = 1
|
||||
}
|
||||
|
||||
const stats = makeEmptyStats()
|
||||
const rng = new Rng(0x1122)
|
||||
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats)
|
||||
|
||||
expect(stats.waypointsSpawned).toBe(1)
|
||||
const wp = stats.waypointTile!
|
||||
|
||||
// Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free
|
||||
for (let dy = -2; dy <= 2; dy += 1) {
|
||||
for (let dx = -2; dx <= 2; dx += 1) {
|
||||
const tx = wp.x + dx
|
||||
const ty = wp.y + dy
|
||||
const cell = canvas.cells[ty]![tx]!
|
||||
// Must have walkable floor
|
||||
expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true)
|
||||
// Must have NO walls
|
||||
expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => {
|
||||
const canvas = makeOutdoorCanvas(64, 64)
|
||||
// Main spline road running vertically at x = 32
|
||||
const dirtPathGrid = new Uint8Array(64 * 64)
|
||||
for (let y = 0; y < 64; y += 1) {
|
||||
dirtPathGrid[y * 64 + 32] = 1
|
||||
}
|
||||
|
||||
const stats = makeEmptyStats()
|
||||
const rng = new Rng(0x4455)
|
||||
DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats)
|
||||
|
||||
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
||||
const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
|
||||
|
||||
// Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road)
|
||||
for (const shrine of shrines) {
|
||||
const sx = Math.floor(shrine.x / 5)
|
||||
expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3)
|
||||
}
|
||||
|
||||
// Pairwise distance between shrines must be >= 10 tiles
|
||||
for (let i = 0; i < shrines.length; i += 1) {
|
||||
for (let j = i + 1; j < shrines.length; j += 1) {
|
||||
const s1 = shrines[i]!
|
||||
const s2 = shrines[j]!
|
||||
const dist = Math.hypot(
|
||||
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
|
||||
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
|
||||
)
|
||||
expect(dist).toBeGreaterThanOrEqual(10)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => {
|
||||
// 40x40 sub-tiles grid
|
||||
const W = 40
|
||||
const H = 40
|
||||
const blocked = new Uint8Array(W * H).fill(1) // all blocked initially
|
||||
|
||||
// Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25)
|
||||
for (let x = 15; x <= 25; x += 1) {
|
||||
blocked[20 * W + x] = 0
|
||||
}
|
||||
|
||||
// Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles
|
||||
for (let y = 7; y <= 13; y += 1) {
|
||||
for (let x = 7; x <= 13; x += 1) {
|
||||
blocked[y * W + x] = 0
|
||||
}
|
||||
}
|
||||
|
||||
const grid: LinkGrid = {
|
||||
cellsX: 8,
|
||||
cellsY: 8,
|
||||
gridWidth: W,
|
||||
gridHeight: H,
|
||||
blocked,
|
||||
}
|
||||
|
||||
// The naive BFS from center would pick (20, 20) in the narrow corridor
|
||||
// findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room!
|
||||
const spot = findWaypointSpot(grid)
|
||||
expect(spot).not.toBeNull()
|
||||
expect(spot!.x).toBeGreaterThanOrEqual(8)
|
||||
expect(spot!.x).toBeLessThanOrEqual(12)
|
||||
expect(spot!.y).toBeGreaterThanOrEqual(8)
|
||||
expect(spot!.y).toBeLessThanOrEqual(12)
|
||||
|
||||
// Verify DRLGOUTDOORS_SpawnAct12Waypoint in level-links.ts is an alias and returns the same
|
||||
const d2Spot = linksSpawnAct12Waypoint(grid)
|
||||
expect(d2Spot).toEqual(spot)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue