refactor(map-gen/maze): align special room placement with D2 scanning rules (fixes #46)

This commit is contained in:
troytt 2026-09-17 06:20:59 +00:00
parent 1fa3f8b7a2
commit 8bb99ded7e
1 changed files with 14 additions and 46 deletions

View File

@ -1591,29 +1591,9 @@ function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeSt
}
/**
* Compute BFS topological distance of every room from the level's entrance room
* (the room holding a 'prev' or 'entrance' piece, or `level.first`).
*/
function bfsDistances(level: MazeLevel): Map<Room, number> {
const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first
const dist = new Map<Room, number>([[start, 0]])
const queue: Room[] = [start]
while (queue.length > 0) {
const current = queue.shift()!
const d = dist.get(current)!
for (const orth of current.orths) {
if (!dist.has(orth.room)) {
dist.set(orth.room, d + 1)
queue.push(orth.room)
}
}
}
return dist
}
/**
* Place a special room group using topological dead-end / BFS distance selection
* and `LvlPrest.txt` full-room preset substitution.
* Place a special room group following standard D2 scanning rules:
* scans room candidates against valid wall connections, LvlPrest file definitions,
* and room flags, rather than imposing an artificial global maximum BFS distance.
*
* @param level - the level.
* @param pieces - the level's pieces.
@ -1630,11 +1610,9 @@ function placeSpecialGroup(
index: PieceIndex,
stats: MazeStats,
): void {
const isExit = group.kind === 'next' || group.kind === 'down' || group.kind === 'quest'
const distMap = bfsDistances(level)
// Phase 1: Try to replace an existing unfinished room in place, preferring
// maximum BFS distance from entrance for exits/objectives and minimum for entrances.
// Phase 1: Try to replace an existing unfinished room in place, scanning
// room candidates against valid wall connections, LvlPrest definitions,
// and room flags in standard D2 roster order.
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
@ -1642,20 +1620,14 @@ function placeSpecialGroup(
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const candidates = level.rooms.filter(r => {
const chosen = level.rooms.find(r => {
if (roomSideMask(r) !== wanted) return false
if (!r.hasDs1) return true
if (group.inPlaceOnly === true && (r.piece === special || r.piece?.kind === 'theme')) return true
return false
})
if (candidates.length > 0) {
candidates.sort((a, b) => {
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
const chosen = candidates[0]!
if (chosen !== undefined) {
chosen.piece = special
chosen.variant = 0
chosen.hasDs1 = true
@ -1670,16 +1642,12 @@ function placeSpecialGroup(
return
}
// Phase 2: Grow a new dead-end room off the best available parent room.
const parentCandidates = level.rooms.filter(
r => !r.hasDs1 || r.piece?.kind === 'theme',
)
parentCandidates.sort((a, b) => {
if (a.hasDs1 !== b.hasDs1) return a.hasDs1 ? 1 : -1
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
// Phase 2: Grow a new dead-end room off an available parent room in D2
// roster order (plain rooms without DS1 scanned first, followed by theme rooms).
const parentCandidates = [
...level.rooms.filter(r => !r.hasDs1),
...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'),
]
for (const parent of parentCandidates) {
for (let offset = 0; offset < group.entries.length; offset += 1) {