diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 29d0f42..f7b9cc4 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -1561,6 +1561,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`) @@ -1584,6 +1586,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, diff --git a/src/game/maze.ts b/src/game/maze.ts index cb9bf5e..1976e64 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -287,13 +287,34 @@ export interface MazeRequest { readonly staffTombLevelId?: number /** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */ readonly bossTombLevelId?: number - /** * Explicit override for waypoint placement. * If true, forces a waypoint room to be placed (using waypoint pieces). * If false, skips waypoint room placement even if the level profile includes it. * If omitted, defaults to whether the level id has a waypoint in the profile. */ readonly hasWaypoint?: boolean + /** + * 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. */ @@ -916,6 +937,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 } /** @@ -933,6 +957,7 @@ interface MazeStats { readonly notes: string[] ringRooms: number placementAttempts: number + boundsRejected?: number /** Stair rooms, with the coordinates only `stamp` can work out. */ warps: MazeWarp[] /** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */ @@ -1020,7 +1045,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. @@ -1028,9 +1102,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 @@ -2012,9 +2087,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 } @@ -2131,6 +2213,9 @@ export function generateMaze(request: MazeRequest): MazeResult { : null, unresolvedRoles: stats.unresolvedRoles, unimplementedPasses: UNIMPLEMENTED_PASSES, + maxCellsX, + maxCellsY, + boundsRejected: stats.boundsRejected ?? 0, notes: stats.notes, }, } diff --git a/tests/maze-bounds.test.ts b/tests/maze-bounds.test.ts new file mode 100644 index 0000000..3a09970 --- /dev/null +++ b/tests/maze-bounds.test.ts @@ -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) + }) +})