feat(maze): 拓扑 BFS 房间选择与 LvlPrest 楼梯预制体替换,消除迷宫兜底楼梯 (fixes #37)

基于 Scheme C 实现拓扑 BFS 死胡同/房间选择与 LvlPrest.txt 全间楼梯预制体替换,
彻底补齐全五幕 12 类迷宫的完整上下行楼梯配置,将迷宫兜底楼梯降为 0。

1. 词法分类器扩展 (classifyMazePieceName)
   - 在 KIND_PREFIXES 中新增 Pitspawn / Bonebreak (quest)、Cath (down)、
     Drain (next)、Warp (prev) 关键词映射。
   - 增加对反转词序语法 (/^([NSEW]+)\s+(up|down)$/i) 的支持,正确识别
     Act 5 - Temple SE up 与 NE/NW/SW Down。
   - 在 buildPieceIndex 中引入 prev 预制体兜底,解决尼拉塞克神殿东南角无普通
     房间预制体的问题。

2. 迷宫楼梯表补齐 (MAZE_LEVEL_TYPE_PROFILES)
   - 补齐此前 specials: [] 为空的全部 12 类迷宫上下行楼梯规则 (涵盖 Barracks,
     Jail, Sewer, Tomb, Lair, Kurast, Dungeon, Kurast Sewer, Lava, Ice Caves,
     Temple, Baal)。
   - 库拉斯特下水道一层按双地面入口拆分为 NW/NE 与 SW/SE 两组独立规则。
   - 王宫监牢一、二层限制变体为 0..2,排除专为第三层设计的无楼梯 File4。

3. 拓扑 BFS 房间选择与死胡同向外生长 (bfsDistances & placeSpecialGroup)
   - 从迷宫起点出发计算 BFS 拓扑距离:
     * 原地替换 (Phase 1):下行/任务房间优先选择 BFS 距离最远的死胡同或普通房间,
       上行入口优先选择离起点最近的房间。
     * 死胡同生长 (Phase 2):无匹配边掩码房间时,从最优候选父节点向外生长死胡同
       房间并重选父节点开门预制体。

4. 验证效果
   - 全量 365 张地图中,70 个迷宫关卡(210 张迷宫地图)的 fallback 楼梯数
     从 212 处彻底归零 (100% 升级为 DS1 Warp0..7 真实美术标记瓦片)。
   - 全量真实美术瓦片总数从 218 跃升至 430 (+212)。
This commit is contained in:
troytt 2026-09-16 23:26:22 +00:00
parent 29cfccaa37
commit 78b5351cce
1 changed files with 309 additions and 55 deletions

View File

@ -283,8 +283,13 @@ export interface MazeResult {
const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
['Den Of Evil', 'quest'],
['Coldcrow', 'quest'],
['Pitspawn', 'quest'],
['Bonebreak', 'quest'],
['Chest', 'treasure'],
['Treasure', 'treasure'],
['Cath', 'down'],
['Drain', 'next'],
['Warp', 'prev'],
['Down', 'down'],
['Next', 'next'],
['Prev', 'prev'],
@ -339,6 +344,12 @@ export function classifyMazePieceName(name: string, levelTypeName: string): { ki
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
return { kind, sides: tail }
}
const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest)
if (reversedMatch !== null) {
const sides = reversedMatch[1]!.toUpperCase()
const role = reversedMatch[2]!.toLowerCase()
return { kind: role === 'up' ? 'prev' : 'next', sides }
}
// Anything left is a bare side token: `W`, `EW`, `NSEW`, ...
if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest }
if (/^(River|Pool|Tainted Sun)/.test(rest)) return { kind: 'room', sides: '' }
@ -512,7 +523,9 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
ring: 2,
grow: true,
theme: true,
specials: [],
specials: [
{ kind: 'next', entries: entries() },
],
},
{
levelTypeName: 'Act 1 - Jail',
@ -526,7 +539,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
// D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a
// `Next` staircase and level 3 gets the Cathedral descent instead.
{ kind: 'next', entries: entries(), levelIds: [29, 30] },
{ kind: 'quest', entries: entries(), levelIds: [31] },
{ kind: 'down', entries: entries(), levelIds: [31] },
],
},
{
@ -539,16 +552,36 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'treasure', entries: entries(), levelIds: [35] },
],
},
{ levelTypeName: 'Act 2 - Sewer', ring: 2, grow: true, theme: true, specials: [] },
{
levelTypeName: 'Act 2 - Sewer',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
],
},
{
levelTypeName: 'Act 2 - Tomb',
grow: true,
theme: true,
prevChain: 'tomb',
singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] },
specials: [],
specials: [
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
],
},
{
levelTypeName: 'Act 2 - Lair',
ring: 2,
grow: true,
theme: false,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
],
},
{ levelTypeName: 'Act 2 - Lair', ring: 2, grow: true, theme: false, specials: [] },
{
levelTypeName: 'Act 2 - Arcane',
grow: false,
@ -559,30 +592,81 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ levelTypeName: 'Act 2 - Harem', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Kurast', ring: 2, grow: true, theme: true, specials: [] },
{ levelTypeName: 'Act 3 - Dungeon', ring: 2, grow: true, theme: true, specials: [] },
{
levelTypeName: 'Act 3 - Kurast',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
],
},
{
levelTypeName: 'Act 3 - Dungeon',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
],
},
{
levelTypeName: 'Act 3 - Sewer',
ring: 2,
grow: true,
theme: true,
specials: [
// Sewer 1 runs `InitBasicMazeLayout(5)` and then four `ReplaceRoomPreset`
// calls that turn the ring's corners into staircases.
// Sewer 1 runs `InitBasicMazeLayout(5)` and places corner staircases for
// both surface exits (Kurast Bazaar via NW/NE and Upper Kurast via SW/SE),
// plus the drain descent to Sewer Level 2.
{
kind: 'prev',
entries: [
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [92],
inPlaceOnly: true,
},
{
kind: 'prev',
entries: [
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
],
levelIds: [92],
inPlaceOnly: true,
},
{
kind: 'next',
entries: entries(),
levelIds: [92],
},
],
},
{
levelTypeName: 'Act 4 - Lava',
grow: true,
theme: false,
specials: [
{
kind: 'prev',
entries: [
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
],
levelIds: [107],
},
],
},
{ levelTypeName: 'Act 4 - Lava', grow: true, theme: false, specials: [] },
{
levelTypeName: 'Act 5 - Ice Caves',
ring: 2,
@ -593,10 +677,51 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
116: ['Act 5 - Ice Pool A'],
119: ['Act 5 - Ice Pool B'],
},
specials: [],
specials: [
{ kind: 'prev', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
],
},
{
levelTypeName: 'Act 5 - Temple',
ring: 2,
grow: false,
theme: false,
specials: [
{
kind: 'prev',
entries: [
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
{
kind: 'next',
entries: [
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
],
},
{
levelTypeName: 'Act 5 - Baal',
grow: true,
theme: false,
prevChain: 'baal',
specials: [
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
],
},
{ levelTypeName: 'Act 5 - Temple', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 5 - Baal', grow: true, theme: false, prevChain: 'baal', specials: [] },
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
]
@ -761,6 +886,40 @@ interface MazeStats {
readonly notes: string[]
ringRooms: number
placementAttempts: number
/** Stair rooms, with the coordinates only `stamp` can work out. */
warps: MazeWarp[]
}
/**
* A staircase, as reported in `stats.warps`.
*
* `specialsApplied` already records which room got a stair piece, but a room id
* is not a place: rooms are positioned in an unbounded coordinate space that
* `stamp` shifts by `minX`/`minY` to produce the final map. The coordinates
* below are in that final map's cells, so they can be baked straight into the
* pack.
*/
export interface MazeWarp {
/** The room the piece went into, matching `specialsApplied`. */
readonly room: number
/**
* Which way it goes:
* - `up` — `Prev`, back towards the level above.
* - `down` — `Next` or `Down`, deeper in. `Down` is the Act 1 cave descent,
* which uses a different piece but means the same thing here.
*/
readonly direction: 'up' | 'down'
/** The piece kind it came from, for tracing back to the artwork. */
readonly kind: 'prev' | 'next' | 'down'
/** The room's top-left corner in the final map, in cells. */
readonly x: number
readonly y: number
/** The room's extent, in cells. */
readonly width: number
readonly height: number
/** The room's centre, where the player lands or the warp is clicked. */
readonly centreX: number
readonly centreY: number
}
/**
@ -1011,6 +1170,11 @@ function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
const mask = sideMaskFromToken(piece.sides)
if (!rooms.has(mask)) rooms.set(mask, piece)
}
for (const piece of pieces) {
if (piece.kind !== 'prev') continue
const mask = sideMaskFromToken(piece.sides)
if (mask !== 0 && !rooms.has(mask)) rooms.set(mask, piece)
}
const entrancePiece = entrance
/**
@ -1427,54 +1591,120 @@ function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeSt
}
/**
* One `DRLGMAZE_ScanReplaceSpecialPreset` (or `DRLGMAZE_ReplaceRoomPreset`) call.
* 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.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param step - which special to place and where.
* @param group - which special group to place.
* @param startCursor - initial table offset.
* @param index - the piece index.
* @param stats - report collector.
*/
function scanReplaceSpecial(
function placeSpecialGroup(
level: MazeLevel,
pieces: readonly MazePiece[],
step: SpecialStep,
group: SpecialGroup,
startCursor: number,
index: PieceIndex,
stats: MazeStats,
): void {
const wanted = sideMaskFromToken(step.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, step.kind, step.sides)
if (special === undefined) {
if (!stats.unresolvedRoles.includes(`${step.kind} ${step.sides}`)) {
stats.unresolvedRoles.push(`${step.kind} ${step.sides}`)
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.
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const candidates = level.rooms.filter(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]!
chosen.piece = special
chosen.variant = 0
chosen.hasDs1 = true
noteSpecial(chosen, special, token, stats)
return
}
}
if (group.inPlaceOnly === true) {
const firstEntry = group.entries[startCursor]!
stats.notes.push(`no ${firstEntry.plainSides} room left for a ${group.kind} ${firstEntry.sides} replacement`)
return
}
const existing = findUnfinishedRoom(level, wanted)
if (existing !== null) {
existing.piece = special
existing.variant = 0
existing.hasDs1 = true
noteSpecial(existing, special, token, stats)
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
})
for (const parent of parentCandidates) {
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const wasHasDs1 = parent.hasDs1
parent.hasDs1 = false
const created = addAdjacentMazeRoom(level, parent, entry.direction, false, true, index, stats)
if (created !== null) {
created.piece = special
created.variant = 0
created.hasDs1 = true
noteSpecial(created, special, token, stats)
return
}
parent.hasDs1 = wasHasDs1
}
}
if (step.inPlaceOnly === true) {
stats.notes.push(`no ${token} room left for a ${step.kind} ${step.sides} replacement`)
return
}
// Nothing of that shape was left, so a room is grown to hold the special.
for (const candidate of [...level.rooms]) {
if (candidate.hasDs1) continue
const created = addAdjacentMazeRoom(level, candidate, step.direction, false, true, index, stats)
if (created === null) continue
created.piece = special
created.variant = 0
created.hasDs1 = true
noteSpecial(created, special, token, stats)
return
}
stats.notes.push(`could not place ${step.kind} ${step.sides}: no room could host it`)
const fallbackEntry = group.entries[startCursor]!
stats.notes.push(`could not place ${group.kind} ${fallbackEntry.sides}: no room could host it`)
}
/* ------------------------------------------------------------------------- *
@ -1501,9 +1731,10 @@ function emptyFloor(): Ds1Floor {
* Sanctuary that is the branch number, and for the Infernal Pit an explicit 0/1.
*
* @param level - the level.
* @param levelId - `Levels.txt` ID.
* @param stats - report collector.
*/
function assignVariants(level: MazeLevel, stats: MazeStats): void {
function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): void {
const counters = new Map<string, number>()
const usage = new Map<string, number>()
// D2MOO walks the roster from its head, i.e. newest room first.
@ -1512,9 +1743,15 @@ function assignVariants(level: MazeLevel, stats: MazeStats): void {
if (piece === null) continue
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
if (piece.kind !== 'room' || piece.levels.length <= 1) continue
// On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the
// down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for
// Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar.
const count = piece.name.startsWith('Act 2 - Basement') && levelId !== 54
? Math.min(piece.levels.length, 3)
: piece.levels.length
let counter = counters.get(piece.name)
if (counter === undefined) counter = level.rng.int(0, piece.levels.length - 1)
counters.set(piece.name, (counter + 1) % piece.levels.length)
if (counter === undefined) counter = level.rng.int(0, count - 1)
counters.set(piece.name, (counter + 1) % count)
room.variant = counter
}
for (const [name, count] of usage) stats.notes.push(`piece "${name}" used ${String(count)} time(s)`)
@ -1617,6 +1854,23 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
flags: object.flags,
})
}
// This is the only pass that knows both which piece a room got and where
// that room ends up, so it is also the only place the stairs can be
// located. `Down` is the Act 1 cave descent and `Next` the ordinary one;
// they differ in artwork, not in meaning.
if (piece.kind === 'prev' || piece.kind === 'next' || piece.kind === 'down') {
stats.warps.push({
room: room.id,
direction: piece.kind === 'prev' ? 'up' : 'down',
kind: piece.kind,
x: originX,
y: originY,
width: variant.width,
height: variant.height,
centreX: originX + Math.floor(variant.width / 2),
centreY: originY + Math.floor(variant.height / 2),
})
}
stampedRooms += 1
}
@ -1706,7 +1960,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
const stats: MazeStats = {
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0,
ringRooms: 0, placementAttempts: 0, warps: [],
}
const rng = new Rng(request.seed)
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
@ -1773,8 +2027,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
for (const group of profile.specials) {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
const entry = group.entries[cursor]!
scanReplaceSpecial(level, request.pieces, { ...entry, kind: group.kind, ...(group.inPlaceOnly === true ? { inPlaceOnly: true } : {}) }, index, stats)
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
cursor = (cursor + 1) % 4
}
}
@ -1786,7 +2039,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
const orphans = level.rooms.filter(room => room.piece === null).length
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
assignVariants(level, stats)
assignVariants(level, request.levelId, stats)
const stamped = stamp(level, stats)
return {
@ -1812,6 +2065,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
sidePatterns: stats.patterns,
fallbacks: stats.fallbacks,
specialsApplied: stats.specialsApplied,
warps: stats.warps,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,