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:
parent
29cfccaa37
commit
78b5351cce
364
src/game/maze.ts
364
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<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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue