fix(map-gen/maze): enforce Levels.txt global bounds SizeX/SizeY on room growth (fixes #48)
This commit is contained in:
parent
1fa3f8b7a2
commit
801afd7ddb
|
|
@ -1538,6 +1538,8 @@ for (const entry of LEVELS) {
|
|||
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
|
||||
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
|
||||
const merge = Number(cell(lvlmaze, row, 'Merge'))
|
||||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||||
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||||
const pieces = await getMazePieces(type.name, type.id)
|
||||
if (pieces.length === 0) {
|
||||
console.warn(`skipping maze ${entry.name}: no maze pieces`)
|
||||
|
|
@ -1561,6 +1563,8 @@ for (const entry of LEVELS) {
|
|||
merge,
|
||||
seed,
|
||||
pieces,
|
||||
maxCellsX: sizeX > sectionX ? sizeX : undefined,
|
||||
maxCellsY: sizeY > sectionY ? sizeY : undefined,
|
||||
})
|
||||
await bakeDs1Variant(
|
||||
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
|
||||
|
|
|
|||
|
|
@ -265,6 +265,28 @@ export interface MazeRequest {
|
|||
readonly staffTombLevelId?: number
|
||||
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
|
||||
readonly bossTombLevelId?: number
|
||||
/**
|
||||
* Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`).
|
||||
* When specified, any room placement that would expand the overall maze bounding box
|
||||
* beyond this width will be rejected.
|
||||
*/
|
||||
readonly maxCellsX?: number | undefined
|
||||
/**
|
||||
* Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`).
|
||||
* When specified, any room placement that would expand the overall maze bounding box
|
||||
* beyond this height will be rejected.
|
||||
*/
|
||||
readonly maxCellsY?: number | undefined
|
||||
/**
|
||||
* Maximum allowed sections along X axis.
|
||||
* If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`.
|
||||
*/
|
||||
readonly maxSectionsX?: number | undefined
|
||||
/**
|
||||
* Maximum allowed sections along Y axis.
|
||||
* If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`.
|
||||
*/
|
||||
readonly maxSectionsY?: number | undefined
|
||||
}
|
||||
|
||||
/** The generated level. */
|
||||
|
|
@ -869,6 +891,9 @@ interface MazeLevel {
|
|||
nextId: number
|
||||
/** Doorways added by the merge pass, for reporting. */
|
||||
merges: number
|
||||
readonly maxCellsX?: number | undefined
|
||||
readonly maxCellsY?: number | undefined
|
||||
readonly stats?: MazeStats | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -886,6 +911,7 @@ interface MazeStats {
|
|||
readonly notes: string[]
|
||||
ringRooms: number
|
||||
placementAttempts: number
|
||||
boundsRejected?: number
|
||||
/** Stair rooms, with the coordinates only `stamp` can work out. */
|
||||
warps: MazeWarp[]
|
||||
}
|
||||
|
|
@ -971,7 +997,56 @@ function roomSideMask(room: Room): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether a room overlaps anything other than itself and one ignored room.
|
||||
* Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`.
|
||||
*
|
||||
* In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits
|
||||
* from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed
|
||||
* rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze
|
||||
* to grow in other available directions and cluster compactly.
|
||||
*
|
||||
* @param level - the level.
|
||||
* @param room - the candidate room with proposed coordinates and dimensions.
|
||||
* @returns true if within bounds or bounds are unconstrained.
|
||||
*/
|
||||
function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean {
|
||||
if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true
|
||||
if (level.rooms.length === 0) return true
|
||||
|
||||
if (level.maxCellsX !== undefined && level.maxCellsX > 0) {
|
||||
let minX = room.x
|
||||
let maxX = room.x + room.width
|
||||
for (const other of level.rooms) {
|
||||
if (other.x < minX) minX = other.x
|
||||
if (other.x + other.width > maxX) maxX = other.x + other.width
|
||||
}
|
||||
if (maxX - minX > level.maxCellsX) {
|
||||
if (level.stats) {
|
||||
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (level.maxCellsY !== undefined && level.maxCellsY > 0) {
|
||||
let minY = room.y
|
||||
let maxY = room.y + room.height
|
||||
for (const other of level.rooms) {
|
||||
if (other.y < minY) minY = other.y
|
||||
if (other.y + other.height > maxY) maxY = other.y + other.height
|
||||
}
|
||||
if (maxY - minY > level.maxCellsY) {
|
||||
if (level.stats) {
|
||||
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds.
|
||||
*
|
||||
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
|
||||
* are fine and only genuine overlap fails.
|
||||
|
|
@ -979,9 +1054,10 @@ function roomSideMask(room: Room): number {
|
|||
* @param level - the level.
|
||||
* @param room - the candidate.
|
||||
* @param ignored - a room to skip, usually its parent.
|
||||
* @returns true when the room is clear.
|
||||
* @returns true when the room is clear and within global level bounds.
|
||||
*/
|
||||
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
|
||||
if (!checkRoomWithinBounds(level, room)) return false
|
||||
for (const other of level.rooms) {
|
||||
if (other === room || other === ignored) continue
|
||||
if (!notOverlapping(room, other, 0)) return false
|
||||
|
|
@ -1965,9 +2041,16 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
const rng = new Rng(request.seed)
|
||||
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
|
||||
const index = buildPieceIndex(request.pieces)
|
||||
const maxCellsX = request.maxCellsX !== undefined
|
||||
? request.maxCellsX
|
||||
: (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined)
|
||||
const maxCellsY = request.maxCellsY !== undefined
|
||||
? request.maxCellsY
|
||||
: (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined)
|
||||
|
||||
const level: MazeLevel = {
|
||||
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
|
||||
maxCellsX, maxCellsY, stats,
|
||||
}
|
||||
const first = makeRoom(level)
|
||||
const levelRef: MazeLevel = { ...level, rooms: [first], first }
|
||||
|
|
@ -2068,6 +2151,9 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
warps: stats.warps,
|
||||
unresolvedRoles: stats.unresolvedRoles,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
maxCellsX,
|
||||
maxCellsY,
|
||||
boundsRejected: stats.boundsRejected ?? 0,
|
||||
notes: stats.notes,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell } from '../src/formats/ds1'
|
||||
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze'
|
||||
|
||||
function makeMockDs1(width = 10, height = 10): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.push({
|
||||
walls: [{ prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 1,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockPiece(name: string, kind: MazePieceKind, sides: string): MazePiece {
|
||||
return {
|
||||
name,
|
||||
kind,
|
||||
sides,
|
||||
levels: [makeMockDs1(10, 10)],
|
||||
}
|
||||
}
|
||||
|
||||
function makeCavePieceSet(): MazePiece[] {
|
||||
const ds1 = makeMockDs1(10, 10)
|
||||
const tokens = [
|
||||
'NSEW',
|
||||
'NSE', 'NSW', 'NEW', 'SEW',
|
||||
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
|
||||
'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = tokens.map(token => ({
|
||||
name: `Act 1 - Cave ${token}`,
|
||||
kind: 'room',
|
||||
sides: token,
|
||||
levels: [ds1],
|
||||
}))
|
||||
pieces.push({
|
||||
name: 'Act 1 - Cave Prev NS',
|
||||
kind: 'prev',
|
||||
sides: 'NS',
|
||||
levels: [ds1],
|
||||
})
|
||||
pieces.push({
|
||||
name: 'Act 1 - Cave Prev EW',
|
||||
kind: 'prev',
|
||||
sides: 'EW',
|
||||
levels: [ds1],
|
||||
})
|
||||
pieces.push({
|
||||
name: 'Act 1 - Cave Down NS',
|
||||
kind: 'down',
|
||||
sides: 'NS',
|
||||
levels: [ds1],
|
||||
})
|
||||
pieces.push({
|
||||
name: 'Act 1 - Cave Down EW',
|
||||
kind: 'down',
|
||||
sides: 'EW',
|
||||
levels: [ds1],
|
||||
})
|
||||
return pieces
|
||||
}
|
||||
|
||||
describe('Maze Global Bounding Box Constraints (Issue #48)', () => {
|
||||
test('generateMaze respects maxSectionsX and maxSectionsY limits', () => {
|
||||
const pieces = makeCavePieceSet()
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 10,
|
||||
minRooms: 9,
|
||||
merge: 0,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
maxSectionsX: 3,
|
||||
maxSectionsY: 3,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.level.width).toBeLessThanOrEqual(30)
|
||||
expect(result.level.height).toBeLessThanOrEqual(30)
|
||||
expect(result.stats.maxCellsX).toBe(30)
|
||||
expect(result.stats.maxCellsY).toBe(30)
|
||||
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
|
||||
})
|
||||
|
||||
test('generateMaze respects maxCellsX and maxCellsY directly', () => {
|
||||
const pieces = makeCavePieceSet()
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 10,
|
||||
minRooms: 8,
|
||||
merge: 500,
|
||||
seed: 99999,
|
||||
pieces,
|
||||
maxCellsX: 30,
|
||||
maxCellsY: 30,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.level.width).toBeLessThanOrEqual(30)
|
||||
expect(result.level.height).toBeLessThanOrEqual(30)
|
||||
expect(result.stats.maxCellsX).toBe(30)
|
||||
expect(result.stats.maxCellsY).toBe(30)
|
||||
})
|
||||
|
||||
test('forces maze to fold vertically when maxSectionsX is narrow (e.g. 2 sections wide)', () => {
|
||||
const pieces = makeCavePieceSet()
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 10,
|
||||
minRooms: 8,
|
||||
merge: 0,
|
||||
seed: 42,
|
||||
pieces,
|
||||
maxSectionsX: 2,
|
||||
maxSectionsY: 6,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.level.width).toBeLessThanOrEqual(20)
|
||||
expect(result.level.height).toBeLessThanOrEqual(60)
|
||||
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(8)
|
||||
expect(Number(result.stats.boundsRejected)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('records boundsRejected when room growth attempts cross the configured boundary', () => {
|
||||
const pieces = makeCavePieceSet()
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 10,
|
||||
minRooms: 9,
|
||||
merge: 800,
|
||||
seed: 54321,
|
||||
pieces,
|
||||
maxSectionsX: 3,
|
||||
maxSectionsY: 3,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.stats.boundsRejected).toBeDefined()
|
||||
expect(typeof result.stats.boundsRejected).toBe('number')
|
||||
expect(result.level.width).toBeLessThanOrEqual(30)
|
||||
expect(result.level.height).toBeLessThanOrEqual(30)
|
||||
})
|
||||
|
||||
test('unconstrained maze allows wide sprawl when no bounds are specified', () => {
|
||||
const pieces = makeCavePieceSet()
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Cave 1',
|
||||
levelTypeName: 'Act 1 - Cave',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 10,
|
||||
minRooms: 9,
|
||||
merge: 0,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.stats.maxCellsX).toBeUndefined()
|
||||
expect(result.stats.maxCellsY).toBeUndefined()
|
||||
expect(result.stats.boundsRejected).toBe(0)
|
||||
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue