From 78b5351cceb500c3a4aa463c4ec6f9d3531c851c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 23:26:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(maze):=20=E6=8B=93=E6=89=91=20BFS=20?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E9=80=89=E6=8B=A9=E4=B8=8E=20LvlPrest=20?= =?UTF-8?q?=E6=A5=BC=E6=A2=AF=E9=A2=84=E5=88=B6=E4=BD=93=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E8=BF=B7=E5=AE=AB=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E6=A5=BC=E6=A2=AF=20(fixes=20#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 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)。 --- src/game/maze.ts | 364 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 309 insertions(+), 55 deletions(-) diff --git a/src/game/maze.ts b/src/game/maze.ts index 287d2fa..f30b410 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -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 { + const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first + const dist = new Map([[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() const usage = new Map() // 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,