feat(maze): 还原相邻 Room 边界消隐去重 (bKillEdge) 与门洞平滑衔接 (Issue #77)
以 Diablo II v1.13c D2Common.dll 为黄金标准,在 maze.ts 的 stamp() 中补齐两处
原版装载期行为。
1) bKillEdge 外边界接缝裁剪
原版 DRLGPRESET_InitPresetRoomGrids (DrlgPreset.cpp:1105) 由 LvlPrest.KillEdge
与房间是否贴合地图外缘共同推导:
bKillEdgeX = pDrlgRoom->nTileXPos + nTileWidth == pDrlgCoord.nPosX + nWidth
再由 DRLGROOMTILE_LoadInitRoomTiles (DrlgRoomTile.cpp:449) 转成
nTileCountX = nTileWidth + (bKillEdgeX == 0)。
- MazePiece 新增 killEdge 字段(来源 LvlPrest.txt 第 7 列 KillEdge),
MazeRequest 新增同名覆盖项;generateMaze 优先取 request,否则由 pieces 推导。
- stamp() 在 KillEdge 生效时将合成图收窄一列一行,并对贴合外缘的房间按
variant.width - 1 / height - 1 落图,内部接缝的一格重叠保持不变。
- pack-act-assets.ts 的 getMazePieces 读取真实 KillEdge 列。
经实测 MPQ 内 LvlPrest.txt:该列按关卡族整齐二分——Act 1 Cave、Act 2 Sewer、
Act 2 Arcane、Act 4 Mesa、Act 5 Temple、Act 5 Ice 为 1;Act 1 Crypt、Barracks、
Jail、Catacombs、Act 2 Basement、Act 2 Tomb、Act 3 Sewer 为 0。此前引擎完全忽略
该列,上述 6 个关卡族生成的地图恒比原版多一列一行。
2) 门洞 bHidden 消隐与门槛过道地砖补充
- wallSurvivesLoad() 复刻 LoadInitRoomTiles 的三条过滤规则:style >= 8 的 EXIT
图元直接跳过 (line 466);bHidden 的 DOOR 图元只生成门单位 (line 485);
bHidden 的 EXIT 图元只登记传送点 (line 500)——三者均不输出墙体美术。
- needsThresholdBackfill() 限定仅 bHidden 且 style <= 7 的 EXIT 触发补砖,
对齐 LoadFloorWarpTiles 在 line 505 的唯一调用点(bHidden 的 DOOR 走上一分支,
不铺地砖)。
- backfillThresholdFloor() 按原版 gWarpTileOffsets_6FDD1320 = {0,0},{1,0},
{0,1},{1,1} 在 (nX-1, nY-1) 铺 2x2 地砖并标记 hidden,供体取门洞邻近的非空
地砖,等价于 DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)。
- 补砖在所有房间落图完成后统一执行:门洞位于两房交界,供体可能属于当时尚未
落图的邻居。
verify-packs 只校验 preset 关卡(entry.kind !== 'preset' 时跳过),故迷宫尺寸
变化不会影响 1671 项逐字节断言。
新增 tests/killedge-113c.test.ts(8 个用例)覆盖:KillEdge 精确收窄一列一行、
由 pieces 推导与 request 覆盖、内部接缝不受影响、bHidden 门体消隐、style >= 8
的 EXIT 丢弃、2x2 门槛补砖、bHidden 门不补砖。已做变异测试:关闭裁剪或关闭墙体
过滤各会导致 3 个用例失败,确认断言有效。
Testing: npm run typecheck (0 errors); npx vitest run (68 files / 1127 passed /
2 skipped); npx tsx scripts/verify-packs.ts (1671/1671).
TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
e64e9a9ccd
commit
a15da9e000
|
|
@ -1052,7 +1052,14 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
|
|||
if (classified === null) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
|
||||
pieces.push({
|
||||
name,
|
||||
kind: classified.kind satisfies MazePieceKind,
|
||||
sides: classified.sides,
|
||||
levels,
|
||||
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
|
||||
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
|
||||
})
|
||||
}
|
||||
return pieces
|
||||
})()
|
||||
|
|
|
|||
269
src/game/maze.ts
269
src/game/maze.ts
|
|
@ -260,6 +260,22 @@ export interface MazePiece {
|
|||
* {@link generateMaze} does the same.
|
||||
*/
|
||||
readonly levels: readonly Ds1[]
|
||||
/**
|
||||
* `LvlPrest.txt` `KillEdge`.
|
||||
*
|
||||
* 1.13c reads this off the maze map's `LvlPrest` record and, when set, trims
|
||||
* the one-cell overhang off whichever room sits on the map's outer edge — see
|
||||
* {@link stamp} and `DRLGPRESET_InitPresetRoomGrids`
|
||||
* (`DrlgPreset.cpp:1105`). The shipped table sets it per *family*, not per
|
||||
* row: `Act 1 - Cave`, `Act 2 - Sewer`, `Act 2 - Arcane`, `Act 4 - Mesa`,
|
||||
* `Act 5 - Temple` and `Act 5 - Ice` are all `1`, while `Act 1 - Crypt`,
|
||||
* `Barracks`, `Jail`, `Catacombs`, `Act 2 - Basement`, `Act 2 - Tomb` and
|
||||
* `Act 3 - Sewer` are all `0`.
|
||||
*
|
||||
* Optional so that callers which do not read the column keep the pre-1.13c
|
||||
* behaviour of never trimming.
|
||||
*/
|
||||
readonly killEdge?: boolean
|
||||
}
|
||||
|
||||
/** Everything {@link generateMaze} needs. */
|
||||
|
|
@ -290,6 +306,17 @@ export interface MazeRequest {
|
|||
readonly seed: number
|
||||
/** The level type's pieces, classified by kind and side token. */
|
||||
readonly pieces: readonly MazePiece[]
|
||||
/**
|
||||
* Overrides the `KillEdge` value derived from {@link pieces}.
|
||||
*
|
||||
* 1.13c takes the flag from the maze map's own `LvlPrest` record
|
||||
* (`pMazeMap->pLvlPrestTxtRecord->dwKillEdge`), which in the shipped table
|
||||
* always agrees with the level type's piece rows. When this is omitted the
|
||||
* value is derived from the pieces instead, so a caller that already parsed
|
||||
* `LvlPrest.txt` needs to do nothing extra.
|
||||
*/
|
||||
readonly killEdge?: boolean
|
||||
|
||||
/**
|
||||
* `LvlTypes.txt` name, e.g. `Act 1 - Cave`.
|
||||
*
|
||||
|
|
@ -2020,6 +2047,158 @@ function emptyFloor(): Ds1Floor {
|
|||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Edge suppression and doorway thresholds (1.13c `DrlgRoomTile.cpp`)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** `TILETYPE_WALL_LEFT_DOOR` — a wall cell that a door unit occupies. */
|
||||
const TILETYPE_WALL_LEFT_DOOR = 8
|
||||
/** `TILETYPE_WALL_RIGHT_DOOR` — the mirrored door wall. */
|
||||
const TILETYPE_WALL_RIGHT_DOOR = 9
|
||||
/** `TILETYPE_WALL_LEFT_EXIT` — a warp marker, not artwork. */
|
||||
const TILETYPE_WALL_LEFT_EXIT = 10
|
||||
/** `TILETYPE_WALL_RIGHT_EXIT` — the mirrored warp marker. */
|
||||
const TILETYPE_WALL_RIGHT_EXIT = 11
|
||||
|
||||
/**
|
||||
* Highest `style` an exit tile may carry before 1.13c ignores it outright.
|
||||
*
|
||||
* `DRLGROOMTILE_LoadInitRoomTiles` opens with
|
||||
* `if ((nTileType == TILETYPE_WALL_LEFT_EXIT || nTileType == TILETYPE_WALL_RIGHT_EXIT)
|
||||
* && nTileStyle >= 8) continue;` — the style is the `Levels.txt` `Warp0..7` slot,
|
||||
* so anything at 8 or above is not a warp and the cell is dropped.
|
||||
*/
|
||||
const MAX_EXIT_WARP_SLOT = 7
|
||||
|
||||
/**
|
||||
* The 2×2 offsets `DRLGROOMTILE_LoadFloorWarpTiles` writes its threshold under.
|
||||
*
|
||||
* Verbatim `gWarpTileOffsets_6FDD1320`; each is applied to `(nX - 1, nY - 1)`, so
|
||||
* the patch covers the doorway cell and the three cells up-left of it.
|
||||
*/
|
||||
const WARP_TILE_OFFSETS: readonly (readonly [number, number])[] = [
|
||||
[0, 0], [1, 0], [0, 1], [1, 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* Decide whether a wall cell survives 1.13c's load pass.
|
||||
*
|
||||
* Three rules from `DRLGROOMTILE_LoadInitRoomTiles`, in the order it applies
|
||||
* them:
|
||||
*
|
||||
* 1. An exit tile whose `style` is above {@link MAX_EXIT_WARP_SLOT} is skipped
|
||||
* before anything else looks at it (line 466).
|
||||
* 2. A *hidden* door tile spawns a door unit and `continue`s, so its wall
|
||||
* artwork is never emitted (line 485).
|
||||
* 3. A *hidden* exit tile registers a warp and `continue`s likewise (line 500).
|
||||
*
|
||||
* Everything else is ordinary artwork and is kept.
|
||||
*
|
||||
* @param wall - the wall cell as the piece authored it.
|
||||
* @returns whether the wall should be drawn.
|
||||
*/
|
||||
function wallSurvivesLoad(wall: Ds1Wall): boolean {
|
||||
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
|
||||
if (isExit && wall.style > MAX_EXIT_WARP_SLOT) return false
|
||||
if (!wall.hidden) return true
|
||||
const isDoor = wall.type === TILETYPE_WALL_LEFT_DOOR || wall.type === TILETYPE_WALL_RIGHT_DOOR
|
||||
return !(isDoor || isExit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dropped wall is the kind that gets a threshold patch.
|
||||
*
|
||||
* 1.13c calls `DRLGROOMTILE_LoadFloorWarpTiles` from exactly one place
|
||||
* (`DrlgRoomTile.cpp:505`): a *hidden exit* tile whose style is a real
|
||||
* `Warp0..7` slot. A hidden *door* takes the branch above it and only spawns a
|
||||
* door unit, and an out-of-range exit is skipped before either runs — neither
|
||||
* lays down floor, so neither may widen the patch here.
|
||||
*
|
||||
* @param wall - the wall that `wallSurvivesLoad` rejected.
|
||||
* @returns whether its cell needs the 2×2 floor patch.
|
||||
*/
|
||||
function needsThresholdBackfill(wall: Ds1Wall): boolean {
|
||||
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
|
||||
return isExit && wall.hidden && wall.style <= MAX_EXIT_WARP_SLOT
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a floor cell actually draws something.
|
||||
*
|
||||
* A slot left at style 0 / sequence 0 is the decoder's placeholder, which is the
|
||||
* hole the threshold backfill exists to cover.
|
||||
*
|
||||
* @param floor - the floor cell.
|
||||
* @returns whether it references a tile.
|
||||
*/
|
||||
function floorIsBlank(floor: Ds1Floor | undefined): boolean {
|
||||
return floor === undefined || (floor.style === 0 && floor.sequence === 0 && floor.prop1 === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill the threshold floor under a doorway, the way 1.13c does.
|
||||
*
|
||||
* `DRLGROOMTILE_LoadFloorWarpTiles` reserves six floor slots per hidden exit
|
||||
* (`DRLGROOMTILE_CountWallWarpTiles` does `pTiles.nFloors += 6`) and fills a 2×2
|
||||
* block anchored one cell up-left of the doorway, flagging each tile hidden so
|
||||
* it renders under the warp artwork rather than over it. Without it the cells a
|
||||
* doorway punches through the wall line keep the piece's blank floor slot and
|
||||
* the ground shows a hole at the threshold.
|
||||
*
|
||||
* The donor tile is the nearest non-blank floor around the doorway, which is the
|
||||
* practical stand-in for `DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)`: the
|
||||
* room's own floor style is exactly what the tile cache would have returned.
|
||||
*
|
||||
* @param cells - the composite grid, mutated in place.
|
||||
* @param doorX - the doorway cell's x in the composite grid.
|
||||
* @param doorY - the doorway cell's y in the composite grid.
|
||||
* @returns how many blank cells were filled.
|
||||
*/
|
||||
function backfillThresholdFloor(cells: Ds1Cell[][], doorX: number, doorY: number): number {
|
||||
const donor = findDonorFloor(cells, doorX, doorY)
|
||||
if (donor === null) return 0
|
||||
let filled = 0
|
||||
for (const [dx, dy] of WARP_TILE_OFFSETS) {
|
||||
const x = doorX - 1 + dx
|
||||
const y = doorY - 1 + dy
|
||||
const row = cells[y]
|
||||
if (row === undefined) continue
|
||||
const cell = row[x]
|
||||
if (cell === undefined) continue
|
||||
if (!floorIsBlank(cell.floors[0])) continue
|
||||
const floors = [...cell.floors]
|
||||
// 1.13c marks these `MAPTILE_HIDDEN` so they sit beneath the warp artwork.
|
||||
floors[0] = { ...donor, hidden: true }
|
||||
row[x] = { ...cell, floors }
|
||||
filled += 1
|
||||
}
|
||||
return filled
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a floor tile to copy into a doorway threshold.
|
||||
*
|
||||
* Searches the doorway cell first and then rings outwards, so the patch always
|
||||
* picks up the floor of the room the doorway belongs to rather than a default.
|
||||
*
|
||||
* @param cells - the composite grid.
|
||||
* @param atX - the doorway cell's x.
|
||||
* @param atY - the doorway cell's y.
|
||||
* @returns the donor floor, or `null` when nothing nearby draws a floor.
|
||||
*/
|
||||
function findDonorFloor(cells: readonly (readonly Ds1Cell[])[], atX: number, atY: number): Ds1Floor | null {
|
||||
for (let radius = 0; radius <= 2; radius += 1) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||||
const floor = cells[atY + dy]?.[atX + dx]?.floors[0]
|
||||
if (!floorIsBlank(floor)) return floor!
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does.
|
||||
*
|
||||
|
|
@ -2070,11 +2249,26 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
|
|||
* Objects keep their per-piece sub-tile coordinates and gain the room origin
|
||||
* converted from cells at 5 sub-tiles per cell.
|
||||
*
|
||||
* Two 1.13c load-time behaviours are reproduced here rather than at draw time,
|
||||
* because this is the only pass that sees both the piece and its place:
|
||||
*
|
||||
* - **`bKillEdge`** (`DrlgPreset.cpp:1105`, `DrlgRoomTile.cpp:449`). When the
|
||||
* level type's `LvlPrest.KillEdge` is set, the room flush against the map's
|
||||
* outer edge stamps `nTileWidth` cells instead of `nTileWidth + 1`, so the
|
||||
* shared-border overhang that has no neighbour to be overwritten by is
|
||||
* dropped instead of duplicating the wall line one cell past the map.
|
||||
* - **Hidden door and exit walls** (`DrlgRoomTile.cpp:466`, `479`). A hidden
|
||||
* door or exit tile is a marker, not artwork: 1.13c spawns a door unit or
|
||||
* registers a warp and `continue`s, then backfills the threshold with the 2×2
|
||||
* floor patch `DRLGROOMTILE_LoadFloorWarpTiles` writes. Emitting the wall
|
||||
* instead leaves the doorway blocked and the threshold floor blank.
|
||||
*
|
||||
* @param level - the level.
|
||||
* @param stats - report collector.
|
||||
* @param killEdge - the level type's `LvlPrest.KillEdge`.
|
||||
* @returns the synthesized map.
|
||||
*/
|
||||
function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
||||
function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
|
||||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||||
const maxX = Math.max(...level.rooms.map(room => room.x))
|
||||
|
|
@ -2101,8 +2295,15 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
|||
highest = Math.max(highest, variant.height)
|
||||
}
|
||||
|
||||
const width = (maxX - minX) + widest
|
||||
const height = (maxY - minY) + highest
|
||||
// `KillEdge` shortens the map by the overhang column and row that the
|
||||
// right-most and bottom-most rooms would otherwise contribute. A piece is one
|
||||
// cell wider than its section so that neighbours share a border; at the outer
|
||||
// edge there is no neighbour to share with, so 1.13c drops it rather than
|
||||
// letting the piece spill past `pDrlgCoord.nWidth`
|
||||
// (`DrlgPreset.cpp:1109`, `DrlgRoomTile.cpp:449`).
|
||||
const edgeTrim = killEdge ? 1 : 0
|
||||
const width = (maxX - minX) + Math.max(1, widest - edgeTrim)
|
||||
const height = (maxY - minY) + Math.max(1, highest - edgeTrim)
|
||||
if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE
|
||||
|| width * height > MAX_CELLS) {
|
||||
throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`)
|
||||
|
|
@ -2124,26 +2325,60 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
|||
}
|
||||
|
||||
const objects: Ds1Object[] = []
|
||||
// Doorway cells discovered while blitting; their thresholds are backfilled
|
||||
// after every room is down, so a neighbour's floor can serve as the donor.
|
||||
const thresholds: { x: number; y: number }[] = []
|
||||
let stampedRooms = 0
|
||||
let killedEdgeX = 0
|
||||
let killedEdgeY = 0
|
||||
let hiddenWalls = 0
|
||||
for (const room of level.rooms) {
|
||||
const piece = room.piece
|
||||
if (piece === null) continue
|
||||
const variant = piece.levels[room.variant % piece.levels.length]!
|
||||
const originX = room.x - minX
|
||||
const originY = room.y - minY
|
||||
if (originX + variant.width > width || originY + variant.height > height) {
|
||||
|
||||
// `bKillEdgeX = pDrlgRoom->nTileXPos + pDrlgRoom->nTileWidth == pDrlgCoord.nPosX + nWidth`
|
||||
// — true only for the room flush against the map's outer edge, which is the
|
||||
// one whose overhang has no neighbour to be overwritten by.
|
||||
const killEdgeX = killEdge && originX + room.width >= width
|
||||
const killEdgeY = killEdge && originY + room.height >= height
|
||||
if (killEdgeX) killedEdgeX += 1
|
||||
if (killEdgeY) killedEdgeY += 1
|
||||
|
||||
// `nTileCountX = pDrlgRoom->nTileWidth + (bKillEdgeX == 0)`. The clamp keeps
|
||||
// an oddly-sized piece from running off the grid rather than trusting that
|
||||
// every piece is exactly one cell wider than its section.
|
||||
const spanX = Math.min(variant.width - (killEdgeX ? 1 : 0), width - originX)
|
||||
const spanY = Math.min(variant.height - (killEdgeY ? 1 : 0), height - originY)
|
||||
if (originX + spanX > width || originY + spanY > height) {
|
||||
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
|
||||
}
|
||||
for (let y = 0; y < variant.height; y += 1) {
|
||||
for (let y = 0; y < spanY; y += 1) {
|
||||
const source = variant.cells[y]
|
||||
if (source === undefined) continue
|
||||
const target = cells[originY + y]!
|
||||
for (let x = 0; x < variant.width; x += 1) {
|
||||
for (let x = 0; x < spanX; x += 1) {
|
||||
const cell = source[x]
|
||||
if (cell === undefined) continue
|
||||
target[originX + x] = cell
|
||||
// 1.13c filters the wall list while loading rather than while drawing:
|
||||
// hidden door and exit tiles are markers that spawn a unit or register a
|
||||
// warp, and their artwork is never emitted.
|
||||
const kept = cell.walls.filter(wallSurvivesLoad)
|
||||
if (kept.length === cell.walls.length) {
|
||||
target[originX + x] = cell
|
||||
} else {
|
||||
hiddenWalls += cell.walls.length - kept.length
|
||||
while (kept.length < cell.walls.length) kept.push(emptyWall())
|
||||
target[originX + x] = { ...cell, walls: kept }
|
||||
if (cell.walls.some(needsThresholdBackfill)) {
|
||||
thresholds.push({ x: originX + x, y: originY + y })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const object of variant.objects) {
|
||||
objects.push({
|
||||
type: object.type,
|
||||
|
|
@ -2203,7 +2438,20 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
|||
stampedRooms += 1
|
||||
}
|
||||
|
||||
// Thresholds are filled only after every room is down: a doorway sits on the
|
||||
// border between two rooms, so the donor floor may belong to the neighbour
|
||||
// that had not been stamped yet when the doorway was found.
|
||||
let thresholdCells = 0
|
||||
for (const at of thresholds) thresholdCells += backfillThresholdFloor(cells, at.x, at.y)
|
||||
|
||||
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
|
||||
if (killEdge) {
|
||||
stats.notes.push(`killEdge trimmed ${String(killedEdgeX)} east and ${String(killedEdgeY)} south room edges`)
|
||||
}
|
||||
if (hiddenWalls > 0) {
|
||||
stats.notes.push(`hid ${String(hiddenWalls)} door/exit walls, backfilled ${String(thresholdCells)} threshold floor cells`)
|
||||
}
|
||||
|
||||
return {
|
||||
version: version === 0 ? 18 : version,
|
||||
width,
|
||||
|
|
@ -2395,7 +2643,12 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
|
||||
|
||||
assignVariants(level, request.levelId, stats)
|
||||
const stamped = stamp(level, stats)
|
||||
// 1.13c reads `dwKillEdge` off the maze map's own `LvlPrest` record. The
|
||||
// shipped table sets the column uniformly across a level type's piece rows, so
|
||||
// deriving it from the pieces gives the same answer without asking callers to
|
||||
// look the maze map up separately.
|
||||
const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true)
|
||||
const stamped = stamp(level, stats, killEdge)
|
||||
|
||||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Ground-truth tests for the 1.13c room-seam and doorway rules in `stamp`.
|
||||
*
|
||||
* Both behaviours are transcribed from Diablo II v1.13c `D2Common.dll`
|
||||
* (MD5 `ee1238806ef6d6d9801d12a09d128fe1`), via D2MOO:
|
||||
*
|
||||
* - `DRLGPRESET_InitPresetRoomGrids` (`DrlgPreset.cpp:1105`) derives
|
||||
* `bKillEdgeX`/`bKillEdgeY` from `LvlPrest.KillEdge` and the room's position
|
||||
* relative to the map's outer edge, and `DRLGROOMTILE_LoadInitRoomTiles`
|
||||
* (`DrlgRoomTile.cpp:449`) turns them into
|
||||
* `nTileCountX = nTileWidth + (bKillEdgeX == 0)`.
|
||||
* - `DRLGROOMTILE_LoadInitRoomTiles` drops hidden door and exit walls
|
||||
* (`DrlgRoomTile.cpp:466`, `479`) and `DRLGROOMTILE_LoadFloorWarpTiles`
|
||||
* (`DrlgRoomTile.cpp:706`) backfills a 2×2 threshold patch at `(nX-1, nY-1)`
|
||||
* using `gWarpTileOffsets_6FDD1320 = {0,0},{1,0},{0,1},{1,1}`.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../src/formats/ds1.ts'
|
||||
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze.ts'
|
||||
|
||||
/** DS1 wall types 1.13c treats as markers rather than artwork. */
|
||||
const TYPE_LEFT_DOOR = 8
|
||||
const TYPE_LEFT_EXIT = 10
|
||||
|
||||
/** A floor slot that draws something. */
|
||||
function solidFloor(): Ds1Floor {
|
||||
return { prop1: 1, sequence: 0, style: 5, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** The decoder's placeholder floor — what `floorIsBlank` looks for. */
|
||||
function blankFloor(): Ds1Floor {
|
||||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** A plain wall slot. */
|
||||
function plainWall(): Ds1Wall {
|
||||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** Where a marker wall goes inside every piece, and what it looks like. */
|
||||
interface MarkerSpec {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly type: number
|
||||
readonly style: number
|
||||
readonly hidden: boolean
|
||||
/** Blank the 2×2 threshold the backfill is expected to repair. */
|
||||
readonly blankThreshold?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a piece DS1 of the given size, optionally carrying one marker wall.
|
||||
*
|
||||
* @param size - width and height in cells.
|
||||
* @param marker - the marker wall to embed, if any.
|
||||
* @returns the map.
|
||||
*/
|
||||
function pieceDs1(size: number, marker?: MarkerSpec): Ds1 {
|
||||
const blanks = new Set<string>()
|
||||
if (marker?.blankThreshold === true) {
|
||||
for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
|
||||
blanks.add(`${String(marker.x - 1 + dx)},${String(marker.y - 1 + dy)}`)
|
||||
}
|
||||
}
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const isMarker = marker !== undefined && marker.x === x && marker.y === y
|
||||
row.push({
|
||||
walls: [isMarker
|
||||
? { prop1: 0, sequence: 0, style: marker.style, type: marker.type, unknown1: 0, unknown2: 0, hidden: marker.hidden }
|
||||
: plainWall()],
|
||||
floors: [blanks.has(`${String(x)},${String(y)}`) ? blankFloor() : solidFloor()],
|
||||
shadows: [blankFloor()],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18, width: size, height: size, act: 1, substitutionType: 0,
|
||||
wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 15 side shapes plus the entrance and warp pieces `generateMaze` needs.
|
||||
*
|
||||
* Every piece is the same size and carries the same marker, so whichever piece
|
||||
* the generator picks the assertion below still has something to find.
|
||||
*
|
||||
* @param size - piece size in cells; one larger than the section stride.
|
||||
* @param killEdge - the `LvlPrest.KillEdge` value to report.
|
||||
* @param marker - the marker wall to embed in every piece.
|
||||
* @returns the piece set.
|
||||
*/
|
||||
function pieceSet(size: number, killEdge: boolean, marker?: MarkerSpec): MazePiece[] {
|
||||
const sides = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW']
|
||||
const make = (name: string, kind: MazePieceKind, side: string): MazePiece =>
|
||||
({ name, kind, sides: side, levels: [pieceDs1(size, marker)], killEdge })
|
||||
return [
|
||||
make('Act 1 - Cave Entrance', 'entrance', ''),
|
||||
...sides.map(side => make(`Act 1 - Cave ${side}`, 'room', side)),
|
||||
...['N', 'S', 'E', 'W'].flatMap(side => [
|
||||
make(`Act 1 - Cave Prev ${side}`, 'prev', side),
|
||||
make(`Act 1 - Cave Next ${side}`, 'next', side),
|
||||
make(`Act 1 - Cave Down ${side}`, 'down', side),
|
||||
make(`Act 1 - Cave Theme ${side}`, 'theme', side),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
/** A small deterministic Act 1 Cave request. */
|
||||
function request(pieces: readonly MazePiece[], overrides: Partial<MazeRequest> = {}): MazeRequest {
|
||||
return {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 7,
|
||||
minRooms: 5,
|
||||
merge: 0,
|
||||
seed: 0x1337,
|
||||
pieces,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every wall slot in a generated map, flattened. */
|
||||
function allWalls(level: Ds1): Ds1Wall[] {
|
||||
return level.cells.flatMap(row => row.flatMap(cell => [...cell.walls]))
|
||||
}
|
||||
|
||||
describe('1.13c bKillEdge room-seam suppression', () => {
|
||||
test('KillEdge trims exactly one column and one row off the composite map', () => {
|
||||
// A piece is one cell wider than the 7-cell stride, so without KillEdge the
|
||||
// outer room contributes an overhang that has no neighbour to overwrite it.
|
||||
const off = generateMaze(request(pieceSet(8, false)))
|
||||
const on = generateMaze(request(pieceSet(8, true)))
|
||||
|
||||
expect(on.level.width).toBe(off.level.width - 1)
|
||||
expect(on.level.height).toBe(off.level.height - 1)
|
||||
})
|
||||
|
||||
test('KillEdge is read off the pieces when the request does not override it', () => {
|
||||
const derived = generateMaze(request(pieceSet(8, true)))
|
||||
const overridden = generateMaze(request(pieceSet(8, true), { killEdge: false }))
|
||||
const plain = generateMaze(request(pieceSet(8, false)))
|
||||
|
||||
// The override wins over the piece rows, and matches the un-trimmed map.
|
||||
expect(overridden.level.width).toBe(plain.level.width)
|
||||
expect(derived.level.width).toBe(plain.level.width - 1)
|
||||
|
||||
const notes = derived.stats.notes as string[]
|
||||
expect(notes.some(note => note.startsWith('killEdge trimmed'))).toBe(true)
|
||||
expect((plain.stats.notes as string[]).some(note => note.startsWith('killEdge trimmed'))).toBe(false)
|
||||
})
|
||||
|
||||
test('trimming never drops interior seams, only the outer edge', () => {
|
||||
// With KillEdge on, the map is exactly `(span of origins) + stride`, which
|
||||
// is what `pDrlgCoord.nWidth` is in 1.13c. Every interior room therefore
|
||||
// still stamps its full 8 cells and keeps overlapping its neighbour.
|
||||
const on = generateMaze(request(pieceSet(8, true), { minRooms: 9 }))
|
||||
const stride = 7
|
||||
expect((on.level.width - stride) % stride).toBe(0)
|
||||
expect((on.level.height - stride) % stride).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('1.13c hidden door and exit walls', () => {
|
||||
test('a hidden door wall is never emitted as artwork', () => {
|
||||
const hidden = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true,
|
||||
})))
|
||||
const shown = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: false,
|
||||
})))
|
||||
|
||||
expect(allWalls(hidden.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(false)
|
||||
// The identical non-hidden door is ordinary artwork and must survive.
|
||||
expect(allWalls(shown.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(true)
|
||||
})
|
||||
|
||||
test('an exit tile above the Warp0..7 slot range is dropped outright', () => {
|
||||
// `nTileStyle >= 8` is not a warp slot, so 1.13c skips the cell even though
|
||||
// the tile is not flagged hidden.
|
||||
const tooHigh = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 8, hidden: false,
|
||||
})))
|
||||
const inRange = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 7, hidden: false,
|
||||
})))
|
||||
|
||||
expect(allWalls(tooHigh.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(false)
|
||||
expect(allWalls(inRange.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(true)
|
||||
})
|
||||
|
||||
test('a hidden exit backfills the 2x2 threshold floor patch', () => {
|
||||
const marker: MarkerSpec = {
|
||||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
|
||||
}
|
||||
const patched = generateMaze(request(pieceSet(8, false, marker)))
|
||||
|
||||
const notes = patched.stats.notes as string[]
|
||||
const note = notes.find(entry => entry.startsWith('hid '))
|
||||
expect(note).toBeDefined()
|
||||
|
||||
const filled = Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
|
||||
expect(filled).toBeGreaterThan(0)
|
||||
|
||||
// Every backfilled cell must draw a floor and be flagged hidden, the way
|
||||
// `DRLGROOMTILE_LoadFloorWarpTiles` writes them.
|
||||
let hiddenFloors = 0
|
||||
for (const row of patched.level.cells) {
|
||||
for (const cell of row) {
|
||||
const floor = cell.floors[0]
|
||||
if (floor !== undefined && floor.hidden && floor.style === 5) hiddenFloors += 1
|
||||
}
|
||||
}
|
||||
expect(hiddenFloors).toBe(filled)
|
||||
})
|
||||
|
||||
test('a hidden door suppresses its wall but gets no warp threshold patch', () => {
|
||||
// `LoadFloorWarpTiles` is reached only from the exit branch; the door branch
|
||||
// above it spawns a unit and `continue`s without touching the floor.
|
||||
const door = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true, blankThreshold: true,
|
||||
})))
|
||||
const exit = generateMaze(request(pieceSet(8, false, {
|
||||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
|
||||
})))
|
||||
|
||||
const backfilled = (result: typeof door): number => {
|
||||
const note = (result.stats.notes as string[]).find(entry => entry.startsWith('hid '))
|
||||
return Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
|
||||
}
|
||||
|
||||
expect(backfilled(door)).toBe(0)
|
||||
expect(backfilled(exit)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('a map with no markers reports no hidden walls and no backfill', () => {
|
||||
const plain = generateMaze(request(pieceSet(8, false)))
|
||||
expect((plain.stats.notes as string[]).some(note => note.startsWith('hid '))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue