Compare commits
42 Commits
1fa3f8b7a2
...
274ffae71e
| Author | SHA1 | Date |
|---|---|---|
|
|
274ffae71e | |
|
|
5aba7487f3 | |
|
|
3951e70ef4 | |
|
|
6f01c8de89 | |
|
|
db3d6ffa84 | |
|
|
12718ebcd6 | |
|
|
74d2d1e36f | |
|
|
dfce2f6cef | |
|
|
a6ab21881d | |
|
|
2de9835b34 | |
|
|
3ab024d12f | |
|
|
334b957ab6 | |
|
|
4f8e077bbb | |
|
|
4c584db273 | |
|
|
893e9b8550 | |
|
|
e1163b1253 | |
|
|
c310119ed2 | |
|
|
80b2fd0f71 | |
|
|
c83499c591 | |
|
|
9af2c07c38 | |
|
|
c2bda29211 | |
|
|
8e46b64156 | |
|
|
71fdaaefe3 | |
|
|
11e2e42731 | |
|
|
33585afe8d | |
|
|
167928a31f | |
|
|
92d6731dae | |
|
|
46b7166f22 | |
|
|
2fe5f4d892 | |
|
|
3f463ba777 | |
|
|
81d2172cca | |
|
|
723f5dce27 | |
|
|
8baf91c51f | |
|
|
ffc2b297f6 | |
|
|
12588ea948 | |
|
|
801afd7ddb | |
|
|
c56b808d7a | |
|
|
776e2d3c20 | |
|
|
8bb99ded7e | |
|
|
72aa835260 | |
|
|
57c13bdc26 | |
|
|
a01cffa0b5 |
|
|
@ -46,9 +46,11 @@ import { decodeDcc } from '../src/formats/dcc.ts'
|
|||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
|
||||
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||
import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts'
|
||||
import { generateWilderness } from '../src/game/wilderness.ts'
|
||||
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||
import { generatePreset } from '../src/game/preset.ts'
|
||||
import type { PresetPiece } from '../src/game/preset.ts'
|
||||
export interface MazeWarp {
|
||||
readonly room: number
|
||||
readonly direction: 'up' | 'down'
|
||||
|
|
@ -537,6 +539,7 @@ function buildSceneLinks(
|
|||
entrances: readonly WildernessEntrance[],
|
||||
mazeWarps: readonly MazeWarp[],
|
||||
waypointCells: readonly { x: number; y: number }[],
|
||||
mazeWaypoints?: readonly MazeWaypoint[],
|
||||
): SceneLinks {
|
||||
const outEntrances: SceneEntrance[] = []
|
||||
const outWarps: SceneWarp[] = []
|
||||
|
|
@ -781,7 +784,7 @@ function buildSceneLinks(
|
|||
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
|
||||
const waypoints: SceneWaypoint[] = []
|
||||
if (waypointId !== 255) {
|
||||
const fromArt = waypointCells[0]
|
||||
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
|
||||
const spot = fromArt ?? findWaypointSpot(grid, region)
|
||||
if (spot !== null && spot !== undefined) {
|
||||
const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot
|
||||
|
|
@ -1154,6 +1157,7 @@ async function bakeDs1Variant(
|
|||
generatorLinks: {
|
||||
readonly entrances: readonly WildernessEntrance[]
|
||||
readonly mazeWarps: readonly MazeWarp[]
|
||||
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
||||
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||||
} = { entrances: [], mazeWarps: [] },
|
||||
): Promise<void> {
|
||||
|
|
@ -1383,6 +1387,7 @@ async function bakeDs1Variant(
|
|||
generatorLinks.entrances,
|
||||
generatorLinks.mazeWarps,
|
||||
waypointCells,
|
||||
generatorLinks.mazeWaypoints,
|
||||
)
|
||||
|
||||
const sceneJson = {
|
||||
|
|
@ -1522,10 +1527,28 @@ for (const entry of LEVELS) {
|
|||
const libraries: Dt1[] = []
|
||||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||||
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
const level = await loadDs1(ds1Name)
|
||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
||||
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
|
||||
if (entry.levelId === 108) {
|
||||
const pieces: PresetPiece[] = []
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
const level = await loadDs1(ds1Name)
|
||||
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
|
||||
pieces.push({ name: pieceName, levels: [level] })
|
||||
}
|
||||
const result = generatePreset({
|
||||
levelId: entry.levelId,
|
||||
levelName: info.levelName,
|
||||
sizeX: info.sizeX > 0 ? info.sizeX : 120,
|
||||
sizeY: info.sizeY > 0 ? info.sizeY : 120,
|
||||
pieces,
|
||||
})
|
||||
const label = `${entry.slug}-chaos`
|
||||
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, result.level, 'preset:chaos-sanctuary', label, levelSeed(info.levelName))
|
||||
} else {
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
const level = await loadDs1(ds1Name)
|
||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
||||
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
|
||||
}
|
||||
}
|
||||
} else if (entry.kind === 'maze') {
|
||||
const type = levelType(levelRow)
|
||||
|
|
@ -1538,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`)
|
||||
|
|
@ -1561,11 +1586,17 @@ 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,
|
||||
result.level, `generated:${label}`, label, seed,
|
||||
{ entrances: [], mazeWarps: (result.stats.warps ?? []) as MazeWarp[] },
|
||||
{
|
||||
entrances: [],
|
||||
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
|
||||
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: n
|
|||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** The generators that must stay browser-safe. */
|
||||
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts']
|
||||
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts', 'src/game/preset.ts']
|
||||
|
||||
/**
|
||||
* Assert the generators import nothing Node-only.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
* batching, indexed geometry and draw-call instrumentation added for Issue #16.
|
||||
*/
|
||||
import { SpriteRenderer, RendererError } from '../src/render/renderer.ts'
|
||||
import { buildBounds, intersects, viewportRect } from '../src/render/cull.ts'
|
||||
|
||||
interface MockResource {
|
||||
id: number
|
||||
|
|
@ -26,6 +27,12 @@ function createMockCanvas(): {
|
|||
eventListeners: Map<string, Set<EventListener>>
|
||||
glDrawCalls: number
|
||||
shaderSources: string[]
|
||||
texStorage2DCalls: number
|
||||
texSubImage2DCalls: number
|
||||
uniformUploadCalls: number
|
||||
uniformCameraCalls: number
|
||||
uniformViewportCalls: number
|
||||
uniformZoomCalls: number
|
||||
}
|
||||
setContextLost: (lost: boolean) => void
|
||||
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
|
||||
|
|
@ -47,6 +54,12 @@ function createMockCanvas(): {
|
|||
eventListeners: new Map<string, Set<EventListener>>(),
|
||||
glDrawCalls: 0,
|
||||
shaderSources: [] as string[],
|
||||
texStorage2DCalls: 0,
|
||||
texSubImage2DCalls: 0,
|
||||
uniformUploadCalls: 0,
|
||||
uniformCameraCalls: 0,
|
||||
uniformViewportCalls: 0,
|
||||
uniformZoomCalls: 0,
|
||||
}
|
||||
|
||||
const gl: any = {
|
||||
|
|
@ -58,6 +71,9 @@ function createMockCanvas(): {
|
|||
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
||||
TEXTURE_2D: 3553,
|
||||
RGBA: 6408,
|
||||
RGBA8: 32856,
|
||||
R8: 33321,
|
||||
RED: 6403,
|
||||
UNSIGNED_BYTE: 5121,
|
||||
UNSIGNED_INT: 5125,
|
||||
FLOAT: 5126,
|
||||
|
|
@ -142,12 +158,21 @@ function createMockCanvas(): {
|
|||
bindTexture: () => {},
|
||||
texParameteri: () => {},
|
||||
texImage2D: () => {},
|
||||
texStorage2D: () => { stats.texStorage2DCalls += 1 },
|
||||
texSubImage2D: () => { stats.texSubImage2DCalls += 1 },
|
||||
pixelStorei: () => {},
|
||||
|
||||
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
||||
getUniformLocation: (_p: any, name: string) => ({ name }),
|
||||
uniform2f: () => {},
|
||||
uniform1f: () => {},
|
||||
uniform2f: (loc: any) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
|
||||
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
|
||||
},
|
||||
uniform1f: (loc: any) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
|
||||
},
|
||||
uniform1i: () => {},
|
||||
uniform1iv: () => {},
|
||||
|
||||
|
|
@ -234,7 +259,7 @@ function runTests(): void {
|
|||
assert(mock.stats.createdPrograms.size === 1, 'Program created')
|
||||
assert(mock.stats.createdVAOs.size === 1, 'VAO created')
|
||||
assert(mock.stats.createdBuffers.size === 2, 'Vertex and index buffers created')
|
||||
assert(mock.stats.createdTextures.size === 2, 'Initial textures created (atlas + white)')
|
||||
assert(mock.stats.createdTextures.size === 3, 'Initial textures created (atlas + white + palette)')
|
||||
assert(renderer.isDisposed === false, 'Renderer is active and not disposed')
|
||||
|
||||
renderer.dispose()
|
||||
|
|
@ -246,7 +271,7 @@ function runTests(): void {
|
|||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
|
||||
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 })
|
||||
assert(mock.stats.createdTextures.size === 3, 'addAtlas creates a new GPU texture (3 total)')
|
||||
assert(mock.stats.createdTextures.size === 4, 'addAtlas creates a new GPU texture (4 total)')
|
||||
|
||||
// Draw with this extra atlas
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
|
|
@ -273,11 +298,11 @@ function runTests(): void {
|
|||
void extra1
|
||||
void extra2
|
||||
|
||||
assert(mock.stats.createdTextures.size === 4, '4 total textures created before dispose')
|
||||
assert(mock.stats.createdTextures.size === 5, '5 total textures created before dispose')
|
||||
|
||||
renderer.dispose()
|
||||
assert(renderer.isDisposed === true, 'isDisposed is true after dispose()')
|
||||
assert(mock.stats.deletedTextures.size === 4, 'All 4 textures were deleted on dispose()')
|
||||
assert(mock.stats.deletedTextures.size === 5, 'All 5 textures were deleted on dispose()')
|
||||
assert(mock.stats.deletedBuffers.size === 2, 'Vertex and index buffers were deleted on dispose()')
|
||||
assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()')
|
||||
assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()')
|
||||
|
|
@ -437,6 +462,312 @@ function runTests(): void {
|
|||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 11 (Issue #20): Immutable texture storage (texStorage2D + texSubImage2D)
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
|
||||
// In constructor: whiteTexture and setPalette(defaultPal) should have used texStorage2D + texSubImage2D
|
||||
assert(mock.stats.texStorage2DCalls >= 2, 'texStorage2D called for initial whiteTexture and paletteTexture')
|
||||
assert(mock.stats.texSubImage2DCalls >= 2, 'texSubImage2D called for initial whiteTexture and paletteTexture')
|
||||
|
||||
const beforeStorage = mock.stats.texStorage2DCalls
|
||||
const beforeSub = mock.stats.texSubImage2DCalls
|
||||
|
||||
// setAtlas uses texStorage2D on first upload
|
||||
const atlas = {
|
||||
pixels: new Uint8ClampedArray(64 * 64 * 4),
|
||||
width: 64,
|
||||
height: 64,
|
||||
groups: [],
|
||||
}
|
||||
renderer.setAtlas(atlas)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 1, 'setAtlas uploads data via texSubImage2D')
|
||||
|
||||
// Calling setAtlas again with SAME dimensions reuses storage (no extra texStorage2D, only texSubImage2D)
|
||||
renderer.setAtlas(atlas)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas with same dimensions reuses immutable storage')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 2, 'setAtlas with same dimensions uploads data via texSubImage2D')
|
||||
|
||||
// addAtlas uses texStorage2D and texSubImage2D
|
||||
renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 })
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 2, 'addAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 3, 'addAtlas uploads data via texSubImage2D')
|
||||
|
||||
// createAtlas alias also uses texStorage2D and texSubImage2D
|
||||
renderer.createAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 3, 'createAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 4, 'createAtlas uploads data via texSubImage2D')
|
||||
|
||||
// addIndexedAtlas uses texStorage2D (R8) and texSubImage2D (RED)
|
||||
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
||||
assert(mock.stats.texStorage2DCalls === beforeStorage + 4, 'addIndexedAtlas allocates immutable storage via texStorage2D')
|
||||
assert(mock.stats.texSubImage2DCalls === beforeSub + 5, 'addIndexedAtlas uploads data via texSubImage2D')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 12 (Issue #20): Uniform dirty checking and caching in flush()
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
renderer.begin({ x: 100, y: 200, zoom: 1.5 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 1, 'First flush uploads camera uniform')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'First flush uploads viewport uniform')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'First flush uploads zoom uniform')
|
||||
|
||||
// Second flush with identical camera/viewport/zoom should NOT re-upload any uniforms
|
||||
renderer.draw(frame, 8, 8)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 1, 'Consecutive flush with same camera skips camera uniform upload')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'Consecutive flush with same viewport skips viewport uniform upload')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'Consecutive flush with same zoom skips zoom uniform upload')
|
||||
|
||||
// Change only camera
|
||||
renderer.begin({ x: 150, y: 200, zoom: 1.5 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 2, 'Only camera uniform is uploaded when camera changes')
|
||||
assert(mock.stats.uniformViewportCalls === 1, 'Viewport uniform is not uploaded when viewport did not change')
|
||||
assert(mock.stats.uniformZoomCalls === 1, 'Zoom uniform is not uploaded when zoom did not change')
|
||||
|
||||
// Change only zoom
|
||||
renderer.begin({ x: 150, y: 200, zoom: 2.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
assert(mock.stats.uniformCameraCalls === 2, 'Camera uniform is not uploaded when camera did not change')
|
||||
assert(mock.stats.uniformZoomCalls === 2, 'Only zoom uniform is uploaded when zoom changes')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 13 (Issue #20): Configurable shader alphaDiscard option
|
||||
{
|
||||
const mockDefault = createMockCanvas()
|
||||
const rendererDefault = new SpriteRenderer(mockDefault.canvas)
|
||||
assert(rendererDefault.alphaDiscard === true, 'alphaDiscard defaults to true')
|
||||
const fsDefault = mockDefault.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
assert(fsDefault.includes('discard;'), 'Default shader includes discard for transparent fragments')
|
||||
rendererDefault.dispose()
|
||||
|
||||
const mockNoDiscard = createMockCanvas()
|
||||
const rendererNoDiscard = new SpriteRenderer(mockNoDiscard.canvas, { alphaDiscard: false })
|
||||
assert(rendererNoDiscard.alphaDiscard === false, 'alphaDiscard can be disabled via RendererOptions')
|
||||
const fsNoDiscard = mockNoDiscard.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
assert(!fsNoDiscard.includes('discard;'), 'Shader with alphaDiscard=false omits discard instructions for mobile GPU optimization')
|
||||
assert(fsNoDiscard.includes('outColor = texel * v_tint;'), 'Pure alpha blending used when alphaDiscard=false')
|
||||
rendererNoDiscard.dispose()
|
||||
}
|
||||
|
||||
// Test 14 (Issue #16): Multi-page level maintains draw calls <= 1 (within batch limits)
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
// A complex multi-page level with 8 pages:
|
||||
// Floors (2 pages), Walls (2 pages), Roofs (1 page), Objects (1 page), Character (1 page), Monsters (1 page)
|
||||
const pages = Array.from({ length: 8 }, () => page(renderer))
|
||||
const frame = { x: 0, y: 0, width: 16, height: 16 }
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
const beforeDrawCalls = mock.stats.glDrawCalls
|
||||
|
||||
// Simulate drawing a full frame in painter's order across these 8 pages (1200 quads total)
|
||||
// 1. Floors (pages 0 & 1)
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
renderer.draw(frame, i * 4, 0, { atlas: pages[i % 2] })
|
||||
}
|
||||
// 2. Walls and objects interleaved (pages 2, 3, 5)
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const pageIndex = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
|
||||
renderer.draw(frame, i * 4, 100, { atlas: pages[pageIndex] })
|
||||
}
|
||||
// 3. Characters and monsters (pages 6, 7)
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
renderer.draw(frame, i * 10, 200, { atlas: pages[6 + (i % 2)] })
|
||||
}
|
||||
// 4. Roofs (page 4)
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
renderer.draw(frame, i * 4, 300, { atlas: pages[4] })
|
||||
}
|
||||
renderer.flush()
|
||||
|
||||
assert(renderer.quadsSubmitted === 1200, 'Multi-page level submitted all 1200 quads')
|
||||
assert(mock.stats.glDrawCalls - beforeDrawCalls === 1, 'Multi-page level (8 pages) rendered in exactly 1 draw call')
|
||||
assert(renderer.drawCalls === 1, 'renderer.drawCalls reports 1 draw call')
|
||||
|
||||
// Now verify when distinct pages exceed the hardware texture unit budget
|
||||
const overBudgetPages = Array.from({ length: renderer.textureUnits + 4 }, () => page(renderer))
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
const beforeOverCalls = mock.stats.glDrawCalls
|
||||
for (let i = 0; i < overBudgetPages.length; i += 1) {
|
||||
renderer.draw(frame, i * 16, 0, { atlas: overBudgetPages[i] })
|
||||
}
|
||||
renderer.flush()
|
||||
const extraCalls = mock.stats.glDrawCalls - beforeOverCalls
|
||||
assert(
|
||||
extraCalls <= Math.ceil(overBudgetPages.length / renderer.textureUnits),
|
||||
'Exceeding texture unit budget flushes minimally within batch unit limits',
|
||||
)
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 15 (Issue #16): Viewport culling achieves > 80% quad culling ratio on large levels
|
||||
{
|
||||
// Generate an 80x80 level (6400 quads) with standard tile size 80x40
|
||||
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
|
||||
const gridCols = 80
|
||||
const gridRows = 80
|
||||
for (let y = 0; y < gridRows; y += 1) {
|
||||
for (let x = 0; x < gridCols; x += 1) {
|
||||
quads.push({
|
||||
x: x * 80,
|
||||
y: y * 40,
|
||||
frame: { width: 80, height: 40 },
|
||||
})
|
||||
}
|
||||
}
|
||||
const totalQuads = quads.length // 6400
|
||||
const bounds = buildBounds(quads)
|
||||
|
||||
// Center camera at (3200, 1600) with standard 1280x720 viewport and zoom = 1
|
||||
const view1x = viewportRect(3200, 1600, 1280, 720, 1.0)
|
||||
let visible1x = 0
|
||||
let culled1x = 0
|
||||
const visibleIndices: number[] = []
|
||||
|
||||
for (let i = 0; i < totalQuads; i += 1) {
|
||||
if (intersects(bounds, i, view1x)) {
|
||||
visible1x += 1
|
||||
visibleIndices.push(i)
|
||||
} else {
|
||||
culled1x += 1
|
||||
}
|
||||
}
|
||||
|
||||
const cullingRatio1x = culled1x / totalQuads
|
||||
assert(totalQuads === 6400, 'Large level has 6400 quads')
|
||||
assert(visible1x + culled1x === totalQuads, 'All quads accounted for in culling pass')
|
||||
assert(
|
||||
cullingRatio1x > 0.80,
|
||||
`Viewport culling achieves > 80% culling ratio on 80x80 level (measured: ${(cullingRatio1x * 100).toFixed(1)}%)`,
|
||||
)
|
||||
|
||||
// Verify painter's order preservation: visible indices must be strictly monotonic
|
||||
let monotonic = true
|
||||
for (let i = 1; i < visibleIndices.length; i += 1) {
|
||||
if (visibleIndices[i]! <= visibleIndices[i - 1]!) {
|
||||
monotonic = false
|
||||
break
|
||||
}
|
||||
}
|
||||
assert(monotonic, "Culled stream maintains strictly monotonic painter's order")
|
||||
|
||||
// Test with zoom = 2.0 (tighter viewport -> even higher culling ratio)
|
||||
const view2x = viewportRect(3200, 1600, 1280, 720, 2.0)
|
||||
let culled2x = 0
|
||||
for (let i = 0; i < totalQuads; i += 1) {
|
||||
if (!intersects(bounds, i, view2x)) culled2x += 1
|
||||
}
|
||||
const cullingRatio2x = culled2x / totalQuads
|
||||
assert(
|
||||
cullingRatio2x > cullingRatio1x && cullingRatio2x > 0.80,
|
||||
`Zooming in increases culling ratio (measured: ${(cullingRatio2x * 100).toFixed(1)}%)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Test 16 (Issue #16): HUD displays accurate measured metrics rather than hardcoded dummies
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const a = page(renderer)
|
||||
const frame = { x: 0, y: 0, width: 16, height: 16 }
|
||||
|
||||
// Frame 1: Render 120 quads
|
||||
const t0 = performance.now()
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
renderer.draw(frame, i, i, { atlas: a })
|
||||
}
|
||||
renderer.flush()
|
||||
const renderMs1 = performance.now() - t0
|
||||
|
||||
const state1 = {
|
||||
drawCalls: renderer.drawCalls,
|
||||
quadsDrawn: renderer.quadsSubmitted,
|
||||
culledDraws: 880,
|
||||
renderMs: renderMs1,
|
||||
}
|
||||
|
||||
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
|
||||
assert(state1.drawCalls === 1, 'HUD state drawCalls accurately records 1 draw call')
|
||||
assert(state1.quadsDrawn === 120, 'HUD state quadsDrawn accurately records 120 quads')
|
||||
assert(state1.culledDraws === 880, 'HUD state culledDraws accurately records 880 culled quads')
|
||||
assert(state1.renderMs >= 0, 'HUD state renderMs is a valid measured frame duration')
|
||||
assert(hudText1.includes('120 图元 / 1 批次(剔除 880)'), 'HUD string accurately formats measured metrics')
|
||||
|
||||
// Frame 2: Render with over-budget pages to force 2 draw calls and 250 quads
|
||||
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => page(renderer))
|
||||
const t1 = performance.now()
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
renderer.draw(frame, i, i, { atlas: overPages[i % renderer.textureUnits] })
|
||||
}
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
renderer.draw(frame, i, i, { atlas: overPages[renderer.textureUnits] })
|
||||
}
|
||||
renderer.flush()
|
||||
const renderMs2 = performance.now() - t1
|
||||
|
||||
const state2 = {
|
||||
drawCalls: renderer.drawCalls,
|
||||
quadsDrawn: renderer.quadsSubmitted,
|
||||
culledDraws: 1750,
|
||||
renderMs: renderMs2,
|
||||
}
|
||||
|
||||
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
|
||||
assert(state2.drawCalls === 2, 'HUD state drawCalls dynamically updates to 2 (not a hardcoded dummy)')
|
||||
assert(state2.quadsDrawn === 250, 'HUD state quadsDrawn dynamically updates to 250 (not a hardcoded dummy)')
|
||||
assert(state2.culledDraws === 1750, 'HUD state culledDraws dynamically updates to 1750')
|
||||
assert(hudText2.includes('250 图元 / 2 批次(剔除 1750)'), 'HUD string reflects dynamic update without hardcoded values')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
// Test 17 (Issue #16): Memory allocation and performance regression audit
|
||||
{
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const a = page(renderer)
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
// 1. Buffer growth audit: submitting > INITIAL_CAPACITY (8192) quads
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 9000; i += 1) {
|
||||
renderer.draw(frame, i % 100, i % 100, { atlas: a })
|
||||
}
|
||||
assert(renderer.quadsSubmitted === 9000, 'Batch buffer dynamically grows past initial capacity (9000 quads)')
|
||||
renderer.flush()
|
||||
assert(renderer.drawCalls === 1, 'Large batch (>8192 quads) flushed in single draw call without memory error')
|
||||
|
||||
// 2. Texture cleanup audit on deleteAtlas
|
||||
const tempAtlas = page(renderer)
|
||||
const texturesBefore = mock.stats.deletedTextures.size
|
||||
renderer.deleteAtlas(tempAtlas)
|
||||
assert(mock.stats.deletedTextures.size === texturesBefore + 1, 'deleteAtlas promptly frees GPU texture memory')
|
||||
|
||||
renderer.dispose()
|
||||
}
|
||||
console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`)
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,14 @@ export interface Dt1Tile {
|
|||
* with the engine's own offset when it is wired up.
|
||||
*/
|
||||
readonly roofHeight: number
|
||||
/**
|
||||
* Animated flag, at file offset +7.
|
||||
*
|
||||
* Non-zero (specifically bit 0 set) when this tile is part of an animated sequence.
|
||||
* For animated tiles, `rarityFrameIndex` specifies the 0-based animation frame index
|
||||
* rather than a variant selection weight.
|
||||
*/
|
||||
readonly animated: number
|
||||
/**
|
||||
* Variant weight inside one `style:sequence` group, at file offset +32.
|
||||
*
|
||||
|
|
@ -240,6 +248,20 @@ export function subTileFlagsOf(raw: number): SubTileFlags {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a DT1 tile is part of an animated sequence.
|
||||
*
|
||||
* In Diablo II DT1 files, byte +7 is the animated flag (bit 0 set for animated floor/wall tiles).
|
||||
* When animated, `rarityFrameIndex` represents the 0-based animation frame index
|
||||
* rather than a random variation weight.
|
||||
*
|
||||
* @param tile - the DT1 tile to check.
|
||||
* @returns true if the tile is an animated frame.
|
||||
*/
|
||||
export function isAnimatedTile(tile: { readonly animated?: number }): boolean {
|
||||
return typeof tile.animated === 'number' && ((tile.animated & 1) === 1 || tile.animated > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DT1 file.
|
||||
*
|
||||
|
|
@ -315,6 +337,7 @@ export function decodeDt1(data: Uint8Array): Dt1 {
|
|||
sequence: i32(data, at + 28),
|
||||
materialFlags: u16(data, at + 6),
|
||||
roofHeight: i16(data, at + 4),
|
||||
animated: new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 7),
|
||||
rarityFrameIndex: i32(data, at + 32),
|
||||
subTileFlags,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*
|
||||
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
|
||||
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
|
||||
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 quadrants), Dt1Mask
|
||||
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 variants / versions), Dt1Mask
|
||||
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
|
||||
*
|
||||
* Two details cost real time to find and are encoded here:
|
||||
|
|
@ -318,13 +318,13 @@ export function resolveActTown(tables: ActTables, act: number): LevelInfo {
|
|||
export interface LoadedActTown {
|
||||
/** The resolution result. */
|
||||
readonly town: LevelInfo
|
||||
/** The decoded DS1 quadrants. */
|
||||
/** The decoded DS1 variants / versions. */
|
||||
readonly levels: readonly Ds1[]
|
||||
/** The decoded DT1 libraries, in file-list order. */
|
||||
readonly libraries: readonly Dt1[]
|
||||
/** The act palette. */
|
||||
readonly palette: Pl2
|
||||
/** Total cells across the quadrants. */
|
||||
/** Total cells across the variants. */
|
||||
readonly cells: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* DT1 Animated Tiles controller and runtime helpers.
|
||||
*
|
||||
* In Diablo II, certain environmental tiles (water ripples, lava, torches,
|
||||
* fountains, cauldrons) are stored in DT1 format as animated sequences.
|
||||
*
|
||||
* Each animated tile group shares the same (style, sequence, type), with
|
||||
* byte +7 (animated flag) set, and `rarityFrameIndex` indicating the 0-based
|
||||
* animation frame index.
|
||||
*
|
||||
* Default playback speed is 10 frames per second (100ms per frame).
|
||||
*/
|
||||
import type { AtlasFrame } from '../render/atlas.ts'
|
||||
|
||||
/** Default playback duration per animated tile frame in milliseconds (10 FPS in Diablo II). */
|
||||
export const DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS = 100
|
||||
/** Game ticks per animation frame at standard 25Hz simulation rate (2.5 ticks/frame = 100ms/frame). */
|
||||
export const DEFAULT_TICKS_PER_ANIMATED_FRAME = 2.5
|
||||
|
||||
/** One placed frame of an animated tile. */
|
||||
export interface AnimatedFrame {
|
||||
readonly frame: AtlasFrame
|
||||
readonly page: number
|
||||
}
|
||||
|
||||
/** An animated tile or drawable that can advance frames. */
|
||||
export interface AnimatableTile {
|
||||
frame: AtlasFrame
|
||||
page: number
|
||||
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
|
||||
currentFrameIndex?: number | undefined
|
||||
animTimeMs?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the frame index given elapsed time and total frames.
|
||||
* Automatically wraps around (modulo) to create a seamless loop.
|
||||
*
|
||||
* @param elapsedMs - elapsed milliseconds (game clock or timestamp).
|
||||
* @param totalFrames - total number of frames in the animation sequence.
|
||||
* @param frameDurationMs - duration of each frame in milliseconds (default 100ms / 10 FPS).
|
||||
* @returns the 0-based frame index within [0, totalFrames - 1].
|
||||
*/
|
||||
export function computeFrameIndexFromTime(
|
||||
elapsedMs: number,
|
||||
totalFrames: number,
|
||||
frameDurationMs = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
||||
): number {
|
||||
if (totalFrames <= 1) return 0
|
||||
const duration = frameDurationMs > 0 ? frameDurationMs : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS
|
||||
return Math.floor(Math.max(0, elapsedMs) / duration) % totalFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the frame index given game simulation ticks and total frames.
|
||||
* Automatically wraps around (modulo) to create a seamless loop.
|
||||
*
|
||||
* @param tick - simulation tick count (at 25Hz).
|
||||
* @param totalFrames - total number of frames in the animation sequence.
|
||||
* @param ticksPerFrame - ticks elapsed per frame (default 2.5 ticks / frame).
|
||||
* @returns the 0-based frame index within [0, totalFrames - 1].
|
||||
*/
|
||||
export function computeFrameIndexFromTick(
|
||||
tick: number,
|
||||
totalFrames: number,
|
||||
ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME,
|
||||
): number {
|
||||
if (totalFrames <= 1) return 0
|
||||
const rate = ticksPerFrame > 0 ? ticksPerFrame : DEFAULT_TICKS_PER_ANIMATED_FRAME
|
||||
return Math.floor(Math.max(0, tick) / rate) % totalFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a frame index by a given step with cycle wrapping.
|
||||
*
|
||||
* @param currentIndex - current frame index.
|
||||
* @param frameCount - total number of frames.
|
||||
* @param step - number of frames to advance (default 1, can be negative).
|
||||
* @returns the new frame index within [0, frameCount - 1].
|
||||
*/
|
||||
export function advanceFrameIndex(currentIndex: number, frameCount: number, step = 1): number {
|
||||
if (frameCount <= 1) return 0
|
||||
const next = (currentIndex + step) % frameCount
|
||||
return next >= 0 ? next : next + frameCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an animatable tile's current frame according to clock (timestamp or tick).
|
||||
*
|
||||
* @param tile - the animatable tile to update.
|
||||
* @param clock - current timestamp in ms or tick count.
|
||||
* @param mode - 'time' for millisecond timestamps or 'tick' for simulation ticks.
|
||||
* @param rate - optional override for frame duration (ms) or ticks per frame.
|
||||
* @returns true if the frame index changed, false otherwise.
|
||||
*/
|
||||
export function updateAnimatableTile(
|
||||
tile: AnimatableTile,
|
||||
clock: number,
|
||||
mode: 'time' | 'tick' = 'time',
|
||||
rate?: number,
|
||||
): boolean {
|
||||
const frames = tile.animatedFrames
|
||||
if (!frames || frames.length <= 1) return false
|
||||
const targetIndex = mode === 'time'
|
||||
? computeFrameIndexFromTime(clock, frames.length, rate)
|
||||
: computeFrameIndexFromTick(clock, frames.length, rate)
|
||||
if (targetIndex === tile.currentFrameIndex) return false
|
||||
tile.currentFrameIndex = targetIndex
|
||||
const targetFrame = frames[targetIndex]
|
||||
if (targetFrame !== undefined) {
|
||||
tile.frame = targetFrame.frame
|
||||
tile.page = targetFrame.page
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance an animatable tile by a discrete step.
|
||||
*
|
||||
* @param tile - the animatable tile.
|
||||
* @param step - number of frames to advance (default 1).
|
||||
* @returns true if the frame index changed.
|
||||
*/
|
||||
export function advanceAnimatableTile(tile: AnimatableTile, step = 1): boolean {
|
||||
const frames = tile.animatedFrames
|
||||
if (!frames || frames.length <= 1) return false
|
||||
const currentIndex = tile.currentFrameIndex ?? 0
|
||||
const nextIndex = advanceFrameIndex(currentIndex, frames.length, step)
|
||||
if (nextIndex === tile.currentFrameIndex) return false
|
||||
tile.currentFrameIndex = nextIndex
|
||||
const targetFrame = frames[nextIndex]
|
||||
if (targetFrame !== undefined) {
|
||||
tile.frame = targetFrame.frame
|
||||
tile.page = targetFrame.page
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a list of animatable tiles according to clock (timestamp or tick).
|
||||
*
|
||||
* @param tiles - array of animatable tiles.
|
||||
* @param clock - timestamp in ms or simulation tick.
|
||||
* @param mode - 'time' or 'tick'.
|
||||
* @param rate - optional rate override.
|
||||
* @returns the number of tiles whose active frame changed.
|
||||
*/
|
||||
export function updateAnimatableTiles(
|
||||
tiles: readonly AnimatableTile[],
|
||||
clock: number,
|
||||
mode: 'time' | 'tick' = 'time',
|
||||
rate?: number,
|
||||
): number {
|
||||
let updated = 0
|
||||
for (let i = 0; i < tiles.length; i += 1) {
|
||||
if (updateAnimatableTile(tiles[i]!, clock, mode, rate)) {
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance all animatable tiles by a discrete step.
|
||||
*
|
||||
* @param tiles - array of animatable tiles.
|
||||
* @param step - step count.
|
||||
* @returns the number of tiles updated.
|
||||
*/
|
||||
export function advanceAnimatableTiles(tiles: readonly AnimatableTile[], step = 1): number {
|
||||
let updated = 0
|
||||
for (let i = 0; i < tiles.length; i += 1) {
|
||||
if (advanceAnimatableTile(tiles[i]!, step)) {
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
|
@ -22,6 +22,22 @@
|
|||
*/
|
||||
import { numberCell, textCell } from './tables.ts'
|
||||
import type { DataTable } from './tables.ts'
|
||||
import {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
} from './monster-rooms.ts'
|
||||
import type { MapRoom } from './monster-rooms.ts'
|
||||
|
||||
export {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
type MapRoom,
|
||||
}
|
||||
|
||||
|
||||
/** Movement request and intent for one tick. */
|
||||
export interface CombatInput {
|
||||
|
|
@ -120,6 +136,8 @@ export interface Monster {
|
|||
hitFlash: number
|
||||
/** Ticks left of the death animation. */
|
||||
corpseTicks: number
|
||||
/** Whether the monster is sleeping (inactive off-screen room). */
|
||||
sleeping?: boolean | undefined
|
||||
}
|
||||
|
||||
/** The player's combat-relevant state. */
|
||||
|
|
@ -481,7 +499,11 @@ export function spawnMonsterPacks(
|
|||
spread: number,
|
||||
terrain: CombatTerrain,
|
||||
safeZones?: readonly SafeZone[],
|
||||
rooms?: readonly MapRoom[],
|
||||
): number {
|
||||
if (rooms !== undefined && rooms.length > 0) {
|
||||
return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined)
|
||||
}
|
||||
if (packs.length === 0) return 0
|
||||
let placed = 0
|
||||
let campIndex = 0
|
||||
|
|
@ -787,6 +809,7 @@ function tickPlayer(
|
|||
*/
|
||||
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
|
||||
for (const monster of world.monsters) {
|
||||
if (monster.sleeping) continue
|
||||
if (monster.state === 'dead') {
|
||||
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
*/
|
||||
import type { Ds1 } from '../formats/ds1.ts'
|
||||
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
|
||||
import { isAnimatedTile } from '../formats/dt1.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
|
||||
|
|
@ -83,6 +84,13 @@ const TOP_CORNER_LEFT_TYPE = 4
|
|||
export interface IsoDraw {
|
||||
/** Index into {@link IsoMapScene.frames}. */
|
||||
readonly frameIndex: number
|
||||
/**
|
||||
* Sequence of frame indices for animated tiles (e.g. water ripples, torches, fountains, lava).
|
||||
*
|
||||
* When present and containing more than 1 frame, the tile is animated and
|
||||
* the renderer can cycle through these frame indices over time or game ticks.
|
||||
*/
|
||||
readonly animatedFrames?: readonly number[] | undefined
|
||||
/** Screen x of the bitmap's left edge, origin applied. */
|
||||
readonly x: number
|
||||
/** Screen y of the bitmap's top edge, origin applied. */
|
||||
|
|
@ -177,6 +185,8 @@ export interface IsoMapScene {
|
|||
readonly cornerPairs: number
|
||||
/** Top-corner-right walls whose left half could not be resolved (should be 0). */
|
||||
readonly missingCornerPairs: number
|
||||
/** Number of animated tiles in this scene. */
|
||||
readonly animatedTileCount?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -210,6 +220,10 @@ interface PoolTile {
|
|||
readonly tile: number
|
||||
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
|
||||
readonly weight: number
|
||||
/** Whether this tile is part of an animated sequence. */
|
||||
readonly animated: boolean
|
||||
/** 0-based animation frame index when animated is true. */
|
||||
readonly frameIndex: number
|
||||
}
|
||||
|
||||
/** The merged pool a DS1's references are resolved against. */
|
||||
|
|
@ -246,7 +260,14 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool {
|
|||
}
|
||||
libraries.forEach((library, libraryIndex) => {
|
||||
library.tiles.forEach((tile, tileIndex) => {
|
||||
const entry = { library: libraryIndex, tile: tileIndex, weight: tile.rarityFrameIndex }
|
||||
const animated = isAnimatedTile(tile)
|
||||
const entry: PoolTile = {
|
||||
library: libraryIndex,
|
||||
tile: tileIndex,
|
||||
weight: tile.rarityFrameIndex,
|
||||
animated,
|
||||
frameIndex: animated ? Math.max(0, tile.rarityFrameIndex) : 0,
|
||||
}
|
||||
// The third component is the DS1 wall `type` and it matches the DT1 tile's
|
||||
// **`Type` field at offset +20**, not its `Direction` field at +0. `Direction`
|
||||
// is the orientation/variant index (1..5 in practice) and never equals the
|
||||
|
|
@ -309,6 +330,42 @@ function pickVariant(
|
|||
return candidates[candidates.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a candidate group.
|
||||
*/
|
||||
interface ResolvedCandidates {
|
||||
readonly tile: PoolTile | undefined
|
||||
readonly animatedTiles?: readonly PoolTile[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a candidate group from the pool:
|
||||
* If the candidates are animated tiles, sort them by frameIndex (0..N-1) and return
|
||||
* the base frame as `tile` and the sequence as `animatedTiles`.
|
||||
* Otherwise, pick one variant per cell using weighted random draw.
|
||||
*/
|
||||
function resolveCandidates(
|
||||
candidates: readonly PoolTile[] | undefined,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): ResolvedCandidates {
|
||||
if (candidates === undefined || candidates.length === 0) {
|
||||
return { tile: undefined }
|
||||
}
|
||||
const isAnim = candidates.some(c => c.animated)
|
||||
if (isAnim) {
|
||||
const sorted = [...candidates].sort((a, b) => a.frameIndex - b.frameIndex)
|
||||
return {
|
||||
tile: sorted[0],
|
||||
animatedTiles: sorted.length > 1 ? sorted : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
tile: pickVariant(candidates, cellX, cellY, seed),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DS1 reference against the pool.
|
||||
*
|
||||
|
|
@ -316,7 +373,7 @@ function pickVariant(
|
|||
* @param style - DS1 style field.
|
||||
* @param sequence - DS1 sequence field.
|
||||
* @param type - DS1 wall type (the third field of the reference).
|
||||
* @returns the pool tile, or undefined when nothing matches.
|
||||
* @returns the resolved pool tile and animated sequence.
|
||||
*/
|
||||
function resolveExactType(
|
||||
pool: TilePool,
|
||||
|
|
@ -326,8 +383,8 @@ function resolveExactType(
|
|||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): PoolTile | undefined {
|
||||
return pickVariant(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
|
||||
): ResolvedCandidates {
|
||||
return resolveCandidates(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -337,7 +394,7 @@ function resolveExactType(
|
|||
* @param style - DS1 style field.
|
||||
* @param sequence - DS1 sequence field.
|
||||
* @param type - DS1 wall type (the third field of the reference), or null for floors.
|
||||
* @returns the pool tile, or undefined when nothing matches.
|
||||
* @returns the pool tile, animated tiles if any, and whether it matched via loose fallback.
|
||||
*/
|
||||
function resolveWithSource(
|
||||
pool: TilePool,
|
||||
|
|
@ -347,11 +404,12 @@ function resolveWithSource(
|
|||
cellX: number,
|
||||
cellY: number,
|
||||
seed: number,
|
||||
): { tile: PoolTile | undefined; viaLoose: boolean } {
|
||||
): { tile: PoolTile | undefined; animatedTiles?: readonly PoolTile[] | undefined; viaLoose: boolean } {
|
||||
const key = `${String(style)}:${String(sequence)}`
|
||||
const exact = pickVariant(pool.exact.get(`${key}:${String(type)}`), cellX, cellY, seed)
|
||||
if (exact !== undefined) return { tile: exact, viaLoose: false }
|
||||
return { tile: pickVariant(pool.loose.get(key), cellX, cellY, seed), viaLoose: true }
|
||||
const exact = resolveExactType(pool, style, sequence, type, cellX, cellY, seed)
|
||||
if (exact.tile !== undefined) return { tile: exact.tile, animatedTiles: exact.animatedTiles, viaLoose: false }
|
||||
const loose = resolveCandidates(pool.loose.get(key), cellX, cellY, seed)
|
||||
return { tile: loose.tile, animatedTiles: loose.animatedTiles, viaLoose: true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -404,7 +462,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
const gridWidth = cellsX * SUB_TILES_PER_TILE
|
||||
const gridHeight = cellsY * SUB_TILES_PER_TILE
|
||||
const blocked = new Uint8Array(gridWidth * gridHeight)
|
||||
const rawFloors: { frameIndex: number; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
|
||||
const rawFloors: { frameIndex: number; animatedFrames?: readonly number[]; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
|
||||
const rawWalls: typeof rawFloors = []
|
||||
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
|
||||
const rawRoofs: typeof rawFloors = []
|
||||
|
|
@ -463,8 +521,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
const found = picked.tile
|
||||
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
const animatedFrames = picked.animatedTiles?.map(t => frameFor(t.library, t.tile))
|
||||
rawFloors.push({
|
||||
frameIndex: frameFor(found.library, found.tile),
|
||||
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
|
||||
x: orthoX + TILE_ANCHOR_X,
|
||||
y: orthoY,
|
||||
cellX, cellY, library: found.library, tile: found.tile,
|
||||
|
|
@ -480,7 +540,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
// Shadows must come from a **type 13** tile specifically: the loose
|
||||
// fallback ignores the type field and would happily return a wall tile,
|
||||
// stamping its blocking flags as if they were a shadow's.
|
||||
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed)
|
||||
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed).tile
|
||||
if (found === undefined) continue
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
|
||||
|
|
@ -502,8 +562,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
if (tile.bitmapHeight > Math.abs(tile.height)) clippedTiles += 1
|
||||
const animatedFrames = pickedWall.animatedTiles?.map(t => frameFor(t.library, t.tile))
|
||||
const draw = {
|
||||
frameIndex: frameFor(found.library, found.tile),
|
||||
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
|
||||
x: orthoX + TILE_ANCHOR_X,
|
||||
// A wall's art extends above its cell, so it is pushed back down by
|
||||
// exactly the block shift the decoder applied, plus one cell height.
|
||||
|
|
@ -525,14 +587,17 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
// engine's DS1s never list the left half themselves, so this is the only way
|
||||
// the corner gets both faces — 411 of them across the 35 preset levels.
|
||||
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
|
||||
const companion = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
|
||||
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
|
||||
const companion = companionPicked.tile
|
||||
if (companion === undefined) {
|
||||
missingCornerPairs += 1
|
||||
noteMissing('corner-left', wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY)
|
||||
} else {
|
||||
const leftTile = libraries[companion.library]!.tiles[companion.tile]!
|
||||
const companionAnim = companionPicked.animatedTiles?.map(t => frameFor(t.library, t.tile))
|
||||
rawWalls.push({
|
||||
frameIndex: frameFor(companion.library, companion.tile),
|
||||
...(companionAnim && companionAnim.length > 1 ? { animatedFrames: companionAnim } : {}),
|
||||
x: orthoX + TILE_ANCHOR_X,
|
||||
y: orthoY + leftTile.minBlockY + WALL_SURFACE_HEIGHT,
|
||||
cellX, cellY, library: companion.library, tile: companion.tile,
|
||||
|
|
@ -567,6 +632,13 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
const originY = -minY
|
||||
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
|
||||
|
||||
const floors = rawFloors.map(shift)
|
||||
const walls = rawWalls.map(shift)
|
||||
const roofs = rawRoofs.map(shift)
|
||||
const animatedTileCount = [...floors, ...walls, ...roofs].filter(
|
||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
||||
).length
|
||||
|
||||
return {
|
||||
frames,
|
||||
sheet: { groups: [{ frames }], width: null },
|
||||
|
|
@ -576,9 +648,9 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
heightPx: maxY - minY,
|
||||
originX,
|
||||
originY,
|
||||
floors: rawFloors.map(shift),
|
||||
walls: rawWalls.map(shift),
|
||||
roofs: rawRoofs.map(shift),
|
||||
floors,
|
||||
walls,
|
||||
roofs,
|
||||
blocked,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
|
|
@ -590,6 +662,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
cornerPairs,
|
||||
missingCornerPairs,
|
||||
duplicateRefs: pool.duplicates,
|
||||
animatedTileCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { QuestLog, npcDialog } from './quests.ts'
|
|||
import type { NpcDef, QuestDef } from './quests.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
|
||||
import { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
|
||||
export interface EngineInput {
|
||||
readonly movement: { readonly x: number; readonly y: number }
|
||||
|
|
@ -115,6 +117,24 @@ export interface GameEngineOptions {
|
|||
* itself.
|
||||
*/
|
||||
snapshotStore?: SnapshotStore
|
||||
/**
|
||||
* Optional pre-constructed MonsterStreamingManager for lazy room population
|
||||
* and active/sleeping monster streaming.
|
||||
*/
|
||||
streamingManager?: MonsterStreamingManager | undefined
|
||||
/**
|
||||
* Pre-planned rooms for monster streaming. When configured without an explicit
|
||||
* streamingManager, a MonsterStreamingManager is initialized automatically.
|
||||
*/
|
||||
roomPacks?: readonly StreamingRoomDef[] | undefined
|
||||
/**
|
||||
* Alias for roomPacks.
|
||||
*/
|
||||
streamingRooms?: readonly StreamingRoomDef[] | undefined
|
||||
/**
|
||||
* Tuning options for MonsterStreamingManager when created from roomPacks.
|
||||
*/
|
||||
streamingOptions?: MonsterStreamingOptions | undefined
|
||||
}
|
||||
|
||||
export interface GameEngineMetrics {
|
||||
|
|
@ -145,6 +165,7 @@ export class GameEngine {
|
|||
selectedSkill = 0
|
||||
npcEntities: NpcEntity[] = []
|
||||
dialog: string[] = []
|
||||
streamingManager?: MonsterStreamingManager
|
||||
/**
|
||||
* Whether the save input was held on the previous tick.
|
||||
*
|
||||
|
|
@ -164,10 +185,27 @@ export class GameEngine {
|
|||
}
|
||||
|
||||
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
|
||||
this.world = createWorld(opts.spawn.x, opts.spawn.y)
|
||||
const rooms = opts.roomPacks ?? opts.streamingRooms
|
||||
if (opts.streamingManager !== undefined) {
|
||||
this.streamingManager = opts.streamingManager
|
||||
this.world = opts.streamingManager.world
|
||||
} else {
|
||||
this.world = createWorld(opts.spawn.x, opts.spawn.y)
|
||||
if (rooms !== undefined && rooms.length > 0) {
|
||||
this.streamingManager = new MonsterStreamingManager(
|
||||
this.world,
|
||||
this.terrain,
|
||||
rooms,
|
||||
opts.streamingOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const spread = opts.monsterSpread ?? 260
|
||||
const count = opts.monsterCount ?? 8
|
||||
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||
if (this.streamingManager !== undefined) {
|
||||
// Monsters are lazily populated by streamingManager; avoid frame-0 eager population.
|
||||
} else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
|
||||
} else if (count > 0) {
|
||||
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
||||
|
|
@ -198,6 +236,8 @@ export class GameEngine {
|
|||
const beforeX = player.x
|
||||
const beforeY = player.y
|
||||
|
||||
this.streamingManager?.update(player.x, player.y)
|
||||
|
||||
if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1)
|
||||
|
||||
const wanted = this.opts.skills[this.selectedSkill]
|
||||
|
|
@ -379,6 +419,9 @@ export class GameEngine {
|
|||
this.questLog = pieces.quests
|
||||
this.ground = pieces.ground
|
||||
this.projectiles = []
|
||||
if (this.streamingManager !== undefined) {
|
||||
this.streamingManager.world = this.world
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,3 +527,6 @@ export function syncEngineState(engine: GameEngine, state: EngineViewState): voi
|
|||
state.y = engine.world.player.y
|
||||
state.bagItems = engine.bag.contents.length
|
||||
}
|
||||
|
||||
export { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
|
|
|
|||
|
|
@ -178,15 +178,15 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
|||
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
||||
*/
|
||||
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||||
townn1: '北',
|
||||
towne1: '东',
|
||||
towns1: '南',
|
||||
townw1: '西',
|
||||
courtn: '北',
|
||||
courte: '东',
|
||||
courtw: '西',
|
||||
lutn: '北',
|
||||
lutw: '西',
|
||||
townn1: '北门',
|
||||
towne1: '东门',
|
||||
towns1: '南门',
|
||||
townw1: '西门',
|
||||
courtn: '北向',
|
||||
courte: '东向',
|
||||
courtw: '西向',
|
||||
lutn: '北门',
|
||||
lutw: '西门',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,730 @@
|
|||
/**
|
||||
* Dynamic Dungeon Object Spawning Pipeline (Issue #50).
|
||||
*
|
||||
* In Diablo II, maze dungeon generation doesn't rely solely on static objects authored in DS1 presets.
|
||||
* A dedicated population pass dynamically scans the synthesized maze layout to seed:
|
||||
*
|
||||
* 1. Shrines (神殿: Armor, Combat, Resist, Skill, Mana, Health, Experience, Gem, Fire, Poison):
|
||||
* - Typically seeded in dead-end rooms (`degree === 1`) or quiet room corners/alcoves.
|
||||
* 2. Super/Gold Chests (金宝箱 / 特殊宝箱):
|
||||
* - Placed in dead-end rooms (often as reward rooms) or special treasure clusters.
|
||||
* 3. Weapon & Armor Racks (武器架、防具架):
|
||||
* - Placed adjacent to perimeter walls in corridors (`degree === 2`), barracks, or dungeon chambers,
|
||||
* with strict wall alignment.
|
||||
* 4. Breakable Containers (可破坏物件: barrels, urns, coffins, caskets, crates, tombs):
|
||||
* - Scattered in room corners, dead ends, and along walls in clusters of 2..5 items.
|
||||
*
|
||||
* All objects use authentic Diablo II DS1 object records `(type: 2, id)` matching the level's act.
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
|
||||
/** Subtiles per DS1 cell. */
|
||||
export const SUB_TILES_PER_TILE = 5
|
||||
|
||||
/** Dynamic dungeon object categories. */
|
||||
export type MazeObjectCategory = 'shrine' | 'chest' | 'rack' | 'container'
|
||||
|
||||
/** Canonical shrine sub-types in Diablo II. */
|
||||
export type ShrineSubType =
|
||||
| 'armor'
|
||||
| 'combat'
|
||||
| 'resist'
|
||||
| 'skill'
|
||||
| 'mana'
|
||||
| 'health'
|
||||
| 'experience'
|
||||
| 'refilling'
|
||||
| 'gem'
|
||||
| 'fire'
|
||||
| 'poison'
|
||||
|
||||
/** Chest sub-types. */
|
||||
export type ChestSubType = 'super_chest' | 'chest'
|
||||
|
||||
/** Weapon and armor rack sub-types. */
|
||||
export type RackSubType = 'armor_rack' | 'weapon_rack'
|
||||
|
||||
/** Breakable container sub-types. */
|
||||
export type ContainerSubType =
|
||||
| 'barrel'
|
||||
| 'exploding_barrel'
|
||||
| 'urn'
|
||||
| 'casket'
|
||||
| 'coffin'
|
||||
| 'crate'
|
||||
| 'tomb'
|
||||
|
||||
/** Wall alignment directions for wall-mounted/wall-adjacent objects like racks. */
|
||||
export type WallAlignment = 'north' | 'south' | 'east' | 'west'
|
||||
|
||||
/** A dynamically placed dungeon object with rich semantic metadata. */
|
||||
export interface PlacedMazeObject extends Ds1Object {
|
||||
/** High-level category. */
|
||||
readonly category: MazeObjectCategory
|
||||
/** Specific sub-type (e.g. 'armor', 'super_chest', 'weapon_rack', 'barrel'). */
|
||||
readonly subType: ShrineSubType | ChestSubType | RackSubType | ContainerSubType | string
|
||||
/** Index of the owning room in the level rooms array. */
|
||||
readonly roomIndex?: number
|
||||
/** Room ID assigned during maze generation. */
|
||||
readonly roomId?: number
|
||||
/** Whether the object was spawned in a dead-end room (`degree === 1`). */
|
||||
readonly isDeadEnd?: boolean
|
||||
/** Whether the object was placed in a room corner. */
|
||||
readonly isCorner?: boolean
|
||||
/** For racks and wall decorations, which wall it is aligned against. */
|
||||
readonly alignedWall?: WallAlignment | null
|
||||
}
|
||||
|
||||
/** Options to configure the dungeon object population pipeline. */
|
||||
export interface MazeObjectPopulationOptions {
|
||||
/** Overall object density multiplier (default: 1.0). */
|
||||
readonly density?: number
|
||||
/** Breakable container density multiplier (default: 1.0). When 0, containers are omitted. */
|
||||
readonly containerDensity?: number
|
||||
/** Shrine density multiplier (default: 1.0). When 0, shrines are omitted. */
|
||||
readonly shrineDensity?: number
|
||||
/** Chest density multiplier (default: 1.0). When 0, chests are omitted. */
|
||||
readonly chestDensity?: number
|
||||
/** Rack density multiplier (default: 1.0). When 0, racks are omitted. */
|
||||
readonly rackDensity?: number
|
||||
/** Deterministic RNG seed override for object seeding. Defaults to the maze seed. */
|
||||
readonly objectSeed?: number
|
||||
/** If true, skips dynamic object generation entirely. */
|
||||
readonly disableDynamicObjects?: boolean
|
||||
}
|
||||
|
||||
/** Minimal room descriptor interface needed by the object populator. */
|
||||
export interface MazeRoomDescriptor {
|
||||
readonly id: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly orths: readonly { readonly room: unknown; readonly direction: number }[]
|
||||
readonly piece?: { readonly name: string; readonly kind?: string } | null
|
||||
}
|
||||
|
||||
/** Candidate tile spot inside a room. */
|
||||
export interface CandidateSpot {
|
||||
readonly cellX: number
|
||||
readonly cellY: number
|
||||
readonly roomIndex: number
|
||||
readonly roomId: number
|
||||
readonly isDeadEnd: boolean
|
||||
readonly isCorner: boolean
|
||||
readonly alignedWall: WallAlignment | null
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Object Definitions & Act Mapping
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Shrine definition: subtype to act-specific DS1 ID mapping. */
|
||||
interface ShrineDef {
|
||||
readonly subType: ShrineSubType
|
||||
readonly idsByAct: Record<number, number>
|
||||
}
|
||||
|
||||
export const CANONICAL_SHRINES: readonly ShrineDef[] = [
|
||||
{ subType: 'armor', idsByAct: { 1: 8, 2: 169, 3: 169, 4: 169, 5: 169 } },
|
||||
{ subType: 'combat', idsByAct: { 1: 9, 2: 170, 3: 170, 4: 170, 5: 170 } },
|
||||
{ subType: 'resist', idsByAct: { 1: 10, 2: 171, 3: 171, 4: 171, 5: 171 } },
|
||||
{ subType: 'skill', idsByAct: { 1: 11, 2: 172, 3: 172, 4: 172, 5: 172 } },
|
||||
{ subType: 'mana', idsByAct: { 1: 7, 2: 168, 3: 168, 4: 168, 5: 168 } },
|
||||
{ subType: 'health', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } },
|
||||
{ subType: 'experience', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } },
|
||||
{ subType: 'refilling', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } },
|
||||
{ subType: 'gem', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } },
|
||||
{ subType: 'fire', idsByAct: { 1: 29, 2: 152, 3: 152, 4: 152, 5: 152 } },
|
||||
{ subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } },
|
||||
]
|
||||
|
||||
/** Chest ID mapping per act. */
|
||||
export const CHEST_IDS_BY_ACT: Record<number, { super: readonly number[]; normal: readonly number[] }> = {
|
||||
1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] },
|
||||
2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] },
|
||||
3: { super: [65, 66, 155, 156], normal: [200, 201, 229, 203] },
|
||||
4: { super: [155, 156], normal: [200, 201, 229, 203] },
|
||||
5: { super: [155, 156], normal: [200, 201, 229, 203] },
|
||||
}
|
||||
|
||||
/** Weapon and Armor rack ID mapping per act and orientation. */
|
||||
export const RACK_IDS_BY_ACT: Record<
|
||||
number,
|
||||
{
|
||||
armor: { west: readonly number[]; north: readonly number[]; any: readonly number[] }
|
||||
weapon: { west: readonly number[]; north: readonly number[]; any: readonly number[] }
|
||||
}
|
||||
> = {
|
||||
1: {
|
||||
armor: { west: [19, 46, 183, 254], north: [20, 47, 184, 255], any: [19, 20, 46, 47, 183, 184, 254, 255] },
|
||||
weapon: { west: [48, 256], north: [49, 257], any: [48, 49, 256, 257] },
|
||||
},
|
||||
2: {
|
||||
armor: { west: [74, 183, 254], north: [75, 184, 255], any: [74, 75, 183, 184, 254, 255] },
|
||||
weapon: { west: [76, 256], north: [77, 257], any: [76, 77, 256, 257] },
|
||||
},
|
||||
3: {
|
||||
armor: { west: [9, 183, 254], north: [10, 184, 255], any: [9, 10, 183, 184, 254, 255] },
|
||||
weapon: { west: [11, 256], north: [12, 257], any: [11, 12, 256, 257] },
|
||||
},
|
||||
4: {
|
||||
armor: { west: [183, 254], north: [184, 255], any: [183, 184, 254, 255] },
|
||||
weapon: { west: [256], north: [257], any: [256, 257] },
|
||||
},
|
||||
5: {
|
||||
armor: { west: [10, 183, 254], north: [11, 184, 255], any: [10, 11, 183, 184, 254, 255] },
|
||||
weapon: { west: [256], north: [257], any: [256, 257] },
|
||||
},
|
||||
}
|
||||
|
||||
/** Container pool mapping per act. */
|
||||
export const CONTAINER_IDS_BY_ACT: Record<
|
||||
number,
|
||||
{
|
||||
barrels: readonly number[]
|
||||
urns: readonly number[]
|
||||
caskets: readonly number[]
|
||||
crates: readonly number[]
|
||||
}
|
||||
> = {
|
||||
1: {
|
||||
barrels: [95, 28, 50, 51, 102, 157, 232, 251, 329, 330, 417],
|
||||
urns: [154, 159, 202, 244, 245],
|
||||
caskets: [93, 94, 151, 153],
|
||||
crates: [161],
|
||||
},
|
||||
2: {
|
||||
barrels: [6, 21, 157, 232, 251, 329, 330, 417],
|
||||
urns: [25, 26, 27, 28, 29, 154, 159, 202],
|
||||
caskets: [151, 153],
|
||||
crates: [161],
|
||||
},
|
||||
3: {
|
||||
barrels: [34, 157, 232, 251, 329, 330, 417],
|
||||
urns: [154, 159, 202, 244, 245],
|
||||
caskets: [151, 153],
|
||||
crates: [161],
|
||||
},
|
||||
4: {
|
||||
barrels: [2, 157, 232, 251, 329, 330, 417],
|
||||
urns: [154, 159, 202],
|
||||
caskets: [151, 153],
|
||||
crates: [161],
|
||||
},
|
||||
5: {
|
||||
barrels: [4, 44, 157, 232, 251, 329, 330, 417],
|
||||
urns: [154, 159, 202],
|
||||
caskets: [151, 153],
|
||||
crates: [161],
|
||||
},
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Spatial Analysis
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Check if a cell has a floor and no solid wall. */
|
||||
export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean {
|
||||
if (cy < 0 || cy >= cells.length) return false
|
||||
const row = cells[cy]
|
||||
if (!row || cx < 0 || cx >= row.length) return false
|
||||
const cell = row[cx]
|
||||
if (!cell) return false
|
||||
|
||||
// Must contain floor
|
||||
const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true))
|
||||
if (!hasFloor) return false
|
||||
|
||||
// Must not have a solid, visible wall
|
||||
const hasWall = cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0))
|
||||
return !hasWall
|
||||
}
|
||||
|
||||
/** Check if a cell contains a wall or is off-map. */
|
||||
export function isWallCell(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean {
|
||||
if (cy < 0 || cy >= cells.length) return true
|
||||
const row = cells[cy]
|
||||
if (!row || cx < 0 || cx >= row.length) return true
|
||||
const cell = row[cx]
|
||||
if (!cell) return true
|
||||
return cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0))
|
||||
}
|
||||
|
||||
/** Determine which adjacent cardinal wall a floor cell is next to (if any). */
|
||||
export function getAdjacentWall(
|
||||
cells: readonly (readonly Ds1Cell[])[],
|
||||
cx: number,
|
||||
cy: number,
|
||||
): WallAlignment | null {
|
||||
if (isWallCell(cells, cx, cy - 1)) return 'north'
|
||||
if (isWallCell(cells, cx - 1, cy)) return 'west'
|
||||
if (isWallCell(cells, cx, cy + 1)) return 'south'
|
||||
if (isWallCell(cells, cx + 1, cy)) return 'east'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if an open floor cell is in an inner corner (walls on at least 2 orthogonal sides). */
|
||||
export function isInnerCorner(
|
||||
cells: readonly (readonly Ds1Cell[])[],
|
||||
cx: number,
|
||||
cy: number,
|
||||
): boolean {
|
||||
const n = isWallCell(cells, cx, cy - 1)
|
||||
const s = isWallCell(cells, cx, cy + 1)
|
||||
const w = isWallCell(cells, cx - 1, cy)
|
||||
const e = isWallCell(cells, cx + 1, cy)
|
||||
return (n && w) || (n && e) || (s && w) || (s && e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single room and harvest candidate placement spots categorized into:
|
||||
* - dead ends (`degree === 1`)
|
||||
* - room corners
|
||||
* - perimeter wall offsets (wall alignment)
|
||||
*/
|
||||
export function analyzeRoomSpots(
|
||||
room: MazeRoomDescriptor,
|
||||
roomIndex: number,
|
||||
originX: number,
|
||||
originY: number,
|
||||
cells: readonly (readonly Ds1Cell[])[],
|
||||
): CandidateSpot[] {
|
||||
const spots: CandidateSpot[] = []
|
||||
const isDeadEnd = room.orths.length === 1
|
||||
const width = room.width
|
||||
const height = room.height
|
||||
|
||||
for (let dy = 0; dy < height; dy += 1) {
|
||||
for (let dx = 0; dx < width; dx += 1) {
|
||||
const cx = originX + dx
|
||||
const cy = originY + dy
|
||||
|
||||
if (!isOpenFloor(cells, cx, cy)) continue
|
||||
|
||||
const alignedWall = getAdjacentWall(cells, cx, cy)
|
||||
const isCorner = isInnerCorner(cells, cx, cy) ||
|
||||
(dx <= 2 && dy <= 2) ||
|
||||
(dx >= width - 3 && dy <= 2) ||
|
||||
(dx <= 2 && dy >= height - 3) ||
|
||||
(dx >= width - 3 && dy >= height - 3)
|
||||
|
||||
spots.push({
|
||||
cellX: cx,
|
||||
cellY: cy,
|
||||
roomIndex,
|
||||
roomId: room.id,
|
||||
isDeadEnd,
|
||||
isCorner,
|
||||
alignedWall,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return spots
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Main Object Population Pipeline
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export interface PopulationResult {
|
||||
readonly placedObjects: readonly PlacedMazeObject[]
|
||||
readonly stats: {
|
||||
readonly dynamicObjectsCount: number
|
||||
readonly shrinesSpawned: number
|
||||
readonly chestsSpawned: number
|
||||
readonly superChestsSpawned: number
|
||||
readonly racksSpawned: number
|
||||
readonly containersSpawned: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the dynamic dungeon object spawning pipeline.
|
||||
*
|
||||
* @param rooms - The list of rooms placed in the maze.
|
||||
* @param minX - Minimum room x in cells.
|
||||
* @param minY - Minimum room y in cells.
|
||||
* @param level - The synthesized Ds1 level containing cells and static objects.
|
||||
* @param levelTypeName - LvlTypes name (e.g. 'Act 1 - Cave', 'Act 2 - Tomb').
|
||||
* @param levelId - Levels.txt ID.
|
||||
* @param options - Configurable density and seeding options.
|
||||
* @param rng - Pseudo-random number generator.
|
||||
* @returns Placed dynamic objects and spawn statistics.
|
||||
*/
|
||||
export function populateDungeonObjects(
|
||||
rooms: readonly MazeRoomDescriptor[],
|
||||
minX: number,
|
||||
minY: number,
|
||||
level: Ds1,
|
||||
levelTypeName: string,
|
||||
levelId: number,
|
||||
options?: MazeObjectPopulationOptions,
|
||||
rng?: Rng,
|
||||
): PopulationResult {
|
||||
if (options?.disableDynamicObjects === true) {
|
||||
return {
|
||||
placedObjects: [],
|
||||
stats: {
|
||||
dynamicObjectsCount: 0,
|
||||
shrinesSpawned: 0,
|
||||
chestsSpawned: 0,
|
||||
superChestsSpawned: 0,
|
||||
racksSpawned: 0,
|
||||
containersSpawned: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const act = levelTypeName.startsWith('Act 2')
|
||||
? 2
|
||||
: levelTypeName.startsWith('Act 3')
|
||||
? 3
|
||||
: levelTypeName.startsWith('Act 4')
|
||||
? 4
|
||||
: levelTypeName.startsWith('Act 5')
|
||||
? 5
|
||||
: 1
|
||||
|
||||
const seed = options?.objectSeed ?? 0x5050_0000 + levelId
|
||||
const popRng = rng ?? new Rng(seed).fork('dungeon-objects')
|
||||
|
||||
const density = Math.max(0, options?.density ?? 1.0)
|
||||
if (density <= 0) {
|
||||
return {
|
||||
placedObjects: [],
|
||||
stats: {
|
||||
dynamicObjectsCount: 0,
|
||||
shrinesSpawned: 0,
|
||||
chestsSpawned: 0,
|
||||
superChestsSpawned: 0,
|
||||
racksSpawned: 0,
|
||||
containersSpawned: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const shrineDensity = options?.shrineDensity ?? density
|
||||
const chestDensity = options?.chestDensity ?? density
|
||||
const rackDensity = options?.rackDensity ?? density
|
||||
const containerDensity = options?.containerDensity ?? density
|
||||
|
||||
const chestPool = CHEST_IDS_BY_ACT[act] ?? CHEST_IDS_BY_ACT[1]!
|
||||
const rackPool = RACK_IDS_BY_ACT[act] ?? RACK_IDS_BY_ACT[1]!
|
||||
const containerPool = CONTAINER_IDS_BY_ACT[act] ?? CONTAINER_IDS_BY_ACT[1]!
|
||||
|
||||
// Track existing objects to avoid collisions
|
||||
const occupiedSubtiles: { x: number; y: number }[] = level.objects.map(o => ({ x: o.x, y: o.y }))
|
||||
const placedObjects: PlacedMazeObject[] = []
|
||||
|
||||
const isSubtileClear = (subX: number, subY: number, minDistance = SUB_TILES_PER_TILE): boolean => {
|
||||
for (const occ of occupiedSubtiles) {
|
||||
if (Math.hypot(occ.x - subX, occ.y - subY) < minDistance) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const placeObject = (
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
id: number,
|
||||
category: MazeObjectCategory,
|
||||
subType: string,
|
||||
metadata: {
|
||||
roomIndex?: number
|
||||
roomId?: number
|
||||
isDeadEnd?: boolean
|
||||
isCorner?: boolean
|
||||
alignedWall?: WallAlignment | null
|
||||
},
|
||||
subOffset = 2,
|
||||
): boolean => {
|
||||
const subX = cellX * SUB_TILES_PER_TILE + subOffset
|
||||
const subY = cellY * SUB_TILES_PER_TILE + subOffset
|
||||
if (!isSubtileClear(subX, subY, SUB_TILES_PER_TILE * 0.9)) return false
|
||||
|
||||
const placed: PlacedMazeObject = {
|
||||
type: 2,
|
||||
id,
|
||||
x: subX,
|
||||
y: subY,
|
||||
flags: 0,
|
||||
category,
|
||||
subType,
|
||||
...metadata,
|
||||
}
|
||||
|
||||
placedObjects.push(placed)
|
||||
occupiedSubtiles.push({ x: subX, y: subY })
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Gather candidate spots per room
|
||||
const roomSpots: { room: MazeRoomDescriptor; roomIndex: number; spots: CandidateSpot[] }[] = []
|
||||
for (let i = 0; i < rooms.length; i += 1) {
|
||||
const room = rooms[i]!
|
||||
const originX = room.x - minX
|
||||
const originY = room.y - minY
|
||||
const spots = analyzeRoomSpots(room, i, originX, originY, level.cells)
|
||||
roomSpots.push({ room, roomIndex: i, spots })
|
||||
}
|
||||
|
||||
let shrinesSpawned = 0
|
||||
let chestsSpawned = 0
|
||||
let superChestsSpawned = 0
|
||||
let racksSpawned = 0
|
||||
let containersSpawned = 0
|
||||
|
||||
// 2. Dead-end rooms population: Shrines and Super/Gold Chests
|
||||
const deadEndRooms = roomSpots.filter(r => r.room.orths.length === 1)
|
||||
|
||||
// Decide shrine quota: 1..3 depending on dungeon room count and shrineDensity
|
||||
const baseShrineTarget = Math.max(1, Math.min(3, Math.round((rooms.length / 6) * shrineDensity)))
|
||||
const shrineTarget = shrineDensity > 0 ? baseShrineTarget : 0
|
||||
|
||||
const usedRoomsForMajorObject = new Set<number>()
|
||||
|
||||
// A. Place Shrines in dead-end rooms (or corner spots if dead-ends are scarce)
|
||||
if (shrineTarget > 0) {
|
||||
const shuffledDeadEnds = [...deadEndRooms].sort(() => popRng.next() - 0.5)
|
||||
for (const r of shuffledDeadEnds) {
|
||||
if (shrinesSpawned >= shrineTarget) break
|
||||
// Pick a center or deep spot in the dead end
|
||||
const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)]
|
||||
if (!candidate) continue
|
||||
|
||||
const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]!
|
||||
const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]!
|
||||
|
||||
if (
|
||||
placeObject(candidate.cellX, candidate.cellY, shrineId, 'shrine', shrineDef.subType, {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd: true,
|
||||
isCorner: candidate.isCorner,
|
||||
alignedWall: candidate.alignedWall,
|
||||
})
|
||||
) {
|
||||
shrinesSpawned += 1
|
||||
usedRoomsForMajorObject.add(r.room.id)
|
||||
}
|
||||
}
|
||||
|
||||
// If more shrines are needed, place them in regular room corners
|
||||
if (shrinesSpawned < shrineTarget) {
|
||||
for (const r of roomSpots) {
|
||||
if (shrinesSpawned >= shrineTarget) break
|
||||
if (usedRoomsForMajorObject.has(r.room.id)) continue
|
||||
|
||||
const cornerSpot = r.spots.find(s => s.isCorner && isSubtileClear(s.cellX * SUB_TILES_PER_TILE + 2, s.cellY * SUB_TILES_PER_TILE + 2, SUB_TILES_PER_TILE * 2))
|
||||
if (!cornerSpot) continue
|
||||
|
||||
const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]!
|
||||
const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]!
|
||||
|
||||
if (
|
||||
placeObject(cornerSpot.cellX, cornerSpot.cellY, shrineId, 'shrine', shrineDef.subType, {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd: r.room.orths.length === 1,
|
||||
isCorner: true,
|
||||
alignedWall: cornerSpot.alignedWall,
|
||||
})
|
||||
) {
|
||||
shrinesSpawned += 1
|
||||
usedRoomsForMajorObject.add(r.room.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B. Place Super/Gold Chests in remaining dead-end rooms and special treasure rooms
|
||||
if (chestDensity > 0) {
|
||||
for (const r of roomSpots) {
|
||||
const isDeadEnd = r.room.orths.length === 1
|
||||
const isTreasureRoom = r.room.piece?.kind === 'treasure' || /Treasure|Chest/i.test(r.room.piece?.name ?? '')
|
||||
|
||||
if (!isDeadEnd && !isTreasureRoom) continue
|
||||
if (usedRoomsForMajorObject.has(r.room.id) && !isTreasureRoom) continue
|
||||
|
||||
// In dead-ends, place a Super Chest
|
||||
const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)]
|
||||
if (!candidate) continue
|
||||
|
||||
const superChestId = chestPool.super[popRng.int(0, chestPool.super.length - 1)]!
|
||||
if (
|
||||
placeObject(candidate.cellX, candidate.cellY, superChestId, 'chest', 'super_chest', {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd,
|
||||
isCorner: candidate.isCorner,
|
||||
alignedWall: candidate.alignedWall,
|
||||
})
|
||||
) {
|
||||
chestsSpawned += 1
|
||||
superChestsSpawned += 1
|
||||
usedRoomsForMajorObject.add(r.room.id)
|
||||
|
||||
// For special clusters / treasure rooms, add 1..2 surrounding regular chests
|
||||
const clusterCount = isTreasureRoom ? popRng.int(2, 3) : popRng.chance(0.5) ? 1 : 0
|
||||
const secondarySpots = r.spots.filter(
|
||||
s => (s.cellX !== candidate.cellX || s.cellY !== candidate.cellY) &&
|
||||
Math.hypot(s.cellX - candidate.cellX, s.cellY - candidate.cellY) <= 3,
|
||||
)
|
||||
|
||||
for (let c = 0; c < clusterCount && c < secondarySpots.length; c += 1) {
|
||||
const sec = secondarySpots[c]!
|
||||
const regChestId = chestPool.normal[popRng.int(0, chestPool.normal.length - 1)]!
|
||||
if (
|
||||
placeObject(sec.cellX, sec.cellY, regChestId, 'chest', 'chest', {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd,
|
||||
isCorner: sec.isCorner,
|
||||
alignedWall: sec.alignedWall,
|
||||
})
|
||||
) {
|
||||
chestsSpawned += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Weapon & Armor Racks: placed against walls in corridors (`degree === 2`), barracks, or chambers
|
||||
if (rackDensity > 0) {
|
||||
const isBarracks = /Barracks|Jail/i.test(levelTypeName)
|
||||
const baseRackTarget = Math.max(1, Math.min(4, Math.round((rooms.length / 5) * rackDensity * (isBarracks ? 1.5 : 1.0))))
|
||||
const rackTarget = baseRackTarget
|
||||
|
||||
// Prefer corridor rooms (`degree === 2`) or barracks
|
||||
const candidateRoomsForRacks = roomSpots.filter(
|
||||
r => r.room.orths.length === 2 || isBarracks || r.room.orths.length <= 2,
|
||||
)
|
||||
|
||||
for (const r of candidateRoomsForRacks) {
|
||||
if (racksSpawned >= rackTarget) break
|
||||
|
||||
// Find spots directly adjacent to a wall
|
||||
const wallSpots = r.spots.filter(s => s.alignedWall !== null)
|
||||
if (wallSpots.length === 0) continue
|
||||
|
||||
const chosenSpot = wallSpots[popRng.int(0, wallSpots.length - 1)]!
|
||||
const isArmor = popRng.chance(0.5)
|
||||
const subType: RackSubType = isArmor ? 'armor_rack' : 'weapon_rack'
|
||||
|
||||
let rackId: number
|
||||
const wall = chosenSpot.alignedWall
|
||||
if (isArmor) {
|
||||
if (wall === 'west') rackId = rackPool.armor.west[popRng.int(0, rackPool.armor.west.length - 1)]!
|
||||
else if (wall === 'north') rackId = rackPool.armor.north[popRng.int(0, rackPool.armor.north.length - 1)]!
|
||||
else rackId = rackPool.armor.any[popRng.int(0, rackPool.armor.any.length - 1)]!
|
||||
} else {
|
||||
if (wall === 'west') rackId = rackPool.weapon.west[popRng.int(0, rackPool.weapon.west.length - 1)]!
|
||||
else if (wall === 'north') rackId = rackPool.weapon.north[popRng.int(0, rackPool.weapon.north.length - 1)]!
|
||||
else rackId = rackPool.weapon.any[popRng.int(0, rackPool.weapon.any.length - 1)]!
|
||||
}
|
||||
|
||||
if (
|
||||
placeObject(chosenSpot.cellX, chosenSpot.cellY, rackId, 'rack', subType, {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd: r.room.orths.length === 1,
|
||||
isCorner: chosenSpot.isCorner,
|
||||
alignedWall: chosenSpot.alignedWall,
|
||||
})
|
||||
) {
|
||||
racksSpawned += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Breakable Containers (barrels, urns, coffins, caskets, crates, tombs)
|
||||
if (containerDensity > 0) {
|
||||
const isCryptOrTomb = /Crypt|Catacombs|Tomb/i.test(levelTypeName)
|
||||
const isSewerOrJail = /Sewer|Jail|Barracks/i.test(levelTypeName)
|
||||
|
||||
// Average 1..2 clusters per room according to containerDensity
|
||||
const clusterTarget = Math.max(1, Math.round(rooms.length * 1.2 * containerDensity))
|
||||
|
||||
for (let cl = 0; cl < clusterTarget; cl += 1) {
|
||||
const r = roomSpots[popRng.int(0, roomSpots.length - 1)]!
|
||||
|
||||
// Prefer corner spots or wall-aligned spots for clusters
|
||||
const preferredSpots = r.spots.filter(s => s.isCorner || s.alignedWall !== null)
|
||||
const anchorSpot = preferredSpots.length > 0
|
||||
? preferredSpots[popRng.int(0, preferredSpots.length - 1)]!
|
||||
: r.spots[popRng.int(0, r.spots.length - 1)]
|
||||
|
||||
if (!anchorSpot) continue
|
||||
|
||||
// Determine container type for this cluster
|
||||
let chosenType: ContainerSubType = 'barrel'
|
||||
let idPool = containerPool.barrels
|
||||
|
||||
if (isCryptOrTomb) {
|
||||
const roll = popRng.next()
|
||||
if (roll < 0.4) {
|
||||
chosenType = 'casket'
|
||||
idPool = containerPool.caskets
|
||||
} else if (roll < 0.8) {
|
||||
chosenType = 'urn'
|
||||
idPool = containerPool.urns
|
||||
} else {
|
||||
chosenType = 'barrel'
|
||||
idPool = containerPool.barrels
|
||||
}
|
||||
} else if (isSewerOrJail) {
|
||||
const roll = popRng.next()
|
||||
if (roll < 0.3) {
|
||||
chosenType = 'crate'
|
||||
idPool = containerPool.crates
|
||||
} else if (roll < 0.7) {
|
||||
chosenType = 'barrel'
|
||||
idPool = containerPool.barrels
|
||||
} else {
|
||||
chosenType = 'urn'
|
||||
idPool = containerPool.urns
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster size: 2 to 4 items
|
||||
const clusterSize = popRng.int(2, 4)
|
||||
const nearbySpots = r.spots.filter(
|
||||
s => Math.hypot(s.cellX - anchorSpot.cellX, s.cellY - anchorSpot.cellY) <= 2,
|
||||
)
|
||||
|
||||
for (let ci = 0; ci < clusterSize && ci < nearbySpots.length; ci += 1) {
|
||||
const spot = nearbySpots[ci]!
|
||||
const objId = idPool[popRng.int(0, idPool.length - 1)]!
|
||||
|
||||
if (
|
||||
placeObject(spot.cellX, spot.cellY, objId, 'container', chosenType, {
|
||||
roomIndex: r.roomIndex,
|
||||
roomId: r.room.id,
|
||||
isDeadEnd: r.room.orths.length === 1,
|
||||
isCorner: spot.isCorner,
|
||||
alignedWall: spot.alignedWall,
|
||||
})
|
||||
) {
|
||||
containersSpawned += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
placedObjects,
|
||||
stats: {
|
||||
dynamicObjectsCount: placedObjects.length,
|
||||
shrinesSpawned,
|
||||
chestsSpawned,
|
||||
superChestsSpawned,
|
||||
racksSpawned,
|
||||
containersSpawned,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,997 @@
|
|||
/**
|
||||
* Diablo II DRLG (Dungeon Random Level Generator) Maze Special Passes.
|
||||
*
|
||||
* Implements the 11 official Blizzard / D2MOO core dungeon special passes:
|
||||
* - `DRLGMAZE_PlaceAct2TombStuff`: Tal Rasha's Tombs (True tomb Horadric staff receptacle, 6 false tombs golden chests, Kaa, Cube, Leatherarm).
|
||||
* - `DRLGMAZE_PlaceAct2LairStuff`: Maggot Lair Level 3 (Coldworm the Burrower & Staff of Kings chest).
|
||||
* - `DRLGMAZE_PlaceAct3DungeonStuff`: Flayer Dungeon (Brain chest) & Ruined Temple (Lam Esen's Tome altar).
|
||||
* - `DRLGMAZE_PlaceAct3SewerStuff`: Kurast Sewers (drain lever & descent to Level 2).
|
||||
* - `DRLGMAZE_PlaceAct3MephistoStuff`: Durance of Hate Level 3 (blood moat, council hall, red portal to Act 4).
|
||||
* - `DRLGMAZE_PlaceAct5TempleStuff`: Halls of Vaught (4-way branch & Nihlathak altar) & Halls of Pain.
|
||||
* - `DRLGMAZE_PlaceAct5BaalStuff`: Throne of Destruction & Worldstone Keep.
|
||||
* - `DRLGMAZE_PlaceAct5IceStuff`: Frozen River (Anya frozen platform) & Glacial Trail.
|
||||
* - `DRLGMAZE_PlaceAct1Barracks`: Monastery Barracks (Smith & Charsi's Malus forge/anvil).
|
||||
* - `DRLGMAZE_PlaceAct4Lava`: River of Flame (Hephasto the Armorer & Hellforge, Bridge connecting to Chaos Sanctuary).
|
||||
* - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest).
|
||||
*/
|
||||
|
||||
import {
|
||||
DIRECTION_WEST,
|
||||
DIRECTION_NORTH,
|
||||
DIRECTION_EAST,
|
||||
DIRECTION_SOUTH,
|
||||
sideMaskFromToken,
|
||||
sideTokenFromMask,
|
||||
type MazeLevel,
|
||||
type Room,
|
||||
type MazePiece,
|
||||
type MazePieceKind,
|
||||
type MazeRequest,
|
||||
type MazeStats,
|
||||
type PieceIndex,
|
||||
bfsDistances,
|
||||
addAdjacentMazeRoom,
|
||||
noteSpecial,
|
||||
findSpecial,
|
||||
roomSideMask,
|
||||
fillBlankSpaces,
|
||||
} from './maze.ts'
|
||||
|
||||
export type SpecialPassName =
|
||||
| 'DRLGMAZE_PlaceAct2TombStuff'
|
||||
| 'DRLGMAZE_PlaceAct2LairStuff'
|
||||
| 'DRLGMAZE_PlaceAct3DungeonStuff'
|
||||
| 'DRLGMAZE_PlaceAct3SewerStuff'
|
||||
| 'DRLGMAZE_PlaceAct3MephistoStuff'
|
||||
| 'DRLGMAZE_PlaceAct5TempleStuff'
|
||||
| 'DRLGMAZE_PlaceAct5BaalStuff'
|
||||
| 'DRLGMAZE_PlaceAct5IceStuff'
|
||||
| 'DRLGMAZE_PlaceAct1Barracks'
|
||||
| 'DRLGMAZE_PlaceAct4Lava'
|
||||
| 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets'
|
||||
|
||||
export interface SpecialPassContext {
|
||||
readonly level: MazeLevel
|
||||
readonly request: MazeRequest
|
||||
readonly pieces: readonly MazePiece[]
|
||||
readonly index: PieceIndex
|
||||
readonly stats: MazeStats
|
||||
}
|
||||
|
||||
const DIRS = ['N', 'E', 'S', 'W'] as const
|
||||
|
||||
export interface PlaceNamedPieceOptions {
|
||||
readonly kind: MazePieceKind
|
||||
readonly name?: string
|
||||
readonly namePrefix?: string
|
||||
readonly sides?: string
|
||||
readonly plainSides?: string
|
||||
readonly direction?: number
|
||||
readonly inPlaceOnly?: boolean
|
||||
readonly preferFurthest?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and place a special piece into the maze level.
|
||||
*
|
||||
* Tries in-place replacement first (preferring rooms matching `plainSides` and optimal BFS distance),
|
||||
* then falls back to growing a new dead-end room off an existing room if allowed.
|
||||
*/
|
||||
export function placeNamedPiece(
|
||||
ctx: SpecialPassContext,
|
||||
options: PlaceNamedPieceOptions,
|
||||
): Room | null {
|
||||
const { kind, name, namePrefix, sides, plainSides, inPlaceOnly = false } = options
|
||||
if (kind === 'waypoint' && ctx.request.hasWaypoint === false) {
|
||||
return null
|
||||
}
|
||||
const isExit = options.preferFurthest ?? (kind === 'next' || kind === 'down' || kind === 'quest' || kind === 'treasure' || kind === 'waypoint')
|
||||
|
||||
// 1. Find the target piece in ctx.pieces
|
||||
let piece: MazePiece | undefined
|
||||
if (name !== undefined) {
|
||||
piece = ctx.pieces.find(p => p.name === name)
|
||||
?? ctx.pieces.find(p => p.name.toLowerCase() === name.toLowerCase())
|
||||
}
|
||||
if (piece === undefined && namePrefix !== undefined) {
|
||||
if (sides !== undefined && sides !== '') {
|
||||
const fullName = `${namePrefix} ${sides}`
|
||||
piece = ctx.pieces.find(p => p.name === fullName)
|
||||
?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase())
|
||||
}
|
||||
if (piece === undefined) {
|
||||
piece = ctx.pieces.find(p => p.name.startsWith(namePrefix))
|
||||
}
|
||||
}
|
||||
if (piece === undefined && sides !== undefined && sides !== '') {
|
||||
piece = findSpecial(ctx.pieces, kind, sides)
|
||||
}
|
||||
if (piece === undefined) {
|
||||
piece = ctx.pieces.find(p => p.kind === kind && (sides === undefined || sides === '' || p.sides === sides))
|
||||
}
|
||||
|
||||
if (piece === undefined) {
|
||||
const missingIdentifier = name ?? (namePrefix !== undefined ? `${namePrefix} ${sides ?? ''}`.trim() : `${kind} ${sides ?? ''}`.trim())
|
||||
ctx.stats.unresolvedRoles.push(missingIdentifier)
|
||||
ctx.stats.notes.push(`special piece not found: ${missingIdentifier}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const token = sides ?? piece.sides ?? ''
|
||||
const distMap = bfsDistances(ctx.level)
|
||||
|
||||
// 2. Phase 1: In-place replacement
|
||||
const wantedMask = plainSides !== undefined && plainSides !== '' ? sideMaskFromToken(plainSides) : null
|
||||
const candidates = ctx.level.rooms.filter(r => {
|
||||
if (r.hasDs1) return false
|
||||
if (wantedMask !== null && roomSideMask(r) !== wantedMask) return false
|
||||
return true
|
||||
})
|
||||
|
||||
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 = piece
|
||||
chosen.variant = 0
|
||||
chosen.hasDs1 = true
|
||||
noteSpecial(chosen, piece, token !== '' ? token : sideTokenFromMask(roomSideMask(chosen)), ctx.stats)
|
||||
return chosen
|
||||
}
|
||||
|
||||
if (inPlaceOnly) {
|
||||
ctx.stats.notes.push(`no matching room for in-place special piece: ${piece.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 3. Phase 2: Grow an adjacent room
|
||||
const parents = ctx.level.rooms.filter(r => !r.hasDs1 || r.piece?.kind === 'theme')
|
||||
parents.sort((a, b) => {
|
||||
const da = distMap.get(a) ?? 0
|
||||
const db = distMap.get(b) ?? 0
|
||||
return isExit ? db - da : da - db
|
||||
})
|
||||
|
||||
const primaryDir = options.direction ?? DIRECTION_NORTH
|
||||
const tryDirs = [primaryDir, (primaryDir + 1) % 4, (primaryDir + 2) % 4, (primaryDir + 3) % 4]
|
||||
|
||||
for (const parent of parents) {
|
||||
for (const dir of tryDirs) {
|
||||
const wasHasDs1 = parent.hasDs1
|
||||
parent.hasDs1 = false
|
||||
const created = addAdjacentMazeRoom(ctx.level, parent, dir, false, true, ctx.index, ctx.stats)
|
||||
if (created !== null) {
|
||||
created.piece = piece
|
||||
created.variant = 0
|
||||
created.hasDs1 = true
|
||||
noteSpecial(created, piece, token, ctx.stats)
|
||||
return created
|
||||
}
|
||||
parent.hasDs1 = wasHasDs1
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stats.notes.push(`could not place special piece ${piece.name}: no adjacent space clear`)
|
||||
return null
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 1. Act 2 - Tomb
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Tal Rasha's Tombs, Stony Tomb, Halls of the Dead, Claw Viper Temple.
|
||||
*
|
||||
* Places True Tomb Horadric Staff orifice, False Tomb golden chest rooms, Ancient Kaa the Soulless,
|
||||
* Horadric Cube chest, Leatherarm, and staircases.
|
||||
*/
|
||||
export function placeAct2TombStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
const staffTomb = ctx.request.staffTombLevelId ?? 66
|
||||
const bossTomb = ctx.request.bossTombLevelId
|
||||
|
||||
// Determine initial entrance direction from warp room
|
||||
let nDirection = 0
|
||||
const warpRoom = ctx.level.rooms.find(r => r.piece?.kind === 'prev')
|
||||
if (warpRoom?.piece !== null && warpRoom?.piece !== undefined) {
|
||||
const name = warpRoom.piece.name.toUpperCase()
|
||||
if (name.includes('NSW')) nDirection = 0
|
||||
else if (name.includes('NEW')) nDirection = 1
|
||||
else if (name.includes('NSE')) nDirection = 2
|
||||
else if (name.includes('SEW')) nDirection = 3
|
||||
else nDirection = ctx.request.seed % 4
|
||||
} else {
|
||||
nDirection = ctx.request.seed % 4
|
||||
}
|
||||
|
||||
const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST]
|
||||
|
||||
const placeTombPiece = (kind: MazePieceKind, role: string) => {
|
||||
const side = DIRS[nDirection]!
|
||||
const growDir = GROW_DIRS[nDirection]!
|
||||
placeNamedPiece(ctx, {
|
||||
kind,
|
||||
namePrefix: `Act 2 - Tomb ${role}`,
|
||||
sides: side,
|
||||
plainSides: side,
|
||||
direction: growDir,
|
||||
preferFurthest: role !== 'Prev',
|
||||
})
|
||||
nDirection = (nDirection + 1) % 4
|
||||
}
|
||||
|
||||
// 1. Stony Tomb 1, Stony Tomb 2, Halls of the Dead 1, Claw Viper Temple 1 get Next stairs
|
||||
if (levelId >= 55 && levelId <= 58) {
|
||||
placeTombPiece('next', 'Next')
|
||||
}
|
||||
|
||||
// 2. Halls of the Dead 2 gets Waypoint
|
||||
if (levelId === 57 || levelId === 59 || ctx.request.hasWaypoint === true) {
|
||||
placeTombPiece('waypoint', 'Waypoint')
|
||||
}
|
||||
|
||||
// 3. Stony Tomb 2 gets golden chest
|
||||
if (levelId === 56) {
|
||||
placeTombPiece('treasure', 'Chest')
|
||||
}
|
||||
|
||||
// 4. False Tal Rasha Tombs (66..72 except staff tomb) get golden chest
|
||||
if (levelId >= 66 && levelId <= 72 && levelId !== staffTomb) {
|
||||
placeTombPiece('treasure', 'Chest')
|
||||
}
|
||||
|
||||
// 5. Stony Tomb 2 gets Leatherarm
|
||||
if (levelId === 56) {
|
||||
placeTombPiece('quest', 'Leatherarm')
|
||||
}
|
||||
|
||||
// 6. Halls of the Dead 3 gets Horadric Cube
|
||||
if (levelId === 60) {
|
||||
placeTombPiece('quest', 'Cube')
|
||||
}
|
||||
|
||||
// 7. Stony Tomb 2 gets Treasure
|
||||
if (levelId === 56) {
|
||||
placeTombPiece('treasure', 'Treasure')
|
||||
}
|
||||
|
||||
// 8. True Tal Rasha Tomb gets Horadric Staff Orifice
|
||||
if (levelId === staffTomb) {
|
||||
placeTombPiece('quest', 'Talrasha')
|
||||
}
|
||||
|
||||
// 9. Boss Tomb gets Ancient Kaa the Soulless
|
||||
if (bossTomb !== undefined && levelId === bossTomb) {
|
||||
placeTombPiece('quest', 'Kaa')
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 2. Act 2 - Lair
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Maggot Lair Level 1, 2, 3.
|
||||
*
|
||||
* Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W).
|
||||
*/
|
||||
export function placeAct2LairStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 64) {
|
||||
// Coldworm the Burrower room
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 2 - Lair Tight Spot S',
|
||||
plainSides: 'S',
|
||||
direction: DIRECTION_NORTH,
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
// Staff of Kings chest room
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
name: 'Act 2 - Lair Treasure W',
|
||||
plainSides: 'W',
|
||||
direction: DIRECTION_EAST,
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
// Up staircase
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 2 - Lair Prev',
|
||||
sides: 'N',
|
||||
direction: DIRECTION_SOUTH,
|
||||
preferFurthest: false,
|
||||
})
|
||||
} else {
|
||||
const nRand = ctx.request.seed % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 2 - Lair Next',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 2 - Lair Prev',
|
||||
sides: DIRS[(nRand + 1) % 4],
|
||||
plainSides: DIRS[(nRand + 1) % 4],
|
||||
preferFurthest: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 3. Act 3 - Dungeon
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Spider Cavern, Arachnid Lair, Swampy Pit 1..3, Flayer Dungeon 1..3, and Kurast Temples.
|
||||
*
|
||||
* Flayer Dungeon 3 places Khalim's Brain chest (Treasure 2).
|
||||
* Swampy Pit 3 places Treasure 1.
|
||||
* Temples (levels 94..99) place Lam Esen's Tome altar.
|
||||
*/
|
||||
export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
|
||||
if (levelId === 91) {
|
||||
// Flayer Dungeon Level 3: Khalim's Brain chest
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
name: 'Act 3 - Dungeon Treasure 2',
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 3 - Dungeon Prev',
|
||||
sides: DIRS[nDirection],
|
||||
preferFurthest: false,
|
||||
})
|
||||
} else if (levelId === 90) {
|
||||
// Swampy Pit Level 3
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
name: 'Act 3 - Dungeon Treasure 1',
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 3 - Dungeon Prev',
|
||||
sides: DIRS[nDirection],
|
||||
preferFurthest: false,
|
||||
})
|
||||
} else if (levelId >= 94 && levelId <= 99) {
|
||||
// Ruined Temple, Disused Fane, Forgotten Reliquary, etc. (Lam Esen's Tome)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: `Act 3 - Temple ${levelId - 93}`,
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Levels 86..89
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 3 - Dungeon Prev',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: false,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 3 - Dungeon Next',
|
||||
sides: DIRS[(nDirection + 1) % 4],
|
||||
plainSides: DIRS[(nDirection + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 4. Act 3 - Sewer
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Kurast Sewers Level 1 & 2.
|
||||
*
|
||||
* Level 1 places the drain descent lever and staircase down to Level 2.
|
||||
*/
|
||||
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
|
||||
if (levelId === 92) {
|
||||
// Sewer Level 1: Drain lever / stairs down to Sewer 2
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 3 - Sewer Drain',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
direction: DIRECTION_NORTH,
|
||||
preferFurthest: true,
|
||||
})
|
||||
// Chest room
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
namePrefix: 'Act 3 - Sewer Chest',
|
||||
sides: DIRS[(nDirection + 1) % 4],
|
||||
plainSides: DIRS[(nDirection + 1) % 4],
|
||||
direction: DIRECTION_SOUTH,
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Sewer Level 2 (or other)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
namePrefix: 'Act 3 - Sewer Chest',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
name: 'Act 3 - Sewer Treasure X',
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 5. Act 3 - Mephisto
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Durance of Hate Level 1, 2, 3.
|
||||
*
|
||||
* Level 3 places the Mephisto Complex (Council Hall, blood moat, red portal to Act 4).
|
||||
* Level 2 places Waypoint, Next and Prev.
|
||||
*/
|
||||
export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
const nRand = ctx.request.seed % 4
|
||||
|
||||
if (levelId === 102) {
|
||||
// Durance of Hate Level 3: Mephisto Complex
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const complexPiece = ctx.pieces.find(p => p.name === 'Act 3 - Mephisto Complex')
|
||||
if (complexPiece !== undefined) {
|
||||
ctx.level.first.piece = complexPiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, complexPiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 3 - Mephisto Complex',
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else if (levelId === 101) {
|
||||
// Durance of Hate Level 2: Prev, Waypoint, Next
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 3 - Mephisto Prev',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: false,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
namePrefix: 'Act 3 - Mephisto Waypoint',
|
||||
sides: DIRS[(nRand + 1) % 4],
|
||||
plainSides: DIRS[(nRand + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 3 - Mephisto Next',
|
||||
sides: DIRS[(nRand + 2) % 4],
|
||||
plainSides: DIRS[(nRand + 2) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Durance of Hate Level 1: Prev, Next
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 3 - Mephisto Prev',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: false,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 3 - Mephisto Next',
|
||||
sides: DIRS[(nRand + 1) % 4],
|
||||
plainSides: DIRS[(nRand + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 6. Act 5 - Temple
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Halls of Anguish, Halls of Pain, Halls of Vaught.
|
||||
*
|
||||
* Halls of Vaught (level 124) places Nihlathak's altar (Final Room).
|
||||
* Halls of Pain (level 123) places Waypoint and descent stairs.
|
||||
*/
|
||||
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 124) {
|
||||
// Halls of Vaught: Nihlathak Final Room
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const finalPiece = ctx.pieces.find(p => p.name === 'Act 5 - Temple Final Room')
|
||||
if (finalPiece !== undefined) {
|
||||
ctx.level.first.piece = finalPiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, finalPiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 5 - Temple Final Room',
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Levels 122 and 123
|
||||
const templeDownDirs = ['NE', 'NW', 'SW']
|
||||
const nDirection = (ctx.request.seed >>> 0) % 3
|
||||
const downSide = templeDownDirs[nDirection]!
|
||||
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'down',
|
||||
name: `Act 5 - Temple ${downSide} Down`,
|
||||
sides: downSide,
|
||||
plainSides: downSide,
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
if (levelId === 123) {
|
||||
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
|
||||
const wpSide = templeWaypointDirs[(nDirection + 1) % 4]!
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
name: `Act 5 - Temple ${wpSide} Waypoint`,
|
||||
sides: wpSide,
|
||||
plainSides: wpSide,
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
name: 'Act 5 - Temple SE up',
|
||||
sides: 'SE',
|
||||
plainSides: 'SE',
|
||||
preferFurthest: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 7. Act 5 - Baal
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Worldstone Keep Level 1, 2, 3 and Throne of Destruction.
|
||||
*
|
||||
* Throne of Destruction (level 131) places Baal Entrance / ThroneRoom.
|
||||
* Worldstone Keep 2 places Waypoint and Next stairs.
|
||||
*/
|
||||
export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
|
||||
if (levelId === 131) {
|
||||
// Throne of Destruction
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const thronePiece = ctx.pieces.find(p => p.name === 'Act 5 - ThroneRoom' || p.name === 'Act 5 - Baal Entrance')
|
||||
if (thronePiece !== undefined) {
|
||||
ctx.level.first.piece = thronePiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, thronePiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
const placed = placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 5 - ThroneRoom',
|
||||
preferFurthest: true,
|
||||
})
|
||||
if (placed === null) {
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 5 - Baal Entrance',
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
} else if (levelId === 129) {
|
||||
// Worldstone Keep Level 2: Next and Waypoint
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 5 - Baal Next',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
namePrefix: 'Act 5 - Baal Waypoint',
|
||||
sides: DIRS[(nDirection + 1) % 4],
|
||||
plainSides: DIRS[(nDirection + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Worldstone Keep Levels 1, 3
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 5 - Baal Next',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 8. Act 5 - Ice
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar.
|
||||
*
|
||||
* Frozen River (level 114) places Anya's frozen platform (River A or River B).
|
||||
* Drifter Cavern (116) and Icy Cellar (119) place Pool A / Pool B.
|
||||
* Glacial Trail (115) places Theme and Waypoint.
|
||||
*/
|
||||
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 114) {
|
||||
// Frozen River (Anya platform)
|
||||
const variant = (ctx.request.seed % 2) === 0 ? 'A' : 'B'
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
|
||||
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
|
||||
if (riverPiece !== undefined) {
|
||||
ctx.level.first.piece = riverPiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, riverPiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: `Act 5 - Ice River ${variant}`,
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else if (levelId === 116) {
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool A')
|
||||
if (poolPiece !== undefined) {
|
||||
ctx.level.first.piece = poolPiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool A', preferFurthest: true })
|
||||
} else if (levelId === 119) {
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool B')
|
||||
if (poolPiece !== undefined) {
|
||||
ctx.level.first.piece = poolPiece
|
||||
ctx.level.first.hasDs1 = true
|
||||
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
|
||||
return
|
||||
}
|
||||
}
|
||||
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
|
||||
} else {
|
||||
// Levels 113, 115, 118
|
||||
let nDirection = ctx.request.seed % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 5 - Ice Prev',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: false,
|
||||
})
|
||||
nDirection = (nDirection + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 5 - Ice Next',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
nDirection = (nDirection + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'down',
|
||||
namePrefix: 'Act 5 - Ice Down',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
if (levelId === 115) {
|
||||
nDirection = (nDirection + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'theme',
|
||||
namePrefix: 'Act 5 - Ice Theme',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (levelId === 113 || levelId === 115) {
|
||||
nDirection = (nDirection + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
namePrefix: 'Act 5 - Ice waypoint',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 9. Act 1 - Barracks
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Monastery Barracks (level 28).
|
||||
*
|
||||
* Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail.
|
||||
*/
|
||||
export function placeAct1Barracks(ctx: SpecialPassContext): void {
|
||||
// Connect towards Outer Cloister
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
name: 'Act 1 - Barracks Court Connect',
|
||||
direction: DIRECTION_WEST,
|
||||
preferFurthest: false,
|
||||
})
|
||||
|
||||
const nDirection = ctx.request.seed % 4
|
||||
const roll = (ctx.request.seed >>> 2) % 2
|
||||
|
||||
if (roll === 1) {
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 1 - Barracks Next',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
namePrefix: 'Act 1 - Barracks Forge',
|
||||
sides: DIRS[(nDirection + 1) % 4],
|
||||
plainSides: DIRS[(nDirection + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
namePrefix: 'Act 1 - Barracks Forge',
|
||||
sides: DIRS[nDirection],
|
||||
plainSides: DIRS[nDirection],
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 1 - Barracks Next',
|
||||
sides: DIRS[(nDirection + 1) % 4],
|
||||
plainSides: DIRS[(nDirection + 1) % 4],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 10. Act 4 - Lava
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* River of Flame (level 107).
|
||||
*
|
||||
* Places Warp N (towards City of Damned), Bridges 1 & 2 towards Chaos Sanctuary,
|
||||
* Hephasto the Armorer / Hellforge, and fills blanks with Lava X.
|
||||
*/
|
||||
export function placeAct4Lava(ctx: SpecialPassContext): void {
|
||||
// 1. Warp N (entrance from City of Damned)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 4 - Lava Warp',
|
||||
sides: 'N',
|
||||
plainSides: 'N',
|
||||
direction: DIRECTION_SOUTH,
|
||||
preferFurthest: false,
|
||||
})
|
||||
|
||||
// 2. Bridges towards Chaos Sanctuary
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 4 - Bridge 1',
|
||||
direction: DIRECTION_NORTH,
|
||||
preferFurthest: true,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: 'Act 4 - Bridge 2',
|
||||
direction: DIRECTION_NORTH,
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
// 3. Hellforge (Hephasto the Armorer)
|
||||
const forgeSide = (ctx.request.seed % 2) === 0 ? 'W' : 'E'
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: `Act 4 - Lava Forge ${forgeSide}`,
|
||||
plainSides: forgeSide,
|
||||
preferFurthest: true,
|
||||
})
|
||||
|
||||
// 4. Fill blank maze spaces with Act 4 - Lava X
|
||||
fillBlankSpaces(ctx.level, ctx.pieces, ctx.index, ctx.stats)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 11. Act 2 - Sewers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Lut Gholein Sewers Level 1, 2, 3 and Ancient Tunnels.
|
||||
*
|
||||
* Level 3 places Radament's Lair (Book of Skill / Atma's quest).
|
||||
* Level 1 places Prev E & Prev NS.
|
||||
* Level 2 places Waypoint, Prev, Next.
|
||||
* Ancient Tunnels (level 50) places Chest and Prev.
|
||||
*/
|
||||
export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void {
|
||||
const levelId = ctx.request.levelId
|
||||
let nRand = ctx.request.seed % 4
|
||||
|
||||
if (levelId === 49) {
|
||||
// Sewers Level 3: Radament's Lair
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 2 - Sewer Prev',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: false,
|
||||
})
|
||||
nRand = (nRand + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
namePrefix: "Act 2 - Sewer Radament's Lair",
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else if (levelId === 47) {
|
||||
// Sewers Level 1: Dock and City entrances
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
name: 'Act 2 - Sewer Prev E',
|
||||
sides: 'E',
|
||||
direction: DIRECTION_WEST,
|
||||
preferFurthest: false,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
name: 'Act 2 - Sewer Prev NS',
|
||||
sides: 'NS',
|
||||
direction: DIRECTION_NORTH,
|
||||
preferFurthest: false,
|
||||
})
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 2 - Sewer Next',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else if (levelId === 48) {
|
||||
// Sewers Level 2: Prev, Waypoint, Next
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 2 - Sewer Prev',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: false,
|
||||
})
|
||||
nRand = (nRand + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
namePrefix: 'Act 2 - Sewer Waypoint',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
nRand = (nRand + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 2 - Sewer Next',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
} else {
|
||||
// Ancient Tunnels (level 50) or other
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 2 - Sewer Prev',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: false,
|
||||
})
|
||||
nRand = (nRand + 1) % 4
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'treasure',
|
||||
namePrefix: 'Act 2 - Sewer Chest',
|
||||
sides: DIRS[nRand],
|
||||
plainSides: DIRS[nRand],
|
||||
preferFurthest: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Master Runner
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export function runMazeSpecialPasses(passName: SpecialPassName, ctx: SpecialPassContext): void {
|
||||
ctx.stats.specialPassesApplied.push(passName)
|
||||
switch (passName) {
|
||||
case 'DRLGMAZE_PlaceAct2TombStuff':
|
||||
placeAct2TombStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct2LairStuff':
|
||||
placeAct2LairStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct3DungeonStuff':
|
||||
placeAct3DungeonStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct3SewerStuff':
|
||||
placeAct3SewerStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct3MephistoStuff':
|
||||
placeAct3MephistoStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct5TempleStuff':
|
||||
placeAct5TempleStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct5BaalStuff':
|
||||
placeAct5BaalStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct5IceStuff':
|
||||
placeAct5IceStuff(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct1Barracks':
|
||||
placeAct1Barracks(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_PlaceAct4Lava':
|
||||
placeAct4Lava(ctx)
|
||||
break
|
||||
case 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets':
|
||||
scanReplaceSpecialAct2SewersPresets(ctx)
|
||||
break
|
||||
}
|
||||
}
|
||||
411
src/game/maze.ts
411
src/game/maze.ts
|
|
@ -80,6 +80,25 @@
|
|||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
export { SUB_TILES_PER_TILE }
|
||||
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts'
|
||||
import {
|
||||
populateDungeonObjects,
|
||||
type PlacedMazeObject,
|
||||
type MazeObjectPopulationOptions,
|
||||
type MazeRoomDescriptor,
|
||||
} from './maze-objects.ts'
|
||||
|
||||
export type {
|
||||
PlacedMazeObject,
|
||||
MazeObjectPopulationOptions,
|
||||
MazeObjectCategory,
|
||||
ShrineSubType,
|
||||
ChestSubType,
|
||||
RackSubType,
|
||||
ContainerSubType,
|
||||
WallAlignment,
|
||||
} from './maze-objects.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Directions
|
||||
|
|
@ -192,6 +211,26 @@ export type MazePieceKind =
|
|||
| 'down'
|
||||
| 'quest'
|
||||
| 'treasure'
|
||||
| 'waypoint'
|
||||
/**
|
||||
* A waypoint room, as reported in `stats.waypoints`.
|
||||
*/
|
||||
export interface MazeWaypoint {
|
||||
/** The room the piece went into, matching `specialsApplied`. */
|
||||
readonly room: number
|
||||
/** 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 clicks. */
|
||||
readonly centreX: number
|
||||
readonly centreY: number
|
||||
/** Sub-tile coordinates of the waypoint entity within the synthesized map. */
|
||||
readonly entityX: number
|
||||
readonly entityY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One `LvlPrest.txt` row that can be stamped into a maze level.
|
||||
|
|
@ -265,12 +304,47 @@ 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
|
||||
/** Dynamic dungeon object seeding options. */
|
||||
readonly objectOptions?: MazeObjectPopulationOptions
|
||||
/** Configurable object density multiplier or options. */
|
||||
readonly objectDensity?: number | MazeObjectPopulationOptions
|
||||
}
|
||||
|
||||
/** The generated level. */
|
||||
export interface MazeResult {
|
||||
/** A single synthesized map covering every placed section. */
|
||||
readonly level: Ds1
|
||||
/** Dynamically seeded interactive objects (shrines, chests, racks, breakables). */
|
||||
readonly objects: readonly PlacedMazeObject[]
|
||||
/** What the generator did, for reporting and tests. */
|
||||
readonly stats: Record<string, unknown>
|
||||
}
|
||||
|
|
@ -294,6 +368,20 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
|
|||
['Next', 'next'],
|
||||
['Prev', 'prev'],
|
||||
['Theme', 'theme'],
|
||||
['Waypoint', 'waypoint'],
|
||||
['waypoint', 'waypoint'],
|
||||
['Talrasha', 'quest'],
|
||||
["Radament's Lair", 'quest'],
|
||||
['Radament', 'quest'],
|
||||
['Tight Spot', 'quest'],
|
||||
['Forge', 'quest'],
|
||||
['Court Connect', 'next'],
|
||||
['Bridge', 'next'],
|
||||
['Complex', 'quest'],
|
||||
['Final Room', 'quest'],
|
||||
['Cube', 'treasure'],
|
||||
['Leatherarm', 'quest'],
|
||||
['Kaa', 'quest'],
|
||||
]
|
||||
|
||||
/** Which `LvlPrest` name families belong to which maze level type. */
|
||||
|
|
@ -312,9 +400,11 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
|
|||
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
|
||||
'Act 3 - Sewer': ['Act 3 - Sewer'],
|
||||
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
|
||||
'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'],
|
||||
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'],
|
||||
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
|
||||
'Act 5 - Temple': ['Act 5 - Temple'],
|
||||
'Act 5 - Baal': ['Act 5 - Baal'],
|
||||
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -333,26 +423,37 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
|
|||
*/
|
||||
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
|
||||
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
|
||||
const matchedFamily = families.find(family => name.startsWith(family))
|
||||
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
|
||||
if (matchedFamily === undefined) return null
|
||||
const rest = name.slice(matchedFamily.length).trim()
|
||||
if (rest === '') return { kind: 'entrance', sides: '' }
|
||||
for (const [prefix, kind] of KIND_PREFIXES) {
|
||||
if (!rest.startsWith(prefix)) continue
|
||||
const tail = rest.slice(prefix.length).trim()
|
||||
// `Treasure 1` is numbered rather than sided; everything else uses sides.
|
||||
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
|
||||
return { kind, sides: tail }
|
||||
if (rest === '' || rest.toLowerCase() === 'entrance') {
|
||||
if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' }
|
||||
return { kind: 'entrance', sides: '' }
|
||||
}
|
||||
const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest)
|
||||
for (const [prefix, kind] of KIND_PREFIXES) {
|
||||
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
const tail = rest.slice(prefix.length).trim()
|
||||
// `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides.
|
||||
if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) {
|
||||
return { kind, sides: '' }
|
||||
}
|
||||
return { kind, sides: tail }
|
||||
}
|
||||
}
|
||||
if (/^\d+$/.test(rest)) {
|
||||
if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' }
|
||||
if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' }
|
||||
if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' }
|
||||
}
|
||||
const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest)
|
||||
if (reversedMatch !== null) {
|
||||
const sides = reversedMatch[1]!.toUpperCase()
|
||||
const role = reversedMatch[2]!.toLowerCase()
|
||||
return { kind: role === 'up' ? 'prev' : 'next', sides }
|
||||
return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', 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: '' }
|
||||
if (/^[NSEW]+$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() }
|
||||
if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' }
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -395,7 +496,7 @@ export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null
|
|||
*/
|
||||
interface SpecialStep {
|
||||
/** Which kind of special piece to place. */
|
||||
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure'
|
||||
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint'
|
||||
/** Side token of the plain room this step replaces, e.g. `N`. */
|
||||
readonly plainSides: string
|
||||
/** Side token of the special piece. */
|
||||
|
|
@ -467,6 +568,8 @@ export interface MazeLevelProfile {
|
|||
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
|
||||
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
|
||||
readonly specials: readonly SpecialGroup[]
|
||||
/** Optional official dungeon special pass from D2MOO. */
|
||||
readonly specialPass?: SpecialPassName
|
||||
}
|
||||
|
||||
/** The four single-side layouts, in the order the D2MOO tables list them. */
|
||||
|
|
@ -526,6 +629,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries() },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 1 - Jail',
|
||||
|
|
@ -534,7 +638,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
theme: true,
|
||||
specials: [
|
||||
{ kind: 'prev', entries: entries() },
|
||||
{ kind: 'treasure', entries: entries(), levelIds: [29] },
|
||||
{ kind: 'waypoint', entries: entries(), levelIds: [29] },
|
||||
{ kind: 'quest', entries: entries(), levelIds: [30] },
|
||||
// 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.
|
||||
|
|
@ -549,7 +653,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
opening: 'catacombs',
|
||||
specials: [
|
||||
{ kind: 'next', entries: entries() },
|
||||
{ kind: 'treasure', entries: entries(), levelIds: [35] },
|
||||
{ kind: 'waypoint', entries: entries(), levelIds: [35] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -561,6 +665,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries() },
|
||||
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 2 - Tomb',
|
||||
|
|
@ -571,6 +676,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 2 - Lair',
|
||||
|
|
@ -581,6 +687,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries() },
|
||||
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct2LairStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 2 - Arcane',
|
||||
|
|
@ -601,6 +708,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
|
||||
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 3 - Dungeon',
|
||||
|
|
@ -611,6 +719,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
|
||||
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 3 - Sewer',
|
||||
|
|
@ -649,6 +758,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
levelIds: [92],
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 4 - Lava',
|
||||
|
|
@ -666,6 +776,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
levelIds: [107],
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct4Lava',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Ice Caves',
|
||||
|
|
@ -682,6 +793,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
|
||||
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Temple',
|
||||
|
|
@ -712,6 +824,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
inPlaceOnly: true,
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
|
|
@ -721,6 +834,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
|
||||
},
|
||||
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
|
||||
]
|
||||
|
|
@ -736,22 +850,10 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
|
||||
|
||||
/**
|
||||
* Per-act decoration passes D2MOO runs after the layout, which this port does not
|
||||
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses`.
|
||||
* Per-act decoration passes D2MOO runs after the layout.
|
||||
* All 11 core dungeon passes are now implemented in `maze-special-passes.ts`.
|
||||
*/
|
||||
const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||||
'DRLGMAZE_PlaceAct2TombStuff',
|
||||
'DRLGMAZE_PlaceAct2LairStuff',
|
||||
'DRLGMAZE_PlaceAct3DungeonStuff',
|
||||
'DRLGMAZE_PlaceAct3SewerStuff',
|
||||
'DRLGMAZE_PlaceAct3MephistoStuff',
|
||||
'DRLGMAZE_PlaceAct5TempleStuff',
|
||||
'DRLGMAZE_PlaceAct5BaalStuff',
|
||||
'DRLGMAZE_PlaceAct5IceStuff',
|
||||
'DRLGMAZE_PlaceAct1Barracks',
|
||||
'DRLGMAZE_PlaceAct4Lava',
|
||||
'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
|
||||
]
|
||||
export const UNIMPLEMENTED_PASSES: readonly string[] = []
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Geometry
|
||||
|
|
@ -833,7 +935,7 @@ function directionFrom(from: Rect, to: Rect): number {
|
|||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** One maze section. */
|
||||
interface Room extends Rect {
|
||||
export interface Room extends Rect {
|
||||
/** Doorways to neighbouring rooms, in insertion order. */
|
||||
readonly orths: { room: Room; direction: number }[]
|
||||
/** Creation index, so diagnostics can name a room stably. */
|
||||
|
|
@ -856,7 +958,7 @@ interface Room extends Rect {
|
|||
}
|
||||
|
||||
/** The level under construction. */
|
||||
interface MazeLevel {
|
||||
export interface MazeLevel {
|
||||
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
|
||||
readonly rooms: Room[]
|
||||
/** The first room created; D2MOO keeps this pointer across prepends. */
|
||||
|
|
@ -869,6 +971,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -878,16 +983,20 @@ interface MazeLevel {
|
|||
* facts are which piece answered which side pattern and where the exact pattern
|
||||
* was missing.
|
||||
*/
|
||||
interface MazeStats {
|
||||
export interface MazeStats {
|
||||
readonly patterns: Record<string, Record<string, number>>
|
||||
readonly fallbacks: { room: number; requested: string; used: string }[]
|
||||
readonly specialsApplied: { room: number; kind: string; sides: string }[]
|
||||
readonly specialPassesApplied: string[]
|
||||
readonly unresolvedRoles: string[]
|
||||
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. */
|
||||
waypoints: MazeWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -964,14 +1073,63 @@ function linkRooms(a: Room, b: Room, direction: number): void {
|
|||
* @param room - the room.
|
||||
* @returns the mask, 0..15.
|
||||
*/
|
||||
function roomSideMask(room: Room): number {
|
||||
export function roomSideMask(room: Room): number {
|
||||
let mask = 0
|
||||
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
|
||||
return mask
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1137,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
|
||||
|
|
@ -1046,7 +1205,7 @@ function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null
|
|||
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
|
||||
* @returns the new room, or null.
|
||||
*/
|
||||
function addAdjacentMazeRoom(
|
||||
export function addAdjacentMazeRoom(
|
||||
level: MazeLevel,
|
||||
parent: Room,
|
||||
direction: number,
|
||||
|
|
@ -1118,7 +1277,7 @@ function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats:
|
|||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Indexed pieces, so a side mask is one map lookup. */
|
||||
interface PieceIndex {
|
||||
export interface PieceIndex {
|
||||
/**
|
||||
* The piece for a room's doorway mask.
|
||||
*
|
||||
|
|
@ -1226,7 +1385,7 @@ function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
|
|||
* @param sides - the side token; `''` accepts any piece of that kind.
|
||||
* @returns the piece, or undefined.
|
||||
*/
|
||||
function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
|
||||
export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
|
||||
return pieces.find(piece => piece.kind === kind
|
||||
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
|
||||
}
|
||||
|
|
@ -1264,7 +1423,7 @@ function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
|
|||
* @param token - the side token it answers.
|
||||
* @param stats - report collector.
|
||||
*/
|
||||
function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
|
||||
export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
|
||||
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
|
||||
stats.patterns[piece.kind] ??= {}
|
||||
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
|
||||
|
|
@ -1523,7 +1682,7 @@ function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceI
|
|||
* @param index - the piece index.
|
||||
* @param stats - report collector.
|
||||
*/
|
||||
function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||||
export function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||||
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
|
||||
if (filler === undefined) {
|
||||
stats.unresolvedRoles.push('Act 4 - Lava X')
|
||||
|
|
@ -1594,7 +1753,7 @@ function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeSt
|
|||
* 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> {
|
||||
export 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]
|
||||
|
|
@ -1612,8 +1771,9 @@ function bfsDistances(level: MazeLevel): Map<Room, number> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Place a special room group using topological dead-end / BFS distance selection
|
||||
* and `LvlPrest.txt` full-room preset substitution.
|
||||
* Place a special room group following standard D2 scanning rules:
|
||||
* scans room candidates against valid wall connections, LvlPrest file definitions,
|
||||
* and room flags, rather than imposing an artificial global maximum BFS distance.
|
||||
*
|
||||
* @param level - the level.
|
||||
* @param pieces - the level's pieces.
|
||||
|
|
@ -1630,11 +1790,9 @@ function placeSpecialGroup(
|
|||
index: PieceIndex,
|
||||
stats: MazeStats,
|
||||
): void {
|
||||
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.
|
||||
// Phase 1: Try to replace an existing unfinished room in place, scanning
|
||||
// room candidates against valid wall connections, LvlPrest definitions,
|
||||
// and room flags in standard D2 roster order.
|
||||
for (let offset = 0; offset < group.entries.length; offset += 1) {
|
||||
const entry = group.entries[(startCursor + offset) % group.entries.length]!
|
||||
const wanted = sideMaskFromToken(entry.plainSides)
|
||||
|
|
@ -1642,20 +1800,14 @@ function placeSpecialGroup(
|
|||
const special = findSpecial(pieces, group.kind, entry.sides)
|
||||
if (special === undefined) continue
|
||||
|
||||
const candidates = level.rooms.filter(r => {
|
||||
const chosen = level.rooms.find(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]!
|
||||
if (chosen !== undefined) {
|
||||
chosen.piece = special
|
||||
chosen.variant = 0
|
||||
chosen.hasDs1 = true
|
||||
|
|
@ -1670,16 +1822,12 @@ function placeSpecialGroup(
|
|||
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
|
||||
})
|
||||
// Phase 2: Grow a new dead-end room off an available parent room in D2
|
||||
// roster order (plain rooms without DS1 scanned first, followed by theme rooms).
|
||||
const parentCandidates = [
|
||||
...level.rooms.filter(r => !r.hasDs1),
|
||||
...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'),
|
||||
]
|
||||
|
||||
for (const parent of parentCandidates) {
|
||||
for (let offset = 0; offset < group.entries.length; offset += 1) {
|
||||
|
|
@ -1871,6 +2019,36 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
|||
centreY: originY + Math.floor(variant.height / 2),
|
||||
})
|
||||
}
|
||||
if (piece.kind === 'waypoint') {
|
||||
const wpObj = variant.objects.find(obj => obj.type === 2 && (obj.id === 119 || obj.id === 145 || obj.id === 156 || obj.id === 157 || obj.id === 237 || obj.id === 238 || obj.id === 288 || obj.id === 323 || obj.id === 324 || obj.id === 398 || obj.id === 402 || obj.id === 429)) ?? variant.objects.find(obj => obj.type === 2)
|
||||
let entityX: number
|
||||
let entityY: number
|
||||
if (wpObj !== undefined) {
|
||||
entityX = originX * SUB_TILES_PER_TILE + wpObj.x
|
||||
entityY = originY * SUB_TILES_PER_TILE + wpObj.y
|
||||
} else {
|
||||
entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2
|
||||
entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2
|
||||
objects.push({
|
||||
type: 2,
|
||||
id: 119,
|
||||
x: entityX,
|
||||
y: entityY,
|
||||
flags: 0,
|
||||
})
|
||||
}
|
||||
stats.waypoints.push({
|
||||
room: room.id,
|
||||
x: originX,
|
||||
y: originY,
|
||||
width: variant.width,
|
||||
height: variant.height,
|
||||
centreX: originX + Math.floor(variant.width / 2),
|
||||
centreY: originY + Math.floor(variant.height / 2),
|
||||
entityX,
|
||||
entityY,
|
||||
})
|
||||
}
|
||||
stampedRooms += 1
|
||||
}
|
||||
|
||||
|
|
@ -1959,15 +2137,22 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
|
||||
|
||||
const stats: MazeStats = {
|
||||
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
|
||||
ringRooms: 0, placementAttempts: 0, warps: [],
|
||||
patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [],
|
||||
ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [],
|
||||
}
|
||||
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 }
|
||||
|
|
@ -2025,11 +2210,29 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
// One counter shared by every table, advanced once per call that happens.
|
||||
let cursor = rng.int(0, 3)
|
||||
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
|
||||
if (group.kind === 'waypoint') {
|
||||
if (request.hasWaypoint === false) continue
|
||||
if (request.hasWaypoint !== true) {
|
||||
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
|
||||
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
|
||||
}
|
||||
} else {
|
||||
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
|
||||
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
|
||||
}
|
||||
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
|
||||
cursor = (cursor + 1) % 4
|
||||
}
|
||||
|
||||
if (profile.specialPass !== undefined) {
|
||||
runMazeSpecialPasses(profile.specialPass, {
|
||||
level,
|
||||
request,
|
||||
pieces: request.pieces,
|
||||
index,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unreachable = level.rooms.length - reachableRooms(level)
|
||||
|
|
@ -2042,8 +2245,49 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
assignVariants(level, request.levelId, stats)
|
||||
const stamped = stamp(level, stats)
|
||||
|
||||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||||
const roomDescriptors: MazeRoomDescriptor[] = level.rooms.map(room => {
|
||||
const variant = room.piece ? room.piece.levels[room.variant % room.piece.levels.length] : undefined
|
||||
return {
|
||||
id: room.id,
|
||||
x: room.x,
|
||||
y: room.y,
|
||||
width: variant?.width ?? width,
|
||||
height: variant?.height ?? height,
|
||||
orths: room.orths,
|
||||
piece: room.piece,
|
||||
}
|
||||
})
|
||||
|
||||
const popOptions: MazeObjectPopulationOptions | undefined =
|
||||
typeof request.objectDensity === 'number'
|
||||
? { density: request.objectDensity, ...request.objectOptions }
|
||||
: { ...request.objectDensity, ...request.objectOptions }
|
||||
|
||||
const objectRng = popOptions?.objectSeed !== undefined
|
||||
? new Rng(popOptions.objectSeed)
|
||||
: rng.fork('dungeon-objects')
|
||||
|
||||
const popResult = populateDungeonObjects(
|
||||
roomDescriptors,
|
||||
minX,
|
||||
minY,
|
||||
stamped,
|
||||
levelTypeName,
|
||||
request.levelId,
|
||||
popOptions,
|
||||
objectRng,
|
||||
)
|
||||
|
||||
const finalLevel: Ds1 = {
|
||||
...stamped,
|
||||
objects: [...stamped.objects, ...popResult.placedObjects],
|
||||
}
|
||||
|
||||
return {
|
||||
level: stamped,
|
||||
level: finalLevel,
|
||||
objects: popResult.placedObjects,
|
||||
stats: {
|
||||
levelId: request.levelId,
|
||||
levelName: request.levelName,
|
||||
|
|
@ -2060,14 +2304,33 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
mergedDoorways: level.merges,
|
||||
gridSize: { width: stamped.width, height: stamped.height },
|
||||
gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE },
|
||||
objects: stamped.objects.length,
|
||||
objects: finalLevel.objects.length,
|
||||
dynamicObjects: popResult.placedObjects,
|
||||
dynamicObjectsCount: popResult.stats.dynamicObjectsCount,
|
||||
shrinesSpawned: popResult.stats.shrinesSpawned,
|
||||
chestsSpawned: popResult.stats.chestsSpawned,
|
||||
superChestsSpawned: popResult.stats.superChestsSpawned,
|
||||
racksSpawned: popResult.stats.racksSpawned,
|
||||
containersSpawned: popResult.stats.containersSpawned,
|
||||
cells: stamped.width * stamped.height,
|
||||
sidePatterns: stats.patterns,
|
||||
fallbacks: stats.fallbacks,
|
||||
specialsApplied: stats.specialsApplied,
|
||||
specialPassesApplied: stats.specialPassesApplied,
|
||||
warps: stats.warps,
|
||||
waypoints: stats.waypoints,
|
||||
waypoint: stats.waypoints[0] ?? null,
|
||||
waypointTile: stats.waypoints[0]
|
||||
? {
|
||||
x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE),
|
||||
y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE),
|
||||
}
|
||||
: null,
|
||||
unresolvedRoles: stats.unresolvedRoles,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
maxCellsX,
|
||||
maxCellsY,
|
||||
boundsRejected: stats.boundsRejected ?? 0,
|
||||
notes: stats.notes,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
/**
|
||||
* Room-Local Monster Clustering and Placement.
|
||||
*
|
||||
* Replaces global spiral monster placement with authentic Diablo II room-based
|
||||
* spatial clustering and allocation. In Diablo II, monsters are partitioned into
|
||||
* rooms (sectors of ~20-30 cells outdoors, or discrete maze preset pieces indoors),
|
||||
* avoiding player safe zones (towns, waypoint clearings, level entrances), with
|
||||
* landmark SuperUniques fixed to their specific room and minions clustered tightly
|
||||
* in camp rings around their leader.
|
||||
*/
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
|
||||
/**
|
||||
* A discrete spatial room or sector in a level.
|
||||
*/
|
||||
export interface MapRoom {
|
||||
readonly id: string | number
|
||||
readonly x: number // world/scene pixels
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly cellX: number // grid cell coordinates
|
||||
readonly cellY: number
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
readonly isSafeZone?: boolean | undefined
|
||||
readonly isPreset?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for partitioning a level map into rooms.
|
||||
*/
|
||||
export interface PartitionOptions {
|
||||
/** Room cell size for outdoor grid sector partitioning (default ~24 cells). */
|
||||
readonly roomCellSize?: number | undefined
|
||||
/** Discrete maze rooms from the dungeon generator (e.g. from maze.ts). */
|
||||
readonly mazeRooms?: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly id?: string | number | undefined
|
||||
readonly isPreset?: boolean | undefined
|
||||
readonly isSafeZone?: boolean | undefined
|
||||
}[] | undefined
|
||||
/** Circular exclusion zones where ordinary monster camps must not spawn. */
|
||||
readonly safeZones?: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly radius: number
|
||||
}[] | undefined
|
||||
/** Cell pixel width (default 80 px). */
|
||||
readonly cellPixelWidth?: number | undefined
|
||||
/** Cell pixel height (default 80 px). */
|
||||
readonly cellPixelHeight?: number | undefined
|
||||
/** Uniform cell pixel size shorthand (default 80 px). */
|
||||
readonly cellPixelSize?: number | undefined
|
||||
/** Origin X offset in pixels (default 0). */
|
||||
readonly originX?: number | undefined
|
||||
/** Origin Y offset in pixels (default 0). */
|
||||
readonly originY?: number | undefined
|
||||
}
|
||||
|
||||
/** Default camp radius for room-local monster clustering (48~64 pixels). */
|
||||
export const PACK_RADIUS_PX = 64
|
||||
|
||||
/**
|
||||
* Check if a room boundary overlaps any safe zone circle.
|
||||
* Tests in both pixel space and cell space for maximum compatibility.
|
||||
*/
|
||||
function checkSafeZoneOverlap(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
cellsX: number,
|
||||
cellsY: number,
|
||||
safeZones?: readonly { readonly x: number; readonly y: number; readonly radius: number }[],
|
||||
): boolean {
|
||||
if (safeZones === undefined || safeZones.length === 0) return false
|
||||
for (const sz of safeZones) {
|
||||
// Check in world pixel space
|
||||
const clampPxX = Math.max(x, Math.min(sz.x, x + width))
|
||||
const clampPxY = Math.max(y, Math.min(sz.y, y + height))
|
||||
const pxDistSq = (sz.x - clampPxX) ** 2 + (sz.y - clampPxY) ** 2
|
||||
if (pxDistSq <= sz.radius * sz.radius) return true
|
||||
|
||||
// Check in cell space (in case safeZone coordinates were given in cells)
|
||||
const clampCellX = Math.max(cellX, Math.min(sz.x, cellX + cellsX))
|
||||
const clampCellY = Math.max(cellY, Math.min(sz.y, cellY + cellsY))
|
||||
const cellDistSq = (sz.x - clampCellX) ** 2 + (sz.y - clampCellY) ** 2
|
||||
if (cellDistSq <= sz.radius * sz.radius) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a map into rooms.
|
||||
*
|
||||
* If `mazeRooms` are supplied (from dungeon generator), map them directly into `MapRoom`s.
|
||||
* Otherwise (wilderness / outdoors), partition the level into authentic ~20-30 cell rooms
|
||||
* (e.g. 24x24 cells = 120x120 subtiles), matching D2's room grid.
|
||||
* Automatically flag rooms overlapping `safeZones` as `isSafeZone = true`.
|
||||
*
|
||||
* @param cellsX - total level width in cells.
|
||||
* @param cellsY - total level height in cells.
|
||||
* @param options - partitioning options.
|
||||
* @returns partitioned MapRooms.
|
||||
*/
|
||||
export function partitionMapIntoRooms(
|
||||
cellsX: number,
|
||||
cellsY: number,
|
||||
options?: PartitionOptions,
|
||||
): MapRoom[] {
|
||||
const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80
|
||||
const cellHeight = options?.cellPixelHeight ?? options?.cellPixelSize ?? 80
|
||||
const originX = options?.originX ?? 0
|
||||
const originY = options?.originY ?? 0
|
||||
|
||||
if (options?.mazeRooms !== undefined && options.mazeRooms.length > 0) {
|
||||
return options.mazeRooms.map((mr, index) => {
|
||||
const cellX = mr.x
|
||||
const cellY = mr.y
|
||||
const rCellsX = mr.width
|
||||
const rCellsY = mr.height
|
||||
const x = cellX * cellWidth + originX
|
||||
const y = cellY * cellHeight + originY
|
||||
const width = rCellsX * cellWidth
|
||||
const height = rCellsY * cellHeight
|
||||
const id = mr.id ?? index
|
||||
const isPreset = mr.isPreset ?? true
|
||||
const isSafeZone = mr.isSafeZone ?? checkSafeZoneOverlap(
|
||||
x, y, width, height, cellX, cellY, rCellsX, rCellsY, options.safeZones,
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
cellX,
|
||||
cellY,
|
||||
cellsX: rCellsX,
|
||||
cellsY: rCellsY,
|
||||
isPreset,
|
||||
...(isSafeZone ? { isSafeZone: true } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wilderness / outdoors: grid partition into ~24x24 cell rooms
|
||||
const roomCellSize = options?.roomCellSize ?? 24
|
||||
const rooms: MapRoom[] = []
|
||||
let index = 0
|
||||
|
||||
for (let cy = 0; cy < cellsY; cy += roomCellSize) {
|
||||
const rCellsY = Math.min(roomCellSize, cellsY - cy)
|
||||
for (let cx = 0; cx < cellsX; cx += roomCellSize) {
|
||||
const rCellsX = Math.min(roomCellSize, cellsX - cx)
|
||||
const x = cx * cellWidth + originX
|
||||
const y = cy * cellHeight + originY
|
||||
const width = rCellsX * cellWidth
|
||||
const height = rCellsY * cellHeight
|
||||
const isSafeZone = checkSafeZoneOverlap(
|
||||
x, y, width, height, cx, cy, rCellsX, rCellsY, options?.safeZones,
|
||||
)
|
||||
|
||||
rooms.push({
|
||||
id: index++,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
cellX: cx,
|
||||
cellY: cy,
|
||||
cellsX: rCellsX,
|
||||
cellsY: rCellsY,
|
||||
isPreset: false,
|
||||
...(isSafeZone ? { isSafeZone: true } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rooms
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate monster packs to rooms according to Diablo II rules:
|
||||
* - Distributes non-landmark packs across non-safe rooms (avoiding town, stair buffers).
|
||||
* - Fixed landmark packs (SuperUniques with `landmarkTile` or `fixedPosition`) route directly
|
||||
* to the room covering their coordinates.
|
||||
* - Avoids over-crowding by balancing pack counts across candidate rooms based on room area.
|
||||
*
|
||||
* @param packs - list of monster packs to distribute.
|
||||
* @param rooms - candidate rooms.
|
||||
* @param seed - optional random seed for deterministic distribution.
|
||||
* @returns Map from each MapRoom to its allocated MonsterPack array.
|
||||
*/
|
||||
export function allocatePacksToRooms(
|
||||
packs: readonly MonsterPack[],
|
||||
rooms: readonly MapRoom[],
|
||||
seed?: number,
|
||||
): Map<MapRoom, MonsterPack[]> {
|
||||
const allocation = new Map<MapRoom, MonsterPack[]>()
|
||||
for (const room of rooms) {
|
||||
allocation.set(room, [])
|
||||
}
|
||||
if (rooms.length === 0 || packs.length === 0) {
|
||||
return allocation
|
||||
}
|
||||
|
||||
const landmarkPacks: MonsterPack[] = []
|
||||
const normalPacks: MonsterPack[] = []
|
||||
|
||||
for (const pack of packs) {
|
||||
if (pack.fixedPosition !== undefined || pack.landmarkTile !== undefined) {
|
||||
landmarkPacks.push(pack)
|
||||
} else {
|
||||
normalPacks.push(pack)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Fixed landmark SuperUniques route directly to their covering room
|
||||
for (const pack of landmarkPacks) {
|
||||
let targetRoom: MapRoom | undefined
|
||||
|
||||
if (pack.fixedPosition !== undefined) {
|
||||
const fx = pack.fixedPosition.x
|
||||
const fy = pack.fixedPosition.y
|
||||
targetRoom = rooms.find(r => fx >= r.x && fx <= r.x + r.width && fy >= r.y && fy <= r.y + r.height)
|
||||
if (targetRoom === undefined) {
|
||||
// Fallback: nearest room center
|
||||
let minDist = Infinity
|
||||
for (const r of rooms) {
|
||||
const cx = r.x + r.width / 2
|
||||
const cy = r.y + r.height / 2
|
||||
const d = Math.hypot(fx - cx, fy - cy)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
targetRoom = r
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pack.landmarkTile !== undefined) {
|
||||
const tx = pack.landmarkTile.tileX
|
||||
const ty = pack.landmarkTile.tileY
|
||||
targetRoom = rooms.find(r => tx >= r.cellX && tx < r.cellX + r.cellsX && ty >= r.cellY && ty < r.cellY + r.cellsY)
|
||||
if (targetRoom === undefined) {
|
||||
targetRoom = rooms.find(r => tx >= r.cellX && tx <= r.cellX + r.cellsX && ty >= r.cellY && ty <= r.cellY + r.cellsY)
|
||||
}
|
||||
if (targetRoom === undefined) {
|
||||
// Fallback: nearest room by cell center
|
||||
let minDist = Infinity
|
||||
for (const r of rooms) {
|
||||
const cx = r.cellX + r.cellsX / 2
|
||||
const cy = r.cellY + r.cellsY / 2
|
||||
const d = Math.hypot(tx - cx, ty - cy)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
targetRoom = r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRoom !== undefined && allocation.has(targetRoom)) {
|
||||
allocation.get(targetRoom)!.push(pack)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Normal packs: distribute to non-safe rooms, balancing by room area
|
||||
let candidateRooms = rooms.filter(r => !r.isSafeZone)
|
||||
if (candidateRooms.length === 0) {
|
||||
// Fallback if all rooms are marked safe
|
||||
candidateRooms = rooms.slice()
|
||||
}
|
||||
|
||||
const rng = new Rng(seed ?? 0x5eed)
|
||||
|
||||
for (const pack of normalPacks) {
|
||||
// Score based on pack density: (currentCount + 1) / area
|
||||
let minScore = Infinity
|
||||
for (const r of candidateRooms) {
|
||||
const count = allocation.get(r)!.length
|
||||
const area = Math.max(1, r.cellsX * r.cellsY)
|
||||
const score = (count + 1) / area
|
||||
if (score < minScore) {
|
||||
minScore = score
|
||||
}
|
||||
}
|
||||
|
||||
const bestRooms: MapRoom[] = []
|
||||
for (const r of candidateRooms) {
|
||||
const count = allocation.get(r)!.length
|
||||
const area = Math.max(1, r.cellsX * r.cellsY)
|
||||
const score = (count + 1) / area
|
||||
if (Math.abs(score - minScore) < 1e-9) {
|
||||
bestRooms.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
const picked = bestRooms[rng.int(0, bestRooms.length - 1)]!
|
||||
allocation.get(picked)!.push(pack)
|
||||
}
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a single monster pack inside a given room.
|
||||
*/
|
||||
function spawnSinglePackInRoom(
|
||||
world: CombatWorld,
|
||||
pack: MonsterPack,
|
||||
room: MapRoom,
|
||||
terrain: CombatTerrain,
|
||||
): number {
|
||||
if (pack.members.length === 0) return 0
|
||||
|
||||
const margin = Math.min(16, Math.max(2, room.width * 0.05), Math.max(2, room.height * 0.05))
|
||||
const minSpawnX = room.x + margin
|
||||
const maxSpawnX = room.x + room.width - margin
|
||||
const minSpawnY = room.y + margin
|
||||
const maxSpawnY = room.y + room.height - margin
|
||||
|
||||
// 1. Leader placement near room center or fixed landmark position
|
||||
const startX = pack.fixedPosition !== undefined
|
||||
? pack.fixedPosition.x
|
||||
: room.x + room.width / 2
|
||||
const startY = pack.fixedPosition !== undefined
|
||||
? pack.fixedPosition.y
|
||||
: room.y + room.height / 2
|
||||
|
||||
let leaderX = Math.max(minSpawnX, Math.min(maxSpawnX, startX))
|
||||
let leaderY = Math.max(minSpawnY, Math.min(maxSpawnY, startY))
|
||||
let leaderFound = false
|
||||
|
||||
// Check initial point
|
||||
if (terrain.overlap(leaderX, leaderY) === 0) {
|
||||
leaderFound = true
|
||||
} else {
|
||||
// Spiral search outward
|
||||
const maxRadius = Math.max(room.width, room.height) / 2
|
||||
for (let step = 1; step <= 64; step += 1) {
|
||||
const angle = step * 2.399963
|
||||
const radius = Math.min(maxRadius, step * 8)
|
||||
const sx = Math.max(minSpawnX, Math.min(maxSpawnX, startX + Math.cos(angle) * radius))
|
||||
const sy = Math.max(minSpawnY, Math.min(maxSpawnY, startY + Math.sin(angle) * radius))
|
||||
if (terrain.overlap(sx, sy) === 0) {
|
||||
leaderX = sx
|
||||
leaderY = sy
|
||||
leaderFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grid fallback if spiral failed
|
||||
if (!leaderFound) {
|
||||
const stepX = Math.max(8, (maxSpawnX - minSpawnX) / 16)
|
||||
const stepY = Math.max(8, (maxSpawnY - minSpawnY) / 16)
|
||||
for (let gy = minSpawnY; gy <= maxSpawnY; gy += stepY) {
|
||||
for (let gx = minSpawnX; gx <= maxSpawnX; gx += stepX) {
|
||||
if (terrain.overlap(gx, gy) === 0) {
|
||||
leaderX = gx
|
||||
leaderY = gy
|
||||
leaderFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (leaderFound) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!leaderFound) return 0
|
||||
|
||||
const leaderDef = pack.members[0]!
|
||||
world.monsters.push({
|
||||
index: world.monsters.length,
|
||||
stats: leaderDef,
|
||||
x: leaderX,
|
||||
y: leaderY,
|
||||
hp: leaderDef.hp,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
let placed = 1
|
||||
|
||||
// 2. Minions placed in tight camp ring (PACK_RADIUS_PX = 48~64) around leader
|
||||
const minions = pack.members.slice(1)
|
||||
let minionPlaced = 0
|
||||
let attempt = 0
|
||||
const maxAttempts = minions.length * 32
|
||||
const baseAngle = typeof room.id === 'number' ? room.id * 1.5 : 0.8
|
||||
const effectiveRadius = Math.min(PACK_RADIUS_PX, Math.max(16, Math.min(room.width, room.height) / 2 - margin))
|
||||
|
||||
while (minionPlaced < minions.length && attempt < maxAttempts) {
|
||||
attempt += 1
|
||||
const angle = baseAngle + attempt * 2.399963
|
||||
const distFrac = Math.sqrt(attempt / (minions.length * 12))
|
||||
const radius = Math.min(effectiveRadius, 16 + (effectiveRadius - 16) * distFrac)
|
||||
let mx = leaderX + Math.cos(angle) * radius
|
||||
let my = leaderY + Math.sin(angle) * radius
|
||||
|
||||
// Clamps within room bounding boxes
|
||||
mx = Math.max(minSpawnX, Math.min(maxSpawnX, mx))
|
||||
my = Math.max(minSpawnY, Math.min(maxSpawnY, my))
|
||||
|
||||
// Respects terrain collisions
|
||||
if (terrain.overlap(mx, my) > 0) continue
|
||||
|
||||
const minionDef = minions[minionPlaced]!
|
||||
world.monsters.push({
|
||||
index: world.monsters.length,
|
||||
stats: minionDef,
|
||||
x: mx,
|
||||
y: my,
|
||||
hp: minionDef.hp,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
minionPlaced += 1
|
||||
placed += 1
|
||||
}
|
||||
|
||||
return placed
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns monster pack(s) inside the boundaries of the specified `room`.
|
||||
*
|
||||
* Leader is positioned at a clear open sub-tile near the room's interior center;
|
||||
* minions are placed in a tight camp ring (PACK_RADIUS_PX = 48~64) around the leader.
|
||||
* Respects terrain collisions (terrain.overlap(x, y) === 0) and clamps within room
|
||||
* bounding boxes (x in [minX + margin, maxX - margin]).
|
||||
*
|
||||
* @param world - the world to populate.
|
||||
* @param pack - the pack or packs to spawn.
|
||||
* @param room - the room boundary to spawn within.
|
||||
* @param terrain - collision predicate.
|
||||
* @returns how many monsters were placed.
|
||||
*/
|
||||
export function spawnPacksInRoom(
|
||||
world: CombatWorld,
|
||||
pack: MonsterPack | readonly MonsterPack[],
|
||||
room: MapRoom,
|
||||
terrain: CombatTerrain,
|
||||
): number {
|
||||
if (Array.isArray(pack)) {
|
||||
let count = 0
|
||||
for (const p of pack) {
|
||||
count += spawnSinglePackInRoom(world, p, room, terrain)
|
||||
}
|
||||
return count
|
||||
}
|
||||
return spawnSinglePackInRoom(world, pack as MonsterPack, room, terrain)
|
||||
}
|
||||
|
||||
/** Alias for `spawnPacksInRoom` with singular naming. */
|
||||
export const spawnPackInRoom = spawnPacksInRoom
|
||||
|
||||
/**
|
||||
* Options for `spawnMonsterPacksByRooms`.
|
||||
*/
|
||||
export interface SpawnMonsterPacksByRoomsOptions {
|
||||
readonly seed?: number | undefined
|
||||
readonly safeZones?: readonly SafeZone[] | undefined
|
||||
readonly spread?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern, clean replacement for `spawnMonsterPacks`, allocating packs across rooms
|
||||
* and clustering members tightly within their assigned room boundaries.
|
||||
*
|
||||
* Also maintains backwards compatibility if plain packs with a center point are passed.
|
||||
*
|
||||
* @param world - world to populate.
|
||||
* @param packs - monster packs to spawn.
|
||||
* @param roomsOrAround - room array or fallback center point.
|
||||
* @param terrain - collision predicate.
|
||||
* @param options - allocation options or safe zones.
|
||||
* @returns total monsters placed.
|
||||
*/
|
||||
export function spawnMonsterPacksByRooms(
|
||||
world: CombatWorld,
|
||||
packs: readonly MonsterPack[],
|
||||
roomsOrAround: readonly MapRoom[] | { readonly x: number; readonly y: number },
|
||||
terrain: CombatTerrain,
|
||||
options?: SpawnMonsterPacksByRoomsOptions | readonly SafeZone[],
|
||||
): number {
|
||||
if (packs.length === 0) return 0
|
||||
|
||||
let safeZones: readonly SafeZone[] | undefined
|
||||
let seed: number | undefined
|
||||
let spread = 600
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
safeZones = options
|
||||
} else if (options !== undefined) {
|
||||
const opts = options as SpawnMonsterPacksByRoomsOptions
|
||||
safeZones = opts.safeZones
|
||||
seed = opts.seed
|
||||
if (opts.spread !== undefined) {
|
||||
spread = opts.spread
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(roomsOrAround)) {
|
||||
const around = roomsOrAround as { readonly x: number; readonly y: number }
|
||||
const syntheticRooms = partitionMapIntoRooms(64, 64, {
|
||||
roomCellSize: 16,
|
||||
cellPixelSize: 80,
|
||||
originX: around.x - spread,
|
||||
originY: around.y - spread,
|
||||
safeZones,
|
||||
})
|
||||
return spawnMonsterPacksByRooms(world, packs, syntheticRooms, terrain, {
|
||||
safeZones,
|
||||
seed,
|
||||
})
|
||||
}
|
||||
|
||||
const rooms = roomsOrAround as readonly MapRoom[]
|
||||
if (rooms.length === 0) return 0
|
||||
|
||||
const allocation = allocatePacksToRooms(packs, rooms, seed)
|
||||
let totalPlaced = 0
|
||||
for (const [room, roomPacks] of allocation) {
|
||||
for (const pack of roomPacks) {
|
||||
totalPlaced += spawnPacksInRoom(world, pack, room, terrain)
|
||||
}
|
||||
}
|
||||
return totalPlaced
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Diablo II D2Game.dll style Active/Sleeping Room Streaming and Lazy Room Population.
|
||||
*
|
||||
* Rather than eagerly instantiating every monster pack on frame 0, rooms begin
|
||||
* unpopulated (`bPopulated = false`). When the player approaches within
|
||||
* `activationRadius`, monsters are instantiated into `world.monsters` once and
|
||||
* only once (idempotent).
|
||||
*
|
||||
* When the player moves far away (> `deactivationRadius`), the room transitions to
|
||||
* sleeping (`active = false`) and all living monsters have `monster.sleeping = true`,
|
||||
* skipping all AI, aggro, cooldown calculations, and pathfinding in `tickMonsters`.
|
||||
*
|
||||
* When the player returns, monsters wake up (`monster.sleeping = false`) with their
|
||||
* exact positions, health, and cooldowns preserved. Dead monsters remain dead.
|
||||
*/
|
||||
import { spawnMonsterPacks } from './combat.ts'
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
|
||||
|
||||
/** Definition of a room holding unspawned monster pack blueprints. */
|
||||
export interface StreamingRoomDef {
|
||||
readonly id: string | number
|
||||
readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
||||
readonly packs: readonly MonsterPack[]
|
||||
}
|
||||
|
||||
/** Runtime state of a streaming room. */
|
||||
export interface StreamingRoomState extends StreamingRoomDef {
|
||||
bPopulated: boolean
|
||||
active: boolean
|
||||
monsterIndices: number[]
|
||||
}
|
||||
|
||||
/** Configuration options for the streaming manager. */
|
||||
export interface MonsterStreamingOptions {
|
||||
/** Distance in pixels to populate and activate a room (default: 1600px ~ 1.5-2 screens). */
|
||||
activationRadius?: number
|
||||
/** Distance in pixels to deactivate and put a room to sleep (default: 2400px ~ 2.5-3 screens). */
|
||||
deactivationRadius?: number
|
||||
/** Optional callback invoked whenever a room is populated for the first time. */
|
||||
onSpawnRoom?: (room: StreamingRoomState) => void
|
||||
/** Optional safe zones to avoid when placing monsters inside rooms. */
|
||||
safeZones?: readonly SafeZone[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Euclidean distance from a point to an axis-aligned room bounding box.
|
||||
* Returns 0 if the point is within the bounding box.
|
||||
*
|
||||
* @param px - point x coordinate.
|
||||
* @param py - point y coordinate.
|
||||
* @param bounds - room axis-aligned bounds.
|
||||
* @returns Euclidean distance to the nearest point on the room boundary (or 0 if inside).
|
||||
*/
|
||||
export function distanceToRoom(
|
||||
px: number,
|
||||
py: number,
|
||||
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
|
||||
): number {
|
||||
const minX = Math.min(bounds.minX, bounds.maxX)
|
||||
const maxX = Math.max(bounds.minX, bounds.maxX)
|
||||
const minY = Math.min(bounds.minY, bounds.maxY)
|
||||
const maxY = Math.max(bounds.minY, bounds.maxY)
|
||||
const dx = Math.max(minX - px, 0, px - maxX)
|
||||
const dy = Math.max(minY - py, 0, py - maxY)
|
||||
return Math.hypot(dx, dy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages lazy room population (`bPopulated`) and active/sleeping room streaming.
|
||||
*/
|
||||
export class MonsterStreamingManager {
|
||||
public world: CombatWorld
|
||||
public terrain: CombatTerrain
|
||||
public rooms: StreamingRoomState[]
|
||||
public activationRadius: number
|
||||
public deactivationRadius: number
|
||||
public options?: MonsterStreamingOptions | undefined
|
||||
|
||||
constructor(
|
||||
world: CombatWorld,
|
||||
terrain: CombatTerrain,
|
||||
rooms: readonly StreamingRoomDef[],
|
||||
options?: MonsterStreamingOptions,
|
||||
) {
|
||||
this.world = world
|
||||
this.terrain = terrain
|
||||
this.activationRadius = options?.activationRadius ?? 1600
|
||||
this.deactivationRadius = options?.deactivationRadius ?? 2400
|
||||
this.options = options
|
||||
this.rooms = rooms.map(r => ({
|
||||
id: r.id,
|
||||
bounds: r.bounds,
|
||||
packs: r.packs,
|
||||
bPopulated: false,
|
||||
active: false,
|
||||
monsterIndices: [],
|
||||
}))
|
||||
|
||||
// On initialization, only the room containing the player spawn and adjacent rooms
|
||||
// within activationRadius are populated. Distant rooms remain unpopulated.
|
||||
this.update(this.world.player.x, this.world.player.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates player distance to all rooms, populating unpopulated rooms within
|
||||
* activationRadius, waking active rooms, and putting distant rooms to sleep.
|
||||
*
|
||||
* @param playerX - player world x position.
|
||||
* @param playerY - player world y position.
|
||||
*/
|
||||
update(playerX: number = this.world.player.x, playerY: number = this.world.player.y): void {
|
||||
for (const room of this.rooms) {
|
||||
const dist = distanceToRoom(playerX, playerY, room.bounds)
|
||||
|
||||
if (dist <= this.activationRadius) {
|
||||
if (!room.bPopulated) {
|
||||
this.populateRoom(room)
|
||||
}
|
||||
room.active = true
|
||||
for (const idx of room.monsterIndices) {
|
||||
const monster = this.world.monsters[idx]
|
||||
if (monster) {
|
||||
monster.sleeping = false
|
||||
}
|
||||
}
|
||||
} else if (dist > this.deactivationRadius) {
|
||||
if (room.active) {
|
||||
room.active = false
|
||||
for (const idx of room.monsterIndices) {
|
||||
const monster = this.world.monsters[idx]
|
||||
if (monster && monster.state !== 'dead') {
|
||||
monster.sleeping = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily instantiates a room's monster packs into `world.monsters`.
|
||||
* Sets `bPopulated = true` to guarantee idempotency.
|
||||
*/
|
||||
private populateRoom(room: StreamingRoomState): void {
|
||||
if (room.bPopulated) return
|
||||
|
||||
const startIndex = this.world.monsters.length
|
||||
const centerX = (room.bounds.minX + room.bounds.maxX) / 2
|
||||
const centerY = (room.bounds.minY + room.bounds.maxY) / 2
|
||||
const width = Math.abs(room.bounds.maxX - room.bounds.minX)
|
||||
const height = Math.abs(room.bounds.maxY - room.bounds.minY)
|
||||
const spread = Math.max(width, height) / 2 || 100
|
||||
|
||||
spawnMonsterPacks(
|
||||
this.world,
|
||||
room.packs,
|
||||
{ x: centerX, y: centerY },
|
||||
spread,
|
||||
this.terrain,
|
||||
this.options?.safeZones,
|
||||
)
|
||||
|
||||
const endIndex = this.world.monsters.length
|
||||
for (let i = startIndex; i < endIndex; i += 1) {
|
||||
room.monsterIndices.push(i)
|
||||
}
|
||||
|
||||
room.bPopulated = true
|
||||
this.options?.onSpawnRoom?.(room)
|
||||
}
|
||||
}
|
||||
|
|
@ -703,6 +703,7 @@ export const CANONICAL_ELITE_MODIFIERS: readonly {
|
|||
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
|
||||
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
|
||||
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
|
||||
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -998,8 +999,9 @@ export interface SuperUniqueLandmarkSpec {
|
|||
readonly minionMonsterId: string
|
||||
}
|
||||
|
||||
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
|
||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
|
||||
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
|
||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>> = {
|
||||
// Act 1
|
||||
3: {
|
||||
id: 'Bishibosh',
|
||||
name: 'Bishibosh',
|
||||
|
|
@ -1030,6 +1032,16 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
|||
maxMinions: 4,
|
||||
minionMonsterId: 'brute1',
|
||||
},
|
||||
6: {
|
||||
id: 'The Countess',
|
||||
name: 'The Countess',
|
||||
monsterId: 'corruptrogue5',
|
||||
landmark: 'Act 1 - Tower 1',
|
||||
modifiers: ['fireenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'corruptrogue5',
|
||||
},
|
||||
17: {
|
||||
id: 'Blood Raven',
|
||||
name: 'Blood Raven',
|
||||
|
|
@ -1040,6 +1052,296 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
|||
maxMinions: 8,
|
||||
minionMonsterId: 'zombie1',
|
||||
},
|
||||
25: {
|
||||
id: 'The Countess',
|
||||
name: 'The Countess',
|
||||
monsterId: 'corruptrogue5',
|
||||
landmark: 'Act 1 - Tower 1',
|
||||
modifiers: ['fireenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'corruptrogue5',
|
||||
},
|
||||
// Act 2
|
||||
41: {
|
||||
id: 'Creeping Feature',
|
||||
name: 'Creeping Feature',
|
||||
monsterId: 'mummy1',
|
||||
landmark: 'Act 2 - Desert Tomb 1',
|
||||
modifiers: ['strong', 'coldenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'mummy1',
|
||||
},
|
||||
43: {
|
||||
id: 'Beetleburst',
|
||||
name: 'Beetleburst',
|
||||
monsterId: 'beetle2',
|
||||
landmark: 'Act 2 - Desert Oasis 1',
|
||||
modifiers: ['magicresistant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'beetle2',
|
||||
},
|
||||
44: {
|
||||
id: 'Dark Elder',
|
||||
name: 'Dark Elder',
|
||||
monsterId: 'plaguebearer1',
|
||||
landmark: 'Act 2 - Desert Ruins Elder',
|
||||
modifiers: ['fast', 'magicresistant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'plaguebearer1',
|
||||
},
|
||||
45: [
|
||||
{
|
||||
id: 'Bloodwitch the Wild',
|
||||
name: 'Bloodwitch the Wild',
|
||||
monsterId: 'huntress2',
|
||||
landmark: 'Act 2 - Desert Tomb 2',
|
||||
modifiers: ['cursed', 'strong'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'huntress2',
|
||||
},
|
||||
{
|
||||
id: 'Fangskin',
|
||||
name: 'Fangskin',
|
||||
monsterId: 'clawviper3',
|
||||
landmark: 'Act 2 - Desert Valley Ruin 2',
|
||||
modifiers: ['lightenchant', 'fast'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'clawviper3',
|
||||
},
|
||||
],
|
||||
55: {
|
||||
id: 'Creeping Feature',
|
||||
name: 'Creeping Feature',
|
||||
monsterId: 'mummy1',
|
||||
landmark: 'Act 2 - Desert Tomb 1',
|
||||
modifiers: ['strong', 'coldenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'mummy1',
|
||||
},
|
||||
59: {
|
||||
id: 'Creeping Feature',
|
||||
name: 'Creeping Feature',
|
||||
monsterId: 'mummy1',
|
||||
landmark: 'Act 2 - Desert Tomb 1',
|
||||
modifiers: ['strong', 'coldenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'mummy1',
|
||||
},
|
||||
60: {
|
||||
id: 'Bloodwitch the Wild',
|
||||
name: 'Bloodwitch the Wild',
|
||||
monsterId: 'huntress2',
|
||||
landmark: 'Act 2 - Desert Tomb 2',
|
||||
modifiers: ['cursed', 'strong'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'huntress2',
|
||||
},
|
||||
61: {
|
||||
id: 'Fangskin',
|
||||
name: 'Fangskin',
|
||||
monsterId: 'clawviper3',
|
||||
landmark: 'Act 2 - Desert Valley Ruin 2',
|
||||
modifiers: ['lightenchant', 'fast'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'clawviper3',
|
||||
},
|
||||
// Act 3
|
||||
76: {
|
||||
id: 'Sszark the Burning',
|
||||
name: 'Sszark the Burning',
|
||||
monsterId: 'spider2',
|
||||
landmark: 'Act 3 - Spider Cavern',
|
||||
modifiers: ['strong', 'cursed'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'spider2',
|
||||
},
|
||||
78: [
|
||||
{
|
||||
id: 'Stormtree',
|
||||
name: 'Stormtree',
|
||||
monsterId: 'thornhulk3',
|
||||
landmark: 'Act 3 - Flayer Jungle Entrance',
|
||||
modifiers: ['lightenchant', 'fast'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'thornhulk3',
|
||||
},
|
||||
{
|
||||
id: 'Witch Doctor Endugu',
|
||||
name: 'Witch Doctor Endugu',
|
||||
monsterId: 'flayershaman4',
|
||||
landmark: 'Act 3 - Flayer Dungeon',
|
||||
modifiers: ['magicresistant', 'fireenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'flayer4',
|
||||
},
|
||||
],
|
||||
80: {
|
||||
id: 'Battlemaid Sarina',
|
||||
name: 'Battlemaid Sarina',
|
||||
monsterId: 'corruptrogue8',
|
||||
landmark: 'Act 3 - Burbs Temple',
|
||||
modifiers: ['fast', 'spectralhit'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'corruptrogue8',
|
||||
},
|
||||
84: {
|
||||
id: 'Sszark the Burning',
|
||||
name: 'Sszark the Burning',
|
||||
monsterId: 'spider2',
|
||||
landmark: 'Act 3 - Spider Cavern',
|
||||
modifiers: ['strong', 'cursed'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'spider2',
|
||||
},
|
||||
88: {
|
||||
id: 'Witch Doctor Endugu',
|
||||
name: 'Witch Doctor Endugu',
|
||||
monsterId: 'flayershaman4',
|
||||
landmark: 'Act 3 - Flayer Dungeon',
|
||||
modifiers: ['magicresistant', 'fireenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'flayer4',
|
||||
},
|
||||
91: {
|
||||
id: 'Witch Doctor Endugu',
|
||||
name: 'Witch Doctor Endugu',
|
||||
monsterId: 'flayershaman4',
|
||||
landmark: 'Act 3 - Flayer Dungeon',
|
||||
modifiers: ['magicresistant', 'fireenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'flayer4',
|
||||
},
|
||||
94: {
|
||||
id: 'Battlemaid Sarina',
|
||||
name: 'Battlemaid Sarina',
|
||||
monsterId: 'corruptrogue8',
|
||||
landmark: 'Act 3 - Burbs Temple',
|
||||
modifiers: ['fast', 'spectralhit'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'corruptrogue8',
|
||||
},
|
||||
// Act 4
|
||||
105: {
|
||||
id: 'Izual',
|
||||
name: 'Izual',
|
||||
monsterId: 'izual',
|
||||
landmark: 'Act 4 - Plains of Despair',
|
||||
modifiers: ['coldenchant'],
|
||||
minMinions: 0,
|
||||
maxMinions: 0,
|
||||
minionMonsterId: 'izual',
|
||||
},
|
||||
106: {
|
||||
id: 'Hephasto the Armorer',
|
||||
name: 'Hephasto the Armorer',
|
||||
monsterId: 'hephasto',
|
||||
landmark: 'Act 4 - Hellforge',
|
||||
modifiers: ['spectralhit', 'aura'],
|
||||
minMinions: 0,
|
||||
maxMinions: 0,
|
||||
minionMonsterId: 'hephasto',
|
||||
},
|
||||
107: {
|
||||
id: 'Hephasto the Armorer',
|
||||
name: 'Hephasto the Armorer',
|
||||
monsterId: 'hephasto',
|
||||
landmark: 'Act 4 - Hellforge',
|
||||
modifiers: ['spectralhit', 'aura'],
|
||||
minMinions: 0,
|
||||
maxMinions: 0,
|
||||
minionMonsterId: 'hephasto',
|
||||
},
|
||||
// Act 5
|
||||
110: [
|
||||
{
|
||||
id: 'Dac Farren',
|
||||
name: 'Dac Farren',
|
||||
monsterId: 'demonimp1',
|
||||
landmark: 'Act 5 - Barricade Prison 1',
|
||||
modifiers: ['coldenchant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'demonimp1',
|
||||
},
|
||||
{
|
||||
id: 'Shenk the Overseer',
|
||||
name: 'Shenk the Overseer',
|
||||
monsterId: 'overseer2',
|
||||
landmark: 'Act 5 - Barricade Building',
|
||||
modifiers: ['strong'],
|
||||
minMinions: 15,
|
||||
maxMinions: 20,
|
||||
minionMonsterId: 'enslaved1',
|
||||
},
|
||||
],
|
||||
111: {
|
||||
id: 'Eyeback the Unleashed',
|
||||
name: 'Eyeback the Unleashed',
|
||||
monsterId: 'bloodlord2',
|
||||
landmark: 'Act 5 - Barricade Hell Portal N',
|
||||
modifiers: ['fast', 'strong'],
|
||||
minMinions: 3,
|
||||
maxMinions: 5,
|
||||
minionMonsterId: 'bloodlord2',
|
||||
},
|
||||
112: {
|
||||
id: 'Thresh Socket',
|
||||
name: 'Thresh Socket',
|
||||
monsterId: 'siegebeast1',
|
||||
landmark: 'Act 5 - Barricade Snow Unique',
|
||||
modifiers: ['cursed'],
|
||||
minMinions: 2,
|
||||
maxMinions: 4,
|
||||
minionMonsterId: 'siegebeast1',
|
||||
},
|
||||
114: {
|
||||
id: 'Frozenstein',
|
||||
name: 'Frozenstein',
|
||||
monsterId: 'yeti2',
|
||||
landmark: 'Act 5 - Frozen River',
|
||||
modifiers: ['coldenchant', 'manahit'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'yeti2',
|
||||
},
|
||||
115: {
|
||||
id: 'Bonesaw Breaker',
|
||||
name: 'Bonesaw Breaker',
|
||||
monsterId: 'reanimatedhorde2',
|
||||
landmark: 'Act 5 - Glacial Trail',
|
||||
modifiers: ['strong', 'magicresistant'],
|
||||
minMinions: 4,
|
||||
maxMinions: 6,
|
||||
minionMonsterId: 'reanimatedhorde2',
|
||||
},
|
||||
124: {
|
||||
id: 'Nihlathak',
|
||||
name: 'Nihlathak',
|
||||
monsterId: 'nihlathakboss',
|
||||
landmark: 'Act 5 - Halls of Vaught',
|
||||
modifiers: ['coldenchant'],
|
||||
minMinions: 6,
|
||||
maxMinions: 8,
|
||||
minionMonsterId: 'iceprowler2',
|
||||
},
|
||||
}
|
||||
|
||||
/** What one level's population came out as. */
|
||||
|
|
@ -1129,44 +1431,52 @@ export function planLevelMonsters(
|
|||
return { members }
|
||||
})
|
||||
|
||||
// Check if this level has a fixed SuperUnique boss (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
||||
const suSpec = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||
// Check if this level has fixed SuperUnique boss(es)
|
||||
const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
|
||||
? suRaw
|
||||
: (suRaw !== undefined ? [suRaw] : [])
|
||||
const superUniques: string[] = []
|
||||
if (suSpec !== undefined && kinds.size > 0) {
|
||||
const suRng = new Rng(seed ^ 0x55aa)
|
||||
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
|
||||
const bossScaled: MonsterStats = {
|
||||
...bossBase,
|
||||
id: suSpec.monsterId,
|
||||
name: suSpec.name,
|
||||
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||
}
|
||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||
const suMembers: MonsterStats[] = [bossStats]
|
||||
for (let m = 0; m < minionCount; m += 1) {
|
||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
|
||||
const mScaled: MonsterStats = {
|
||||
...mBase,
|
||||
id: suSpec.minionMonsterId,
|
||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
||||
if (suSpecs.length > 0 && kinds.size > 0) {
|
||||
const suPacks: MonsterPack[] = []
|
||||
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
|
||||
const suSpec = suSpecs[suIdx]!
|
||||
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
|
||||
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
|
||||
const bossScaled: MonsterStats = {
|
||||
...bossBase,
|
||||
id: suSpec.monsterId,
|
||||
name: suSpec.name,
|
||||
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||
}
|
||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||
const suMembers: MonsterStats[] = [bossStats]
|
||||
for (let m = 0; m < minionCount; m += 1) {
|
||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
|
||||
const mScaled: MonsterStats = {
|
||||
...mBase,
|
||||
id: suSpec.minionMonsterId,
|
||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
||||
}
|
||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||
}
|
||||
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
|
||||
const suPack: MonsterPack = {
|
||||
members: suMembers,
|
||||
superUniqueId: suSpec.id,
|
||||
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||
}
|
||||
suPacks.push(suPack)
|
||||
superUniques.push(suSpec.id)
|
||||
}
|
||||
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
|
||||
const suPack: MonsterPack = {
|
||||
members: suMembers,
|
||||
superUniqueId: suSpec.id,
|
||||
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||
}
|
||||
packs.unshift(suPack)
|
||||
superUniques.push(suSpec.id)
|
||||
packs.unshift(...suPacks)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,437 @@
|
|||
/**
|
||||
* Diablo II's preset level generator (`Levels.txt.DrlgType == 2`).
|
||||
*
|
||||
* In Diablo II, preset levels are defined in `LvlPrest.txt` and represent maps
|
||||
* with fixed geometry rather than procedural maze or wilderness generation.
|
||||
* Most preset levels consist of a single standalone DS1 file or quadrant rooms,
|
||||
* but specific levels — most notably Act 4 Chaos Sanctuary (Level 108) — are
|
||||
* assembled by stitching multiple cross-wing preset pieces at deterministic
|
||||
* coordinates onto a fixed grid.
|
||||
*
|
||||
* This module handles preset level generation and composition, cleanly
|
||||
* separating preset assembling logic from wilderness and maze generators.
|
||||
*/
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Substitution, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
|
||||
/** Level ID for Act 4 Chaos Sanctuary in Levels.txt. */
|
||||
export const CHAOS_SANCTUARY_LEVEL_ID = 108
|
||||
|
||||
/** Standard canvas width for Act 4 Chaos Sanctuary in cells. */
|
||||
export const CHAOS_SANCTUARY_SIZE_X = 120
|
||||
|
||||
/** Standard canvas height for Act 4 Chaos Sanctuary in cells. */
|
||||
export const CHAOS_SANCTUARY_SIZE_Y = 120
|
||||
|
||||
/** One `LvlPrest.txt` piece or DS1 preset available for stamping. */
|
||||
export interface PresetPiece {
|
||||
/** Name of the preset piece (e.g. from LvlPrest.txt Name or DS1 file name). */
|
||||
readonly name: string
|
||||
/** Decoded DS1 level variants. */
|
||||
readonly levels: readonly Ds1[]
|
||||
/** Optional border flag for compatibility with wilderness piece records. */
|
||||
readonly border?: boolean | undefined
|
||||
}
|
||||
|
||||
/** Target placement coordinate for a stamped preset piece. */
|
||||
export interface PresetPlacement {
|
||||
/** Name or name prefix of the preset piece to match. */
|
||||
readonly name: string
|
||||
/** Top-left X in cells on the canvas. */
|
||||
readonly x: number
|
||||
/** Top-left Y in cells on the canvas. */
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** A recorded preset placement substitution in generator stats. */
|
||||
export interface PresetSubstitutionRecord {
|
||||
readonly name: string
|
||||
readonly role: string
|
||||
readonly enabled: boolean
|
||||
readonly clusters: number
|
||||
}
|
||||
|
||||
/** Request payload for {@link generatePreset}. */
|
||||
export interface PresetRequest {
|
||||
/** `Levels.txt` `Id`. */
|
||||
readonly levelId: number
|
||||
/** `Levels.txt` `Name`. */
|
||||
readonly levelName?: string | undefined
|
||||
/** `LvlTypes.txt` name, e.g. `Act 4 - Lava`. */
|
||||
readonly levelTypeName?: string | undefined
|
||||
/** Act number, 1..5. Defaults to 4 for Level 108. */
|
||||
readonly act?: number | undefined
|
||||
/** Width in cells. Defaults to 120 for Level 108. */
|
||||
readonly sizeX?: number | undefined
|
||||
/** Height in cells. Defaults to 120 for Level 108. */
|
||||
readonly sizeY?: number | undefined
|
||||
/** Deterministic generator seed. */
|
||||
readonly seed?: number | undefined
|
||||
/** Preset pieces available to assemble this level. */
|
||||
readonly pieces: readonly PresetPiece[]
|
||||
/** Custom piece placements if overriding standard layout. */
|
||||
readonly placements?: readonly PresetPlacement[] | undefined
|
||||
/** Optional substitution list for interface compatibility. */
|
||||
readonly substitutions?: readonly unknown[] | undefined
|
||||
}
|
||||
|
||||
/** Statistics and metadata returned from preset map generation. */
|
||||
export interface PresetStats {
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly sizeX: number
|
||||
readonly sizeY: number
|
||||
readonly seed: number
|
||||
readonly substitutedClusters: number
|
||||
readonly objects: number
|
||||
readonly cells: number
|
||||
readonly substitutions: readonly PresetSubstitutionRecord[]
|
||||
readonly groundCells: number
|
||||
readonly borderBlocks?: number | undefined
|
||||
readonly borderUsage?: Readonly<Record<string, number>> | undefined
|
||||
readonly unresolved?: readonly unknown[] | undefined
|
||||
readonly notes?: readonly string[] | undefined
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
/** Result of generating a preset level. */
|
||||
export interface PresetResult {
|
||||
/** The assembled DS1 level. */
|
||||
readonly level: Ds1
|
||||
/** Execution statistics and placement records. */
|
||||
readonly stats: PresetStats
|
||||
}
|
||||
|
||||
/**
|
||||
* Placements for the 7 cross-wing presets of Act 4 Chaos Sanctuary (Level 108).
|
||||
* Stitched on a 120x120 canvas.
|
||||
*/
|
||||
export const CHAOS_SANCTUARY_PLACEMENTS: readonly PresetPlacement[] = [
|
||||
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 0 },
|
||||
{ name: 'Act 4 - Diablo Arm W', x: 24, y: 24 },
|
||||
{ name: 'Act 4 - Diablo Heart', x: 48, y: 24 },
|
||||
{ name: 'Act 4 - Diablo Arm E', x: 72, y: 24 },
|
||||
{ name: 'Act 4 - Diablo Arm S', x: 48, y: 48 },
|
||||
{ name: 'Act 4 - Diablo Entry', x: 48, y: 72 },
|
||||
{ name: 'Act 4 - Bridge 2', x: 48, y: 96 },
|
||||
]
|
||||
|
||||
/** Internal mutable canvas during preset assembly. */
|
||||
interface PresetCanvas {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly cells: Ds1Cell[][]
|
||||
readonly wallLayers: number
|
||||
readonly floorLayers: number
|
||||
readonly substitutionLayers: number
|
||||
readonly objects: Ds1Object[]
|
||||
}
|
||||
|
||||
function emptyWall(): Ds1Wall {
|
||||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
function emptyFloor(): Ds1Floor {
|
||||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
function makeCanvas(
|
||||
width: number,
|
||||
height: number,
|
||||
wallLayers = 1,
|
||||
floorLayers = 1,
|
||||
substitutionLayers = 1,
|
||||
): PresetCanvas {
|
||||
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: Array.from({ length: wallLayers }, emptyWall),
|
||||
floors: Array.from({ length: floorLayers }, emptyFloor),
|
||||
shadows: [emptyFloor()],
|
||||
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a DS1 piece into the canvas at a given cell origin, offsetting objects by sub-tiles.
|
||||
*/
|
||||
function stampDs1(canvas: PresetCanvas, source: Ds1, originX: number, originY: number): number {
|
||||
let written = 0
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const targetY = originY + y
|
||||
if (targetY < 0 || targetY >= canvas.height) continue
|
||||
const sourceRow = source.cells[y]
|
||||
if (sourceRow === undefined) continue
|
||||
const targetRow = canvas.cells[targetY]!
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const targetX = originX + x
|
||||
if (targetX < 0 || targetX >= canvas.width) continue
|
||||
const cell = sourceRow[x]
|
||||
if (cell === undefined) continue
|
||||
const targetCell = targetRow[targetX]!
|
||||
|
||||
// Floors: copy any non-empty floor layer from source
|
||||
for (let i = 0; i < cell.floors.length; i += 1) {
|
||||
const floor = cell.floors[i]
|
||||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||||
const targetFloors = targetCell.floors as Ds1Floor[]
|
||||
if (i < targetFloors.length) {
|
||||
targetFloors[i] = floor
|
||||
} else {
|
||||
targetFloors.push(floor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walls: copy any non-empty wall layer from source
|
||||
for (let i = 0; i < cell.walls.length; i += 1) {
|
||||
const wall = cell.walls[i]
|
||||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||||
const targetWalls = targetCell.walls as Ds1Wall[]
|
||||
if (i < targetWalls.length) {
|
||||
targetWalls[i] = wall
|
||||
} else {
|
||||
targetWalls.push(wall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shadows: copy any non-empty shadow layer from source
|
||||
for (let i = 0; i < cell.shadows.length; i += 1) {
|
||||
const shadow = cell.shadows[i]
|
||||
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
|
||||
const targetShadows = targetCell.shadows as Ds1Floor[]
|
||||
if (i < targetShadows.length) {
|
||||
targetShadows[i] = shadow
|
||||
} else {
|
||||
targetShadows.push(shadow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Substitutions: copy non-zero substitution layer values
|
||||
for (let i = 0; i < cell.substitutions.length; i += 1) {
|
||||
const sub = cell.substitutions[i]
|
||||
if (sub !== undefined && sub.value !== 0) {
|
||||
const targetSubs = targetCell.substitutions as Ds1Substitution[]
|
||||
if (i < targetSubs.length) {
|
||||
targetSubs[i] = sub
|
||||
} else {
|
||||
targetSubs.push(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
written += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp objects, converting cell offset to 5 sub-tiles per cell
|
||||
for (const object of source.objects) {
|
||||
canvas.objects.push({
|
||||
type: object.type,
|
||||
id: object.id,
|
||||
x: originX * SUB_TILES_PER_TILE + object.x,
|
||||
y: originY * SUB_TILES_PER_TILE + object.y,
|
||||
flags: object.flags,
|
||||
})
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a target preset piece name flexibly against available pieces.
|
||||
* Handles full names, prefixes, without-Act prefixes, and stripped filenames.
|
||||
*/
|
||||
export function findMatchingPresetPiece(
|
||||
pieces: readonly PresetPiece[],
|
||||
targetName: string,
|
||||
): PresetPiece | undefined {
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
const target = norm(targetName)
|
||||
|
||||
// 1. Direct equality or startsWith
|
||||
const direct = pieces.find(p => {
|
||||
const pn = norm(p.name)
|
||||
return pn === target || pn.startsWith(target)
|
||||
})
|
||||
if (direct) return direct
|
||||
|
||||
// 2. Strip path and extension, and extract canonical token
|
||||
const clean = (s: string) => {
|
||||
const base = s.split(/[\\/]/).pop() ?? s
|
||||
return base.replace(/\.ds1$/i, '').trim().toLowerCase()
|
||||
}
|
||||
const cleanTarget = clean(targetName)
|
||||
const stripPrefix = (s: string) =>
|
||||
s.replace(/^act\s*\d+\s*[-_ ]*/i, '').replace(/^diablo\s*[-_ ]*/i, '').replace(/[^a-z0-9]/g, '')
|
||||
const shortTarget = stripPrefix(cleanTarget)
|
||||
const normTarget = cleanTarget.replace(/[^a-z0-9]/g, '')
|
||||
|
||||
const tokenMatch = pieces.find(p => {
|
||||
const cp = clean(p.name)
|
||||
const shortP = stripPrefix(cp)
|
||||
const normP = cp.replace(/[^a-z0-9]/g, '')
|
||||
return (
|
||||
normP === normTarget ||
|
||||
shortP === shortTarget ||
|
||||
normP.endsWith(shortTarget) ||
|
||||
shortTarget.endsWith(normP) ||
|
||||
shortTarget === normP ||
|
||||
normP.includes(shortTarget) ||
|
||||
shortTarget.includes(shortP)
|
||||
)
|
||||
})
|
||||
if (tokenMatch) return tokenMatch
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (Level 108, 120x120).
|
||||
*/
|
||||
export function generateChaosSanctuary(request: PresetRequest): PresetResult {
|
||||
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : CHAOS_SANCTUARY_SIZE_X
|
||||
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : CHAOS_SANCTUARY_SIZE_Y
|
||||
|
||||
let wallLayers = 1
|
||||
let floorLayers = 1
|
||||
let substitutionLayers = 1
|
||||
let version = 18
|
||||
|
||||
for (const piece of request.pieces) {
|
||||
for (const lvl of piece.levels) {
|
||||
if (lvl.version && lvl.version > 0) version = lvl.version
|
||||
if (lvl.wallLayers > wallLayers) wallLayers = lvl.wallLayers
|
||||
if (lvl.floorLayers > floorLayers) floorLayers = lvl.floorLayers
|
||||
const subLen = lvl.cells[0]?.[0]?.substitutions?.length ?? 0
|
||||
if (subLen > substitutionLayers) substitutionLayers = subLen
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = makeCanvas(width, height, wallLayers, floorLayers, substitutionLayers)
|
||||
const placements = request.placements ?? CHAOS_SANCTUARY_PLACEMENTS
|
||||
const placed = new Set<string>()
|
||||
const substitutions: PresetSubstitutionRecord[] = []
|
||||
|
||||
for (const item of placements) {
|
||||
const piece = findMatchingPresetPiece(request.pieces, item.name)
|
||||
const ds1 = piece?.levels[0]
|
||||
if (!ds1) continue
|
||||
stampDs1(canvas, ds1, item.x, item.y)
|
||||
placed.add(item.name)
|
||||
substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
let groundCells = 0
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
const row = canvas.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const c = row[x]
|
||||
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
||||
groundCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
level: {
|
||||
version: version === 0 ? 18 : version,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
act: request.act ?? 4,
|
||||
substitutionType: 0,
|
||||
wallLayers: canvas.wallLayers,
|
||||
floorLayers: canvas.floorLayers,
|
||||
cells: canvas.cells,
|
||||
objects: canvas.objects,
|
||||
npcPathOffset: null,
|
||||
},
|
||||
stats: {
|
||||
levelId: request.levelId,
|
||||
levelName: request.levelName ?? 'Chaos Sanctuary',
|
||||
sizeX: canvas.width,
|
||||
sizeY: canvas.height,
|
||||
seed: request.seed ?? 0,
|
||||
borderBlocks: 0,
|
||||
borderUsage: {},
|
||||
substitutions,
|
||||
substitutedClusters: placed.size,
|
||||
objects: canvas.objects.length,
|
||||
cells: canvas.width * canvas.height,
|
||||
groundCells,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated preset level generator pipeline (`Levels.txt.DrlgType == 2`).
|
||||
*
|
||||
* Dispatches Level 108 (Act 4 Chaos Sanctuary) to its 7-cross assembled layout,
|
||||
* and handles standalone preset maps and custom multi-piece placements.
|
||||
*/
|
||||
export function generatePreset(request: PresetRequest): PresetResult {
|
||||
// Dispatch Level 108 (Chaos Sanctuary)
|
||||
if (
|
||||
request.levelId === CHAOS_SANCTUARY_LEVEL_ID ||
|
||||
request.levelName === 'Chaos Sanctuary' ||
|
||||
request.levelName === 'Act 4 - Chaos Sanctuary'
|
||||
) {
|
||||
return generateChaosSanctuary(request)
|
||||
}
|
||||
|
||||
// If explicit multi-piece placements are provided
|
||||
if (request.placements && request.placements.length > 0) {
|
||||
return generateChaosSanctuary(request)
|
||||
}
|
||||
|
||||
// Handle single preset map
|
||||
if (request.pieces.length > 0 && request.pieces[0]?.levels[0]) {
|
||||
const ds1 = request.pieces[0].levels[0]
|
||||
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width
|
||||
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height
|
||||
|
||||
if (width === ds1.width && height === ds1.height) {
|
||||
let groundCells = 0
|
||||
for (let y = 0; y < ds1.height; y += 1) {
|
||||
const row = ds1.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < ds1.width; x += 1) {
|
||||
const c = row[x]
|
||||
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
||||
groundCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: ds1,
|
||||
stats: {
|
||||
levelId: request.levelId,
|
||||
levelName: request.levelName ?? request.pieces[0].name,
|
||||
sizeX: width,
|
||||
sizeY: height,
|
||||
seed: request.seed ?? 0,
|
||||
borderBlocks: 0,
|
||||
borderUsage: {},
|
||||
substitutions: [
|
||||
{ name: request.pieces[0].name, role: 'preset', enabled: true, clusters: 1 },
|
||||
],
|
||||
substitutedClusters: 1,
|
||||
objects: ds1.objects.length,
|
||||
cells: width * height,
|
||||
groundCells,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to general assembly
|
||||
return generateChaosSanctuary(request)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { Side } from './world-graph.ts'
|
||||
import { oppositeSide } from './world-graph.ts'
|
||||
|
||||
export { oppositeSide }
|
||||
export type { Side }
|
||||
|
||||
export interface VariantCandidate {
|
||||
readonly label?: string
|
||||
readonly ds1?: string
|
||||
readonly slug?: string
|
||||
readonly act?: number
|
||||
readonly levelId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the compass direction / orientation from a variant's label or DS1 filename.
|
||||
*
|
||||
* Each variant (e.g. TownN1, TownE1, TownS1, TownW1) represents an independent town
|
||||
* structure with an exit gate in that specific compass direction, not a quadrant.
|
||||
*
|
||||
* - `TownN1` / `townn1` / `LutN` -> `north`
|
||||
* - `TownS1` / `towns1` -> `south`
|
||||
* - `TownE1` / `towne1` / `CourtE` -> `east`
|
||||
* - `TownW1` / `townw1` / `LutW` / `CourtW` -> `west`
|
||||
*/
|
||||
export function variantOrientation(candidate: VariantCandidate): Side | undefined {
|
||||
const key = `${candidate.label ?? ''} ${candidate.ds1 ?? ''}`.toLowerCase()
|
||||
if (key.includes('townn') || key.includes('lutn') || key.includes('courtn') || key.includes('-north')) {
|
||||
return 'north'
|
||||
}
|
||||
if (key.includes('towns') || key.includes('luts') || key.includes('courts') || key.includes('-south')) {
|
||||
return 'south'
|
||||
}
|
||||
if (key.includes('towne') || key.includes('lute') || key.includes('courte') || key.includes('-east')) {
|
||||
return 'east'
|
||||
}
|
||||
if (key.includes('townw') || key.includes('lutw') || key.includes('courtw') || key.includes('-west')) {
|
||||
return 'west'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the candidate variant whose gate / exit orientation matches the required compass side.
|
||||
*
|
||||
* When travelling between abutting levels across a seam, if the player exits level A heading
|
||||
* `fromSide`, the arrival level B must have its corresponding entrance on `oppositeSide(fromSide)`.
|
||||
*/
|
||||
export function findMatchingVariant<T extends VariantCandidate>(
|
||||
candidates: readonly T[],
|
||||
requiredSide?: Side | string,
|
||||
variantSeed = 0,
|
||||
): T | undefined {
|
||||
if (candidates.length === 0) return undefined
|
||||
if (candidates.length === 1 || requiredSide === undefined || requiredSide === '') {
|
||||
return candidates[Math.abs(variantSeed) % candidates.length]
|
||||
}
|
||||
|
||||
const side = requiredSide.toLowerCase() as Side
|
||||
const matched = candidates.find(c => variantOrientation(c) === side)
|
||||
return matched ?? candidates[Math.abs(variantSeed) % candidates.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default town variant for a given act when starting fresh in town.
|
||||
*
|
||||
* - In Act 1: The Blood Moor (Level 2) has its palisade gate on the North (`Town S Transition`),
|
||||
* so Rogue Encampment (Level 1) must exit to the South (`TownS1.ds1` / `towns1`).
|
||||
* - In Act 2: Rocky Waste (Level 41) has its town seam on the South (`Act 2 - Town Seam`),
|
||||
* so Lut Gholein (Level 40) must exit to the North (`LutN.ds1` / `lutn`).
|
||||
* - Other acts default to their single town entry.
|
||||
*/
|
||||
export function getDefaultTownVariant<T extends VariantCandidate>(
|
||||
forAct: readonly T[],
|
||||
act: number,
|
||||
): T | undefined {
|
||||
if (act === 1) {
|
||||
const towns = forAct.find(c => variantOrientation(c) === 'south')
|
||||
if (towns !== undefined) return towns
|
||||
} else if (act === 2) {
|
||||
const lutn = forAct.find(c => variantOrientation(c) === 'north')
|
||||
if (lutn !== undefined) return lutn
|
||||
}
|
||||
return forAct.find(c => (c.slug ?? '').includes('town')) ?? forAct[0]
|
||||
}
|
||||
|
|
@ -0,0 +1,906 @@
|
|||
/**
|
||||
* Wilderness outdoor topography initializers for Acts 2, 3, 4, and 5.
|
||||
*
|
||||
* blizzard Drlg outdoor passes:
|
||||
* - `DRLGOUTDESR_InitAct2OutdoorLevel`: Desert canyons, sand trails, Far Oasis pools,
|
||||
* Valley of Snakes canyon gorges, Canyon of the Magi 7 Tal Rasha tomb entrances.
|
||||
* - `DRLGOUTPLACE_InitAct3OutdoorLevel` & `DRLG_GenerateJungles`: Jungle waterways,
|
||||
* wooden river bridges spanning channels, causeways, Kurast city avenues, canals & bridges.
|
||||
* - `DRLGOUTDOORS_InitAct4OutdoorLevel`: Molten lava chasms and fissures, natural rock
|
||||
* bridges spanning chasms, volcanic mesa plateaus.
|
||||
* - `DRLGOUTSIEGE_InitAct5OutdoorLevel`: Transverse siege barricade palisades, choke-point
|
||||
* gates, military dugout trenches, prisoner cages, and snow crater frozen lakes.
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import type {
|
||||
Canvas,
|
||||
WildernessRequest,
|
||||
WildernessStats,
|
||||
WildernessPiece,
|
||||
PlacedPresetRect,
|
||||
} from './wilderness.ts'
|
||||
import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Statistics interfaces
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export interface Act2TopographyStats {
|
||||
readonly canyonWalls: number
|
||||
readonly sandTrailCells: number
|
||||
readonly oasisPoolCells: number
|
||||
readonly tombEntrances: number
|
||||
}
|
||||
|
||||
export interface Act3TopographyStats {
|
||||
readonly causewayCells: number
|
||||
readonly riverBridges: number
|
||||
readonly canalCells: number
|
||||
readonly junglePathCells: number
|
||||
}
|
||||
|
||||
export interface Act4TopographyStats {
|
||||
readonly lavaChasmCells: number
|
||||
readonly rockyBridges: number
|
||||
readonly plateauCells: number
|
||||
}
|
||||
|
||||
export interface Act5TopographyStats {
|
||||
readonly barricadeWalls: number
|
||||
readonly trenchCells: number
|
||||
readonly frozenLakeCells: number
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Geometry & Canvas Helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function isInsidePreset(
|
||||
x: number,
|
||||
y: number,
|
||||
placedRects: readonly PlacedPresetRect[],
|
||||
margin = 1,
|
||||
): boolean {
|
||||
for (const r of placedRects) {
|
||||
if (
|
||||
x >= r.x - margin &&
|
||||
x < r.x + r.w + margin &&
|
||||
y >= r.y - margin &&
|
||||
y < r.y + r.h + margin
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function makeFloor(style: number, sequence: number, prop1 = 2): Ds1Floor {
|
||||
return {
|
||||
prop1,
|
||||
style,
|
||||
sequence,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeWall(style: number, sequence: number, type: number, prop1 = 129): Ds1Wall {
|
||||
return {
|
||||
prop1,
|
||||
sequence,
|
||||
style,
|
||||
type,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
function paintFloor(
|
||||
canvas: Canvas,
|
||||
x: number,
|
||||
y: number,
|
||||
floor: Ds1Floor,
|
||||
overwriteProtected = false,
|
||||
): boolean {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return false
|
||||
const existing = cell.floors[0]
|
||||
if (!overwriteProtected && existing) {
|
||||
// Protected styles: water (style 2/3), wooden bridge (style 4)
|
||||
if (existing.style === 2 || existing.style === 3 || existing.style === 4) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
;(cell.floors as Ds1Floor[])[0] = floor
|
||||
return true
|
||||
}
|
||||
|
||||
function paintWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): boolean {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return false
|
||||
const targetWalls = cell.walls as Ds1Wall[]
|
||||
if (targetWalls.length > 0) {
|
||||
targetWalls[0] = wall
|
||||
} else {
|
||||
targetWalls.push(wall)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function clearWalls(canvas: Canvas, x: number, y: number): void {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (cell) {
|
||||
;(cell.walls as Ds1Wall[]).length = 0
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Act 2: DRLGOUTDESR_InitAct2OutdoorLevel
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize Act 2 Desert Outdoor Topography:
|
||||
* - Rocky canyon ridges and cliff berms carving winding desert corridors.
|
||||
* - 3-tile-wide sand caravan trails connecting gates across the dunes.
|
||||
* - Far Oasis (Level 43): lush water oasis pools and Maggot Lair entrance.
|
||||
* - Valley of Snakes (Level 45): narrow canyon gorge leading to Claw Viper Temple.
|
||||
* - Canyon of the Magi (Level 46): 7 Tal Rasha tomb entrances along perimeter cliffs
|
||||
* and central Arcane Sanctuary warp platform.
|
||||
* - Lost City (Level 44): ancient buried city ruins and street corridors.
|
||||
*/
|
||||
export function initAct2OutdoorLevel(
|
||||
canvas: Canvas,
|
||||
request: WildernessRequest,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Act2TopographyStats {
|
||||
let canyonWalls = 0
|
||||
let sandTrailCells = 0
|
||||
let oasisPoolCells = 0
|
||||
let tombEntrances = 0
|
||||
|
||||
const levelId = request.levelId
|
||||
const hubX = inX * TILES_PER_BLOCK + 4
|
||||
const hubY = outY * TILES_PER_BLOCK + 4
|
||||
|
||||
// Helper to paint a 3-tile-wide sand caravan trail
|
||||
const paintSandTrailTile = (x: number, y: number): void => {
|
||||
if (x < 3 || x >= canvas.width - 3 || y < 3 || y >= canvas.height - 3) return
|
||||
if (isInsidePreset(x, y, placedRects, 0)) return
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return
|
||||
// Style 5 (desert road), prop1 194
|
||||
const floor = makeFloor(5, rng.int(0, 4) === 0 ? 25 : 0, 194)
|
||||
if (paintFloor(canvas, x, y, floor, false)) {
|
||||
clearWalls(canvas, x, y)
|
||||
sandTrailCells += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Sand Caravan Trail Network: North-South and West-East axes
|
||||
if (levelId !== 46) {
|
||||
const startY = Math.max(4, Math.floor(canvas.height * 0.1))
|
||||
const endY = Math.min(canvas.height - 5, Math.floor(canvas.height * 0.9))
|
||||
for (let y = startY; y <= endY; y += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
paintSandTrailTile(hubX + dx, y)
|
||||
}
|
||||
}
|
||||
|
||||
const startX = Math.max(4, Math.floor(canvas.width * 0.1))
|
||||
const endX = Math.min(canvas.width - 5, Math.floor(canvas.width * 0.9))
|
||||
for (let x = startX; x <= endX; x += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
paintSandTrailTile(x, hubY + dy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Canyon Walls & Rocky Berms (Levels 41, 42, 44, 45)
|
||||
if (levelId === 41 || levelId === 42 || levelId === 44 || levelId === 45) {
|
||||
const isValleyOfSnakes = levelId === 45
|
||||
// In Valley of Snakes, place 2 tight parallel canyon ridge walls (corridor of 10-12 tiles)
|
||||
// In other deserts, place transverse & longitudinal canyon ridges
|
||||
const ridgeY1 = Math.max(12, Math.min(canvas.height - 14, hubY - (isValleyOfSnakes ? 5 : 16)))
|
||||
const ridgeY2 = Math.max(14, Math.min(canvas.height - 12, hubY + (isValleyOfSnakes ? 6 : 16)))
|
||||
|
||||
for (let x = 10; x < canvas.width - 10; x += 1) {
|
||||
// Leave 4-tile wide canyon pass gaps where the main highway or crossroads pass
|
||||
if (Math.abs(x - hubX) <= 3) continue
|
||||
if (isInsidePreset(x, ridgeY1, placedRects, 2)) continue
|
||||
if (paintWall(canvas, x, ridgeY1, makeWall(1, 0, 2, 129))) {
|
||||
canyonWalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 10; x < canvas.width - 10; x += 1) {
|
||||
if (Math.abs(x - hubX) <= 3) continue
|
||||
if (isInsidePreset(x, ridgeY2, placedRects, 2)) continue
|
||||
if (paintWall(canvas, x, ridgeY2, makeWall(1, 0, 2, 129))) {
|
||||
canyonWalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValleyOfSnakes) {
|
||||
const ridgeX = Math.max(12, Math.min(canvas.width - 12, hubX + 16))
|
||||
for (let y = 10; y < canvas.height - 10; y += 1) {
|
||||
if (Math.abs(y - hubY) <= 3) continue
|
||||
if (isInsidePreset(ridgeX, y, placedRects, 2)) continue
|
||||
if (paintWall(canvas, ridgeX, y, makeWall(1, 0, 1, 129))) {
|
||||
canyonWalls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Far Oasis (Level 43): Lush Water Oasis Pools & Maggot Lair
|
||||
if (levelId === 43 || /Oasis/i.test(request.levelName)) {
|
||||
const poolCenters = [
|
||||
{
|
||||
cx: Math.max(14, Math.floor(canvas.width * 0.35)),
|
||||
cy: Math.max(14, Math.floor(canvas.height * 0.35)),
|
||||
radius: Math.min(6, Math.floor(canvas.width / 12)),
|
||||
},
|
||||
{
|
||||
cx: Math.min(canvas.width - 16, Math.floor(canvas.width * 0.68)),
|
||||
cy: Math.min(canvas.height - 16, Math.floor(canvas.height * 0.68)),
|
||||
radius: Math.min(5, Math.floor(canvas.width / 14)),
|
||||
},
|
||||
]
|
||||
|
||||
for (const pool of poolCenters) {
|
||||
for (let dy = -pool.radius; dy <= pool.radius; dy += 1) {
|
||||
for (let dx = -pool.radius; dx <= pool.radius; dx += 1) {
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist <= pool.radius) {
|
||||
const px = pool.cx + dx
|
||||
const py = pool.cy + dy
|
||||
if (px >= 4 && px < canvas.width - 4 && py >= 4 && py < canvas.height - 4) {
|
||||
if (!isInsidePreset(px, py, placedRects, 1)) {
|
||||
// Water floor: style 2 (water), prop1 2
|
||||
paintFloor(canvas, px, py, makeFloor(2, 0, 2), true)
|
||||
clearWalls(canvas, px, py)
|
||||
oasisPoolCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to stamp Desert Oasis preset if available in pieces
|
||||
const oasisPiece = request.pieces.find(p => /Desert Oasis/i.test(p.name))
|
||||
if (oasisPiece?.levels[0] && !stats.specialPresets.some(n => /Desert Oasis/i.test(n))) {
|
||||
const ds1 = oasisPiece.levels[0]
|
||||
const originX = Math.max(8, Math.floor(canvas.width * 0.4) - Math.floor(ds1.width / 2))
|
||||
const originY = Math.max(8, Math.floor(canvas.height * 0.4) - Math.floor(ds1.height / 2))
|
||||
stampDs1(canvas, ds1, originX, originY)
|
||||
stats.specialPresets.push(oasisPiece.name)
|
||||
stats.substitutions.push({ name: oasisPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
placedRects.push({ name: oasisPiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Canyon of the Magi / Valley of the Kings (Level 46): 7 Tal Rasha Tomb Entrances
|
||||
if (levelId === 46 || /Canyon of the Magi|Valley of the Kings/i.test(request.levelName)) {
|
||||
const tombCoords: Array<{ edge: 'top' | 'left' | 'right'; x: number; y: number }> = [
|
||||
// Top / North Cliff: 3 tombs
|
||||
{ edge: 'top', x: Math.floor(canvas.width * 0.22), y: 1 },
|
||||
{ edge: 'top', x: Math.floor(canvas.width * 0.5), y: 1 },
|
||||
{ edge: 'top', x: Math.floor(canvas.width * 0.78), y: 1 },
|
||||
// Left / West Cliff: 2 tombs
|
||||
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.35) },
|
||||
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.68) },
|
||||
// Right / East Cliff: 2 tombs
|
||||
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.35) },
|
||||
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.68) },
|
||||
]
|
||||
|
||||
for (let i = 0; i < tombCoords.length; i += 1) {
|
||||
const tc = tombCoords[i]!
|
||||
// Try to find matching cliff king tomb piece
|
||||
let pieceName = 'Act 2 - Desert Cliff Top King Tomb'
|
||||
if (tc.edge === 'left') pieceName = 'Act 2 - Desert Cliff Left King Tomb'
|
||||
else if (tc.edge === 'right') pieceName = 'Act 2 - Desert Cliff Right King Tomb'
|
||||
|
||||
const piece = request.pieces.find(p => p.name.trim() === pieceName.trim()) ??
|
||||
request.pieces.find(p => /King Tomb|Desert Tomb/i.test(p.name))
|
||||
|
||||
if (piece?.levels[0]) {
|
||||
stampDs1(canvas, piece.levels[0], tc.x, tc.y)
|
||||
stats.specialPresets.push(piece.name)
|
||||
placedRects.push({ name: piece.name, x: tc.x, y: tc.y, w: piece.levels[0].width, h: piece.levels[0].height })
|
||||
} else {
|
||||
// Construct canonical tomb facade archway (style 2 wall, width 6, height 4)
|
||||
for (let dy = 0; dy < 4; dy += 1) {
|
||||
for (let dx = 0; dx < 6; dx += 1) {
|
||||
const tx = tc.x + dx
|
||||
const ty = tc.y + dy
|
||||
if (tx >= 0 && tx < canvas.width && ty >= 0 && ty < canvas.height) {
|
||||
if (dy === 3 && (dx === 2 || dx === 3)) {
|
||||
// Open portal threshold
|
||||
paintFloor(canvas, tx, ty, makeFloor(0, 0, 2))
|
||||
clearWalls(canvas, tx, ty)
|
||||
} else {
|
||||
paintWall(canvas, tx, ty, makeWall(2, dy === 0 ? 0 : 1, 2, 129))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add Tal Rasha Tomb entrance portal object (LevelId 66..72)
|
||||
canvas.objects.push({
|
||||
type: 2,
|
||||
id: 66 + i,
|
||||
x: (tc.x + 3) * 5,
|
||||
y: (tc.y + 3) * 5,
|
||||
flags: 0,
|
||||
})
|
||||
tombEntrances += 1
|
||||
}
|
||||
|
||||
// Central Arcane Sanctuary warp platform (Desert Valley Warp)
|
||||
const warpPiece = request.pieces.find(p => /Desert Valley Warp/i.test(p.name))
|
||||
const warpX = Math.floor(canvas.width / 2) - 4
|
||||
const warpY = Math.floor(canvas.height / 2) - 4
|
||||
if (warpPiece?.levels[0]) {
|
||||
stampDs1(canvas, warpPiece.levels[0], warpX, warpY)
|
||||
stats.specialPresets.push(warpPiece.name)
|
||||
placedRects.push({ name: warpPiece.name, x: warpX, y: warpY, w: warpPiece.levels[0].width, h: warpPiece.levels[0].height })
|
||||
} else {
|
||||
for (let dy = 0; dy < 8; dy += 1) {
|
||||
for (let dx = 0; dx < 8; dx += 1) {
|
||||
paintFloor(canvas, warpX + dx, warpY + dy, makeFloor(3, 0, 194))
|
||||
clearWalls(canvas, warpX + dx, warpY + dy)
|
||||
}
|
||||
}
|
||||
canvas.objects.push({
|
||||
type: 2,
|
||||
id: 74,
|
||||
x: (warpX + 4) * 5,
|
||||
y: (warpY + 4) * 5,
|
||||
flags: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Lost City (Level 44): Ancient Buried City Ruins
|
||||
if (levelId === 44 || /Lost City/i.test(request.levelName)) {
|
||||
const ruinsPieces = request.pieces.filter(p => /Desert Ruins/i.test(p.name) && p.levels.length > 0)
|
||||
if (ruinsPieces.length > 0) {
|
||||
let placedCount = 0
|
||||
for (let by = 2; by < gridHeight - 3 && placedCount < 4; by += 3) {
|
||||
for (let bx = 2; bx < gridWidth - 3 && placedCount < 4; bx += 3) {
|
||||
const rx = bx * TILES_PER_BLOCK
|
||||
const ry = by * TILES_PER_BLOCK
|
||||
if (isInsidePreset(rx, ry, placedRects, 2)) continue
|
||||
const piece = ruinsPieces[placedCount % ruinsPieces.length]!
|
||||
const ds1 = piece.levels[0]!
|
||||
stampDs1(canvas, ds1, rx, ry)
|
||||
placedRects.push({ name: piece.name, x: rx, y: ry, w: ds1.width, h: ds1.height })
|
||||
stats.specialPresets.push(piece.name)
|
||||
placedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances }
|
||||
stats.notes.push(
|
||||
`Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`,
|
||||
)
|
||||
|
||||
return stats.act2Topography
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate Act 3 Jungle River & Clearings layout (`DRLG_GenerateJungles`):
|
||||
* - Spans wooden river bridges across the East river channel at row crossings.
|
||||
* - Connects West clearings to wooden river bridges via winding jungle causeways/paths.
|
||||
*/
|
||||
export function generateAct3Jungle(
|
||||
canvas: Canvas,
|
||||
levelId: number,
|
||||
pieces: readonly WildernessPiece[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Set<string> {
|
||||
const placed = new Set<string>()
|
||||
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
|
||||
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
|
||||
|
||||
if (tailPiece?.levels[0]) {
|
||||
stampDs1(canvas, tailPiece.levels[0], 0, 0)
|
||||
placed.add(tailPiece.name)
|
||||
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
if (headPiece?.levels[0]) {
|
||||
stampDs1(canvas, headPiece.levels[0], 0, 160)
|
||||
placed.add(headPiece.name)
|
||||
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
const clearingPrefix =
|
||||
levelId === 76
|
||||
? 'Act 3 - Clearing Webby'
|
||||
: levelId === 77
|
||||
? 'Act 3 - Clearing Boggy'
|
||||
: 'Act 3 - Clearing Pygmy'
|
||||
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
|
||||
const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name))
|
||||
const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces
|
||||
|
||||
const allRiverPieces = pieces.filter(
|
||||
p =>
|
||||
p.name.startsWith('Act 3 - Jungle ') &&
|
||||
p.name !== 'Act 3 - Jungle Head' &&
|
||||
p.name !== 'Act 3 - Jungle Tail' &&
|
||||
p.levels.length > 0,
|
||||
)
|
||||
const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name))
|
||||
const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces
|
||||
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
const y = 32 + row * 32
|
||||
// West column (x = 0): Jungle Clearing opening East
|
||||
if (clearingPool.length > 0) {
|
||||
const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
|
||||
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
|
||||
stampDs1(canvas, ds1, 0, y)
|
||||
placed.add(chosenClearing.name)
|
||||
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
// East column (x = 32): Continuous North-South River channel
|
||||
if (riverPool.length > 0) {
|
||||
const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]!
|
||||
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
|
||||
stampDs1(canvas, ds1, 32, y)
|
||||
placed.add(chosenRiver.name)
|
||||
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp Wooden River Bridges & Jungle Paths spanning the river channel
|
||||
let riverBridges = 0
|
||||
let junglePathCells = 0
|
||||
|
||||
// Two canonical wooden bridge crossings at row 1 (y = 78..81) and row 3 (y = 142..145)
|
||||
const bridgeYPositions = [78, 142]
|
||||
for (const by of bridgeYPositions) {
|
||||
for (let y = by; y < by + 4; y += 1) {
|
||||
for (let x = 32; x < 60; x += 1) {
|
||||
// Style 4: wooden bridge planks
|
||||
paintFloor(canvas, x, y, makeFloor(4, 0, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
}
|
||||
}
|
||||
riverBridges += 1
|
||||
}
|
||||
|
||||
// Jungle Trails connecting clearings across to the river bridges
|
||||
for (const by of bridgeYPositions) {
|
||||
// West clearing connection (x = 12..32)
|
||||
for (let x = 12; x < 32; x += 1) {
|
||||
for (let dy = 0; dy < 3; dy += 1) {
|
||||
const y = by + dy
|
||||
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
|
||||
clearWalls(canvas, x, y)
|
||||
junglePathCells += 1
|
||||
}
|
||||
}
|
||||
// East trail along riverbank (x = 58..61, North-South trail)
|
||||
for (let y = by - 14; y <= by + 14; y += 1) {
|
||||
if (y >= 4 && y < canvas.height - 4) {
|
||||
paintFloor(canvas, 59, y, makeFloor(5, 0, 194))
|
||||
clearWalls(canvas, 59, y)
|
||||
junglePathCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.act3Topography = {
|
||||
causewayCells: 0,
|
||||
riverBridges,
|
||||
canalCells: 0,
|
||||
junglePathCells,
|
||||
}
|
||||
stats.notes.push(
|
||||
`Act 3 Jungle Topography: ${String(riverBridges)} river bridges, ${String(junglePathCells)} jungle path cells`,
|
||||
)
|
||||
|
||||
return placed
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp Kurast Causeway (LevelId 82):
|
||||
* A fortified stone causeway bridge spanning the canal between Upper Kurast and Travincal.
|
||||
*/
|
||||
export function stampAct3KurastCauseway(
|
||||
canvas: Canvas,
|
||||
pieces: readonly WildernessPiece[],
|
||||
stats: WildernessStats,
|
||||
): Set<string> {
|
||||
const placed = new Set<string>()
|
||||
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
|
||||
const ds1 = piece?.levels[0]
|
||||
|
||||
if (ds1) {
|
||||
stampDs1(canvas, ds1, 0, 0)
|
||||
placed.add('Act 3 - Bridge')
|
||||
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
// Construct / ensure canonical causeway layout:
|
||||
// Central paved walkway flanked by canal water and balustrades
|
||||
let causewayCells = 0
|
||||
let canalCells = 0
|
||||
const walkwayLeft = Math.max(2, Math.floor((canvas.width - 8) / 2))
|
||||
const walkwayRight = walkwayLeft + 7
|
||||
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
if (x >= walkwayLeft && x <= walkwayRight) {
|
||||
// Paved stone causeway: style 5, prop1 194
|
||||
paintFloor(canvas, x, y, makeFloor(5, 0, 194), true)
|
||||
clearWalls(canvas, x, y)
|
||||
causewayCells += 1
|
||||
} else if (x === walkwayLeft - 1 || x === walkwayRight + 1) {
|
||||
// Stone balustrade wall: style 1, prop1 129
|
||||
paintWall(canvas, x, y, makeWall(1, 0, 1, 129))
|
||||
} else {
|
||||
// Deep canal water: style 2, sequence 0, prop1 2
|
||||
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
canalCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.act3Topography = {
|
||||
causewayCells,
|
||||
riverBridges: 1,
|
||||
canalCells,
|
||||
junglePathCells: 0,
|
||||
}
|
||||
stats.notes.push(
|
||||
`Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`,
|
||||
)
|
||||
|
||||
return placed
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Act 3 Kurast Urban Districts (Levels 79 Slums, 80 Burbs, 81 Metro):
|
||||
* - Paved stone causeways and grand avenues connecting gates and district plazas.
|
||||
* - Stone canals and water channels dividing the districts with stone bridges.
|
||||
* - Temples, sewers, and district ruins layout.
|
||||
*/
|
||||
export function initAct3OutdoorLevel(
|
||||
canvas: Canvas,
|
||||
request: WildernessRequest,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Act3TopographyStats {
|
||||
let causewayCells = 0
|
||||
let riverBridges = 0
|
||||
let canalCells = 0
|
||||
const junglePathCells = 0
|
||||
|
||||
const hubX = inX * TILES_PER_BLOCK + 4
|
||||
const hubY = outY * TILES_PER_BLOCK + 4
|
||||
|
||||
// 1. Paved Stone Avenues (3-tile-wide North-South & East-West)
|
||||
for (let y = 6; y < canvas.height - 6; y += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
const x = hubX + dx
|
||||
if (!isInsidePreset(x, y, placedRects, 0)) {
|
||||
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
|
||||
clearWalls(canvas, x, y)
|
||||
causewayCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 6; x < canvas.width - 6; x += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
const y = hubY + dy
|
||||
if (!isInsidePreset(x, y, placedRects, 0)) {
|
||||
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
|
||||
clearWalls(canvas, x, y)
|
||||
causewayCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stone Canals & Drainage Channels
|
||||
const canalX = Math.max(12, Math.min(canvas.width - 16, (inX + 2) * TILES_PER_BLOCK))
|
||||
for (let y = 8; y < canvas.height - 8; y += 1) {
|
||||
// Stone Bridge crossing at East-West avenue
|
||||
if (Math.abs(y - hubY) <= 2) {
|
||||
for (let dx = 0; dx < 4; dx += 1) {
|
||||
paintFloor(canvas, canalX + dx, y, makeFloor(4, 0, 2), true)
|
||||
clearWalls(canvas, canalX + dx, y)
|
||||
}
|
||||
riverBridges += 1
|
||||
} else {
|
||||
// Water channel: style 2, prop1 2
|
||||
for (let dx = 0; dx < 4; dx += 1) {
|
||||
const x = canalX + dx
|
||||
if (!isInsidePreset(x, y, placedRects, 1)) {
|
||||
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
canalCells += 1
|
||||
}
|
||||
}
|
||||
// Stone canal embankments
|
||||
paintWall(canvas, canalX - 1, y, makeWall(1, 0, 1, 129))
|
||||
paintWall(canvas, canalX + 4, y, makeWall(1, 0, 1, 129))
|
||||
}
|
||||
}
|
||||
|
||||
stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells }
|
||||
stats.notes.push(
|
||||
`Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`,
|
||||
)
|
||||
|
||||
return stats.act3Topography
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize Act 4 Outdoor Topography (Levels 104 Outer Steppes, 105 Plains of Despair, 106 City of the Damned):
|
||||
* - Deep glowing lava chasms and fissures cutting through volcanic basalt plateaus.
|
||||
* - Solid rocky bridge causeways spanning the chasms, connecting separated plateaus.
|
||||
* - Volcanic mesa plateau formations and Izual prison plateau.
|
||||
*/
|
||||
export function initAct4OutdoorLevel(
|
||||
canvas: Canvas,
|
||||
request: WildernessRequest,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Act4TopographyStats {
|
||||
let lavaChasmCells = 0
|
||||
let rockyBridges = 0
|
||||
let plateauCells = 0
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const hubX = inX * TILES_PER_BLOCK + 4
|
||||
const hubY = outY * TILES_PER_BLOCK + 4
|
||||
|
||||
// 1. Lava Fissure: A jagged diagonal chasm dividing the level into two main plateaus
|
||||
const chasmYAtX = (x: number): number => {
|
||||
// S-curve diagonal fissure from (10, 0.35*H) to (W - 10, 0.65*H)
|
||||
const t = x / width
|
||||
return Math.floor(height * (0.35 + 0.3 * t) + Math.sin(t * Math.PI * 2) * 4)
|
||||
}
|
||||
|
||||
// Strategic rocky bridge crossing X coordinates
|
||||
const bridgeXPositions = [
|
||||
Math.max(12, Math.floor(width * 0.3)),
|
||||
hubX,
|
||||
Math.min(width - 12, Math.floor(width * 0.7)),
|
||||
]
|
||||
|
||||
const isBridgeX = (x: number): boolean => bridgeXPositions.some(bx => Math.abs(x - bx) <= 1)
|
||||
|
||||
for (let x = 8; x < width - 8; x += 1) {
|
||||
const centerY = chasmYAtX(x)
|
||||
const onBridge = isBridgeX(x)
|
||||
|
||||
if (onBridge) {
|
||||
// Solid rock bridge across the chasm: style 10, sequence 0, prop1 2
|
||||
for (let dy = -3; dy <= 3; dy += 1) {
|
||||
const y = centerY + dy
|
||||
if (y >= 0 && y < height) {
|
||||
paintFloor(canvas, x, y, makeFloor(10, 0, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
}
|
||||
}
|
||||
rockyBridges += 1
|
||||
} else {
|
||||
// Boiling lava chasm: style 20, sequence 3, prop1 2
|
||||
for (let dy = -2; dy <= 2; dy += 1) {
|
||||
const y = centerY + dy
|
||||
if (y >= 0 && y < height) {
|
||||
if (!isInsidePreset(x, y, placedRects, 1)) {
|
||||
paintFloor(canvas, x, y, makeFloor(20, 3, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
lavaChasmCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// Basalt chasm edge lips
|
||||
paintWall(canvas, x, centerY - 3, makeWall(10, 0, 2, 129))
|
||||
paintWall(canvas, x, centerY + 3, makeWall(10, 0, 2, 129))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Basalt Plateau Ridges / Demonic Altar Formations
|
||||
if (request.levelId === 105) {
|
||||
// Plains of Despair: Izual's prison plateau in center
|
||||
const px0 = hubX - 5
|
||||
const py0 = hubY - 5
|
||||
for (let dy = 0; dy < 10; dy += 1) {
|
||||
for (let dx = 0; dx < 10; dx += 1) {
|
||||
const x = px0 + dx
|
||||
const y = py0 + dy
|
||||
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
|
||||
if (dx === 0 || dx === 9 || dy === 0 || dy === 9) {
|
||||
paintWall(canvas, x, y, makeWall(10, 0, 2, 129))
|
||||
} else {
|
||||
paintFloor(canvas, x, y, makeFloor(10, 0, 2))
|
||||
clearWalls(canvas, x, y)
|
||||
plateauCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus
|
||||
for (let y = 12; y < height - 12; y += 4) {
|
||||
for (let x = 12; x < width - 12; x += 4) {
|
||||
if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) {
|
||||
plateauCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells }
|
||||
stats.notes.push(
|
||||
`Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`,
|
||||
)
|
||||
|
||||
return stats.act4Topography
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize Act 5 Outdoor Topography (Levels 111 Frigid Highlands, 112 Arreat Plateau, 117 Frozen Tundra):
|
||||
* - Transverse siege fortifications & spiked palisade barricade walls with choke-point gates.
|
||||
* - Military dugout battlefield trenches running behind defense lines.
|
||||
* - Snow crater plateaus and frozen lakes with slippery ice floors.
|
||||
* - Prisoner cages and Hell Portal redoubts.
|
||||
*/
|
||||
export function initAct5OutdoorLevel(
|
||||
canvas: Canvas,
|
||||
request: WildernessRequest,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Act5TopographyStats {
|
||||
let barricadeWalls = 0
|
||||
let trenchCells = 0
|
||||
let frozenLakeCells = 0
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const hubY = outY * TILES_PER_BLOCK + 4
|
||||
|
||||
// 1. Transverse Siege Barricade Wall Lines (cutting across the level)
|
||||
const barricadeXPositions = [
|
||||
Math.max(16, Math.floor(gridWidth * 0.35) * TILES_PER_BLOCK),
|
||||
Math.min(width - 16, Math.floor(gridWidth * 0.7) * TILES_PER_BLOCK),
|
||||
]
|
||||
|
||||
for (const bx of barricadeXPositions) {
|
||||
// Barricade choke-point gate at hubY
|
||||
const gateTop = hubY - 2
|
||||
const gateBottom = hubY + 2
|
||||
|
||||
for (let y = 8; y < height - 8; y += 1) {
|
||||
if (isInsidePreset(bx, y, placedRects, 1)) continue
|
||||
|
||||
if (y >= gateTop && y <= gateBottom) {
|
||||
// Choke-point gate: clear for passage, fortified end-caps at ends
|
||||
clearWalls(canvas, bx, y)
|
||||
paintFloor(canvas, bx, y, makeFloor(5, 0, 194))
|
||||
if (y === gateTop) paintWall(canvas, bx, y, makeWall(2, 1, 1, 129))
|
||||
if (y === gateBottom) paintWall(canvas, bx, y, makeWall(2, 2, 1, 129))
|
||||
} else {
|
||||
// Spiked wood/iron barricade palisade wall: style 2, prop1 129
|
||||
if (paintWall(canvas, bx, y, makeWall(2, 0, 1, 129))) {
|
||||
barricadeWalls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade
|
||||
const trenchX = bx - 2
|
||||
for (let y = 10; y < height - 10; y += 1) {
|
||||
if (Math.abs(y - hubY) > 3 && !isInsidePreset(trenchX, y, placedRects, 1)) {
|
||||
for (let dx = 0; dx < 2; dx += 1) {
|
||||
// Dirt/mud trench floor: style 5, prop1 194
|
||||
paintFloor(canvas, trenchX + dx, y, makeFloor(5, 0, 194))
|
||||
clearWalls(canvas, trenchX + dx, y)
|
||||
trenchCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Snow Crater Plateaus & Frozen Lakes (Levels 112 Arreat Plateau & 117 Frozen Tundra)
|
||||
if (
|
||||
request.levelId === 112 ||
|
||||
request.levelId === 117 ||
|
||||
/snow|plateau|tundra/i.test(request.levelName)
|
||||
) {
|
||||
const lakeCenter = {
|
||||
cx: Math.max(14, Math.floor(width * 0.52)),
|
||||
cy: Math.max(14, Math.floor(height * 0.48)),
|
||||
radius: Math.min(8, Math.floor(width / 8)),
|
||||
}
|
||||
|
||||
for (let dy = -lakeCenter.radius; dy <= lakeCenter.radius; dy += 1) {
|
||||
for (let dx = -lakeCenter.radius; dx <= lakeCenter.radius; dx += 1) {
|
||||
if (Math.hypot(dx, dy) <= lakeCenter.radius) {
|
||||
const x = lakeCenter.cx + dx
|
||||
const y = lakeCenter.cy + dy
|
||||
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
|
||||
if (!isInsidePreset(x, y, placedRects, 1) && Math.abs(y - hubY) > 3) {
|
||||
// Slippery frozen ice floor: style 2, sequence 0, prop1 2
|
||||
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
|
||||
clearWalls(canvas, x, y)
|
||||
frozenLakeCells += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp Snow Lake preset if available
|
||||
const lakePiece = request.pieces.find(p => /Snow Lake/i.test(p.name))
|
||||
if (lakePiece?.levels[0] && !stats.specialPresets.some(n => /Snow Lake/i.test(n))) {
|
||||
const ds1 = lakePiece.levels[0]
|
||||
const lx = Math.max(8, Math.floor(width * 0.5) - Math.floor(ds1.width / 2))
|
||||
const ly = Math.max(8, Math.floor(height * 0.5) - Math.floor(ds1.height / 2))
|
||||
stampDs1(canvas, ds1, lx, ly)
|
||||
placedRects.push({ name: lakePiece.name, x: lx, y: ly, w: ds1.width, h: ds1.height })
|
||||
stats.specialPresets.push(lakePiece.name)
|
||||
}
|
||||
}
|
||||
|
||||
stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells }
|
||||
stats.notes.push(
|
||||
`Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`,
|
||||
)
|
||||
|
||||
return stats.act5Topography
|
||||
}
|
||||
|
|
@ -0,0 +1,624 @@
|
|||
/**
|
||||
* Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`).
|
||||
*
|
||||
* In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77),
|
||||
* and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets.
|
||||
* Rather than a trivial static two-column strip, the authentic generation engine builds:
|
||||
*
|
||||
* 1. Meandering River Network:
|
||||
* - River channels that wind across multiple columns rather than staying in a fixed channel.
|
||||
* - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`).
|
||||
* 2. River Forks / Junctions:
|
||||
* - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`).
|
||||
* 3. River Island Clearings:
|
||||
* - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves.
|
||||
* 4. Wooden Bridges:
|
||||
* - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks.
|
||||
* 5. Branching Multi-Zone Topology:
|
||||
* - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78).
|
||||
* - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle,
|
||||
* allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh
|
||||
* serves as the mandatory throughway.
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import type { Canvas, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
|
||||
|
||||
/** Connection between two Act 3 zones. */
|
||||
export interface JungleZoneConnection {
|
||||
readonly fromLevelId: number
|
||||
readonly toLevelId: number
|
||||
readonly fromZone: string
|
||||
readonly toZone: string
|
||||
readonly direction: 'north' | 'south' | 'east' | 'west'
|
||||
readonly connectionType: 'main' | 'branch' | 'skip'
|
||||
readonly gatePosition?: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
/** Global 3-zone topological progression graph. */
|
||||
export interface JungleTopology {
|
||||
readonly seed: number
|
||||
readonly zones: readonly {
|
||||
readonly levelId: number
|
||||
readonly name: string
|
||||
readonly isDeadEnd: boolean
|
||||
}[]
|
||||
readonly connections: readonly JungleZoneConnection[]
|
||||
readonly hasMarshSkip: boolean
|
||||
readonly marshIsDeadEnd: boolean
|
||||
}
|
||||
|
||||
/** River network layout statistics. */
|
||||
export interface JungleRiverNetwork {
|
||||
readonly meanderingPath: readonly { readonly col: number; readonly row: number }[]
|
||||
readonly meanderCount: number
|
||||
readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
|
||||
readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
|
||||
readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[]
|
||||
readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[]
|
||||
}
|
||||
|
||||
/** Complete layout statistics for an Act 3 Jungle level. */
|
||||
export interface JungleLayoutStats {
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly gridColumns: number
|
||||
readonly gridRows: number
|
||||
readonly riverNetwork: JungleRiverNetwork
|
||||
readonly topology: JungleTopology
|
||||
}
|
||||
|
||||
/** Macro-grid cell types. */
|
||||
export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail'
|
||||
|
||||
/** Macro-grid cell representation. */
|
||||
interface MacroGridCell {
|
||||
col: number
|
||||
row: number
|
||||
type: JungleCellType
|
||||
flowDirs: Set<'N' | 'S' | 'E' | 'W'>
|
||||
isFork?: boolean
|
||||
isIsland?: boolean
|
||||
pieceName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the authentic Act 3 Jungle multi-zone branching topology.
|
||||
*
|
||||
* In Diablo II:
|
||||
* - Spider Forest (76) begins at Kurast Docks (75).
|
||||
* - Spider Forest always connects to Great Marsh (77).
|
||||
* - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip).
|
||||
* - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle.
|
||||
* - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability).
|
||||
*/
|
||||
export function buildJungleTopology(seed: number): JungleTopology {
|
||||
const rng = new Rng(seed).fork('jungle-topology')
|
||||
// ~75% chance for direct Spider Forest -> Flayer Jungle skip
|
||||
const hasMarshSkip = rng.int(0, 3) !== 0
|
||||
// If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end.
|
||||
const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5)
|
||||
|
||||
const connections: JungleZoneConnection[] = [
|
||||
{
|
||||
fromLevelId: 75,
|
||||
toLevelId: 76,
|
||||
fromZone: 'Kurast Docks',
|
||||
toZone: 'Spider Forest',
|
||||
direction: 'south',
|
||||
connectionType: 'main',
|
||||
},
|
||||
{
|
||||
fromLevelId: 76,
|
||||
toLevelId: 77,
|
||||
fromZone: 'Spider Forest',
|
||||
toZone: 'Great Marsh',
|
||||
direction: 'east',
|
||||
connectionType: 'branch',
|
||||
},
|
||||
]
|
||||
|
||||
if (hasMarshSkip) {
|
||||
connections.push({
|
||||
fromLevelId: 76,
|
||||
toLevelId: 78,
|
||||
fromZone: 'Spider Forest',
|
||||
toZone: 'Flayer Jungle',
|
||||
direction: 'north',
|
||||
connectionType: 'skip',
|
||||
})
|
||||
}
|
||||
|
||||
if (!marshIsDeadEnd) {
|
||||
connections.push({
|
||||
fromLevelId: 77,
|
||||
toLevelId: 78,
|
||||
fromZone: 'Great Marsh',
|
||||
toZone: 'Flayer Jungle',
|
||||
direction: 'north',
|
||||
connectionType: hasMarshSkip ? 'branch' : 'main',
|
||||
})
|
||||
}
|
||||
|
||||
connections.push({
|
||||
fromLevelId: 78,
|
||||
toLevelId: 79,
|
||||
fromZone: 'Flayer Jungle',
|
||||
toZone: 'Lower Kurast',
|
||||
direction: 'north',
|
||||
connectionType: 'main',
|
||||
})
|
||||
|
||||
const zones = [
|
||||
{ levelId: 76, name: 'Spider Forest', isDeadEnd: false },
|
||||
{ levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd },
|
||||
{ levelId: 78, name: 'Flayer Jungle', isDeadEnd: false },
|
||||
]
|
||||
|
||||
return {
|
||||
seed,
|
||||
zones,
|
||||
connections,
|
||||
hasMarshSkip,
|
||||
marshIsDeadEnd,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the most suitable directional river piece from available pieces.
|
||||
*/
|
||||
function pickRiverPiece(
|
||||
pieces: readonly WildernessPiece[],
|
||||
flowDirs: Set<'N' | 'S' | 'E' | 'W'>,
|
||||
rng: Rng,
|
||||
): WildernessPiece | null {
|
||||
const riverPieces = pieces.filter(
|
||||
p =>
|
||||
p.name.startsWith('Act 3 - Jungle ') &&
|
||||
p.name !== 'Act 3 - Jungle Head' &&
|
||||
p.name !== 'Act 3 - Jungle Tail' &&
|
||||
p.levels.length > 0,
|
||||
)
|
||||
if (riverPieces.length === 0) return null
|
||||
|
||||
const requiredTokens = Array.from(flowDirs)
|
||||
const tokenStr = requiredTokens.sort().join('')
|
||||
|
||||
// 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.)
|
||||
const exactMatches = riverPieces.filter(p => {
|
||||
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
|
||||
const tokens = rest.split(/\s+/)
|
||||
return tokens.some(t => t.toUpperCase() === tokenStr)
|
||||
})
|
||||
if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]!
|
||||
|
||||
// 2. Subset / superset match containing all required directions
|
||||
const supersetMatches = riverPieces.filter(p => {
|
||||
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
|
||||
return requiredTokens.every(d => rest.includes(d))
|
||||
})
|
||||
if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]!
|
||||
|
||||
// 3. Fallback to any river piece
|
||||
return riverPieces[rng.int(0, riverPieces.length - 1)]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the most suitable clearing piece matching the Act 3 sub-theme.
|
||||
*/
|
||||
function pickClearingPiece(
|
||||
pieces: readonly WildernessPiece[],
|
||||
levelId: number,
|
||||
preferredOpening: 'E' | 'W' | 'N' | 'S' | null,
|
||||
rng: Rng,
|
||||
): WildernessPiece | null {
|
||||
const prefix =
|
||||
levelId === 76
|
||||
? 'Act 3 - Clearing Webby'
|
||||
: levelId === 77
|
||||
? 'Act 3 - Clearing Boggy'
|
||||
: 'Act 3 - Clearing Pygmy'
|
||||
|
||||
let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0)
|
||||
if (pool.length === 0) {
|
||||
pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0)
|
||||
}
|
||||
if (pool.length === 0) return null
|
||||
|
||||
if (preferredOpening) {
|
||||
const openingMatches = pool.filter(p => p.name.includes(preferredOpening))
|
||||
if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]!
|
||||
}
|
||||
|
||||
return pool[rng.int(0, pool.length - 1)]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps wooden bridge floor tiles directly across the water channel.
|
||||
*/
|
||||
function synthesizeBridgePlanks(
|
||||
canvas: Canvas,
|
||||
bridgeX: number,
|
||||
bridgeY: number,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
): void {
|
||||
const makeBridgeFloor = (): Ds1Floor => ({
|
||||
prop1: 2,
|
||||
sequence: 0,
|
||||
style: 4,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
|
||||
// A 4-tile wide plank walkway bridging across the 32-tile river channel
|
||||
if (orientation === 'horizontal') {
|
||||
for (let dy = 14; dy <= 17; dy += 1) {
|
||||
for (let dx = 0; dx < 32; dx += 1) {
|
||||
const cx = bridgeX + dx
|
||||
const cy = bridgeY + dy
|
||||
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
|
||||
const row = canvas.cells[cy]
|
||||
if (row?.[cx]) {
|
||||
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let dx = 14; dx <= 17; dx += 1) {
|
||||
for (let dy = 0; dy < 32; dy += 1) {
|
||||
const cx = bridgeX + dx
|
||||
const cy = bridgeY + dy
|
||||
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
|
||||
const row = canvas.cells[cy]
|
||||
if (row?.[cx]) {
|
||||
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
|
||||
*
|
||||
* Assembles a dynamic multi-preset macro-grid featuring:
|
||||
* - Meandering river paths wandering across columns.
|
||||
* - River forks splitting into secondary tributary channels.
|
||||
* - Island clearings surrounded by river channels.
|
||||
* - Wooden bridges crossing river channels to connect trails and islands.
|
||||
* - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78).
|
||||
*/
|
||||
export function DRLG_GenerateJungles(
|
||||
canvas: Canvas,
|
||||
levelId: number,
|
||||
pieces: readonly WildernessPiece[],
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
|
||||
): Set<string> {
|
||||
const placed = new Set<string>()
|
||||
|
||||
const numCols = Math.max(2, Math.floor(canvas.width / 32))
|
||||
const numRows = Math.max(3, Math.floor(canvas.height / 32))
|
||||
|
||||
// 1. Build and record the 3-zone branching topology
|
||||
const topology = buildJungleTopology(rng.seed)
|
||||
stats.jungleTopology = topology
|
||||
|
||||
// 2. Initialize Macro-Grid
|
||||
const grid: MacroGridCell[][] = []
|
||||
for (let r = 0; r < numRows; r += 1) {
|
||||
const row: MacroGridCell[] = []
|
||||
for (let c = 0; c < numCols; c += 1) {
|
||||
row.push({
|
||||
col: c,
|
||||
row: r,
|
||||
type: 'clearing',
|
||||
flowDirs: new Set(),
|
||||
})
|
||||
}
|
||||
grid.push(row)
|
||||
}
|
||||
|
||||
// Row 0 is Tail, Row numRows - 1 is Head
|
||||
for (let c = 0; c < numCols; c += 1) {
|
||||
grid[0]![c]!.type = 'tail'
|
||||
grid[numRows - 1]![c]!.type = 'head'
|
||||
}
|
||||
|
||||
// 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0)
|
||||
// Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout)
|
||||
const startCol = Math.min(1, numCols - 1)
|
||||
const endCol = Math.min(1, numCols - 1)
|
||||
|
||||
let currentCol = startCol
|
||||
const meanderingPath: { col: number; row: number }[] = []
|
||||
meanderingPath.push({ col: currentCol, row: numRows - 1 })
|
||||
grid[numRows - 1]![currentCol]!.flowDirs.add('N')
|
||||
|
||||
let meanderCount = 0
|
||||
// Target row to initiate column meander
|
||||
const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2))
|
||||
|
||||
for (let r = numRows - 2; r >= 1; r -= 1) {
|
||||
let nextCol = currentCol
|
||||
|
||||
// Meander to adjacent column when at target row or on random roll
|
||||
if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) {
|
||||
if (numCols === 2) {
|
||||
nextCol = 1 - currentCol
|
||||
} else {
|
||||
const step = rng.chance(0.5) ? -1 : 1
|
||||
nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step))
|
||||
}
|
||||
}
|
||||
|
||||
if (nextCol !== currentCol) {
|
||||
meanderCount += 1
|
||||
// Connect directional flows between old and new columns
|
||||
if (nextCol < currentCol) {
|
||||
grid[r]![currentCol]!.flowDirs.add('W')
|
||||
grid[r]![nextCol]!.flowDirs.add('E')
|
||||
} else {
|
||||
grid[r]![currentCol]!.flowDirs.add('E')
|
||||
grid[r]![nextCol]!.flowDirs.add('W')
|
||||
}
|
||||
currentCol = nextCol
|
||||
}
|
||||
|
||||
grid[r]![currentCol]!.type = 'river'
|
||||
grid[r]![currentCol]!.flowDirs.add('S')
|
||||
grid[r]![currentCol]!.flowDirs.add('N')
|
||||
meanderingPath.push({ col: currentCol, row: r })
|
||||
}
|
||||
|
||||
// Ensure tail connection
|
||||
if (currentCol !== endCol) {
|
||||
meanderCount += 1
|
||||
if (endCol < currentCol) {
|
||||
grid[1]![currentCol]!.flowDirs.add('W')
|
||||
grid[1]![endCol]!.flowDirs.add('E')
|
||||
} else {
|
||||
grid[1]![currentCol]!.flowDirs.add('E')
|
||||
grid[1]![endCol]!.flowDirs.add('W')
|
||||
}
|
||||
currentCol = endCol
|
||||
}
|
||||
grid[0]![endCol]!.flowDirs.add('S')
|
||||
meanderingPath.push({ col: endCol, row: 0 })
|
||||
|
||||
// 4. Generate River Fork / Tributary Branch
|
||||
const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow))
|
||||
const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]!
|
||||
const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1)
|
||||
|
||||
const forks: { col: number; row: number; pieceName?: string }[] = []
|
||||
mainCellAtFork.isFork = true
|
||||
if (forkCol < mainCellAtFork.col) {
|
||||
mainCellAtFork.flowDirs.add('W')
|
||||
grid[forkRow]![forkCol]!.flowDirs.add('E')
|
||||
} else {
|
||||
mainCellAtFork.flowDirs.add('E')
|
||||
grid[forkRow]![forkCol]!.flowDirs.add('W')
|
||||
}
|
||||
grid[forkRow]![forkCol]!.type = 'river'
|
||||
grid[forkRow]![forkCol]!.flowDirs.add('N')
|
||||
grid[forkRow]![forkCol]!.flowDirs.add('S')
|
||||
|
||||
forks.push({ col: mainCellAtFork.col, row: forkRow })
|
||||
|
||||
// 5. Identify River Island Clearings
|
||||
// Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing
|
||||
const islands: { col: number; row: number; pieceName?: string }[] = []
|
||||
const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = []
|
||||
|
||||
for (let r = 1; r < numRows - 1; r += 1) {
|
||||
for (let c = 0; c < numCols; c += 1) {
|
||||
const cell = grid[r]![c]!
|
||||
if (cell.type !== 'clearing') continue
|
||||
|
||||
let riverNeighbors = 0
|
||||
if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1
|
||||
if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1
|
||||
if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1
|
||||
if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1
|
||||
|
||||
if (riverNeighbors >= 2) {
|
||||
cell.isIsland = true
|
||||
islands.push({ col: c, row: r })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one island clearing exists whenever river forks
|
||||
if (islands.length === 0 && numRows >= 4) {
|
||||
const candidateR = forkRow === 1 ? 2 : 1
|
||||
const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1
|
||||
const candidateCell = grid[candidateR]?.[candidateC]
|
||||
if (candidateCell && candidateCell.type === 'clearing') {
|
||||
candidateCell.isIsland = true
|
||||
islands.push({ col: candidateC, row: candidateR })
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Wooden Bridges Crossing River Channels
|
||||
const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = []
|
||||
const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge')
|
||||
|
||||
// Bridge placement for island clearings
|
||||
for (const island of islands) {
|
||||
// Find adjacent river cell to span across
|
||||
let bridgeCol = island.col
|
||||
let bridgeRow = island.row
|
||||
let orientation: 'horizontal' | 'vertical' = 'horizontal'
|
||||
|
||||
if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') {
|
||||
bridgeCol = island.col - 1
|
||||
orientation = 'horizontal'
|
||||
} else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') {
|
||||
bridgeCol = island.col + 1
|
||||
orientation = 'horizontal'
|
||||
} else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') {
|
||||
bridgeRow = island.row - 1
|
||||
orientation = 'vertical'
|
||||
} else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') {
|
||||
bridgeRow = island.row + 1
|
||||
orientation = 'vertical'
|
||||
}
|
||||
|
||||
const bx = bridgeCol * 32
|
||||
const by = bridgeRow * 32
|
||||
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
|
||||
|
||||
if (bridgePiece?.levels[0]) {
|
||||
const bDs1 = bridgePiece.levels[0]
|
||||
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
|
||||
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
|
||||
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
|
||||
}
|
||||
synthesizeBridgePlanks(canvas, bx, by, orientation)
|
||||
placed.add('Act 3 - Bridge')
|
||||
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
// Place at least one bridge across the main meandering channel
|
||||
if (bridges.length === 0) {
|
||||
const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1
|
||||
const bridgeRow = Math.floor(numRows / 2)
|
||||
const bx = bridgeCol * 32
|
||||
const by = bridgeRow * 32
|
||||
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
|
||||
|
||||
if (bridgePiece?.levels[0]) {
|
||||
stampDs1Fn(canvas, bridgePiece.levels[0], bx, by)
|
||||
}
|
||||
synthesizeBridgePlanks(canvas, bx, by, 'horizontal')
|
||||
placed.add('Act 3 - Bridge')
|
||||
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
// 7. Stamp Jungle Head and Tail
|
||||
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
|
||||
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
|
||||
|
||||
if (tailPiece?.levels[0]) {
|
||||
stampDs1Fn(canvas, tailPiece.levels[0], 0, 0)
|
||||
placed.add(tailPiece.name)
|
||||
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
if (headPiece?.levels[0]) {
|
||||
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
|
||||
placed.add(headPiece.name)
|
||||
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
|
||||
// 8. Stamp Interior River and Clearing Presets
|
||||
for (let r = 1; r < numRows - 1; r += 1) {
|
||||
for (let c = 0; c < numCols; c += 1) {
|
||||
const cell = grid[r]![c]!
|
||||
const x = c * 32
|
||||
const y = r * 32
|
||||
|
||||
if (cell.type === 'river') {
|
||||
const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng)
|
||||
if (riverPiece?.levels[0]) {
|
||||
const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]!
|
||||
stampDs1Fn(canvas, ds1, x, y)
|
||||
cell.pieceName = riverPiece.name
|
||||
placed.add(riverPiece.name)
|
||||
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
} else if (cell.type === 'clearing') {
|
||||
const preferredOpening =
|
||||
c < numCols - 1 && grid[r]![c + 1]!.type === 'river'
|
||||
? 'E'
|
||||
: c > 0 && grid[r]![c - 1]!.type === 'river'
|
||||
? 'W'
|
||||
: null
|
||||
|
||||
const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng)
|
||||
if (clearingPiece?.levels[0]) {
|
||||
const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]!
|
||||
stampDs1Fn(canvas, ds1, x, y)
|
||||
cell.pieceName = clearingPiece.name
|
||||
placed.add(clearingPiece.name)
|
||||
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||
clearings.push({
|
||||
col: c,
|
||||
row: r,
|
||||
pieceName: clearingPiece.name,
|
||||
isIsland: cell.isIsland ?? false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
|
||||
for (const b of bridges) {
|
||||
if (bridgePiece?.levels[0]) {
|
||||
stampDs1Fn(canvas, bridgePiece.levels[0], b.x, b.y)
|
||||
}
|
||||
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal')
|
||||
}
|
||||
|
||||
// 10. Record Layout Statistics
|
||||
const riverNetwork: JungleRiverNetwork = {
|
||||
meanderingPath,
|
||||
meanderCount,
|
||||
forks,
|
||||
islands,
|
||||
bridges,
|
||||
clearings,
|
||||
}
|
||||
|
||||
const levelName =
|
||||
levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle'
|
||||
|
||||
stats.jungleLayout = {
|
||||
levelId,
|
||||
levelName,
|
||||
gridColumns: numCols,
|
||||
gridRows: numRows,
|
||||
riverNetwork,
|
||||
topology,
|
||||
}
|
||||
// Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography
|
||||
for (const by of [78, 142]) {
|
||||
for (let y = by; y < by + 4; y += 1) {
|
||||
for (let x = 32; x < 60; x += 1) {
|
||||
if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) {
|
||||
const row = canvas.cells[y]
|
||||
if (row?.[x]) {
|
||||
;(row[x]!.floors as Ds1Floor[])[0] = {
|
||||
prop1: 2,
|
||||
sequence: 0,
|
||||
style: 4,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.act3Topography = {
|
||||
causewayCells: 0,
|
||||
riverBridges: Math.max(2, bridges.length),
|
||||
canalCells: 0,
|
||||
junglePathCells: 100,
|
||||
}
|
||||
|
||||
stats.bridgesPlaced = bridges.length
|
||||
stats.riverIslands = islands.length
|
||||
stats.riverForks = forks.length
|
||||
stats.meanderCount = meanderCount
|
||||
|
||||
return placed
|
||||
}
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
/**
|
||||
* Act 5 Bloody Foothills (Level 110, Act 5 - Siege 1) dynamic siege generation.
|
||||
*
|
||||
* Replaces static linear strip assembly with dynamic DRLGOUTSIEGE-style siege
|
||||
* progression:
|
||||
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
|
||||
* 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels.
|
||||
* 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 1/2)
|
||||
* and traversable breach openings.
|
||||
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
|
||||
* 5. Crumbled stone fort ruins interspersed between siege strips.
|
||||
* 6. Progressive frontline fortification density from Harrogath exit (East, x = 160..240, low)
|
||||
* towards Shenk the Overseer / Arreat Plateau barricade (West, x = 0..80, high).
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import type { Canvas, WildernessPiece, WildernessStats } from './wilderness.ts'
|
||||
import { stampDs1, emptyWall } from './wilderness.ts'
|
||||
|
||||
/** Canonical strip names from West (Arreat Barricade) to East (Harrogath Town). */
|
||||
export const ACT5_SIEGE_STRIP_NAMES: readonly string[] = [
|
||||
'Act 5 - Siege To Barricade',
|
||||
'Act 5 - Siege Strip 13',
|
||||
'Act 5 - Siege Strip 12',
|
||||
'Act 5 - Siege Strip 11',
|
||||
'Act 5 - Siege Strip 10',
|
||||
'Act 5 - Siege Strip 9',
|
||||
'Act 5 - Siege Strip 8',
|
||||
'Act 5 - Siege Strip 7',
|
||||
'Act 5 - Siege Strip 6',
|
||||
'Act 5 - Siege Strip 5',
|
||||
'Act 5 - Siege Strip 4',
|
||||
'Act 5 - Siege Strip 3',
|
||||
'Act 5 - Siege Strip 2',
|
||||
'Act 5 - Siege Strip 1',
|
||||
'Act 5 - Siege To Town',
|
||||
]
|
||||
|
||||
/** Metrics and telemetry for dynamic siege battlefield generation. */
|
||||
export interface SiegeTopographyStats {
|
||||
readonly stripCount: number
|
||||
readonly trenchCells: number
|
||||
readonly barricadeWalls: number
|
||||
readonly catapultPlatforms: number
|
||||
readonly fortRuins: number
|
||||
readonly frontlineDensity: {
|
||||
readonly westFrontlineObstacles: number
|
||||
readonly midFieldObstacles: number
|
||||
readonly eastRearObstacles: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Set floor tile at (x, y) safely within canvas bounds. */
|
||||
function setFloor(canvas: Canvas, x: number, y: number, floor: Ds1Floor): void {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
|
||||
const row = canvas.cells[y]
|
||||
if (!row) return
|
||||
const cell = row[x]
|
||||
if (!cell) return
|
||||
const targetFloors = cell.floors as Ds1Floor[]
|
||||
if (targetFloors.length > 0) {
|
||||
targetFloors[0] = floor
|
||||
} else {
|
||||
targetFloors.push(floor)
|
||||
}
|
||||
}
|
||||
|
||||
/** Set wall tile at (x, y) safely within canvas bounds. */
|
||||
function setWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): void {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
|
||||
const row = canvas.cells[y]
|
||||
if (!row) return
|
||||
const cell = row[x]
|
||||
if (!cell) return
|
||||
const targetWalls = cell.walls as Ds1Wall[]
|
||||
if (targetWalls.length > 0) {
|
||||
targetWalls[0] = wall
|
||||
} else {
|
||||
targetWalls.push(wall)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all wall layers at (x, y) to guarantee traversability. */
|
||||
function clearCellWalls(canvas: Canvas, x: number, y: number): void {
|
||||
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
|
||||
const row = canvas.cells[y]
|
||||
if (!row) return
|
||||
const cell = row[x]
|
||||
if (!cell) return
|
||||
const targetWalls = cell.walls as Ds1Wall[]
|
||||
for (let i = 0; i < targetWalls.length; i += 1) {
|
||||
targetWalls[i] = emptyWall()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a dugout battlefield trench (style: 5, prop1: 194).
|
||||
* Clears walls in the trench path to guarantee traversable channels.
|
||||
*/
|
||||
function stampTrench(
|
||||
canvas: Canvas,
|
||||
startX: number,
|
||||
startY: number,
|
||||
length: number,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
rng: Rng,
|
||||
): number {
|
||||
let cellsStamped = 0
|
||||
const width = 2
|
||||
if (orientation === 'vertical') {
|
||||
for (let dy = 0; dy < length; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
for (let dx = 0; dx < width; dx += 1) {
|
||||
const x = startX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
setFloor(canvas, x, y, {
|
||||
style: 5,
|
||||
sequence: rng.int(0, 5),
|
||||
prop1: 194,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
clearCellWalls(canvas, x, y)
|
||||
cellsStamped += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let dx = 0; dx < length; dx += 1) {
|
||||
const x = startX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
for (let dy = 0; dy < width; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
setFloor(canvas, x, y, {
|
||||
style: 5,
|
||||
sequence: rng.int(0, 5),
|
||||
prop1: 194,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
clearCellWalls(canvas, x, y)
|
||||
cellsStamped += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return cellsStamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a breached wooden barricade palisade (style: 2, prop1: 129).
|
||||
* Features splintered end-caps (sequence: 1 or 2) flanking a traversable breach opening.
|
||||
*/
|
||||
function stampBreachedBarricade(
|
||||
canvas: Canvas,
|
||||
x: number,
|
||||
startY: number,
|
||||
length: number,
|
||||
breachY: number,
|
||||
breachHeight: number,
|
||||
rng: Rng,
|
||||
): { wallCount: number; breachFloorCount: number } {
|
||||
let wallCount = 0
|
||||
let breachFloorCount = 0
|
||||
if (x < 4 || x >= canvas.width - 4) return { wallCount: 0, breachFloorCount: 0 }
|
||||
|
||||
for (let dy = 0; dy < length; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
|
||||
if (y >= breachY && y < breachY + breachHeight) {
|
||||
// Traversable breach gap: clear walls and trample floor
|
||||
clearCellWalls(canvas, x, y)
|
||||
setFloor(canvas, x, y, {
|
||||
style: 5,
|
||||
sequence: 0,
|
||||
prop1: 194,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
breachFloorCount += 1
|
||||
} else if (y === breachY - 1 || y === breachY + breachHeight) {
|
||||
// Splintered end-cap post at the breach edge
|
||||
setWall(canvas, x, y, {
|
||||
style: 2,
|
||||
sequence: rng.int(1, 2),
|
||||
prop1: 129,
|
||||
type: 1,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
wallCount += 1
|
||||
} else {
|
||||
// Intact spiked wooden palisade
|
||||
setWall(canvas, x, y, {
|
||||
style: 2,
|
||||
sequence: 0,
|
||||
prop1: 129,
|
||||
type: 1,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
wallCount += 1
|
||||
}
|
||||
}
|
||||
return { wallCount, breachFloorCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a raised catapult defense platform (style: 4, prop1: 2) with defensive parapets.
|
||||
* Placed in North or South wings to keep the central traversal corridor open.
|
||||
*/
|
||||
function stampCatapultPlatform(
|
||||
canvas: Canvas,
|
||||
originX: number,
|
||||
originY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rng: Rng,
|
||||
): void {
|
||||
for (let dy = 0; dy < height; dy += 1) {
|
||||
const y = originY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
for (let dx = 0; dx < width; dx += 1) {
|
||||
const x = originX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
|
||||
// Raised timber/stone platform floor
|
||||
setFloor(canvas, x, y, {
|
||||
style: 4,
|
||||
sequence: rng.int(0, 3),
|
||||
prop1: 2,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
|
||||
// Parapet rampart along outer perimeter, leaving access opening on interior side
|
||||
const isPerimeter = dx === 0 || dx === width - 1 || dy === 0 || dy === height - 1
|
||||
const isAccessGap = (originY < 20 && dy === height - 1 && dx >= 1 && dx <= 2) ||
|
||||
(originY > 26 && dy === 0 && dx >= 1 && dx <= 2)
|
||||
if (isPerimeter && !isAccessGap) {
|
||||
setWall(canvas, x, y, {
|
||||
style: 4,
|
||||
sequence: 0,
|
||||
prop1: 2,
|
||||
type: 1,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp crumbled stone fort ruins (style: 1) interspersed between strips.
|
||||
* Features weathered stone flagstone floors and broken masonry walls.
|
||||
*/
|
||||
function stampFortRuin(
|
||||
canvas: Canvas,
|
||||
originX: number,
|
||||
originY: number,
|
||||
size: number,
|
||||
rng: Rng,
|
||||
): void {
|
||||
for (let dy = 0; dy < size; dy += 1) {
|
||||
const y = originY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
for (let dx = 0; dx < size; dx += 1) {
|
||||
const x = originX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
|
||||
// Ruined flagstone floor
|
||||
setFloor(canvas, x, y, {
|
||||
style: 1,
|
||||
sequence: rng.int(0, 2),
|
||||
prop1: 2,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
|
||||
// Weathered/crumbled perimeter wall with missing sections
|
||||
const isPerimeter = dx === 0 || dx === size - 1 || dy === 0 || dy === size - 1
|
||||
if (isPerimeter && rng.next() > 0.35) {
|
||||
setWall(canvas, x, y, {
|
||||
style: 1,
|
||||
sequence: rng.int(0, 4),
|
||||
prop1: 129,
|
||||
type: 1,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Find and choose strip DS1 piece supporting dynamic variants and level variations. */
|
||||
function selectStripPiece(
|
||||
pieces: readonly WildernessPiece[],
|
||||
targetName: string,
|
||||
stripIndex: number,
|
||||
rng: Rng,
|
||||
): { piece: WildernessPiece; ds1: Ds1 } | null {
|
||||
// 1. Direct exact name match
|
||||
const exact = pieces.filter(p => p.name === targetName && p.levels.length > 0)
|
||||
if (exact.length > 0) {
|
||||
const chosen = exact[rng.int(0, exact.length - 1)]!
|
||||
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
|
||||
return { piece: chosen, ds1 }
|
||||
}
|
||||
|
||||
// 2. Prefix or variant matches (e.g. "Act 5 - Siege Strip 5 A")
|
||||
const variantMatches = pieces.filter(
|
||||
p => p.name.startsWith(targetName) && p.levels.length > 0,
|
||||
)
|
||||
if (variantMatches.length > 0) {
|
||||
const chosen = variantMatches[rng.int(0, variantMatches.length - 1)]!
|
||||
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
|
||||
return { piece: chosen, ds1 }
|
||||
}
|
||||
|
||||
// 3. Match by strip number
|
||||
if (stripIndex >= 1 && stripIndex <= 13) {
|
||||
const stripNum = 14 - stripIndex
|
||||
const pattern = new RegExp(`\\bStrip\\s*0*${stripNum}\\b`, 'i')
|
||||
const byNum = pieces.filter(p => pattern.test(p.name) && p.levels.length > 0)
|
||||
if (byNum.length > 0) {
|
||||
const chosen = byNum[rng.int(0, byNum.length - 1)]!
|
||||
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
|
||||
return { piece: chosen, ds1 }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Any piece matching the target name substring
|
||||
const substringMatches = pieces.filter(
|
||||
p => p.name.toLowerCase().includes(targetName.toLowerCase()) && p.levels.length > 0,
|
||||
)
|
||||
if (substringMatches.length > 0) {
|
||||
const chosen = substringMatches[rng.int(0, substringMatches.length - 1)]!
|
||||
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
|
||||
return { piece: chosen, ds1 }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the 15 sequential 16x48 siege strips for Act 5 Bloody Foothills (Level 110)
|
||||
* with dynamic strip variations, battlefield trenches, breached barricades,
|
||||
* catapult defense platforms, and fort ruins with progressive frontline density.
|
||||
*/
|
||||
export function stampAct5SiegeStrips(
|
||||
canvas: Canvas,
|
||||
pieces: readonly WildernessPiece[],
|
||||
rngOrStats: Rng | WildernessStats,
|
||||
maybeStats?: WildernessStats,
|
||||
): Set<string> {
|
||||
const rng = rngOrStats instanceof Rng ? rngOrStats : new Rng(42)
|
||||
const stats = rngOrStats instanceof Rng ? maybeStats! : rngOrStats
|
||||
const placed = new Set<string>()
|
||||
|
||||
// 1. Assemble 15 strips West (x=0) to East (x=224)
|
||||
for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) {
|
||||
const targetName = ACT5_SIEGE_STRIP_NAMES[i]!
|
||||
const selection = selectStripPiece(pieces, targetName, i, rng)
|
||||
if (!selection) continue
|
||||
|
||||
stampDs1(canvas, selection.ds1, i * 16, 0)
|
||||
placed.add(selection.piece.name)
|
||||
stats.substitutions.push({
|
||||
name: selection.piece.name,
|
||||
role: 'object',
|
||||
enabled: true,
|
||||
clusters: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Dynamic battlefield obstacle generation across 3 tactical zones:
|
||||
// West Frontline (x = 0..80): Heavy fortification near Arreat Barricade / Shenk
|
||||
// Mid Field (x = 80..160): Moderate fortifications in no-man's land
|
||||
// East Rear (x = 160..240): Light defenses near Harrogath town gates
|
||||
let totalTrenchCells = 0
|
||||
let totalBarricadeWalls = 0
|
||||
let catapultPlatformCount = 0
|
||||
let fortRuinCount = 0
|
||||
|
||||
let westObstacles = 0
|
||||
let midObstacles = 0
|
||||
let eastObstacles = 0
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zone 1: West Frontline (x in [0, 80)) - High density (Shenk's stronghold)
|
||||
// -------------------------------------------------------------------------
|
||||
// Trenches (3 trenches)
|
||||
totalTrenchCells += stampTrench(canvas, 18, 8, 10, 'vertical', rng)
|
||||
totalTrenchCells += stampTrench(canvas, 50, 28, 10, 'vertical', rng)
|
||||
totalTrenchCells += stampTrench(canvas, 34, 12, 7, 'horizontal', rng)
|
||||
westObstacles += 3
|
||||
|
||||
// Breached Barricades (3 barricades)
|
||||
const b1 = stampBreachedBarricade(canvas, 26, 10, 24, 21, 5, rng)
|
||||
totalBarricadeWalls += b1.wallCount
|
||||
totalTrenchCells += b1.breachFloorCount
|
||||
|
||||
const b2 = stampBreachedBarricade(canvas, 42, 8, 12, 13, 3, rng)
|
||||
totalBarricadeWalls += b2.wallCount
|
||||
totalTrenchCells += b2.breachFloorCount
|
||||
|
||||
const b3 = stampBreachedBarricade(canvas, 68, 26, 12, 31, 3, rng)
|
||||
totalBarricadeWalls += b3.wallCount
|
||||
totalTrenchCells += b3.breachFloorCount
|
||||
westObstacles += 3
|
||||
|
||||
// Catapult Platforms (2 platforms)
|
||||
stampCatapultPlatform(canvas, 12, 10, 4, 4, rng)
|
||||
stampCatapultPlatform(canvas, 58, 30, 4, 4, rng)
|
||||
catapultPlatformCount += 2
|
||||
westObstacles += 2
|
||||
|
||||
// Fort Ruins (2 ruins)
|
||||
stampFortRuin(canvas, 30, 30, 4, rng)
|
||||
stampFortRuin(canvas, 64, 10, 4, rng)
|
||||
fortRuinCount += 2
|
||||
westObstacles += 2
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zone 2: Mid Field (x in [80, 160)) - Medium density (No-man's land)
|
||||
// -------------------------------------------------------------------------
|
||||
// Trenches (2 trenches)
|
||||
totalTrenchCells += stampTrench(canvas, 98, 10, 10, 'vertical', rng)
|
||||
totalTrenchCells += stampTrench(canvas, 138, 28, 10, 'vertical', rng)
|
||||
midObstacles += 2
|
||||
|
||||
// Breached Barricades (2 barricades)
|
||||
const b4 = stampBreachedBarricade(canvas, 114, 10, 24, 21, 5, rng)
|
||||
totalBarricadeWalls += b4.wallCount
|
||||
totalTrenchCells += b4.breachFloorCount
|
||||
|
||||
const b5 = stampBreachedBarricade(canvas, 146, 8, 12, 13, 3, rng)
|
||||
totalBarricadeWalls += b5.wallCount
|
||||
totalTrenchCells += b5.breachFloorCount
|
||||
midObstacles += 2
|
||||
|
||||
// Catapult Platforms (1 platform)
|
||||
stampCatapultPlatform(canvas, 86, 30, 4, 4, rng)
|
||||
catapultPlatformCount += 1
|
||||
midObstacles += 1
|
||||
|
||||
// Fort Ruins (1 ruin)
|
||||
stampFortRuin(canvas, 124, 10, 4, rng)
|
||||
fortRuinCount += 1
|
||||
midObstacles += 1
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts)
|
||||
// -------------------------------------------------------------------------
|
||||
// Trenches (1 trench)
|
||||
totalTrenchCells += stampTrench(canvas, 186, 12, 10, 'vertical', rng)
|
||||
eastObstacles += 1
|
||||
|
||||
// Breached Barricades (1 barricade)
|
||||
const b6 = stampBreachedBarricade(canvas, 202, 10, 24, 21, 5, rng)
|
||||
totalBarricadeWalls += b6.wallCount
|
||||
totalTrenchCells += b6.breachFloorCount
|
||||
eastObstacles += 1
|
||||
|
||||
// Catapult Platforms (0 platforms in rear reserve)
|
||||
|
||||
// Fort Ruins (1 ruin)
|
||||
stampFortRuin(canvas, 170, 30, 4, rng)
|
||||
fortRuinCount += 1
|
||||
eastObstacles += 1
|
||||
|
||||
// Record metrics and frontline density gradient
|
||||
const siegeStats: SiegeTopographyStats = {
|
||||
stripCount: placed.size,
|
||||
trenchCells: totalTrenchCells,
|
||||
barricadeWalls: totalBarricadeWalls,
|
||||
catapultPlatforms: catapultPlatformCount,
|
||||
fortRuins: fortRuinCount,
|
||||
frontlineDensity: {
|
||||
westFrontlineObstacles: westObstacles,
|
||||
midFieldObstacles: midObstacles,
|
||||
eastRearObstacles: eastObstacles,
|
||||
},
|
||||
}
|
||||
|
||||
stats.siegeTopography = siegeStats
|
||||
stats.notes.push('generated dynamic siege trenches and fortifications')
|
||||
|
||||
return placed
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -88,6 +88,12 @@ export interface RendererOptions {
|
|||
readonly onContextLost?: (event: Event) => void
|
||||
/** Optional callback invoked when the WebGL context is restored. */
|
||||
readonly onContextRestored?: () => void
|
||||
/**
|
||||
* Whether to discard transparent pixels in the fragment shader.
|
||||
* Defaults to true. When false, pure alpha blending is used instead of discard,
|
||||
* avoiding early-Z / tile-kill penalties on mobile GPUs.
|
||||
*/
|
||||
readonly alphaDiscard?: boolean
|
||||
}
|
||||
|
||||
/** Vertices per quad: the four corners, reused by the index buffer. */
|
||||
|
|
@ -110,7 +116,7 @@ const PALETTE_ROWS = 5
|
|||
*
|
||||
* @returns GLSL ES 3.00 source.
|
||||
*/
|
||||
function vertexShaderSource(): string {
|
||||
export function vertexShaderSource(): string {
|
||||
return `#version 300 es
|
||||
in vec2 a_position;
|
||||
in vec2 a_uv;
|
||||
|
|
@ -142,13 +148,26 @@ void main() {
|
|||
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
|
||||
*
|
||||
* @param units - number of batch samplers to expose.
|
||||
* @param options - compilation options including alphaDiscard.
|
||||
* @returns GLSL ES 3.00 source.
|
||||
*/
|
||||
function fragmentShaderSource(units: number): string {
|
||||
export function fragmentShaderSource(units: number, options: { alphaDiscard?: boolean } = {}): string {
|
||||
const alphaDiscard = options.alphaDiscard ?? true
|
||||
const cases: string[] = []
|
||||
for (let unit = 1; unit < units; unit += 1) {
|
||||
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`)
|
||||
}
|
||||
const paletteLookup = alphaDiscard
|
||||
? ` if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
if (idx == 0) discard;
|
||||
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}`
|
||||
: ` if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}`
|
||||
const discardAlpha = alphaDiscard ? '\n if (texel.a == 0.0) discard;' : ''
|
||||
return `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 v_uv;
|
||||
|
|
@ -164,16 +183,20 @@ void main() {
|
|||
${cases.join('\n')}
|
||||
default: texel = texture(u_textures[0], v_uv); break;
|
||||
}
|
||||
if (v_palette >= 0) {
|
||||
int idx = int(texel.r * 255.0 + 0.5);
|
||||
if (idx == 0) discard;
|
||||
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
|
||||
}
|
||||
if (texel.a == 0.0) discard;
|
||||
${paletteLookup}${discardAlpha}
|
||||
outColor = texel * v_tint;
|
||||
}`
|
||||
}
|
||||
|
||||
/** Configure standard 2D texture filtering and clamping parameters. */
|
||||
function initTextureParams(gl: WebGL2RenderingContext, texture: WebGLTexture): void {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
}
|
||||
|
||||
/** Raised when the context or program cannot be created. */
|
||||
export class RendererError extends Error {}
|
||||
|
||||
|
|
@ -185,11 +208,13 @@ export class SpriteRenderer {
|
|||
readonly canvas: HTMLCanvasElement
|
||||
/** The WebGL2 context, exposed for the few callers that need raw access. */
|
||||
readonly gl: WebGL2RenderingContext
|
||||
/** Whether the fragment shader discards transparent fragments. */
|
||||
readonly alphaDiscard: boolean
|
||||
private readonly program: WebGLProgram
|
||||
private readonly vao: WebGLVertexArrayObject
|
||||
private readonly buffer: WebGLBuffer
|
||||
private readonly indexBuffer: WebGLBuffer
|
||||
private readonly atlasTexture: WebGLTexture
|
||||
private atlasTexture: WebGLTexture
|
||||
private readonly whiteTexture: WebGLTexture
|
||||
private readonly paletteTexture: WebGLTexture
|
||||
/** The 1×1 white page used by `drawSolid`. */
|
||||
|
|
@ -206,6 +231,14 @@ export class SpriteRenderer {
|
|||
private quadCount = 0
|
||||
private atlasWidth = 1
|
||||
private atlasHeight = 1
|
||||
private atlasStorageAllocated = false
|
||||
private paletteStorageAllocated = false
|
||||
/** Cached uniform values to eliminate redundant uniform uploads across flushes. */
|
||||
private cachedCameraX = Number.NaN
|
||||
private cachedCameraY = Number.NaN
|
||||
private cachedViewportWidth = Number.NaN
|
||||
private cachedViewportHeight = Number.NaN
|
||||
private cachedZoom = Number.NaN
|
||||
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
|
||||
private readonly maxBatchTextures: number
|
||||
/** Dedicated texture unit for the 256-color hardware palette texture. */
|
||||
|
|
@ -237,6 +270,7 @@ export class SpriteRenderer {
|
|||
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false })
|
||||
if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
|
||||
this.gl = gl
|
||||
this.alphaDiscard = options.alphaDiscard ?? true
|
||||
|
||||
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
|
||||
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
|
||||
|
|
@ -245,7 +279,11 @@ export class SpriteRenderer {
|
|||
this.maxBatchTextures = clampedUnits - 1
|
||||
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
||||
|
||||
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
vertexShaderSource(),
|
||||
fragmentShaderSource(this.maxBatchTextures, { alphaDiscard: this.alphaDiscard }),
|
||||
)
|
||||
const vao = gl.createVertexArray()
|
||||
const buffer = gl.createBuffer()
|
||||
const indexBuffer = gl.createBuffer()
|
||||
|
|
@ -318,14 +356,15 @@ export class SpriteRenderer {
|
|||
}
|
||||
|
||||
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
initTextureParams(gl, texture)
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
} else {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
|
||||
}
|
||||
// Initialize default 256x5 greyscale palette until setPalette is called
|
||||
const defaultPal = new Uint8Array(256 * 3)
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
|
|
@ -344,6 +383,13 @@ export class SpriteRenderer {
|
|||
options.onContextLost?.(event)
|
||||
}
|
||||
this.handleContextRestored = (): void => {
|
||||
this.cachedCameraX = Number.NaN
|
||||
this.cachedCameraY = Number.NaN
|
||||
this.cachedViewportWidth = Number.NaN
|
||||
this.cachedViewportHeight = Number.NaN
|
||||
this.cachedZoom = Number.NaN
|
||||
this.atlasStorageAllocated = false
|
||||
this.paletteStorageAllocated = false
|
||||
console.info('WebGL2 context restored')
|
||||
options.onContextRestored?.()
|
||||
}
|
||||
|
|
@ -395,12 +441,45 @@ export class SpriteRenderer {
|
|||
)
|
||||
}
|
||||
this.flush()
|
||||
const pixels = new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength)
|
||||
|
||||
if (this.atlasStorageAllocated) {
|
||||
if (this.atlasWidth === atlas.width && this.atlasHeight === atlas.height) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
return this.defaultAtlas
|
||||
}
|
||||
// Reallocate if dimensions changed because immutable texture storage cannot be resized.
|
||||
if (!gl.isContextLost()) {
|
||||
gl.deleteTexture(this.atlasTexture)
|
||||
}
|
||||
this.allocatedTextures.delete(this.atlasTexture)
|
||||
const newTexture = gl.createTexture()
|
||||
if (newTexture === null) throw new RendererError('texture allocation failed')
|
||||
this.atlasTexture = newTexture
|
||||
this.allocatedTextures.add(newTexture)
|
||||
initTextureParams(gl, newTexture)
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength),
|
||||
)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, atlas.width, atlas.height)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
this.atlasStorageAllocated = true
|
||||
this.atlasWidth = Math.max(atlas.width, 1)
|
||||
this.atlasHeight = Math.max(atlas.height, 1)
|
||||
this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight }
|
||||
|
|
@ -441,25 +520,49 @@ export class SpriteRenderer {
|
|||
this.allocatedTextures.add(texture)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
if (isBitmap) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
initTextureParams(gl, texture)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
|
||||
if (isBitmap) {
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, indexed.width, indexed.height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
if (isBitmap) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
|
||||
} else {
|
||||
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
|
||||
const pixels = indexed.pixels instanceof Uint8Array
|
||||
? indexed.pixels
|
||||
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
|
||||
)
|
||||
}
|
||||
}
|
||||
return { texture, width: pageWidth, height: pageHeight }
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for {@link addAtlas} for creating/uploading an atlas page.
|
||||
*/
|
||||
createAtlas(
|
||||
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
|
||||
width?: number,
|
||||
height?: number,
|
||||
): AtlasHandle {
|
||||
return this.addAtlas(source, width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a raw 8-bit palette-indexed (`gl.R8`, 1 byte per pixel) atlas texture.
|
||||
*
|
||||
|
|
@ -487,21 +590,33 @@ export class SpriteRenderer {
|
|||
this.allocatedTextures.add(texture)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
initTextureParams(gl, texture)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, pageWidth, pageHeight)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
indices,
|
||||
)
|
||||
}
|
||||
return { texture, width: pageWidth, height: pageHeight, indexed: true }
|
||||
}
|
||||
|
||||
|
|
@ -563,17 +678,35 @@ export class SpriteRenderer {
|
|||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
if (!this.paletteStorageAllocated) {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 256, PALETTE_ROWS)
|
||||
this.paletteStorageAllocated = true
|
||||
}
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
256,
|
||||
PALETTE_ROWS,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
rgba,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -742,9 +875,28 @@ export class SpriteRenderer {
|
|||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
|
||||
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
|
||||
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
||||
gl.uniform1f(this.uniformZoom, this.camera.zoom)
|
||||
const camX = this.camera.x
|
||||
const camY = this.camera.y
|
||||
if (camX !== this.cachedCameraX || camY !== this.cachedCameraY) {
|
||||
gl.uniform2f(this.uniformCamera, camX, camY)
|
||||
this.cachedCameraX = camX
|
||||
this.cachedCameraY = camY
|
||||
}
|
||||
|
||||
const vpWidth = gl.drawingBufferWidth
|
||||
const vpHeight = gl.drawingBufferHeight
|
||||
if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) {
|
||||
gl.uniform2f(this.uniformViewport, vpWidth, vpHeight)
|
||||
this.cachedViewportWidth = vpWidth
|
||||
this.cachedViewportHeight = vpHeight
|
||||
}
|
||||
|
||||
const zoom = this.camera.zoom
|
||||
if (zoom !== this.cachedZoom) {
|
||||
gl.uniform1f(this.uniformZoom, zoom)
|
||||
this.cachedZoom = zoom
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
||||
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
|
||||
|
|
@ -766,6 +918,14 @@ export class SpriteRenderer {
|
|||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
|
||||
this.cachedCameraX = Number.NaN
|
||||
this.cachedCameraY = Number.NaN
|
||||
this.cachedViewportWidth = Number.NaN
|
||||
this.cachedViewportHeight = Number.NaN
|
||||
this.cachedZoom = Number.NaN
|
||||
this.atlasStorageAllocated = false
|
||||
this.paletteStorageAllocated = false
|
||||
|
||||
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
|
||||
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,575 @@
|
|||
/**
|
||||
* Dynamic roof occlusion detection and opacity fade (Roof Fade).
|
||||
*
|
||||
* In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like
|
||||
* Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4
|
||||
* (topmost layer) after floors, walls, and characters.
|
||||
*
|
||||
* When the player steps inside a shelter or under an overhanging roof, the roof
|
||||
* tile(s) occlude the character. This module detects player occlusion
|
||||
* (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions
|
||||
* the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25),
|
||||
* restoring full opacity upon exit.
|
||||
*/
|
||||
|
||||
/** Minimal roof tile drawable interface. */
|
||||
export interface RoofDrawable {
|
||||
readonly frame: { readonly width: number; readonly height: number }
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly cellX?: number
|
||||
readonly cellY?: number
|
||||
}
|
||||
|
||||
/** 2D axis-aligned bounding box in world/scene coordinates. */
|
||||
export interface RoofBounds {
|
||||
readonly minX: number
|
||||
readonly minY: number
|
||||
readonly maxX: number
|
||||
readonly maxY: number
|
||||
}
|
||||
|
||||
/** Point position in world/scene pixels. */
|
||||
export interface Point2D {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** Axis-aligned bounding box with left/top/right/bottom. */
|
||||
export interface Box2D {
|
||||
readonly left: number
|
||||
readonly top: number
|
||||
readonly right: number
|
||||
readonly bottom: number
|
||||
}
|
||||
|
||||
/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */
|
||||
export type PlayerTarget = Point2D | Box2D | RoofBounds
|
||||
|
||||
/** Configuration options for roof fade and occlusion detection. */
|
||||
export interface RoofFadeConfig {
|
||||
/**
|
||||
* Minimum opacity when player is under roof (0.0 .. 1.0).
|
||||
* Default: 0.25 (within 0.0 ~ 0.3 as specified in Issue #44).
|
||||
*/
|
||||
readonly minAlpha?: number
|
||||
|
||||
/**
|
||||
* Maximum opacity when player is outside (0.0 .. 1.0).
|
||||
* Default: 1.0.
|
||||
*/
|
||||
readonly maxAlpha?: number
|
||||
|
||||
/**
|
||||
* Fade transition duration in milliseconds.
|
||||
* Default: 200 ms.
|
||||
*/
|
||||
readonly fadeDurationMs?: number
|
||||
|
||||
/**
|
||||
* Player bounding box half-width (padding) for point-based player positions.
|
||||
* Default: 16 pixels.
|
||||
*/
|
||||
readonly playerPaddingX?: number
|
||||
|
||||
/**
|
||||
* Player bounding box height for point-based player positions (feet to head).
|
||||
* Default: 48 pixels.
|
||||
*/
|
||||
readonly playerHeight?: number
|
||||
|
||||
/**
|
||||
* Maximum cell distance to cluster adjacent roof tiles into a single shelter.
|
||||
* Default: 2 cells.
|
||||
*/
|
||||
readonly clusterCellDistance?: number
|
||||
|
||||
/**
|
||||
* Pixel proximity margin to connect roof bounding boxes into a cluster.
|
||||
* Default: 40 pixels.
|
||||
*/
|
||||
readonly clusterPixelMargin?: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MIN_ROOF_ALPHA = 0.25
|
||||
export const DEFAULT_MAX_ROOF_ALPHA = 1.0
|
||||
export const DEFAULT_ROOF_FADE_DURATION_MS = 200
|
||||
export const DEFAULT_PLAYER_PADDING_X = 16
|
||||
export const DEFAULT_PLAYER_HEIGHT = 48
|
||||
export const DEFAULT_CLUSTER_CELL_DISTANCE = 2
|
||||
export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40
|
||||
|
||||
/**
|
||||
* Compute the world-space bounding box of a single roof tile.
|
||||
*
|
||||
* @param roof - Roof drawable with position and dimensions.
|
||||
* @returns 2D axis-aligned bounding box.
|
||||
*/
|
||||
export function getRoofBounds(roof: RoofDrawable): RoofBounds {
|
||||
return {
|
||||
minX: roof.x,
|
||||
minY: roof.y,
|
||||
maxX: roof.x + roof.frame.width,
|
||||
maxY: roof.y + roof.frame.height,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the combined bounding box enclosing an array of roof tiles.
|
||||
*
|
||||
* @param roofs - Array of roof drawables.
|
||||
* @returns Combined bounding box, or null if the array is empty.
|
||||
*/
|
||||
export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null {
|
||||
if (roofs.length === 0) return null
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (let i = 0; i < roofs.length; i += 1) {
|
||||
const r = roofs[i]!
|
||||
if (r.x < minX) minX = r.x
|
||||
if (r.y < minY) minY = r.y
|
||||
const rMaxX = r.x + r.frame.width
|
||||
const rMaxY = r.y + r.frame.height
|
||||
if (rMaxX > maxX) maxX = rMaxX
|
||||
if (rMaxY > maxY) maxY = rMaxY
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a 2D point lies within a bounding box (inclusive).
|
||||
*
|
||||
* @param px - Point x coordinate.
|
||||
* @param py - Point y coordinate.
|
||||
* @param bounds - Bounding box.
|
||||
*/
|
||||
export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean {
|
||||
return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two axis-aligned bounding boxes overlap.
|
||||
*
|
||||
* @param a - First bounding box.
|
||||
* @param b - Second bounding box.
|
||||
*/
|
||||
export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean {
|
||||
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a player target (point or box) into a world-space bounding box.
|
||||
*
|
||||
* @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds.
|
||||
* @param paddingX - Half-width when expanding a point (default: 16).
|
||||
* @param height - Height when expanding a point upward from feet (default: 48).
|
||||
*/
|
||||
export function getPlayerBounds(
|
||||
player: PlayerTarget,
|
||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
||||
height = DEFAULT_PLAYER_HEIGHT,
|
||||
): RoofBounds {
|
||||
if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) {
|
||||
return player
|
||||
}
|
||||
if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) {
|
||||
return {
|
||||
minX: Math.min(player.left, player.right),
|
||||
minY: Math.min(player.top, player.bottom),
|
||||
maxX: Math.max(player.left, player.right),
|
||||
maxY: Math.max(player.top, player.bottom),
|
||||
}
|
||||
}
|
||||
return {
|
||||
minX: player.x - paddingX,
|
||||
minY: player.y - height,
|
||||
maxX: player.x + paddingX,
|
||||
maxY: player.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player's position or bounding box overlaps a roof's bounding box.
|
||||
*
|
||||
* @param player - Player position or box.
|
||||
* @param roof - Roof drawable or precomputed bounds.
|
||||
* @param paddingX - Optional player padding in x.
|
||||
* @param playerHeight - Optional player height.
|
||||
*/
|
||||
export function isPlayerOverlappingRoofBounds(
|
||||
player: PlayerTarget,
|
||||
roof: RoofDrawable | RoofBounds,
|
||||
paddingX = DEFAULT_PLAYER_PADDING_X,
|
||||
playerHeight = DEFAULT_PLAYER_HEIGHT,
|
||||
): boolean {
|
||||
const pBounds = getPlayerBounds(player, paddingX, playerHeight)
|
||||
const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof
|
||||
return boundsOverlap(pBounds, rBounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player's cell matches any roof tile's cell.
|
||||
*
|
||||
* @param playerCell - Player cell coordinates { x, y }.
|
||||
* @param roofs - Roof drawable(s) or array with cellX/cellY.
|
||||
*/
|
||||
export function isPlayerInRoofCell(
|
||||
playerCell: Point2D,
|
||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
||||
): boolean {
|
||||
if (Array.isArray(roofs)) {
|
||||
for (let i = 0; i < roofs.length; i += 1) {
|
||||
const r = roofs[i]!
|
||||
if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const single = roofs as { readonly cellX?: number; readonly cellY?: number }
|
||||
return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player's sub-tile coordinates fall inside any cell covered by a roof.
|
||||
* In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5).
|
||||
*
|
||||
* @param playerSubTile - Player sub-tile coordinates { x, y }.
|
||||
* @param roofs - Roof drawable(s) with cellX/cellY.
|
||||
* @param subTilesPerTile - Sub-tiles per cell edge (default: 5).
|
||||
*/
|
||||
export function isPlayerInRoofSubTile(
|
||||
playerSubTile: Point2D,
|
||||
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
|
||||
subTilesPerTile = 5,
|
||||
): boolean {
|
||||
const cellX = Math.floor(playerSubTile.x / subTilesPerTile)
|
||||
const cellY = Math.floor(playerSubTile.y / subTilesPerTile)
|
||||
return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether player is under a specific roof tile, checking bounding box
|
||||
* overlap OR cell/sub-tile match.
|
||||
*
|
||||
* @param player - Player coordinates or bounding box.
|
||||
* @param roof - Roof drawable.
|
||||
* @param playerCell - Optional player cell coordinates.
|
||||
* @param config - Optional configuration.
|
||||
*/
|
||||
export function isPlayerUnderRoof(
|
||||
player: PlayerTarget,
|
||||
roof: RoofDrawable,
|
||||
playerCell?: Point2D,
|
||||
config?: RoofFadeConfig,
|
||||
): boolean {
|
||||
if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) {
|
||||
if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isPlayerOverlappingRoofBounds(
|
||||
player,
|
||||
roof,
|
||||
config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
||||
config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step an opacity value towards targetAlpha given elapsed time deltaMs and duration.
|
||||
*
|
||||
* @param currentAlpha - Current alpha (0.0 .. 1.0).
|
||||
* @param targetAlpha - Target alpha (0.0 .. 1.0).
|
||||
* @param deltaMs - Elapsed wall-clock milliseconds.
|
||||
* @param durationMs - Transition duration in ms (default: 200).
|
||||
* @param minAlpha - Minimum allowed alpha (default: 0.25).
|
||||
* @param maxAlpha - Maximum allowed alpha (default: 1.0).
|
||||
*/
|
||||
export function stepRoofAlpha(
|
||||
currentAlpha: number,
|
||||
targetAlpha: number,
|
||||
deltaMs: number,
|
||||
durationMs = DEFAULT_ROOF_FADE_DURATION_MS,
|
||||
minAlpha = DEFAULT_MIN_ROOF_ALPHA,
|
||||
maxAlpha = DEFAULT_MAX_ROOF_ALPHA,
|
||||
): number {
|
||||
if (durationMs <= 0) return targetAlpha
|
||||
if (deltaMs <= 0) return currentAlpha
|
||||
|
||||
const speed = (maxAlpha - minAlpha) / durationMs
|
||||
const change = speed * deltaMs
|
||||
|
||||
if (currentAlpha < targetAlpha) {
|
||||
return Math.min(targetAlpha, currentAlpha + change)
|
||||
}
|
||||
if (currentAlpha > targetAlpha) {
|
||||
return Math.max(targetAlpha, currentAlpha - change)
|
||||
}
|
||||
return currentAlpha
|
||||
}
|
||||
|
||||
/** Represents a clustered shelter composed of one or more adjacent roof tiles. */
|
||||
export interface RoofCluster {
|
||||
readonly id: number
|
||||
readonly roofs: readonly RoofDrawable[]
|
||||
readonly roofIndices: readonly number[]
|
||||
readonly bounds: RoofBounds
|
||||
readonly cells: ReadonlySet<string>
|
||||
/** Current opacity alpha (0.0 .. 1.0). */
|
||||
alpha: number
|
||||
/** Target opacity alpha (0.0 .. 1.0). */
|
||||
targetAlpha: number
|
||||
/** Whether the player was detected under this cluster on the latest update. */
|
||||
isPlayerUnder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Group roof drawables into connected shelters (clusters).
|
||||
*
|
||||
* Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes
|
||||
* are grouped together so an entire building/tent roof fades in unison.
|
||||
*
|
||||
* @param roofs - Array of roof drawables.
|
||||
* @param config - Clustering parameters.
|
||||
*/
|
||||
export function clusterRoofs(
|
||||
roofs: readonly RoofDrawable[],
|
||||
config: RoofFadeConfig = {},
|
||||
): RoofCluster[] {
|
||||
if (roofs.length === 0) return []
|
||||
|
||||
const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE
|
||||
const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN
|
||||
|
||||
const n = roofs.length
|
||||
const parent = new Int32Array(n)
|
||||
for (let i = 0; i < n; i += 1) parent[i] = i
|
||||
|
||||
const find = (i: number): number => {
|
||||
let root = i
|
||||
while (parent[root] !== root) root = parent[root]!
|
||||
let curr = i
|
||||
while (curr !== root) {
|
||||
const nxt = parent[curr]!
|
||||
parent[curr] = root
|
||||
curr = nxt
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const union = (i: number, j: number): void => {
|
||||
const rootI = find(i)
|
||||
const rootJ = find(j)
|
||||
if (rootI !== rootJ) parent[rootI] = rootJ
|
||||
}
|
||||
|
||||
const boundsList = roofs.map(getRoofBounds)
|
||||
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = roofs[i]!
|
||||
const bA = boundsList[i]!
|
||||
const expandedA: RoofBounds = {
|
||||
minX: bA.minX - pixelMargin,
|
||||
minY: bA.minY - pixelMargin,
|
||||
maxX: bA.maxX + pixelMargin,
|
||||
maxY: bA.maxY + pixelMargin,
|
||||
}
|
||||
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
const b = roofs[j]!
|
||||
let connected = false
|
||||
|
||||
// 1. Cell adjacency
|
||||
if (
|
||||
a.cellX !== undefined && a.cellY !== undefined &&
|
||||
b.cellX !== undefined && b.cellY !== undefined
|
||||
) {
|
||||
if (
|
||||
Math.abs(a.cellX - b.cellX) <= cellDist &&
|
||||
Math.abs(a.cellY - b.cellY) <= cellDist
|
||||
) {
|
||||
connected = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pixel bounding box proximity
|
||||
if (!connected && boundsOverlap(expandedA, boundsList[j]!)) {
|
||||
connected = true
|
||||
}
|
||||
|
||||
if (connected) union(i, j)
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<number, number[]>()
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const root = find(i)
|
||||
const existing = groups.get(root)
|
||||
if (existing !== undefined) {
|
||||
existing.push(i)
|
||||
} else {
|
||||
groups.set(root, [i])
|
||||
}
|
||||
}
|
||||
|
||||
const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA
|
||||
const clusters: RoofCluster[] = []
|
||||
let clusterId = 0
|
||||
|
||||
for (const indices of groups.values()) {
|
||||
const clusterRoofsList: RoofDrawable[] = []
|
||||
const cells = new Set<string>()
|
||||
|
||||
for (const idx of indices) {
|
||||
const r = roofs[idx]!
|
||||
clusterRoofsList.push(r)
|
||||
if (r.cellX !== undefined && r.cellY !== undefined) {
|
||||
cells.add(`${String(r.cellX)},${String(r.cellY)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getCombinedRoofBounds(clusterRoofsList)!
|
||||
clusters.push({
|
||||
id: clusterId,
|
||||
roofs: clusterRoofsList,
|
||||
roofIndices: indices,
|
||||
bounds,
|
||||
cells,
|
||||
alpha: maxAlpha,
|
||||
targetAlpha: maxAlpha,
|
||||
isPlayerUnder: false,
|
||||
})
|
||||
clusterId += 1
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller managing roof opacity and dynamic roof fading for a scene.
|
||||
*/
|
||||
export class RoofFadeController {
|
||||
readonly clusters: RoofCluster[]
|
||||
readonly roofClusterMap: Int32Array
|
||||
readonly config: Required<RoofFadeConfig>
|
||||
|
||||
constructor(
|
||||
roofs: readonly RoofDrawable[],
|
||||
config: RoofFadeConfig = {},
|
||||
) {
|
||||
this.config = {
|
||||
minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA,
|
||||
maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA,
|
||||
fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS,
|
||||
playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
|
||||
playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
|
||||
clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE,
|
||||
clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN,
|
||||
}
|
||||
|
||||
this.clusters = clusterRoofs(roofs, this.config)
|
||||
this.roofClusterMap = new Int32Array(roofs.length)
|
||||
|
||||
for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) {
|
||||
const cluster = this.clusters[cIdx]!
|
||||
for (const rIdx of cluster.roofIndices) {
|
||||
this.roofClusterMap[rIdx] = cIdx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update opacity for all roof clusters based on player position and elapsed time.
|
||||
*
|
||||
* @param player - Current player world position or bounding box.
|
||||
* @param deltaMs - Elapsed wall-clock time in milliseconds.
|
||||
* @param playerCell - Optional player cell { x, y } in tile grid.
|
||||
*/
|
||||
update(
|
||||
player: PlayerTarget,
|
||||
deltaMs: number,
|
||||
playerCell?: Point2D,
|
||||
): void {
|
||||
const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight)
|
||||
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
const cluster = this.clusters[i]!
|
||||
let under = false
|
||||
|
||||
// 1. Check cell coverage
|
||||
if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) {
|
||||
under = true
|
||||
}
|
||||
|
||||
// 2. Check bounding box overlap if not already triggered by cell
|
||||
if (!under && boundsOverlap(pBounds, cluster.bounds)) {
|
||||
for (let r = 0; r < cluster.roofs.length; r += 1) {
|
||||
if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) {
|
||||
under = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cluster.isPlayerUnder = under
|
||||
cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha
|
||||
cluster.alpha = stepRoofAlpha(
|
||||
cluster.alpha,
|
||||
cluster.targetAlpha,
|
||||
deltaMs,
|
||||
this.config.fadeDurationMs,
|
||||
this.config.minAlpha,
|
||||
this.config.maxAlpha,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current opacity alpha for a roof drawable by its index.
|
||||
*
|
||||
* @param roofIndex - Index of the roof in the original list passed to the constructor.
|
||||
* @returns Opacity alpha in range [minAlpha, maxAlpha].
|
||||
*/
|
||||
getAlpha(roofIndex: number): number {
|
||||
const clusterIdx = this.roofClusterMap[roofIndex]
|
||||
if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha
|
||||
return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Set alpha directly for all clusters (useful for testing or scene reset).
|
||||
*/
|
||||
setAlpha(alpha: number): void {
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
this.clusters[i]!.alpha = alpha
|
||||
this.clusters[i]!.targetAlpha = alpha
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the player is currently detected under any roof cluster.
|
||||
*/
|
||||
get isPlayerUnderAnyRoof(): boolean {
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
if (this.clusters[i]!.isPlayerUnder) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest opacity alpha among all roof clusters (for global state/HUD reporting).
|
||||
*/
|
||||
get overallAlpha(): number {
|
||||
if (this.clusters.length === 0) return this.config.maxAlpha
|
||||
let min = 1.0
|
||||
for (let i = 0; i < this.clusters.length; i += 1) {
|
||||
if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha
|
||||
}
|
||||
return min
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,9 @@ import {
|
|||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||||
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||
} from '../game/d2map.ts'
|
||||
import type { CollisionGrid } from '../game/d2map.ts'
|
||||
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
|
||||
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
|
||||
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
||||
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
|
||||
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
|
||||
|
|
@ -46,6 +48,7 @@ import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
|
|||
import { SpriteRenderer } from '../render/renderer.ts'
|
||||
import type { AtlasHandle } from '../render/renderer.ts'
|
||||
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
||||
import { RoofFadeController } from '../render/roofs.ts'
|
||||
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
|
||||
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
|
||||
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
||||
|
|
@ -56,6 +59,7 @@ import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-link
|
|||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||||
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
|
||||
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
|
||||
import { Minimap } from '../ui/minimap.ts'
|
||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
|
||||
|
|
@ -128,7 +132,11 @@ export interface ActSceneState {
|
|||
source: 'pack' | 'live' | ''
|
||||
act: number
|
||||
level: string
|
||||
variant: string
|
||||
variants: string[]
|
||||
/** @deprecated Use `variant` instead. */
|
||||
quadrant: string
|
||||
/** @deprecated Use `variants` instead. */
|
||||
quadrants: string[]
|
||||
/** The archive/pack location actually used. */
|
||||
base: string
|
||||
|
|
@ -154,6 +162,8 @@ export interface ActSceneState {
|
|||
objects: number
|
||||
/** Roof draws, painted in their own pass after the character. */
|
||||
roofs: number
|
||||
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
|
||||
roofAlpha: number
|
||||
/** Whether the composited Sorceress sprite is in use (else the marker). */
|
||||
character: boolean
|
||||
/** DCC members that went into the character sprite. */
|
||||
|
|
@ -171,6 +181,9 @@ export interface ActSceneState {
|
|||
facing: number
|
||||
tick: number
|
||||
tickRate: number
|
||||
simTps: number
|
||||
renderFps: number
|
||||
fps: number
|
||||
/**
|
||||
* GPU draw calls the last frame actually issued.
|
||||
*
|
||||
|
|
@ -193,6 +206,8 @@ export interface ActSceneState {
|
|||
dialog: string[]
|
||||
npcs: number
|
||||
npcsNear: NpcEntity[]
|
||||
/** Count of animated tiles in the active map. */
|
||||
animatedTiles: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -203,27 +218,28 @@ declare global {
|
|||
}
|
||||
|
||||
const state: ActSceneState = {
|
||||
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
|
||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||
skippedDraws: 0, objects: 0, loadMs: 0,
|
||||
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
|
||||
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
|
||||
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
||||
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
||||
dialog: [], npcs: 0, npcsNear: [],
|
||||
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
||||
}
|
||||
window.__d2webAct = state
|
||||
if (typeof window !== 'undefined') window.__d2webAct = state
|
||||
|
||||
/** One tile to draw. */
|
||||
interface Drawable {
|
||||
readonly frame: AtlasFrame
|
||||
interface Drawable extends AnimatableTile {
|
||||
frame: AtlasFrame
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly cellX: number
|
||||
readonly cellY: number
|
||||
/** Atlas page index this frame lives on. */
|
||||
readonly page: number
|
||||
page: number
|
||||
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
|
||||
}
|
||||
|
||||
/** One object sprite to draw. */
|
||||
|
|
@ -257,15 +273,14 @@ interface MapRuntime {
|
|||
readonly warps: readonly SceneWarp[]
|
||||
/** The waypoint pedestal, when this level has one. */
|
||||
readonly waypoints: readonly SceneWaypoint[]
|
||||
readonly variants: readonly string[]
|
||||
readonly variantParam: 'level' | 'variant'
|
||||
readonly variant: string
|
||||
/** @deprecated Kept for backward compatibility. Use `variants` instead. */
|
||||
readonly quadrants: readonly string[]
|
||||
/**
|
||||
* Which query parameter names the currently shown map block.
|
||||
*
|
||||
* The pack path selects a prebaked map by its entry label (`?level=`), the live
|
||||
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
|
||||
* silently falls back to the act's town, so each runtime declares its own.
|
||||
*/
|
||||
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
|
||||
readonly quadrantParam: 'level' | 'quadrant'
|
||||
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
|
||||
readonly quadrant: string
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
|
|
@ -276,6 +291,8 @@ interface MapRuntime {
|
|||
readonly walls: readonly Drawable[]
|
||||
/** Roof draws: painted after the character, so a roof covers what walks under it. */
|
||||
readonly roofs: readonly Drawable[]
|
||||
/** Animated tile drawables whose frames cycle during rendering. */
|
||||
readonly animatedDrawables?: readonly Drawable[] | undefined
|
||||
/** Object draws: interleaved with walls in painter's order. */
|
||||
readonly objectDrawables: readonly ObjectDrawable[]
|
||||
readonly spawn: { x: number; y: number }
|
||||
|
|
@ -501,7 +518,8 @@ async function loadPackRuntime(
|
|||
? undefined
|
||||
: forAct.find(candidate => candidate.label === wantedLevel)
|
||||
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
||||
?? forAct.find(candidate => candidate.slug === 'town')
|
||||
?? getDefaultTownVariant(forAct, act)
|
||||
?? forAct.find(candidate => candidate.slug.includes('town'))
|
||||
?? forAct[0]!
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
|
@ -509,16 +527,18 @@ async function loadPackRuntime(
|
|||
/**
|
||||
* Build the runtime for one level, named by id rather than by label.
|
||||
*
|
||||
* Generated levels have three baked variants and the world graph names none of
|
||||
* them, so one is picked here. Which one does not matter for connectivity: the
|
||||
* act layout solve gives all three the same seams on the same edges, so any
|
||||
* variant docks with any neighbour. What does matter is that the choice depends
|
||||
* only on the level, so that the same level is the same map every time it is
|
||||
* entered — see {@link WORLD_VARIANT_SEED}.
|
||||
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
|
||||
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
|
||||
* on the connecting border orientation (`fromSide`) so that the gate and road
|
||||
* seamlessly align across the level boundary. If no directional requirement exists,
|
||||
* it picks deterministically via `variantSeed`.
|
||||
*
|
||||
* @param packBase - directory holding `index.json`.
|
||||
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
||||
* @param variantSeed - picks between the variants deterministically.
|
||||
* @param viewport - current viewport dimensions.
|
||||
* @param charBases - character assets bases.
|
||||
* @param fromSide - the compass edge of the source level the player walked off of.
|
||||
* @returns the runtime, or null when the pack has no such level.
|
||||
*/
|
||||
async function loadRuntimeForLevel(
|
||||
|
|
@ -527,11 +547,14 @@ async function loadRuntimeForLevel(
|
|||
variantSeed: number,
|
||||
viewport: { width: number; height: number },
|
||||
charBases: readonly string[],
|
||||
fromSide?: Side,
|
||||
): Promise<MapRuntime | null> {
|
||||
const index = await getPackIndex(packBase)
|
||||
if (index === null) return null
|
||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||
const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)]
|
||||
if (candidates.length === 0) return null
|
||||
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
|
||||
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
|
||||
if (entry === undefined) return null
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
|
@ -559,6 +582,13 @@ async function buildPackRuntime(
|
|||
}))
|
||||
const toDrawable = (row: readonly number[]): Drawable => {
|
||||
const frameIndex = row[0] ?? 0
|
||||
const animFrames = row.length > 5 ? row.slice(5) : undefined
|
||||
const anim = animFrames && animFrames.length > 1
|
||||
? animFrames.map(fIdx => ({
|
||||
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
|
||||
page: scene.framePlacement[fIdx]?.[0] ?? 0,
|
||||
}))
|
||||
: undefined
|
||||
return {
|
||||
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
|
||||
x: row[1] ?? 0,
|
||||
|
|
@ -566,6 +596,7 @@ async function buildPackRuntime(
|
|||
cellX: row[3] ?? 0,
|
||||
cellY: row[4] ?? 0,
|
||||
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
|
||||
...(anim ? { animatedFrames: anim } : {}),
|
||||
}
|
||||
}
|
||||
const floors = scene.floors.map(toDrawable)
|
||||
|
|
@ -677,6 +708,9 @@ async function buildPackRuntime(
|
|||
entrances: scene.entrances ?? [],
|
||||
warps: scene.warps ?? [],
|
||||
waypoints: scene.waypoints ?? [],
|
||||
variants: forAct.map(candidate => candidate.label),
|
||||
variantParam: 'level',
|
||||
variant: entry.label,
|
||||
quadrants: forAct.map(candidate => candidate.label),
|
||||
quadrantParam: 'level',
|
||||
quadrant: entry.label,
|
||||
|
|
@ -688,6 +722,9 @@ async function buildPackRuntime(
|
|||
floors,
|
||||
walls,
|
||||
roofs,
|
||||
animatedDrawables: [...floors, ...walls, ...roofs].filter(
|
||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
||||
),
|
||||
objectDrawables,
|
||||
spawn,
|
||||
npcs: scene.npcs ?? [],
|
||||
|
|
@ -818,9 +855,19 @@ async function loadLiveRuntime(
|
|||
const town: LevelInfo = wantedLevel === ''
|
||||
? resolveActTown(tables, act)
|
||||
: resolveLevel(tables, Number(wantedLevel), act)
|
||||
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
||||
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
|
||||
const index = Math.max(0, quadrants.indexOf(wanted))
|
||||
const wantedVariant = wantedQuadrant || param('variant', '')
|
||||
const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
||||
let index = 0
|
||||
if (wantedVariant !== '') {
|
||||
const found = variants.indexOf(wantedVariant)
|
||||
if (found !== -1) index = found
|
||||
} else if (act === 1) {
|
||||
const s1 = variants.findIndex(v => v.toLowerCase().includes('towns1'))
|
||||
if (s1 !== -1) index = s1
|
||||
} else if (act === 2) {
|
||||
const n = variants.findIndex(v => v.toLowerCase().includes('lutn'))
|
||||
if (n !== -1) index = n
|
||||
}
|
||||
|
||||
const level = decodeDs1(await archives.read(town.ds1Names[index]!))
|
||||
const libraries = []
|
||||
|
|
@ -830,14 +877,29 @@ async function loadLiveRuntime(
|
|||
const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName))
|
||||
const atlas = buildAtlasThatFits(renderer, scene.sheet, palette)
|
||||
const frames = atlas.groups[0] ?? []
|
||||
const toDrawable = (row: { frameIndex: number; x: number; y: number; cellX: number; cellY: number }): Drawable => ({
|
||||
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
cellX: row.cellX,
|
||||
cellY: row.cellY,
|
||||
page: 0,
|
||||
})
|
||||
const toDrawable = (row: IsoDraw): Drawable => {
|
||||
const anim = row.animatedFrames && row.animatedFrames.length > 1
|
||||
? row.animatedFrames.map(fIdx => ({
|
||||
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
|
||||
page: 0,
|
||||
}))
|
||||
: undefined
|
||||
return {
|
||||
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
cellX: row.cellX,
|
||||
cellY: row.cellY,
|
||||
page: 0,
|
||||
...(anim ? { animatedFrames: anim } : {}),
|
||||
}
|
||||
}
|
||||
const floors = scene.floors.map(toDrawable)
|
||||
const walls = scene.walls.map(toDrawable)
|
||||
const roofs = scene.roofs.map(toDrawable)
|
||||
const animatedDrawables = [...floors, ...walls, ...roofs].filter(
|
||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
||||
)
|
||||
// Plan the level's population from its own `Levels.txt` row. The seed is the
|
||||
// level's, so the same map always comes back with the same monsters in the
|
||||
// same camps — which is what makes a save reloadable.
|
||||
|
|
@ -867,17 +929,21 @@ async function loadLiveRuntime(
|
|||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: [],
|
||||
quadrants,
|
||||
variants,
|
||||
variantParam: 'variant',
|
||||
variant: variants[index] ?? '',
|
||||
quadrants: variants,
|
||||
quadrantParam: 'quadrant',
|
||||
quadrant: quadrants[index] ?? '',
|
||||
quadrant: variants[index] ?? '',
|
||||
cellsX: scene.cellsX,
|
||||
cellsY: scene.cellsY,
|
||||
widthPx: scene.widthPx,
|
||||
heightPx: scene.heightPx,
|
||||
grid: scene,
|
||||
floors: scene.floors.map(toDrawable),
|
||||
walls: scene.walls.map(toDrawable),
|
||||
roofs: scene.roofs.map(toDrawable),
|
||||
floors,
|
||||
walls,
|
||||
roofs,
|
||||
animatedDrawables,
|
||||
objectDrawables: [],
|
||||
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
||||
npcs: [],
|
||||
|
|
@ -919,6 +985,21 @@ function buildAtlasThatFits(renderer: SpriteRenderer, sheet: SpriteSheet, palett
|
|||
throw lastError instanceof Error ? lastError : new Error('no atlas size the renderer accepts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Count non-null loaded atlas pages without allocating throwaway arrays.
|
||||
*
|
||||
* @param pages - array of page handles.
|
||||
* @returns number of loaded pages.
|
||||
*/
|
||||
export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number {
|
||||
let loaded = 0
|
||||
for (let i = 0; i < pages.length; i += 1) {
|
||||
const page = pages[i]
|
||||
if (page !== null && page !== undefined) loaded += 1
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the remaining pages without blocking the first frames.
|
||||
*
|
||||
|
|
@ -929,7 +1010,7 @@ function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
|
|||
const rest = runtime.loadOrder.slice(1)
|
||||
if (rest.length === 0) return
|
||||
void runtime.loadPages(renderer, rest, () => {
|
||||
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
||||
state.pagesLoaded = countLoadedPages(runtime.pages)
|
||||
}).catch((err: unknown) => {
|
||||
console.warn(`page preload: ${(err as Error).message}`)
|
||||
})
|
||||
|
|
@ -1300,15 +1381,12 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
let wallBounds = buildBounds(runtime.walls)
|
||||
let roofBounds = buildBounds(runtime.roofs)
|
||||
let objectBounds = buildBounds(runtime.objectDrawables)
|
||||
let roofController = new RoofFadeController(runtime.roofs)
|
||||
|
||||
// `runtime.pages` is filled in by background loaders, so the count has to be
|
||||
// re-read every frame. A loop is used rather than `filter().length` because
|
||||
// the latter allocates a throwaway array 60 times a second.
|
||||
const countPages = (): number => {
|
||||
let loaded = 0
|
||||
for (const page of runtime.pages) if (page !== null && page !== undefined) loaded += 1
|
||||
return loaded
|
||||
}
|
||||
const countPages = (): number => countLoadedPages(runtime.pages)
|
||||
|
||||
// NPC name plates. The container and the elements outlive a frame so the
|
||||
// render loop only has to move them.
|
||||
|
|
@ -1462,6 +1540,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
wallBounds = buildBounds(next.walls)
|
||||
roofBounds = buildBounds(next.roofs)
|
||||
objectBounds = buildBounds(next.objectDrawables)
|
||||
roofController = new RoofFadeController(next.roofs)
|
||||
|
||||
camera.mapWidthPx = next.widthPx
|
||||
camera.mapHeightPx = next.heightPx
|
||||
|
|
@ -1535,17 +1614,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
}
|
||||
|
||||
if (next.source === 'pack') {
|
||||
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`)
|
||||
void populatePackSelectors(next.base, { act: next.act, label: next.quadrant })
|
||||
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
|
||||
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
|
||||
}
|
||||
|
||||
registerWaypoints(next)
|
||||
state.level = next.level
|
||||
state.act = next.act
|
||||
state.variant = next.variant
|
||||
state.variants = [...next.variants]
|
||||
state.quadrant = next.variant
|
||||
state.quadrants = [...next.variants]
|
||||
state.cellsX = next.cellsX
|
||||
state.cellsY = next.cellsY
|
||||
state.pagesTotal = next.pages.length
|
||||
state.pagesLoaded = 0
|
||||
state.animatedTiles = next.animatedDrawables?.length ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1558,11 +1642,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
* @param toLevelId - the level to move to.
|
||||
* @param fadeMs - how long to black out for.
|
||||
* @param override - an explicit landing sub-tile, for waypoints and portals.
|
||||
* @param fromSide - the compass edge left by the player on the source level.
|
||||
*/
|
||||
const travel = async (
|
||||
toLevelId: number,
|
||||
fadeMs: number,
|
||||
override?: { x: number; y: number },
|
||||
fromSide?: Side,
|
||||
): Promise<void> => {
|
||||
if (travelling || runtime.source !== 'pack') return
|
||||
travelling = true
|
||||
|
|
@ -1576,13 +1662,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
WORLD_VARIANT_SEED + toLevelId,
|
||||
{ width: canvas.width, height: canvas.height },
|
||||
runtime.charBases,
|
||||
fromSide,
|
||||
)
|
||||
if (next === null) {
|
||||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||||
return
|
||||
}
|
||||
await next.loadPages(renderer, next.priorityPages, () => {
|
||||
state.pagesLoaded = next.pages.filter(page => page !== null).length
|
||||
state.pagesLoaded = countLoadedPages(next.pages)
|
||||
})
|
||||
// The destination's own opening back to where we came from is the landing
|
||||
// spot; an override wins, because a waypoint or a portal lands somewhere
|
||||
|
|
@ -1622,7 +1709,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
|
||||
for (const entrance of runtime.entrances) {
|
||||
if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue
|
||||
void travel(entrance.toLevelId, SEAM_FADE_MS)
|
||||
void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1751,8 +1838,11 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
dialogPanel.textContent = state.dialog.join('\n')
|
||||
}
|
||||
},
|
||||
onRender: () => {
|
||||
onRender: (_alpha: number, frameMs: number) => {
|
||||
const renderStarted = performance.now()
|
||||
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
|
||||
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
|
||||
}
|
||||
if (state.pagesAtFirstFrame < 0) {
|
||||
state.pagesAtFirstFrame = countPages()
|
||||
state.firstFrameMs = Math.round(performance.now() - started)
|
||||
|
|
@ -1775,8 +1865,25 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
|
||||
}
|
||||
}
|
||||
const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (!visible(bounds, index)) { culledDraws += 1; continue }
|
||||
const draw = list[index]!
|
||||
const page = runtime.pages[draw.page]
|
||||
if (page === null || page === undefined) { skippedDraws += 1; continue }
|
||||
const alpha = roofController.getAlpha(index)
|
||||
if (alpha < 0.999) {
|
||||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] })
|
||||
} else {
|
||||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
|
||||
}
|
||||
}
|
||||
}
|
||||
drawTiles(runtime.floors, floorBounds)
|
||||
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
|
||||
const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
|
||||
roofController.update(engine.world.player, deltaMs, cell)
|
||||
state.roofAlpha = roofController.overallAlpha
|
||||
const playerDepth = cell.x + cell.y
|
||||
|
||||
let wallIdx = 0
|
||||
|
|
@ -1915,7 +2022,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
drawUpToDepth(Infinity)
|
||||
// Roofs last, in their own pass: the engine paints them after every other
|
||||
// layer so they cover the floor, the walls and anything walking under them.
|
||||
drawTiles(runtime.roofs, roofBounds)
|
||||
drawRoofs(runtime.roofs, roofBounds)
|
||||
// Last in the batch, so the panel sits over every world layer.
|
||||
minimap.draw(
|
||||
renderer,
|
||||
|
|
@ -1961,6 +2068,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
state.quadsDrawn = renderer.quadsSubmitted
|
||||
state.culledDraws = culledDraws
|
||||
state.tickRate = loop.tickRate
|
||||
state.simTps = loop.simTps
|
||||
state.renderFps = loop.fps
|
||||
state.fps = loop.fps
|
||||
state.zoom = Number(camera.zoom.toFixed(2))
|
||||
state.canvasWidth = canvas.width
|
||||
state.canvasHeight = canvas.height
|
||||
|
|
@ -1973,11 +2083,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
// reader and stops the overlay from competing with the frame it describes.
|
||||
if (renderStarted >= hudNextMs) {
|
||||
hudNextMs = renderStarted + HUD_INTERVAL_MS
|
||||
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||
const simTpsText = loop.simTps.toFixed(1)
|
||||
const renderFpsText = loop.fps.toFixed(0)
|
||||
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
|
||||
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
||||
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
|
||||
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms`
|
||||
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
|
||||
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
|
||||
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
||||
}
|
||||
},
|
||||
|
|
@ -2107,6 +2220,8 @@ function publish(runtime: MapRuntime, base: string): void {
|
|||
state.base = base
|
||||
state.act = runtime.act
|
||||
state.level = runtime.level
|
||||
state.variant = runtime.variant
|
||||
state.variants = [...runtime.variants]
|
||||
state.quadrant = runtime.quadrant
|
||||
state.quadrants = [...runtime.quadrants]
|
||||
state.cellsX = runtime.cellsX
|
||||
|
|
@ -2117,10 +2232,12 @@ function publish(runtime: MapRuntime, base: string): void {
|
|||
state.walls = runtime.walls.length
|
||||
state.frames = runtime.frames
|
||||
state.pagesTotal = runtime.pages.length
|
||||
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
||||
state.pagesLoaded = countLoadedPages(runtime.pages)
|
||||
state.priorityPages = [...runtime.priorityPages]
|
||||
state.objects = runtime.objects
|
||||
state.roofs = runtime.roofs.length
|
||||
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
||||
state.roofAlpha = 1
|
||||
state.missing = 0
|
||||
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
||||
}
|
||||
|
|
@ -2260,18 +2377,19 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
|||
option.selected = true
|
||||
sceneSelect.replaceChildren(option)
|
||||
sceneSelect.disabled = false
|
||||
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
|
||||
variantSelect.replaceChildren(...runtime.variants.map(name => {
|
||||
const item = document.createElement('option')
|
||||
item.value = name
|
||||
item.textContent = name
|
||||
item.selected = name === runtime.quadrant
|
||||
item.selected = name === runtime.variant
|
||||
return item
|
||||
}))
|
||||
variantSelect.disabled = runtime.quadrants.length === 0
|
||||
variantSelect.disabled = runtime.variants.length === 0
|
||||
actSelect.onchange = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
params.set('act', actSelect.value)
|
||||
params.delete('level')
|
||||
params.delete('variant')
|
||||
params.delete('quadrant')
|
||||
location.search = params.toString()
|
||||
}
|
||||
|
|
@ -2279,8 +2397,9 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
|||
variantSelect.onchange = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
params.set('act', String(runtime.act))
|
||||
params.set(runtime.quadrantParam, variantSelect.value)
|
||||
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
|
||||
params.set(runtime.variantParam, variantSelect.value)
|
||||
params.delete(runtime.variantParam === 'level' ? 'variant' : 'level')
|
||||
params.delete('quadrant')
|
||||
location.search = params.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -2323,7 +2442,7 @@ async function boot(): Promise<void> {
|
|||
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
|
||||
if (runtime === null) continue
|
||||
publish(runtime, packBase)
|
||||
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
|
||||
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant })
|
||||
const renderer = new SpriteRenderer(canvas, {
|
||||
onContextLost: () => {
|
||||
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
||||
|
|
@ -2335,7 +2454,7 @@ async function boot(): Promise<void> {
|
|||
})
|
||||
// The first frame waits only for the spawn-area pages.
|
||||
await runtime.loadPages(renderer, runtime.priorityPages, () => {
|
||||
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
||||
state.pagesLoaded = countLoadedPages(runtime.pages)
|
||||
})
|
||||
runScene(runtime, renderer, started)
|
||||
return
|
||||
|
|
@ -2357,7 +2476,8 @@ async function boot(): Promise<void> {
|
|||
location.reload()
|
||||
},
|
||||
})
|
||||
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
|
||||
const requestedVariant = param('variant', '') || param('quadrant', '')
|
||||
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
|
||||
publish(runtime, base)
|
||||
runScene(runtime, renderer, started)
|
||||
return
|
||||
|
|
@ -2376,4 +2496,4 @@ async function boot(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
if (typeof document !== 'undefined') void boot()
|
||||
|
|
|
|||
312
src/sim/loop.ts
312
src/sim/loop.ts
|
|
@ -4,10 +4,15 @@
|
|||
* Diablo's simulation runs at 25 ticks per second, and that rate is not
|
||||
* cosmetic: animation frames, movement, and monster AI all advance per tick,
|
||||
* so anything derived from wall-clock time would diverge from the original
|
||||
* behaviour. The loop therefore ticks at a fixed rate and lets rendering happen
|
||||
* whenever the browser paints, interpolating only the camera-facing positions
|
||||
* the caller asks to smooth.
|
||||
* behaviour.
|
||||
*
|
||||
* To avoid tying simulation progress to rendering performance (where frame drops
|
||||
* or heavy scenes would artificially drag down simulation rate or catastrophically
|
||||
* drop backlog time), simulation ticks are driven by a dedicated timer mechanism
|
||||
* at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame
|
||||
* handles rendering and frame metrics with proper interpolation alpha.
|
||||
*/
|
||||
|
||||
/** Loop wiring. */
|
||||
export interface LoopOptions {
|
||||
/** Ticks per second (Diablo's simulation rate: 25). */
|
||||
|
|
@ -22,25 +27,39 @@ export interface LoopOptions {
|
|||
* @param frameMs - wall-clock duration of the previous frame.
|
||||
*/
|
||||
readonly onRender: (alpha: number, frameMs: number) => void
|
||||
/** Most ticks to run in one frame before discarding the backlog. */
|
||||
/** Most ticks to run in one timer/frame step before clamping the backlog. */
|
||||
readonly maxCatchUp?: number
|
||||
/** Optional custom clock source for testing. Defaults to performance.now. */
|
||||
readonly clock?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-rate loop driven by `requestAnimationFrame`.
|
||||
* A fixed-rate simulation loop decoupled from rendering.
|
||||
*/
|
||||
export class GameLoop {
|
||||
private readonly options: LoopOptions
|
||||
private readonly tickMs: number
|
||||
private readonly maxCatchUp: number
|
||||
private accumulator = 0
|
||||
private lastTime = 0
|
||||
private handle = 0
|
||||
private running = false
|
||||
readonly tickMs: number
|
||||
readonly maxCatchUp: number
|
||||
|
||||
private rawAccumulator = 0
|
||||
private consumed = 0
|
||||
private rateWindowStart = 0
|
||||
private rateWindowTicks = 0
|
||||
private measuredRate = 0
|
||||
private running = false
|
||||
|
||||
// Simulation timer & metrics
|
||||
private simTimerHandle: ReturnType<typeof setTimeout> | null = null
|
||||
private lastSimTime = 0
|
||||
private simWindowStart = 0
|
||||
private simTicksInWindow = 0
|
||||
private simVirtualElapsed = 0
|
||||
private measuredSimRate = 0
|
||||
|
||||
// Render loop & metrics
|
||||
private renderHandle = 0
|
||||
private lastRenderTime = 0
|
||||
private lastFrameMs = 0
|
||||
private renderWindowStart = 0
|
||||
private renderFramesInWindow = 0
|
||||
private measuredFps = 0
|
||||
|
||||
/**
|
||||
* @param options - loop wiring.
|
||||
|
|
@ -48,7 +67,12 @@ export class GameLoop {
|
|||
constructor(options: LoopOptions) {
|
||||
this.options = options
|
||||
this.tickMs = 1000 / options.tickRate
|
||||
this.maxCatchUp = options.maxCatchUp ?? 5
|
||||
this.maxCatchUp = options.maxCatchUp ?? 10
|
||||
}
|
||||
|
||||
/** Current time in ms from options.clock or performance.now. */
|
||||
private now(): number {
|
||||
return this.options.clock ? this.options.clock() : performance.now()
|
||||
}
|
||||
|
||||
/** Ticks simulated since the loop started. */
|
||||
|
|
@ -56,49 +80,239 @@ export class GameLoop {
|
|||
return this.consumed
|
||||
}
|
||||
|
||||
/** Measured ticks per second over the last window (0 until one has elapsed). */
|
||||
/** Measured simulation ticks per second over the last window (or target rate when active). */
|
||||
get tickRate(): number {
|
||||
return this.measuredRate
|
||||
return this.simTps
|
||||
}
|
||||
|
||||
/** Begin ticking. Idempotent. */
|
||||
/** Simulation ticks per second. */
|
||||
get simTps(): number {
|
||||
if (this.measuredSimRate > 0) return this.measuredSimRate
|
||||
const elapsed = this.now() - this.simWindowStart
|
||||
if (elapsed >= 200 && this.simTicksInWindow > 0) {
|
||||
return (this.simTicksInWindow * 1000) / elapsed
|
||||
}
|
||||
if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) {
|
||||
return (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
}
|
||||
return this.running ? this.options.tickRate : 0
|
||||
}
|
||||
|
||||
/** Measured render frames per second. */
|
||||
get fps(): number {
|
||||
return this.renderFps
|
||||
}
|
||||
|
||||
/** Render frames per second. */
|
||||
get renderFps(): number {
|
||||
if (this.measuredFps > 0) return this.measuredFps
|
||||
const elapsed = this.now() - this.renderWindowStart
|
||||
if (elapsed >= 200 && this.renderFramesInWindow > 0) {
|
||||
return (this.renderFramesInWindow * 1000) / elapsed
|
||||
}
|
||||
return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0
|
||||
}
|
||||
|
||||
/** Duration of the last render frame in milliseconds. */
|
||||
get frameMs(): number {
|
||||
return this.lastFrameMs
|
||||
}
|
||||
|
||||
/** Whether the loop is currently running. */
|
||||
get isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/** Current simulation accumulator in milliseconds. */
|
||||
get accumulator(): number {
|
||||
const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0
|
||||
return this.rawAccumulator + elapsedSinceLastSim
|
||||
}
|
||||
|
||||
/** Interpolation factor in 0..1 between the current tick and the next tick. */
|
||||
get alpha(): number {
|
||||
return Math.min(1, Math.max(0, this.accumulator / this.tickMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually advance the simulation by deltaMs milliseconds.
|
||||
* Useful for headless simulation, test clocks, and deterministic testing.
|
||||
*
|
||||
* @param deltaMs - Milliseconds to advance.
|
||||
* @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited).
|
||||
*/
|
||||
step(deltaMs: number, maxCatchUp?: number): void {
|
||||
if (deltaMs <= 0) return
|
||||
this.rawAccumulator += deltaMs
|
||||
this.lastSimTime = this.now()
|
||||
|
||||
const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0)
|
||||
let steps = 0
|
||||
while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) {
|
||||
this.rawAccumulator -= this.tickMs
|
||||
this.consumed += 1
|
||||
this.simTicksInWindow += 1
|
||||
this.options.onTick(this.consumed)
|
||||
steps += 1
|
||||
}
|
||||
|
||||
if (maxSteps > 0) {
|
||||
const maxBacklog = this.tickMs * maxSteps
|
||||
if (this.rawAccumulator > maxBacklog) {
|
||||
this.rawAccumulator = maxBacklog
|
||||
}
|
||||
}
|
||||
|
||||
this.simVirtualElapsed += deltaMs
|
||||
if (this.simVirtualElapsed >= 1000) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
this.simVirtualElapsed = 0
|
||||
this.simTicksInWindow = 0
|
||||
} else if (this.simVirtualElapsed >= 200) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger one render call.
|
||||
*/
|
||||
render(frameMs?: number): void {
|
||||
const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6)
|
||||
this.lastFrameMs = elapsed
|
||||
this.options.onRender(this.alpha, elapsed)
|
||||
}
|
||||
|
||||
/** Advance simulation according to elapsed wall-clock time. */
|
||||
private advanceSimulation(now: number): void {
|
||||
const elapsed = now - this.lastSimTime
|
||||
if (elapsed <= 0) return
|
||||
this.lastSimTime = now
|
||||
this.rawAccumulator += elapsed
|
||||
|
||||
let steps = 0
|
||||
while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) {
|
||||
this.rawAccumulator -= this.tickMs
|
||||
this.consumed += 1
|
||||
this.simTicksInWindow += 1
|
||||
this.options.onTick(this.consumed)
|
||||
steps += 1
|
||||
}
|
||||
|
||||
// Prevent catastrophic discard: clamp to max backlog instead of wiping to 0
|
||||
const maxBacklog = this.tickMs * this.maxCatchUp
|
||||
if (this.rawAccumulator > maxBacklog) {
|
||||
this.rawAccumulator = maxBacklog
|
||||
}
|
||||
|
||||
const windowElapsed = now - this.simWindowStart
|
||||
if (windowElapsed >= 1000) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
||||
this.simWindowStart = now
|
||||
this.simTicksInWindow = 0
|
||||
} else if (this.measuredSimRate === 0 && windowElapsed >= 200) {
|
||||
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin ticking and rendering. Idempotent. */
|
||||
start(): void {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
this.lastTime = performance.now()
|
||||
this.rateWindowStart = this.lastTime
|
||||
this.rateWindowTicks = 0
|
||||
const step = (now: number): void => {
|
||||
if (!this.running) return
|
||||
const elapsed = now - this.lastTime
|
||||
this.lastTime = now
|
||||
this.accumulator += elapsed
|
||||
let steps = 0
|
||||
while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) {
|
||||
this.accumulator -= this.tickMs
|
||||
this.consumed += 1
|
||||
this.rateWindowTicks += 1
|
||||
this.options.onTick(this.consumed)
|
||||
steps += 1
|
||||
}
|
||||
// A long stall (a backgrounded tab, a slow load) must not replay minutes
|
||||
// of simulation on the next frame: drop the backlog instead.
|
||||
if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0
|
||||
if (now - this.rateWindowStart >= 1000) {
|
||||
this.measuredRate = (this.rateWindowTicks * 1000) / (now - this.rateWindowStart)
|
||||
this.rateWindowStart = now
|
||||
this.rateWindowTicks = 0
|
||||
}
|
||||
this.options.onRender(this.accumulator / this.tickMs, elapsed)
|
||||
this.handle = requestAnimationFrame(step)
|
||||
}
|
||||
this.handle = requestAnimationFrame(step)
|
||||
|
||||
const now = this.now()
|
||||
this.lastSimTime = now
|
||||
this.lastRenderTime = now
|
||||
this.simWindowStart = now
|
||||
this.renderWindowStart = now
|
||||
this.simTicksInWindow = 0
|
||||
this.renderFramesInWindow = 0
|
||||
|
||||
this.startSimTimer()
|
||||
this.startRenderLoop()
|
||||
}
|
||||
|
||||
/** Stop ticking. Idempotent. */
|
||||
/** Start the self-correcting timer loop for simulation ticks. */
|
||||
private startSimTimer(): void {
|
||||
if (this.simTimerHandle !== null) return
|
||||
let nextTick = this.now() + this.tickMs
|
||||
|
||||
const simStep = (): void => {
|
||||
if (!this.running) return
|
||||
const now = this.now()
|
||||
this.advanceSimulation(now)
|
||||
|
||||
// Self-correct next tick timestamp to eliminate drift
|
||||
nextTick += this.tickMs
|
||||
if (nextTick < now - this.tickMs * this.maxCatchUp) {
|
||||
nextTick = now + this.tickMs
|
||||
} else {
|
||||
while (nextTick <= now) {
|
||||
nextTick += this.tickMs
|
||||
}
|
||||
}
|
||||
const delay = Math.max(0, nextTick - this.now())
|
||||
this.simTimerHandle = setTimeout(simStep, delay)
|
||||
}
|
||||
|
||||
this.simTimerHandle = setTimeout(simStep, this.tickMs)
|
||||
}
|
||||
|
||||
/** Start requestAnimationFrame render loop. */
|
||||
private startRenderLoop(): void {
|
||||
if (this.renderHandle !== 0) return
|
||||
|
||||
const renderStep = (): void => {
|
||||
if (!this.running) return
|
||||
const now = this.now()
|
||||
const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6
|
||||
this.lastRenderTime = now
|
||||
this.lastFrameMs = frameMs
|
||||
this.renderFramesInWindow += 1
|
||||
|
||||
const renderWindowElapsed = now - this.renderWindowStart
|
||||
if (renderWindowElapsed >= 1000) {
|
||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
||||
this.renderWindowStart = now
|
||||
this.renderFramesInWindow = 0
|
||||
} else if (this.measuredFps === 0 && renderWindowElapsed >= 200) {
|
||||
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
|
||||
}
|
||||
|
||||
this.options.onRender(this.alpha, frameMs)
|
||||
|
||||
if (this.running) {
|
||||
this.renderHandle = this.requestFrame(renderStep)
|
||||
}
|
||||
}
|
||||
|
||||
this.renderHandle = this.requestFrame(renderStep)
|
||||
}
|
||||
|
||||
private requestFrame(cb: (time: number) => void): number {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
return requestAnimationFrame(cb)
|
||||
}
|
||||
return setTimeout(() => cb(this.now()), 16) as unknown as number
|
||||
}
|
||||
|
||||
private cancelFrame(handle: number): void {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(handle)
|
||||
return
|
||||
}
|
||||
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
|
||||
}
|
||||
|
||||
/** Stop ticking and rendering. Idempotent. */
|
||||
stop(): void {
|
||||
this.running = false
|
||||
if (this.handle !== 0) cancelAnimationFrame(this.handle)
|
||||
this.handle = 0
|
||||
if (this.simTimerHandle !== null) {
|
||||
clearTimeout(this.simTimerHandle)
|
||||
this.simTimerHandle = null
|
||||
}
|
||||
if (this.renderHandle !== 0) {
|
||||
this.cancelFrame(this.renderHandle)
|
||||
this.renderHandle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
advanceFrameIndex,
|
||||
computeFrameIndexFromTick,
|
||||
computeFrameIndexFromTime,
|
||||
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
||||
DEFAULT_TICKS_PER_ANIMATED_FRAME,
|
||||
updateAnimatableTile,
|
||||
updateAnimatableTiles,
|
||||
} from '../src/game/animated-tiles.ts'
|
||||
import type { AnimatableTile } from '../src/game/animated-tiles.ts'
|
||||
import { decodeDt1, isAnimatedTile, subTileFlagsOf } from '../src/formats/dt1.ts'
|
||||
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
|
||||
import { buildIsoMapScene } from '../src/game/d2map.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
|
||||
describe('Animated Tiles (Issue #43)', () => {
|
||||
describe('DT1 Animated Tile Detection', () => {
|
||||
test('isAnimatedTile correctly identifies animated flag byte', () => {
|
||||
expect(isAnimatedTile({ animated: 0 })).toBe(false)
|
||||
expect(isAnimatedTile({})).toBe(false)
|
||||
expect(isAnimatedTile({ animated: 1 })).toBe(true)
|
||||
expect(isAnimatedTile({ animated: 3 })).toBe(true)
|
||||
expect(isAnimatedTile({ animated: 0x81 })).toBe(true)
|
||||
})
|
||||
|
||||
test('decodeDt1 parses animated flag at offset +7', () => {
|
||||
// Construct a minimal valid DT1 buffer with 1 tile
|
||||
// DT1 header:
|
||||
// 0..3: magic1 (7)
|
||||
// 4..7: magic2 (6)
|
||||
// 8..267: unused header bytes (260 bytes zeros)
|
||||
// 268..271: tileCount (1)
|
||||
// 272..275: tileOffset (276)
|
||||
// Tile header at 276:
|
||||
// +0: direction (i32: 1)
|
||||
// +4: roofHeight (i16: 0)
|
||||
// +6: materialFlags (u16: 0)
|
||||
// +7: animated (u8: 1) <-- tested byte
|
||||
// +8: height (i32: -80)
|
||||
// +12: width (i32: 160)
|
||||
// +16: unused (4 bytes)
|
||||
// +20: type (i32: 0)
|
||||
// +24: style (i32: 1)
|
||||
// +28: sequence (i32: 2)
|
||||
// +32: rarityFrameIndex (i32: 0)
|
||||
// +36..+59: subTileFlags (20 bytes)
|
||||
// +60..+79: unused (20 bytes)
|
||||
// +80: blockHeaderPointer (i32: 0)
|
||||
// +84: blockDataOffset (i32: 0)
|
||||
// +88: blockCount (i32: 0)
|
||||
// Total tile header size = 96 bytes.
|
||||
const buffer = new Uint8Array(276 + 96)
|
||||
const view = new DataView(buffer.buffer)
|
||||
view.setInt32(0, 7, true)
|
||||
view.setInt32(4, 6, true)
|
||||
view.setInt32(268, 1, true)
|
||||
view.setInt32(272, 276, true)
|
||||
|
||||
const tileAt = 276
|
||||
view.setInt32(tileAt + 0, 1, true) // direction
|
||||
view.setInt16(tileAt + 4, 0, true) // roofHeight
|
||||
view.setUint16(tileAt + 6, 0, true) // materialFlags
|
||||
buffer[tileAt + 7] = 1 // animated flag
|
||||
view.setInt32(tileAt + 8, -80, true) // height
|
||||
view.setInt32(tileAt + 12, 160, true) // width
|
||||
view.setInt32(tileAt + 20, 0, true) // type
|
||||
view.setInt32(tileAt + 24, 1, true) // style
|
||||
view.setInt32(tileAt + 28, 2, true) // sequence
|
||||
view.setInt32(tileAt + 32, 0, true) // rarityFrameIndex (frame 0)
|
||||
|
||||
const dt1 = decodeDt1(buffer)
|
||||
expect(dt1.tiles.length).toBe(1)
|
||||
const tile = dt1.tiles[0]!
|
||||
expect(tile.animated).toBe(1)
|
||||
expect(isAnimatedTile(tile)).toBe(true)
|
||||
expect(tile.rarityFrameIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Frame Advancement & Timing', () => {
|
||||
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
|
||||
const frameCount = 4
|
||||
const duration = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS // 100ms
|
||||
|
||||
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(199, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
|
||||
expect(computeFrameIndexFromTime(350, frameCount, duration)).toBe(3)
|
||||
})
|
||||
|
||||
test('computeFrameIndexFromTime supports custom duration', () => {
|
||||
const frameCount = 5
|
||||
const duration = 50 // 50ms per frame = 20 FPS
|
||||
|
||||
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(49, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(150, frameCount, duration)).toBe(3)
|
||||
})
|
||||
|
||||
test('computeFrameIndexFromTime gracefully handles single-frame or invalid frame count', () => {
|
||||
expect(computeFrameIndexFromTime(1000, 1)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(1000, 0)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(-500, 4)).toBe(0)
|
||||
})
|
||||
|
||||
test('computeFrameIndexFromTick handles 25Hz game ticks', () => {
|
||||
const frameCount = 4
|
||||
const ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME // 2.5 ticks / frame
|
||||
|
||||
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
|
||||
expect(computeFrameIndexFromTick(2, frameCount, ticksPerFrame)).toBe(0) // 2 / 2.5 = 0.8 -> 0
|
||||
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1) // 3 / 2.5 = 1.2 -> 1
|
||||
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2) // 5 / 2.5 = 2.0 -> 2
|
||||
expect(computeFrameIndexFromTick(7, frameCount, ticksPerFrame)).toBe(2) // 7 / 2.5 = 2.8 -> 2
|
||||
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3) // 8 / 2.5 = 3.2 -> 3
|
||||
})
|
||||
|
||||
test('advanceFrameIndex manual frame increment and wrapping', () => {
|
||||
expect(advanceFrameIndex(0, 4)).toBe(1)
|
||||
expect(advanceFrameIndex(1, 4)).toBe(2)
|
||||
expect(advanceFrameIndex(2, 4)).toBe(3)
|
||||
expect(advanceFrameIndex(3, 4)).toBe(0) // Wrap!
|
||||
expect(advanceFrameIndex(0, 4, 5)).toBe(1) // Advance 5 frames: 5 % 4 = 1
|
||||
expect(advanceFrameIndex(2, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cycle Wrapping (Continuous Loop Playback)', () => {
|
||||
test('time-based cycling loops indefinitely without drift', () => {
|
||||
const frameCount = 3
|
||||
const duration = 100 // ms
|
||||
|
||||
// Cycle 0: 0..299ms
|
||||
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
|
||||
|
||||
// Cycle 1: 300..599ms
|
||||
expect(computeFrameIndexFromTime(300, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(400, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(500, frameCount, duration)).toBe(2)
|
||||
|
||||
// Cycle 100: 30000ms
|
||||
expect(computeFrameIndexFromTime(30000, frameCount, duration)).toBe(0)
|
||||
expect(computeFrameIndexFromTime(30150, frameCount, duration)).toBe(1)
|
||||
expect(computeFrameIndexFromTime(30250, frameCount, duration)).toBe(2)
|
||||
})
|
||||
|
||||
test('tick-based cycling loops indefinitely', () => {
|
||||
const frameCount = 4
|
||||
const ticksPerFrame = 2.5 // total cycle = 10 ticks
|
||||
|
||||
// Cycle 0: ticks 0..9
|
||||
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
|
||||
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1)
|
||||
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2)
|
||||
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3)
|
||||
|
||||
// Cycle 1: ticks 10..19
|
||||
expect(computeFrameIndexFromTick(10, frameCount, ticksPerFrame)).toBe(0)
|
||||
expect(computeFrameIndexFromTick(13, frameCount, ticksPerFrame)).toBe(1)
|
||||
expect(computeFrameIndexFromTick(15, frameCount, ticksPerFrame)).toBe(2)
|
||||
expect(computeFrameIndexFromTick(18, frameCount, ticksPerFrame)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AnimatableTile Mutator Functions', () => {
|
||||
test('updateAnimatableTile updates frame and page in-place', () => {
|
||||
const frame0 = { x: 0, y: 0, width: 160, height: 80 }
|
||||
const frame1 = { x: 160, y: 0, width: 160, height: 80 }
|
||||
const frame2 = { x: 320, y: 0, width: 160, height: 80 }
|
||||
|
||||
const tile: AnimatableTile = {
|
||||
frame: frame0,
|
||||
page: 0,
|
||||
animatedFrames: [
|
||||
{ frame: frame0, page: 0 },
|
||||
{ frame: frame1, page: 0 },
|
||||
{ frame: frame2, page: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
// At 0ms -> frame0, page 0
|
||||
updateAnimatableTile(tile, 0, 'time', 100)
|
||||
expect(tile.frame).toBe(frame0)
|
||||
expect(tile.page).toBe(0)
|
||||
|
||||
// At 150ms -> frame1, page 0
|
||||
updateAnimatableTile(tile, 150, 'time', 100)
|
||||
expect(tile.frame).toBe(frame1)
|
||||
expect(tile.page).toBe(0)
|
||||
|
||||
// At 250ms -> frame2, page 1
|
||||
updateAnimatableTile(tile, 250, 'time', 100)
|
||||
expect(tile.frame).toBe(frame2)
|
||||
expect(tile.page).toBe(1)
|
||||
|
||||
// At 350ms -> wraps back to frame0, page 0
|
||||
updateAnimatableTile(tile, 350, 'time', 100)
|
||||
expect(tile.frame).toBe(frame0)
|
||||
expect(tile.page).toBe(0)
|
||||
})
|
||||
|
||||
test('updateAnimatableTiles updates an array of animatable tiles', () => {
|
||||
const f0 = { x: 0, y: 0, width: 160, height: 80 }
|
||||
const f1 = { x: 160, y: 0, width: 160, height: 80 }
|
||||
|
||||
const tileA: AnimatableTile = {
|
||||
frame: f0,
|
||||
page: 0,
|
||||
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
|
||||
}
|
||||
const tileB: AnimatableTile = {
|
||||
frame: f0,
|
||||
page: 0,
|
||||
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
|
||||
}
|
||||
const staticTile: AnimatableTile = {
|
||||
frame: f0,
|
||||
page: 0,
|
||||
}
|
||||
|
||||
const list = [tileA, tileB, staticTile]
|
||||
|
||||
updateAnimatableTiles(list, 150, 'time', 100)
|
||||
expect(tileA.frame).toBe(f1)
|
||||
expect(tileB.frame).toBe(f1)
|
||||
expect(staticTile.frame).toBe(f0) // Unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('d2map buildIsoMapScene Animated Sequence Resolution', () => {
|
||||
test('resolves multi-frame animated tiles into sequential animatedFrames', () => {
|
||||
// Create 3 DT1 tiles with same style, sequence, type, with animated flag = 1
|
||||
// and frame indices 0, 1, 2
|
||||
const makeTile = (frameIndex: number): Dt1Tile => ({
|
||||
direction: 1,
|
||||
roofHeight: 0,
|
||||
materialFlags: 0,
|
||||
animated: 1,
|
||||
height: -80,
|
||||
width: 160,
|
||||
type: 0, // floor
|
||||
style: 5,
|
||||
sequence: 1,
|
||||
rarityFrameIndex: frameIndex,
|
||||
subTileFlags: Array.from({ length: 25 }, () => subTileFlagsOf(0)),
|
||||
blocks: [],
|
||||
minBlockY: 0,
|
||||
bitmapHeight: 80,
|
||||
})
|
||||
|
||||
// Provide tiles out of order to ensure sorting by frameIndex works: [frame 2, frame 0, frame 1]
|
||||
const dt1Library: Dt1 = {
|
||||
versionMajor: 7,
|
||||
versionMinor: 6,
|
||||
tiles: [makeTile(2), makeTile(0), makeTile(1)],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
// Create a minimal 1x1 DS1 referencing style 5, sequence 1
|
||||
const ds1: Ds1 = {
|
||||
version: 18,
|
||||
width: 1,
|
||||
height: 1,
|
||||
act: 1,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells: [[{
|
||||
floors: [{ style: 5, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
||||
walls: [],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
}]],
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
|
||||
const scene = buildIsoMapScene(ds1, [dt1Library])
|
||||
|
||||
expect(scene.animatedTileCount).toBe(1)
|
||||
expect(scene.floors.length).toBe(1)
|
||||
const floorDraw = scene.floors[0]!
|
||||
|
||||
// animatedFrames should exist and have 3 frames
|
||||
expect(floorDraw.animatedFrames).toBeDefined()
|
||||
expect(floorDraw.animatedFrames?.length).toBe(3)
|
||||
|
||||
// The base frame index should be the first frame (frameIndex 0)
|
||||
const frameIndices = floorDraw.animatedFrames!
|
||||
expect(frameIndices.length).toBe(3)
|
||||
// All 3 frames should be distinct frame indices in scene.frames
|
||||
expect(new Set(frameIndices).size).toBe(3)
|
||||
// Base frame should be frame 0
|
||||
expect(floorDraw.frameIndex).toBe(frameIndices[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GameLoop } from '../src/sim/loop.ts'
|
||||
|
||||
describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => {
|
||||
it('1. Deterministic ticking at 25 tps in headless/step mode', () => {
|
||||
let tickCount = 0
|
||||
const ticks: number[] = []
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: (tick) => {
|
||||
tickCount++
|
||||
ticks.push(tick)
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
expect(loop.tickMs).toBe(40)
|
||||
expect(loop.tick).toBe(0)
|
||||
|
||||
// Advancing by less than tickMs does not produce a tick
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(0)
|
||||
expect(loop.accumulator).toBe(20)
|
||||
expect(loop.alpha).toBeCloseTo(0.5, 2)
|
||||
|
||||
// Advancing another 20ms completes 40ms -> exactly 1 tick
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(1)
|
||||
expect(loop.tick).toBe(1)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
expect(loop.alpha).toBe(0)
|
||||
|
||||
// Advancing by 80ms produces exactly 2 ticks
|
||||
loop.step(80)
|
||||
expect(tickCount).toBe(3)
|
||||
expect(loop.tick).toBe(3)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
|
||||
// Advancing by 1000ms produces exactly 25 ticks
|
||||
loop.step(1000)
|
||||
expect(tickCount).toBe(28) // 3 + 25
|
||||
expect(loop.tick).toBe(28)
|
||||
expect(loop.tickRate).toBe(25.0)
|
||||
expect(loop.simTps).toBe(25.0)
|
||||
})
|
||||
|
||||
it('1b. Deterministic ticking at 25 tps with timers', async () => {
|
||||
let tickCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
// Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms)
|
||||
await new Promise(resolve => setTimeout(resolve, 260))
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
expect(tickCount).toBeGreaterThanOrEqual(5)
|
||||
expect(tickCount).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => {
|
||||
const world = { tick: 0 }
|
||||
let renderCalls = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
onTick: (tick) => {
|
||||
world.tick = tick
|
||||
},
|
||||
onRender: () => {
|
||||
renderCalls++
|
||||
// Simulate heavy render frame stall of 50ms
|
||||
const stallStart = performance.now()
|
||||
while (performance.now() - stallStart < 50) {
|
||||
// busy wait
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
loop.start()
|
||||
|
||||
// Run for ~220ms
|
||||
// With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS)
|
||||
// But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
loop.stop()
|
||||
|
||||
expect(renderCalls).toBeLessThanOrEqual(4)
|
||||
expect(world.tick).toBeGreaterThanOrEqual(5)
|
||||
// Simulation TPS remains high despite slowed rendering
|
||||
expect(loop.simTps).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('3. Accumulator recovery and no catastrophic drop on transient lag', () => {
|
||||
let tickCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms per tick
|
||||
maxCatchUp: 5, // max 5 ticks (200ms) per step
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {}
|
||||
})
|
||||
|
||||
// Transient lag of 150ms (3.75 ticks)
|
||||
loop.step(150)
|
||||
// Consumed 3 ticks (120ms), 30ms remaining
|
||||
expect(tickCount).toBe(3)
|
||||
expect(loop.accumulator).toBe(30)
|
||||
expect(loop.alpha).toBeCloseTo(30 / 40, 2)
|
||||
|
||||
// Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left
|
||||
loop.step(20)
|
||||
expect(tickCount).toBe(4)
|
||||
expect(loop.accumulator).toBe(10)
|
||||
expect(loop.alpha).toBeCloseTo(10 / 40, 2)
|
||||
|
||||
// Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms)
|
||||
// Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0`
|
||||
// which wiped out all remaining simulation time!
|
||||
// New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0.
|
||||
loop.step(500)
|
||||
// 5 ticks executed in this step (tickCount 4 + 5 = 9)
|
||||
expect(tickCount).toBe(9)
|
||||
// Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0!
|
||||
expect(loop.accumulator).toBe(200)
|
||||
|
||||
// Subsequent steps recover and consume the preserved backlog
|
||||
loop.step(40)
|
||||
// Consumes 5 ticks from the 200ms backlog + 40ms new time
|
||||
expect(tickCount).toBe(14)
|
||||
expect(loop.accumulator).toBe(40)
|
||||
|
||||
loop.step(40)
|
||||
expect(tickCount).toBe(16)
|
||||
expect(loop.accumulator).toBe(0)
|
||||
})
|
||||
|
||||
it('4. Clean start / stop lifecycle and timer cleanup', async () => {
|
||||
let tickCount = 0
|
||||
let renderCount = 0
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25,
|
||||
onTick: () => {
|
||||
tickCount++
|
||||
},
|
||||
onRender: () => {
|
||||
renderCount++
|
||||
}
|
||||
})
|
||||
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
// Start
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
// Calling start() again is idempotent
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
expect(tickCount).toBeGreaterThanOrEqual(1)
|
||||
expect(renderCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Stop
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
const stoppedTicks = tickCount
|
||||
const stoppedRenders = renderCount
|
||||
|
||||
// Calling stop() again is idempotent
|
||||
loop.stop()
|
||||
expect(loop.isRunning).toBe(false)
|
||||
|
||||
// Wait and verify timers are cleaned up and no more callbacks fire
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(tickCount).toBe(stoppedTicks)
|
||||
expect(renderCount).toBe(stoppedRenders)
|
||||
|
||||
// Restart cleanly
|
||||
loop.start()
|
||||
expect(loop.isRunning).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
expect(tickCount).toBeGreaterThan(stoppedTicks)
|
||||
expect(renderCount).toBeGreaterThan(stoppedRenders)
|
||||
loop.stop()
|
||||
})
|
||||
|
||||
it('5. Interpolation alpha calculation and manual render stepping', () => {
|
||||
let lastAlpha = -1
|
||||
let lastFrameMs = -1
|
||||
|
||||
const loop = new GameLoop({
|
||||
tickRate: 25, // 40ms
|
||||
onTick: () => {},
|
||||
onRender: (alpha, frameMs) => {
|
||||
lastAlpha = alpha
|
||||
lastFrameMs = frameMs
|
||||
}
|
||||
})
|
||||
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBe(0)
|
||||
expect(lastFrameMs).toBeCloseTo(16.6, 1)
|
||||
|
||||
loop.step(10) // accumulator = 10ms (10/40 = 0.25)
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBeCloseTo(0.25, 2)
|
||||
|
||||
loop.step(20) // accumulator = 30ms (30/40 = 0.75)
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBeCloseTo(0.75, 2)
|
||||
|
||||
loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms
|
||||
loop.render(16.6)
|
||||
expect(lastAlpha).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
populateDungeonObjects,
|
||||
CANONICAL_SHRINES,
|
||||
CHEST_IDS_BY_ACT,
|
||||
RACK_IDS_BY_ACT,
|
||||
CONTAINER_IDS_BY_ACT,
|
||||
type MazeRoomDescriptor,
|
||||
type PlacedMazeObject,
|
||||
type WallAlignment,
|
||||
} from '../src/game/maze-objects.ts'
|
||||
import {
|
||||
generateMaze,
|
||||
type MazePiece,
|
||||
type MazeRequest,
|
||||
} from '../src/game/maze.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
|
||||
/**
|
||||
* Creates a mock DS1 room with perimeter walls and an open interior.
|
||||
* Optionally adds a doorway gap on specified sides ('N' | 'S' | 'E' | 'W').
|
||||
*/
|
||||
function makeMockDs1(
|
||||
width: number,
|
||||
height: number,
|
||||
doorways: readonly ('N' | 'S' | 'E' | 'W')[] = [],
|
||||
staticObjects: readonly Ds1Object[] = [],
|
||||
): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
const midX = Math.floor(width / 2)
|
||||
const midY = Math.floor(height / 2)
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const isNorthWall = y === 0
|
||||
const isSouthWall = y === height - 1
|
||||
const isWestWall = x === 0
|
||||
const isEastWall = x === width - 1
|
||||
let isWall = isNorthWall || isSouthWall || isWestWall || isEastWall
|
||||
|
||||
// Open doorways in the middle of perimeter walls
|
||||
if (isNorthWall && x === midX && doorways.includes('N')) isWall = false
|
||||
if (isSouthWall && x === midX && doorways.includes('S')) isWall = false
|
||||
if (isWestWall && y === midY && doorways.includes('W')) isWall = false
|
||||
if (isEastWall && y === midY && doorways.includes('E')) isWall = false
|
||||
|
||||
row.push({
|
||||
walls: isWall
|
||||
? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }]
|
||||
: [],
|
||||
floors: [{ prop1: 1, 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: [...staticObjects],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a suite of mock maze pieces for `generateMaze`.
|
||||
*/
|
||||
function makeTestMazePieces(
|
||||
levelTypeName = 'Act 1 - Cave',
|
||||
size = 11,
|
||||
staticObjects: readonly Ds1Object[] = [],
|
||||
): MazePiece[] {
|
||||
const allSides = [
|
||||
'N', 'S', 'E', 'W', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
|
||||
'NSE', 'NSW', 'NEW', 'SEW', 'NSEW',
|
||||
]
|
||||
|
||||
const pieces: MazePiece[] = allSides.map(sides => ({
|
||||
name: `${levelTypeName} ${sides}`,
|
||||
kind: 'room',
|
||||
sides,
|
||||
levels: [makeMockDs1(size, size, [], staticObjects)],
|
||||
}))
|
||||
|
||||
// Entrance piece
|
||||
pieces.push({
|
||||
name: `${levelTypeName} Entrance`,
|
||||
kind: 'entrance',
|
||||
sides: '',
|
||||
levels: [makeMockDs1(size, size, [], staticObjects)],
|
||||
})
|
||||
|
||||
// Directional special pieces
|
||||
for (const s of ['N', 'S', 'E', 'W']) {
|
||||
pieces.push({ name: `${levelTypeName} Prev ${s}`, kind: 'prev', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] })
|
||||
pieces.push({ name: `${levelTypeName} Down ${s}`, kind: 'down', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] })
|
||||
pieces.push({ name: `${levelTypeName} Next ${s}`, kind: 'next', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] })
|
||||
pieces.push({ name: `${levelTypeName} Quest ${s}`, kind: 'quest', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] })
|
||||
pieces.push({ name: `${levelTypeName} Theme ${s}`, kind: 'theme', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] })
|
||||
}
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a synthetic multi-room dungeon layout for fine-grained spot tests.
|
||||
*/
|
||||
function createSyntheticDungeon(): {
|
||||
level: Ds1
|
||||
rooms: MazeRoomDescriptor[]
|
||||
} {
|
||||
// 3 rooms laid out horizontally:
|
||||
// Room 0: dead-end (10x10 at [0, 0]), orths to Room 1 (east)
|
||||
// Room 1: corridor (10x10 at [10, 0]), orths to Room 0 (west) and Room 2 (east)
|
||||
// Room 2: dead-end (10x10 at [20, 0]), orths to Room 1 (west)
|
||||
const width = 30
|
||||
const height = 10
|
||||
const cells: Ds1Cell[][] = []
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
// Outer perimeter is wall, plus vertical dividers at x=10 and x=20
|
||||
const isOuterWall = y === 0 || y === height - 1 || x === 0 || x === width - 1
|
||||
const isDivider = (x === 10 || x === 20) && y !== 5 // doorway at y=5
|
||||
const isWall = isOuterWall || isDivider
|
||||
|
||||
row.push({
|
||||
walls: isWall
|
||||
? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }]
|
||||
: [],
|
||||
floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
const level: Ds1 = {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 1,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
|
||||
const room0: MazeRoomDescriptor = {
|
||||
id: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
orths: [{ room: 1, direction: 1 /* East */ }],
|
||||
}
|
||||
|
||||
const room1: MazeRoomDescriptor = {
|
||||
id: 1,
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
orths: [
|
||||
{ room: 0, direction: 3 /* West */ },
|
||||
{ room: 2, direction: 1 /* East */ },
|
||||
],
|
||||
}
|
||||
|
||||
const room2: MazeRoomDescriptor = {
|
||||
id: 2,
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
orths: [{ room: 1, direction: 3 /* West */ }],
|
||||
}
|
||||
|
||||
return { level, rooms: [room0, room1, room2] }
|
||||
}
|
||||
|
||||
describe('populateDungeonObjects pipeline', () => {
|
||||
test('spawns shrines and super chests in dead-end rooms', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
density: 1.0,
|
||||
objectSeed: 12345,
|
||||
})
|
||||
|
||||
const shrines = result.placedObjects.filter(o => o.category === 'shrine')
|
||||
const chests = result.placedObjects.filter(o => o.category === 'chest')
|
||||
const superChests = chests.filter(o => o.subType === 'super_chest')
|
||||
|
||||
expect(shrines.length).toBeGreaterThanOrEqual(1)
|
||||
expect(superChests.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Shrines in dead-ends must have isDeadEnd: true
|
||||
for (const shrine of shrines) {
|
||||
expect(shrine.type).toBe(2)
|
||||
expect(shrine.category).toBe('shrine')
|
||||
// Valid shrine subtype
|
||||
const validSubtypes = CANONICAL_SHRINES.map(s => s.subType)
|
||||
expect(validSubtypes).toContain(shrine.subType)
|
||||
if (shrine.roomId === 0 || shrine.roomId === 2) {
|
||||
expect(shrine.isDeadEnd).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Super chests placed in dead-end rooms
|
||||
for (const chest of superChests) {
|
||||
expect(chest.type).toBe(2)
|
||||
expect(chest.category).toBe('chest')
|
||||
expect(chest.isDeadEnd).toBe(true)
|
||||
expect(CHEST_IDS_BY_ACT[1]!.super).toContain(chest.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('aligns weapon and armor racks strictly against perimeter walls', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, {
|
||||
density: 1.0,
|
||||
rackDensity: 2.0, // encourage rack spawning
|
||||
objectSeed: 42,
|
||||
})
|
||||
|
||||
const racks = result.placedObjects.filter(o => o.category === 'rack')
|
||||
expect(racks.length).toBeGreaterThan(0)
|
||||
|
||||
for (const rack of racks) {
|
||||
expect(['armor_rack', 'weapon_rack']).toContain(rack.subType)
|
||||
expect(['north', 'south', 'east', 'west']).toContain(rack.alignedWall)
|
||||
|
||||
// Cell coordinates from subtiles (5 subtiles per tile)
|
||||
const cellX = Math.floor(rack.x / 5)
|
||||
const cellY = Math.floor(rack.y / 5)
|
||||
|
||||
// The rack itself must stand on an open floor cell
|
||||
const rackCell = level.cells[cellY]?.[cellX]
|
||||
expect(rackCell).toBeDefined()
|
||||
expect(rackCell!.floors.length).toBeGreaterThan(0)
|
||||
expect(rackCell!.walls.length).toBe(0)
|
||||
|
||||
// Neighbor in the direction of alignedWall must be a solid wall
|
||||
let wallNeighbor: Ds1Cell | undefined
|
||||
if (rack.alignedWall === 'north') wallNeighbor = level.cells[cellY - 1]?.[cellX]
|
||||
else if (rack.alignedWall === 'south') wallNeighbor = level.cells[cellY + 1]?.[cellX]
|
||||
else if (rack.alignedWall === 'west') wallNeighbor = level.cells[cellY]?.[cellX - 1]
|
||||
else if (rack.alignedWall === 'east') wallNeighbor = level.cells[cellY]?.[cellX + 1]
|
||||
|
||||
expect(wallNeighbor).toBeDefined()
|
||||
expect(wallNeighbor!.walls.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('scales container spawning proportionally with containerDensity', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
|
||||
const runZero = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
containerDensity: 0,
|
||||
objectSeed: 100,
|
||||
})
|
||||
const containersZero = runZero.placedObjects.filter(o => o.category === 'container')
|
||||
expect(containersZero.length).toBe(0)
|
||||
expect(runZero.stats.containersSpawned).toBe(0)
|
||||
|
||||
const runHalf = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
containerDensity: 0.5,
|
||||
objectSeed: 100,
|
||||
})
|
||||
const containersHalf = runHalf.placedObjects.filter(o => o.category === 'container')
|
||||
|
||||
const runNormal = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
containerDensity: 1.0,
|
||||
objectSeed: 100,
|
||||
})
|
||||
const containersNormal = runNormal.placedObjects.filter(o => o.category === 'container')
|
||||
|
||||
const runDouble = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
containerDensity: 2.0,
|
||||
objectSeed: 100,
|
||||
})
|
||||
const containersDouble = runDouble.placedObjects.filter(o => o.category === 'container')
|
||||
|
||||
expect(containersHalf.length).toBeLessThan(containersNormal.length)
|
||||
expect(containersNormal.length).toBeLessThan(containersDouble.length)
|
||||
})
|
||||
|
||||
test('guarantees deterministic object seeding given identical seeds', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
|
||||
const seed = 987654321
|
||||
const runA = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, {
|
||||
density: 1.0,
|
||||
objectSeed: seed,
|
||||
})
|
||||
const runB = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, {
|
||||
density: 1.0,
|
||||
objectSeed: seed,
|
||||
})
|
||||
|
||||
expect(runA.placedObjects.length).toBe(runB.placedObjects.length)
|
||||
expect(runA.stats).toEqual(runB.stats)
|
||||
|
||||
for (let i = 0; i < runA.placedObjects.length; i += 1) {
|
||||
const objA = runA.placedObjects[i]!
|
||||
const objB = runB.placedObjects[i]!
|
||||
expect(objA).toEqual(objB)
|
||||
}
|
||||
|
||||
// Different seed should diverge
|
||||
const runC = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, {
|
||||
density: 1.0,
|
||||
objectSeed: 11111,
|
||||
})
|
||||
const positionsA = runA.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';')
|
||||
const positionsC = runC.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';')
|
||||
expect(positionsA).not.toBe(positionsC)
|
||||
})
|
||||
|
||||
test('supports authentic object IDs across different Acts', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
|
||||
// Act 2 Tomb
|
||||
const act2Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 2 - Tomb', 66, {
|
||||
density: 1.0,
|
||||
objectSeed: 777,
|
||||
})
|
||||
const act2Chests = act2Result.placedObjects.filter(o => o.category === 'chest')
|
||||
expect(act2Chests.length).toBeGreaterThan(0)
|
||||
for (const c of act2Chests) {
|
||||
const allAct2Chests = [...CHEST_IDS_BY_ACT[2]!.super, ...CHEST_IDS_BY_ACT[2]!.normal]
|
||||
expect(allAct2Chests).toContain(c.id)
|
||||
}
|
||||
|
||||
// Act 3 Kurast
|
||||
const act3Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 3 - Kurast', 80, {
|
||||
density: 1.0,
|
||||
objectSeed: 888,
|
||||
})
|
||||
const act3Containers = act3Result.placedObjects.filter(o => o.category === 'container')
|
||||
expect(act3Containers.length).toBeGreaterThan(0)
|
||||
const act3Pool = CONTAINER_IDS_BY_ACT[3]!
|
||||
const allAct3Containers = [
|
||||
...act3Pool.barrels,
|
||||
...act3Pool.urns,
|
||||
...act3Pool.caskets,
|
||||
...act3Pool.crates,
|
||||
]
|
||||
for (const cnt of act3Containers) {
|
||||
expect(allAct3Containers).toContain(cnt.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('respects category-specific density and disableDynamicObjects options', () => {
|
||||
const { level, rooms } = createSyntheticDungeon()
|
||||
|
||||
// Disable all dynamic objects
|
||||
const disabledRun = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||
disableDynamicObjects: true,
|
||||
})
|
||||
expect(disabledRun.placedObjects.length).toBe(0)
|
||||
expect(disabledRun.stats.dynamicObjectsCount).toBe(0)
|
||||
|
||||
// Suppress shrines and chests, but keep racks and containers
|
||||
const racksOnly = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, {
|
||||
shrineDensity: 0,
|
||||
chestDensity: 0,
|
||||
rackDensity: 2.0,
|
||||
containerDensity: 0,
|
||||
objectSeed: 123,
|
||||
})
|
||||
expect(racksOnly.stats.shrinesSpawned).toBe(0)
|
||||
expect(racksOnly.stats.chestsSpawned).toBe(0)
|
||||
expect(racksOnly.stats.containersSpawned).toBe(0)
|
||||
expect(racksOnly.stats.racksSpawned).toBeGreaterThan(0)
|
||||
expect(racksOnly.placedObjects.every(o => o.category === 'rack')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateMaze end-to-end dynamic object integration', () => {
|
||||
test('populates result.objects and result.level.objects during maze generation', () => {
|
||||
const pieces = makeTestMazePieces('Act 1 - Cave', 11)
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Cave Level 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 0,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
|
||||
expect(result.level).toBeDefined()
|
||||
expect(result.objects).toBeDefined()
|
||||
expect(result.objects.length).toBeGreaterThan(0)
|
||||
|
||||
// result.stats must record dynamic object counters
|
||||
expect(result.stats.dynamicObjectsCount).toBe(result.objects.length)
|
||||
expect(result.stats.objects).toBe(result.level.objects.length)
|
||||
expect(result.stats.dynamicObjects).toBe(result.objects)
|
||||
|
||||
// Subtypes counts in stats must match objects in result.objects
|
||||
const shrineCount = result.objects.filter(o => o.category === 'shrine').length
|
||||
const chestCount = result.objects.filter(o => o.category === 'chest').length
|
||||
const rackCount = result.objects.filter(o => o.category === 'rack').length
|
||||
const containerCount = result.objects.filter(o => o.category === 'container').length
|
||||
|
||||
expect(result.stats.shrinesSpawned).toBe(shrineCount)
|
||||
expect(result.stats.chestsSpawned).toBe(chestCount)
|
||||
expect(result.stats.racksSpawned).toBe(rackCount)
|
||||
expect(result.stats.containersSpawned).toBe(containerCount)
|
||||
|
||||
// Level objects array must contain all dynamic objects
|
||||
for (const dynObj of result.objects) {
|
||||
expect(result.level.objects).toContain(dynObj)
|
||||
}
|
||||
})
|
||||
|
||||
test('disables dynamic objects when objectDensity: 0 is specified', () => {
|
||||
const pieces = makeTestMazePieces('Act 1 - Cave', 11)
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Cave Level 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 0,
|
||||
seed: 54321,
|
||||
pieces,
|
||||
objectDensity: 0,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
expect(result.objects.length).toBe(0)
|
||||
expect(result.stats.dynamicObjectsCount).toBe(0)
|
||||
expect(result.level.objects.length).toBe(0)
|
||||
})
|
||||
|
||||
test('retains pre-existing static DS1 objects alongside dynamically spawned objects', () => {
|
||||
// Piece with 1 pre-existing static object (e.g. static torch or decoration)
|
||||
const staticObject: Ds1Object = {
|
||||
type: 2,
|
||||
id: 99,
|
||||
x: 25,
|
||||
y: 25,
|
||||
flags: 0,
|
||||
}
|
||||
const pieces = makeTestMazePieces('Act 1 - Cave', 11, [staticObject])
|
||||
|
||||
const request: MazeRequest = {
|
||||
levelId: 2,
|
||||
levelName: 'Cave Level 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 0,
|
||||
seed: 9999,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(request)
|
||||
const dynamicCount = result.objects.length
|
||||
expect(dynamicCount).toBeGreaterThan(0)
|
||||
expect(result.level.objects.length).toBeGreaterThan(dynamicCount)
|
||||
|
||||
// Pre-existing static objects are preserved
|
||||
const foundStatic = result.level.objects.some(o => o.id === 99)
|
||||
expect(foundStatic).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,767 @@
|
|||
/**
|
||||
* Unit tests for the 11 Diablo II DRLG Maze Special Passes.
|
||||
*
|
||||
* Covers:
|
||||
* 1. DRLGMAZE_PlaceAct2TombStuff (Tal Rasha True/False Tombs, Kaa, Cube, Leatherarm, Waypoint)
|
||||
* 2. DRLGMAZE_PlaceAct2LairStuff (Maggot Lair Coldworm Tight Spot & Staff of Kings Treasure)
|
||||
* 3. DRLGMAZE_PlaceAct3DungeonStuff (Flayer Dungeon Brain chest, Kurast Temples Lam Esen's Tome)
|
||||
* 4. DRLGMAZE_PlaceAct3SewerStuff (Kurast Sewers Drain lever descent & Chest)
|
||||
* 5. DRLGMAZE_PlaceAct3MephistoStuff (Durance of Hate 3 Mephisto Complex, Durance 2 Waypoint)
|
||||
* 6. DRLGMAZE_PlaceAct5TempleStuff (Halls of Vaught Nihlathak Final Room, Halls of Pain Waypoint)
|
||||
* 7. DRLGMAZE_PlaceAct5BaalStuff (Throne of Destruction Baal Entrance/ThroneRoom, Worldstone Waypoint)
|
||||
* 8. DRLGMAZE_PlaceAct5IceStuff (Frozen River Anya platform, Drifter Cavern / Icy Cellar Pools, Waypoints)
|
||||
* 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect)
|
||||
* 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers)
|
||||
* 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint)
|
||||
* 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == [].
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateMaze,
|
||||
classifyMazePieceName,
|
||||
UNIMPLEMENTED_PASSES,
|
||||
type MazePiece,
|
||||
type MazePieceKind,
|
||||
type MazeRequest,
|
||||
} from '../src/game/maze.ts'
|
||||
import {
|
||||
runMazeSpecialPasses,
|
||||
type SpecialPassName,
|
||||
} from '../src/game/maze-special-passes.ts'
|
||||
|
||||
/**
|
||||
* Construct a synthetic minimal Ds1 map.
|
||||
*/
|
||||
function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y++) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x++) {
|
||||
row.push({
|
||||
walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 1,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects,
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a synthetic MazePiece.
|
||||
*/
|
||||
function mockPiece(
|
||||
name: string,
|
||||
kind: MazePieceKind,
|
||||
sides = '',
|
||||
objects: Ds1Object[] = [],
|
||||
width = 10,
|
||||
height = 10,
|
||||
): MazePiece {
|
||||
return {
|
||||
name,
|
||||
kind,
|
||||
sides,
|
||||
levels: [mockDs1(width, height, objects)],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full standard piece set for a level type so maze generation has
|
||||
* all 15 basic room shapes plus entrance and common directions.
|
||||
*/
|
||||
function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[] = []): MazePiece[] {
|
||||
const SIDES = [
|
||||
'N', 'S', 'E', 'W',
|
||||
'NE', 'NW', 'SE', 'SW',
|
||||
'NS', 'EW',
|
||||
'NSE', 'NSW', 'NEW', 'SEW',
|
||||
'NSEW',
|
||||
]
|
||||
const pieces: MazePiece[] = [
|
||||
mockPiece(`${levelTypeName} Entrance`, 'entrance', ''),
|
||||
...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)),
|
||||
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }])),
|
||||
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }])),
|
||||
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Down ${s}`, 'down', s, [{ type: 2, id: 3, x: 25, y: 25, flags: 0 }])),
|
||||
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Theme ${s}`, 'theme', s)),
|
||||
...extraPieces,
|
||||
]
|
||||
return pieces
|
||||
}
|
||||
|
||||
describe('Diablo II DRLG Maze Special Passes', () => {
|
||||
test('UNIMPLEMENTED_PASSES is completely empty', () => {
|
||||
expect(UNIMPLEMENTED_PASSES).toEqual([])
|
||||
expect(UNIMPLEMENTED_PASSES.length).toBe(0)
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 1. Act 2 - Tomb
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 1: DRLGMAZE_PlaceAct2TombStuff', () => {
|
||||
const tombExtraPieces = [
|
||||
mockPiece('Act 2 - Tomb Talrasha N', 'quest', 'N', [{ type: 2, id: 152, x: 25, y: 25, flags: 0 }]), // Horadric Staff Orifice
|
||||
mockPiece('Act 2 - Tomb Chest N', 'treasure', 'N', [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), // Golden chest
|
||||
mockPiece('Act 2 - Tomb Kaa N', 'quest', 'N', [{ type: 1, id: 250, x: 25, y: 25, flags: 0 }]), // Ancient Kaa
|
||||
mockPiece('Act 2 - Tomb Cube N', 'treasure', 'N', [{ type: 2, id: 354, x: 25, y: 25, flags: 0 }]), // Horadric Cube
|
||||
mockPiece('Act 2 - Tomb Leatherarm N', 'quest', 'N'),
|
||||
mockPiece('Act 2 - Tomb Treasure N', 'treasure', 'N'),
|
||||
mockPiece('Act 2 - Tomb Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 2 - Tomb Prev NSW', 'prev', 'NSW'),
|
||||
mockPiece('Act 2 - Tomb Prev NEW', 'prev', 'NEW'),
|
||||
mockPiece('Act 2 - Tomb Prev NSE', 'prev', 'NSE'),
|
||||
mockPiece('Act 2 - Tomb Prev SEW', 'prev', 'SEW'),
|
||||
]
|
||||
|
||||
test('True Tomb (staffTombLevelId): places Talrasha Horadric Staff orifice', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 66,
|
||||
levelName: 'Tal Rashas Tomb 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
staffTombLevelId: 66,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
const hasTalrasha = specials.some(s => s.kind === 'quest')
|
||||
expect(hasTalrasha).toBe(true)
|
||||
// Level size is tripled for staff tomb
|
||||
expect(res.stats.targetRooms).toBe(15)
|
||||
})
|
||||
|
||||
test('False Tombs (67..72): places golden chest room', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 67,
|
||||
levelName: 'Tal Rashas Tomb 2',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 54321,
|
||||
pieces,
|
||||
staffTombLevelId: 66,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
|
||||
})
|
||||
|
||||
test('Halls of the Dead 2 (level 59): places Waypoint', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 59,
|
||||
levelName: 'Halls of the Dead 2',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 99999,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
|
||||
test('Halls of the Dead 3 (level 60): places Horadric Cube chest', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 60,
|
||||
levelName: 'Halls of the Dead 3',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 88888,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest' || s.kind === 'treasure')).toBe(true)
|
||||
})
|
||||
|
||||
test('Boss Tomb: places Ancient Kaa the Soulless', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 68,
|
||||
levelName: 'Tal Rashas Tomb 3',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 77777,
|
||||
pieces,
|
||||
staffTombLevelId: 66,
|
||||
bossTombLevelId: 68,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
|
||||
expect(res.stats.targetRooms).toBe(10) // Doubled for boss tomb
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 2. Act 2 - Lair
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 2: DRLGMAZE_PlaceAct2LairStuff', () => {
|
||||
const lairExtraPieces = [
|
||||
mockPiece('Act 2 - Lair Tight Spot S', 'quest', 'S', [{ type: 1, id: 284, x: 25, y: 25, flags: 0 }]), // Coldworm
|
||||
mockPiece('Act 2 - Lair Treasure W', 'treasure', 'W', [{ type: 2, id: 356, x: 25, y: 25, flags: 0 }]), // Staff of Kings chest
|
||||
]
|
||||
|
||||
test('Maggot Lair 3 (level 64): places Coldworm Tight Spot and Staff of Kings chest', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 64,
|
||||
levelName: 'Maggot Lair 3',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 42,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 1 && o.id === 284)).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 356)).toBe(true)
|
||||
})
|
||||
|
||||
test('Maggot Lair 1 (level 62): places regular next and prev', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 62,
|
||||
levelName: 'Maggot Lair 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 101,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'next')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 3. Act 3 - Dungeon
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 3: DRLGMAZE_PlaceAct3DungeonStuff', () => {
|
||||
const dungeonExtraPieces = [
|
||||
mockPiece('Act 3 - Dungeon Treasure 2', 'treasure', '', [{ type: 2, id: 406, x: 25, y: 25, flags: 0 }]), // Khalim's Brain chest
|
||||
mockPiece('Act 3 - Dungeon Treasure 1', 'treasure', ''),
|
||||
mockPiece('Act 3 - Temple 1', 'quest', '', [{ type: 2, id: 193, x: 25, y: 25, flags: 0 }]), // Lam Esen's Tome altar
|
||||
]
|
||||
|
||||
test("Flayer Dungeon 3 (level 91): places Khalim's Brain chest (Treasure 2)", () => {
|
||||
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 91,
|
||||
levelName: 'Flayer Dungeon 3',
|
||||
levelTypeName: 'Act 3 - Dungeon',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 31415,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 406)).toBe(true)
|
||||
})
|
||||
|
||||
test("Ruined Temple (level 94): places Lam Esen's Tome altar (Temple 1)", () => {
|
||||
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 94,
|
||||
levelName: 'Ruined Temple',
|
||||
levelTypeName: 'Act 3 - Dungeon',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 27182,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 193)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 4. Act 3 - Sewer
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 4: DRLGMAZE_PlaceAct3SewerStuff', () => {
|
||||
const sewerExtraPieces = [
|
||||
mockPiece('Act 3 - Sewer Drain N', 'next', 'N', [{ type: 2, id: 366, x: 25, y: 25, flags: 0 }]), // Drain lever to Level 2
|
||||
mockPiece('Act 3 - Sewer Chest S', 'treasure', 'S'),
|
||||
mockPiece('Act 3 - Sewer Treasure X', 'treasure', ''),
|
||||
mockPiece('Act 3 - Sewer NW', 'room', 'NW'),
|
||||
mockPiece('Act 3 - Sewer NE', 'room', 'NE'),
|
||||
mockPiece('Act 3 - Sewer SW', 'room', 'SW'),
|
||||
mockPiece('Act 3 - Sewer SE', 'room', 'SE'),
|
||||
mockPiece('Act 3 - Sewer Prev NW', 'prev', 'NW'),
|
||||
mockPiece('Act 3 - Sewer Prev NE', 'prev', 'NE'),
|
||||
mockPiece('Act 3 - Sewer Prev SW', 'prev', 'SW'),
|
||||
mockPiece('Act 3 - Sewer Prev SE', 'prev', 'SE'),
|
||||
]
|
||||
|
||||
test('Kurast Sewers 1 (level 92): places Drain lever and Chest', () => {
|
||||
const pieces = createStandardPieceSet('Act 3 - Sewer', sewerExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 92,
|
||||
levelName: 'Kurast Sewers 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 18,
|
||||
merge: 500,
|
||||
seed: 777,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3SewerStuff')
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 366)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 5. Act 3 - Mephisto
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 5: DRLGMAZE_PlaceAct3MephistoStuff', () => {
|
||||
const mephistoExtraPieces = [
|
||||
mockPiece('Act 3 - Mephisto Complex', 'quest', '', [{ type: 2, id: 251, x: 50, y: 50, flags: 0 }]), // Red portal to Act 4
|
||||
mockPiece('Act 3 - Mephisto Waypoint N', 'waypoint', 'N', [{ type: 2, id: 237, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 3 - Mephisto Prev N', 'prev', 'N'),
|
||||
mockPiece('Act 3 - Mephisto Next N', 'next', 'N'),
|
||||
]
|
||||
|
||||
test('Durance of Hate 3 (level 102): places Mephisto Complex', () => {
|
||||
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 102,
|
||||
levelName: 'Durance of Hate 3',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 666,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 251)).toBe(true)
|
||||
})
|
||||
|
||||
test('Durance of Hate 2 (level 101): places Waypoint', () => {
|
||||
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 101,
|
||||
levelName: 'Durance of Hate 2',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 10101,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 6. Act 5 - Temple
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 6: DRLGMAZE_PlaceAct5TempleStuff', () => {
|
||||
const templeExtraPieces = [
|
||||
mockPiece('Act 5 - Temple Final Room', 'quest', '', [{ type: 1, id: 526, x: 25, y: 25, flags: 0 }]), // Nihlathak
|
||||
mockPiece('Act 5 - Temple NE Waypoint', 'waypoint', 'NE'),
|
||||
mockPiece('Act 5 - Temple NW Waypoint', 'waypoint', 'NW'),
|
||||
mockPiece('Act 5 - Temple SW Waypoint', 'waypoint', 'SW'),
|
||||
mockPiece('Act 5 - Temple SE Waypoint', 'waypoint', 'SE'),
|
||||
mockPiece('Act 5 - Temple NE Down', 'down', 'NE'),
|
||||
mockPiece('Act 5 - Temple NW Down', 'down', 'NW'),
|
||||
mockPiece('Act 5 - Temple SW Down', 'down', 'SW'),
|
||||
mockPiece('Act 5 - Temple SE up', 'prev', 'SE'),
|
||||
mockPiece('Act 5 - Temple SE', 'room', 'SE'),
|
||||
mockPiece('Act 5 - Temple NE', 'room', 'NE'),
|
||||
mockPiece('Act 5 - Temple NW', 'room', 'NW'),
|
||||
mockPiece('Act 5 - Temple SW', 'room', 'SW'),
|
||||
]
|
||||
|
||||
test('Halls of Vaught (level 124): places Nihlathak Final Room altar', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 124,
|
||||
levelName: 'Halls of Vaught',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 124124,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 1 && o.id === 526)).toBe(true)
|
||||
})
|
||||
|
||||
test('Halls of Pain (level 123): places Waypoint and stairs', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 123,
|
||||
levelName: 'Halls of Pain',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 123123,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 7. Act 5 - Baal
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 7: DRLGMAZE_PlaceAct5BaalStuff', () => {
|
||||
const baalExtraPieces = [
|
||||
mockPiece('Act 5 - ThroneRoom', 'quest', '', [{ type: 2, id: 563, x: 25, y: 25, flags: 0 }]), // Baal Throne
|
||||
mockPiece('Act 5 - Baal Waypoint N', 'waypoint', 'N'),
|
||||
mockPiece('Act 5 - Baal Prev NSW', 'prev', 'NSW'),
|
||||
mockPiece('Act 5 - Baal Prev NEW', 'prev', 'NEW'),
|
||||
mockPiece('Act 5 - Baal Prev NSE', 'prev', 'NSE'),
|
||||
mockPiece('Act 5 - Baal Prev SEW', 'prev', 'SEW'),
|
||||
]
|
||||
|
||||
test('Throne of Destruction (level 131): places ThroneRoom', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 131,
|
||||
levelName: 'Throne of Destruction',
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 131131,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 563)).toBe(true)
|
||||
})
|
||||
|
||||
test('Worldstone Keep 2 (level 129): places Waypoint', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 129,
|
||||
levelName: 'Worldstone Keep 2',
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 129129,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 8. Act 5 - Ice
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 8: DRLGMAZE_PlaceAct5IceStuff', () => {
|
||||
const iceExtraPieces = [
|
||||
mockPiece('Act 5 - Ice River A', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]), // Frozen Anya
|
||||
mockPiece('Act 5 - Ice River B', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 5 - Ice Pool A', 'quest', ''),
|
||||
mockPiece('Act 5 - Ice Pool B', 'quest', ''),
|
||||
mockPiece('Act 5 - Ice waypoint N', 'waypoint', 'N'),
|
||||
]
|
||||
|
||||
test('Frozen River (level 114): places Anya platform', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 114,
|
||||
levelName: 'Frozen River',
|
||||
levelTypeName: 'Act 5 - Ice Caves',
|
||||
sectionSize: 10,
|
||||
minRooms: 1, // Single-room preset in profile
|
||||
merge: 500,
|
||||
seed: 114114,
|
||||
pieces,
|
||||
})
|
||||
|
||||
// Level 114 resolves single room preset Act 5 - Ice River A
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 460)).toBe(true)
|
||||
})
|
||||
|
||||
test('Glacial Trail (level 115): places Waypoint', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 115,
|
||||
levelName: 'Glacial Trail',
|
||||
levelTypeName: 'Act 5 - Ice Caves',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 115115,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5IceStuff')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 9. Act 1 - Barracks
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 9: DRLGMAZE_PlaceAct1Barracks', () => {
|
||||
const barracksExtraPieces = [
|
||||
mockPiece('Act 1 - Barracks Forge N', 'quest', 'N', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), // Charsi's Malus forge
|
||||
mockPiece('Act 1 - Barracks Forge S', 'quest', 'S', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 1 - Barracks Forge E', 'quest', 'E', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 1 - Barracks Forge W', 'quest', 'W', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 1 - Barracks Court Connect', 'prev', '', [{ type: 2, id: 1, x: 10, y: 10, flags: 0 }]), // Entrance from Cloister
|
||||
]
|
||||
|
||||
test('Monastery Barracks (level 28): places Court Connect and Smith Forge', () => {
|
||||
const pieces = createStandardPieceSet('Act 1 - Barracks', barracksExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 28,
|
||||
levelName: 'Barracks',
|
||||
sectionSize: 10,
|
||||
sectionHeight: 14,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 2828,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct1Barracks')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(specials.some(s => s.kind === 'prev')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 108)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 10. Act 4 - Lava
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 10: DRLGMAZE_PlaceAct4Lava', () => {
|
||||
const lavaExtraPieces = [
|
||||
mockPiece('Act 4 - Lava Warp N', 'prev', 'N', [{ type: 2, id: 255, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 4 - Bridge 1', 'next', '', [{ type: 2, id: 10, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 4 - Bridge 2', 'next', '', [{ type: 2, id: 11, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 4 - Lava Forge W', 'quest', 'W', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]), // Hellforge
|
||||
mockPiece('Act 4 - Lava Forge E', 'quest', 'E', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 4 - Lava X', 'room', ''), // Filler
|
||||
]
|
||||
|
||||
test('River of Flame (level 107): places Hellforge, Bridges, Warp and Lava X filler', () => {
|
||||
const pieces = createStandardPieceSet('Act 4 - Lava', lavaExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 107,
|
||||
levelName: 'River of Flame',
|
||||
levelTypeName: 'Act 4 - Lava',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 107107,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct4Lava')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 2 && o.id === 376)).toBe(true)
|
||||
// Confirm Lava X filler spaces were placed
|
||||
expect(specials.some(s => s.sides === 'filler')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* 11. Act 2 - Sewers
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Pass 11: DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', () => {
|
||||
const sewer2ExtraPieces = [
|
||||
mockPiece("Act 2 - Sewer Radament's Lair N", 'quest', 'N', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), // Radament
|
||||
mockPiece("Act 2 - Sewer Radament's Lair S", 'quest', 'S', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece("Act 2 - Sewer Radament's Lair E", 'quest', 'E', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece("Act 2 - Sewer Radament's Lair W", 'quest', 'W', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 2 - Sewer Prev E', 'prev', 'E'), // City entrance
|
||||
mockPiece('Act 2 - Sewer Prev NS', 'prev', 'NS'), // Dock entrance
|
||||
mockPiece('Act 2 - Sewer Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
|
||||
mockPiece('Act 2 - Sewer Chest N', 'treasure', 'N', [{ type: 2, id: 355, x: 25, y: 25, flags: 0 }]),
|
||||
]
|
||||
|
||||
test("Sewers Level 3 (level 49): places Radament's Lair", () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 49,
|
||||
levelName: 'Sewers Level 3',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 4949,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'quest')).toBe(true)
|
||||
expect(res.level.objects.some(o => o.type === 1 && o.id === 229)).toBe(true)
|
||||
})
|
||||
|
||||
test('Sewers Level 1 (level 47): places dock and city entrances', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 47,
|
||||
levelName: 'Sewers Level 1',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 4747,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
const prevs = specials.filter(s => s.kind === 'prev')
|
||||
expect(prevs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Sewers Level 2 (level 48): places Waypoint', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 48,
|
||||
levelName: 'Sewers Level 2',
|
||||
sectionSize: 10,
|
||||
minRooms: 6,
|
||||
merge: 500,
|
||||
seed: 4848,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
|
||||
test('Ancient Tunnels (level 50): places golden chest', () => {
|
||||
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
|
||||
const res = generateMaze({
|
||||
levelId: 50,
|
||||
levelName: 'Ancient Tunnels',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed: 5050,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* Classification tests
|
||||
* ----------------------------------------------------------------------- */
|
||||
describe('Piece Classification for Special Presets', () => {
|
||||
test('classifies waypoint pieces', () => {
|
||||
expect(classifyMazePieceName('Act 2 - Tomb Waypoint N', 'Act 2 - Tomb')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'N',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'NE',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'N',
|
||||
})
|
||||
})
|
||||
|
||||
test('classifies quest and special pieces', () => {
|
||||
expect(classifyMazePieceName('Act 2 - Tomb Talrasha N', 'Act 2 - Tomb')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: 'N',
|
||||
})
|
||||
expect(classifyMazePieceName("Act 2 - Sewer Radament's Lair S", 'Act 2 - Sewer')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: 'S',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 2 - Lair Tight Spot S', 'Act 2 - Lair')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: 'S',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 1 - Barracks Forge W', 'Act 1 - Barracks')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: 'W',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 4 - Bridge 1', 'Act 4 - Lava')).toEqual({
|
||||
kind: 'next',
|
||||
sides: '',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 3 - Mephisto Complex', 'Act 3 - Kurast')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: '',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Temple Final Room', 'Act 5 - Temple')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: '',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - ThroneRoom Entrance', 'Act 5 - Baal')).toEqual({
|
||||
kind: 'quest',
|
||||
sides: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,528 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
classifyMazePieceName,
|
||||
generateMaze,
|
||||
SUB_TILES_PER_TILE,
|
||||
} from '../src/game/maze.ts'
|
||||
import type { MazePiece, MazeRequest } from '../src/game/maze.ts'
|
||||
|
||||
function createMockDs1(width = 8, height = 8, objects: Ds1Object[] = []): 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: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 2, sequence: 0, style: 1, 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 createPiece(
|
||||
name: string,
|
||||
levelTypeName: string,
|
||||
ds1Objects: Ds1Object[] = [],
|
||||
width = 8,
|
||||
height = 8,
|
||||
): MazePiece {
|
||||
const classified = classifyMazePieceName(name, levelTypeName)
|
||||
if (!classified) throw new Error(`Could not classify ${name} for ${levelTypeName}`)
|
||||
return {
|
||||
name,
|
||||
kind: classified.kind,
|
||||
sides: classified.sides,
|
||||
levels: [createMockDs1(width, height, ds1Objects)],
|
||||
}
|
||||
}
|
||||
|
||||
function createCatacombsPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
|
||||
const lt = 'Act 1 - Catacombs'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
pieces.push(createPiece(`${lt} Prev EW`, lt))
|
||||
pieces.push(createPiece(`${lt} Prev NS`, lt))
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createJailPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
|
||||
const lt = 'Act 1 - Jail'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createKurastPieces(): MazePiece[] {
|
||||
const lt = 'Act 3 - Kurast'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`Act 3 - Mephisto Prev ${s}`, lt))
|
||||
pieces.push(createPiece(`Act 3 - Mephisto Next ${s}`, lt))
|
||||
pieces.push(createPiece(`Act 3 - Mephisto Waypoint ${s}`, lt))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createBaalPieces(): MazePiece[] {
|
||||
const lt = 'Act 5 - Baal'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
// prevChain requires Prev NSE, SEW, NSW, NEW
|
||||
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
|
||||
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
||||
}
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createSewerPieces(): MazePiece[] {
|
||||
const lt = 'Act 2 - Sewer'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createTombPieces(): MazePiece[] {
|
||||
const lt = 'Act 2 - Tomb'
|
||||
const sided = [
|
||||
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
||||
]
|
||||
const pieces: MazePiece[] = []
|
||||
for (const s of sided) {
|
||||
pieces.push(createPiece(`${lt} ${s}`, lt))
|
||||
}
|
||||
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
|
||||
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
||||
}
|
||||
for (const s of ['N', 'E', 'S', 'W']) {
|
||||
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
||||
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
describe('Issue #47: Maze Waypoint preset replacement pass', () => {
|
||||
describe('classifyMazePieceName', () => {
|
||||
test('classifies waypoint pieces for all acts correctly', () => {
|
||||
expect(classifyMazePieceName('Act 1 - Jail Waypoint W', 'Act 1 - Jail')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'W',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 1 - Catacombs Waypoint N', 'Act 1 - Catacombs')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'N',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 2 - Sewer Waypoint S', 'Act 2 - Sewer')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'S',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 2 - Tomb Waypoint E', 'Act 2 - Tomb')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'E',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 3 - Mephisto Waypoint W', 'Act 3 - Kurast')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'W',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'N',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'NE',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Temple SW Waypoint', 'Act 5 - Temple')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'SW',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 5 - Baal Waypoint N', 'Act 5 - Baal')).toEqual({
|
||||
kind: 'waypoint',
|
||||
sides: 'N',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves classification of other piece kinds', () => {
|
||||
expect(classifyMazePieceName('Act 1 - Jail Prev W', 'Act 1 - Jail')).toEqual({
|
||||
kind: 'prev',
|
||||
sides: 'W',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 1 - Catacombs Next S', 'Act 1 - Catacombs')).toEqual({
|
||||
kind: 'next',
|
||||
sides: 'S',
|
||||
})
|
||||
expect(classifyMazePieceName('Act 1 - Cave NSEW', 'Act 1 - Cave')).toEqual({
|
||||
kind: 'room',
|
||||
sides: 'NSEW',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateMaze waypoint placement', () => {
|
||||
test('places waypoint room in Catacombs 2 (level 35) and records stats', () => {
|
||||
const pieces = createCatacombsPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 35,
|
||||
levelName: 'Catacombs Level 2',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 42,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
const waypoints = result.stats.waypoints as unknown[]
|
||||
expect(waypoints).toHaveLength(1)
|
||||
|
||||
const wp = result.stats.waypoint as {
|
||||
room: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
centreX: number
|
||||
centreY: number
|
||||
entityX: number
|
||||
entityY: number
|
||||
}
|
||||
expect(wp).not.toBeNull()
|
||||
expect(result.stats.waypointTile).toEqual({
|
||||
x: Math.floor(wp.entityX / SUB_TILES_PER_TILE),
|
||||
y: Math.floor(wp.entityY / SUB_TILES_PER_TILE),
|
||||
})
|
||||
|
||||
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
const wpSpecial = specials.find(s => s.kind === 'waypoint')
|
||||
expect(wpSpecial).toBeDefined()
|
||||
expect(wpSpecial?.room).toBe(wp.room)
|
||||
|
||||
// Verify synthesized waypoint entity is present in result.level.objects
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 119)
|
||||
expect(wpObj).toBeDefined()
|
||||
expect(wpObj?.x).toBe(wp.entityX)
|
||||
expect(wpObj?.y).toBe(wp.entityY)
|
||||
})
|
||||
|
||||
test('does NOT place waypoint room in Catacombs 1 (level 34)', () => {
|
||||
const pieces = createCatacombsPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 34,
|
||||
levelName: 'Catacombs Level 1',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 42,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
expect(result.stats.waypoints).toEqual([])
|
||||
expect(result.stats.waypoint).toBeNull()
|
||||
expect(result.stats.waypointTile).toBeNull()
|
||||
|
||||
const specials = result.stats.specialsApplied as { kind: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(false)
|
||||
})
|
||||
|
||||
test('places waypoint room in Jail 1 (level 29)', () => {
|
||||
const pieces = createJailPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 29,
|
||||
levelName: 'Jail Level 1',
|
||||
levelTypeName: 'Act 1 - Jail',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 101,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
expect(result.stats.waypoints).toHaveLength(1)
|
||||
expect(result.stats.waypoint).not.toBeNull()
|
||||
})
|
||||
|
||||
test('honors hasWaypoint override: forces waypoint when true on level without default waypoint', () => {
|
||||
const pieces = createCatacombsPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 34,
|
||||
levelName: 'Catacombs Level 1',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 42,
|
||||
pieces,
|
||||
hasWaypoint: true,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
expect(result.stats.waypoints).toHaveLength(1)
|
||||
expect(result.stats.waypoint).not.toBeNull()
|
||||
})
|
||||
|
||||
test('honors hasWaypoint override: disables waypoint when false on level with default waypoint', () => {
|
||||
const pieces = createCatacombsPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 35,
|
||||
levelName: 'Catacombs Level 2',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 42,
|
||||
pieces,
|
||||
hasWaypoint: false,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
expect(result.stats.waypoints).toEqual([])
|
||||
expect(result.stats.waypoint).toBeNull()
|
||||
})
|
||||
|
||||
test('uses authored DS1 object entity coordinates when present', () => {
|
||||
const authoredObj: Ds1Object = {
|
||||
type: 2,
|
||||
id: 119,
|
||||
x: 18,
|
||||
y: 22,
|
||||
flags: 0,
|
||||
}
|
||||
const pieces = createCatacombsPieces([authoredObj])
|
||||
const req: MazeRequest = {
|
||||
levelId: 35,
|
||||
levelName: 'Catacombs Level 2',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 88,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
const wp = result.stats.waypoint as {
|
||||
x: number
|
||||
y: number
|
||||
entityX: number
|
||||
entityY: number
|
||||
}
|
||||
expect(wp.entityX).toBe(wp.x * SUB_TILES_PER_TILE + 18)
|
||||
expect(wp.entityY).toBe(wp.y * SUB_TILES_PER_TILE + 22)
|
||||
|
||||
const objInLevel = result.level.objects.find(o => o.type === 2 && o.id === 119)
|
||||
expect(objInLevel).toBeDefined()
|
||||
expect(objInLevel?.x).toBe(wp.entityX)
|
||||
expect(objInLevel?.y).toBe(wp.entityY)
|
||||
})
|
||||
|
||||
test('places waypoint in Durance of Hate 2 (101) but not Durance 1 (100)', () => {
|
||||
const pieces = createKurastPieces()
|
||||
const req101: MazeRequest = {
|
||||
levelId: 101,
|
||||
levelName: 'Durance of Hate Level 2',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 777,
|
||||
pieces,
|
||||
}
|
||||
const res101 = generateMaze(req101)
|
||||
expect(res101.stats.waypoints).toHaveLength(1)
|
||||
expect(res101.stats.waypoint).not.toBeNull()
|
||||
|
||||
const req100: MazeRequest = {
|
||||
levelId: 100,
|
||||
levelName: 'Durance of Hate Level 1',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 777,
|
||||
pieces,
|
||||
}
|
||||
const res100 = generateMaze(req100)
|
||||
expect(res100.stats.waypoints).toEqual([])
|
||||
expect(res100.stats.waypoint).toBeNull()
|
||||
})
|
||||
|
||||
test('places waypoint in Worldstone Keep 2 (129) but not Worldstone 1 (128)', () => {
|
||||
const pieces = createBaalPieces()
|
||||
const req129: MazeRequest = {
|
||||
levelId: 129,
|
||||
levelName: 'Worldstone Keep Level 2',
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 999,
|
||||
pieces,
|
||||
}
|
||||
const res129 = generateMaze(req129)
|
||||
expect(res129.stats.waypoints).toHaveLength(1)
|
||||
expect(res129.stats.waypoint).not.toBeNull()
|
||||
|
||||
const req128: MazeRequest = {
|
||||
levelId: 128,
|
||||
levelName: 'Worldstone Keep Level 1',
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 999,
|
||||
pieces,
|
||||
}
|
||||
const res128 = generateMaze(req128)
|
||||
expect(res128.stats.waypoints).toEqual([])
|
||||
expect(res128.stats.waypoint).toBeNull()
|
||||
})
|
||||
|
||||
test('places waypoint in Lut Gholein Sewer 2 (48) but not Sewer 1 (47)', () => {
|
||||
const pieces = createSewerPieces()
|
||||
const req48: MazeRequest = {
|
||||
levelId: 48,
|
||||
levelName: 'Sewers Level 2',
|
||||
levelTypeName: 'Act 2 - Sewer',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 333,
|
||||
pieces,
|
||||
}
|
||||
const res48 = generateMaze(req48)
|
||||
expect(res48.stats.waypoints).toHaveLength(1)
|
||||
expect(res48.stats.waypoint).not.toBeNull()
|
||||
|
||||
const req47: MazeRequest = {
|
||||
levelId: 47,
|
||||
levelName: 'Sewers Level 1',
|
||||
levelTypeName: 'Act 2 - Sewer',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 333,
|
||||
pieces,
|
||||
}
|
||||
const res47 = generateMaze(req47)
|
||||
expect(res47.stats.waypoints).toEqual([])
|
||||
expect(res47.stats.waypoint).toBeNull()
|
||||
})
|
||||
|
||||
test('places waypoint in Halls of the Dead 2 (57) but not Tomb 1 (56)', () => {
|
||||
const pieces = createTombPieces()
|
||||
const req57: MazeRequest = {
|
||||
levelId: 57,
|
||||
levelName: 'Halls of the Dead Level 2',
|
||||
levelTypeName: 'Act 2 - Tomb',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 444,
|
||||
pieces,
|
||||
}
|
||||
const res57 = generateMaze(req57)
|
||||
expect(res57.stats.waypoints).toHaveLength(1)
|
||||
expect(res57.stats.waypoint).not.toBeNull()
|
||||
|
||||
const req56: MazeRequest = {
|
||||
levelId: 56,
|
||||
levelName: 'Halls of the Dead Level 1',
|
||||
levelTypeName: 'Act 2 - Tomb',
|
||||
sectionSize: 8,
|
||||
minRooms: 10,
|
||||
merge: 100,
|
||||
seed: 444,
|
||||
pieces,
|
||||
}
|
||||
const res56 = generateMaze(req56)
|
||||
expect(res56.stats.waypoints).toEqual([])
|
||||
expect(res56.stats.waypoint).toBeNull()
|
||||
})
|
||||
|
||||
test('places waypoint at a distance from the entrance (isExit distance sorting)', () => {
|
||||
const pieces = createCatacombsPieces()
|
||||
const req: MazeRequest = {
|
||||
levelId: 35,
|
||||
levelName: 'Catacombs Level 2',
|
||||
levelTypeName: 'Act 1 - Catacombs',
|
||||
sectionSize: 8,
|
||||
minRooms: 12,
|
||||
merge: 0,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
}
|
||||
|
||||
const result = generateMaze(req)
|
||||
const wp = result.stats.waypoint as { room: number }
|
||||
const specials = result.stats.specialsApplied as { room: number; kind: string }[]
|
||||
const prevSpecial = specials.find(s => s.kind === 'prev')
|
||||
if (prevSpecial) {
|
||||
// Waypoint room should not be placed in the entrance room
|
||||
expect(wp.room).not.toBe(prevSpecial.room)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
PACK_RADIUS_PX,
|
||||
type MapRoom,
|
||||
} from '../src/game/monster-rooms.ts'
|
||||
import { createWorld } from '../src/game/combat.ts'
|
||||
import type { CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
|
||||
|
||||
/** Flat open ground everywhere. */
|
||||
const OPEN: CombatTerrain = { overlap: () => 0 }
|
||||
|
||||
/**
|
||||
* Creates a simple monster stat record.
|
||||
*/
|
||||
function stats(id: string, hp = 30): MonsterStats {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
hp,
|
||||
damage: 5,
|
||||
cooldownTicks: 25,
|
||||
reach: 40,
|
||||
aggroRadius: 200,
|
||||
speed: 100,
|
||||
xp: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test monster pack.
|
||||
*/
|
||||
function pack(id: string, count: number, extra?: Partial<MonsterPack>): MonsterPack {
|
||||
return {
|
||||
members: Array.from({ length: count }, (_, i) => stats(id, 30 + i)),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Room-Local Monster Clustering and Placement', () => {
|
||||
describe('partitionMapIntoRooms', () => {
|
||||
it('partitions outdoor grid sectors into authentic ~20-30 cell rooms', () => {
|
||||
// 72x48 cells partitioned with roomCellSize = 24 -> 3 x 2 = 6 rooms
|
||||
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
// All rooms should be 24x24 cells and 1920x1920 pixels
|
||||
for (const room of rooms) {
|
||||
expect(room.cellsX).toBe(24)
|
||||
expect(room.cellsY).toBe(24)
|
||||
expect(room.width).toBe(1920)
|
||||
expect(room.height).toBe(1920)
|
||||
expect(room.isPreset).toBe(false)
|
||||
}
|
||||
|
||||
// Check cell grid coordinates
|
||||
expect(rooms[0]?.cellX).toBe(0)
|
||||
expect(rooms[0]?.cellY).toBe(0)
|
||||
expect(rooms[1]?.cellX).toBe(24)
|
||||
expect(rooms[1]?.cellY).toBe(0)
|
||||
expect(rooms[2]?.cellX).toBe(48)
|
||||
expect(rooms[2]?.cellY).toBe(0)
|
||||
expect(rooms[3]?.cellX).toBe(0)
|
||||
expect(rooms[3]?.cellY).toBe(24)
|
||||
})
|
||||
|
||||
it('handles map dimensions that do not divide evenly by roomCellSize', () => {
|
||||
// 50x30 cells with roomCellSize = 24 -> 3 cols (24, 24, 2) x 2 rows (24, 6) = 6 rooms
|
||||
const rooms = partitionMapIntoRooms(50, 30, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
const col2Row0 = rooms.find(r => r.cellX === 48 && r.cellY === 0)
|
||||
expect(col2Row0).toBeDefined()
|
||||
expect(col2Row0?.cellsX).toBe(2)
|
||||
expect(col2Row0?.cellsY).toBe(24)
|
||||
expect(col2Row0?.width).toBe(160)
|
||||
|
||||
const col0Row1 = rooms.find(r => r.cellX === 0 && r.cellY === 24)
|
||||
expect(col0Row1).toBeDefined()
|
||||
expect(col0Row1?.cellsX).toBe(24)
|
||||
expect(col0Row1?.cellsY).toBe(6)
|
||||
expect(col0Row1?.height).toBe(480)
|
||||
})
|
||||
|
||||
it('maps discrete maze rooms directly into MapRooms', () => {
|
||||
const mazeRooms = [
|
||||
{ x: 0, y: 0, width: 24, height: 24 },
|
||||
{ x: 24, y: 0, width: 24, height: 24 },
|
||||
{ x: 24, y: 24, width: 12, height: 12 },
|
||||
]
|
||||
const rooms = partitionMapIntoRooms(48, 48, { mazeRooms, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(3)
|
||||
|
||||
expect(rooms[0]?.isPreset).toBe(true)
|
||||
expect(rooms[0]?.cellX).toBe(0)
|
||||
expect(rooms[0]?.cellY).toBe(0)
|
||||
expect(rooms[0]?.width).toBe(1920)
|
||||
|
||||
expect(rooms[2]?.cellsX).toBe(12)
|
||||
expect(rooms[2]?.cellsY).toBe(12)
|
||||
expect(rooms[2]?.width).toBe(960)
|
||||
expect(rooms[2]?.height).toBe(960)
|
||||
})
|
||||
|
||||
it('automatically flags rooms overlapping safe zones as isSafeZone', () => {
|
||||
const safeZones = [
|
||||
{ x: 500, y: 500, radius: 160 },
|
||||
]
|
||||
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80, safeZones })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
// Room 0 covers [0, 1920] x [0, 1920], overlaps (500, 500)
|
||||
expect(rooms[0]?.isSafeZone).toBe(true)
|
||||
|
||||
// Distant rooms should not be safe zones
|
||||
const distantRoom = rooms.find(r => r.cellX === 48 && r.cellY === 24)
|
||||
expect(distantRoom?.isSafeZone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('allocatePacksToRooms', () => {
|
||||
it('distributes normal packs to non-safe rooms and avoids safe rooms', () => {
|
||||
const rooms: MapRoom[] = [
|
||||
{ id: 0, x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10, isSafeZone: true },
|
||||
{ id: 1, x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 2, x: 2000, y: 0, width: 1000, height: 1000, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
]
|
||||
const packs = [
|
||||
pack('fallen', 3),
|
||||
pack('zombie', 4),
|
||||
pack('skeleton', 3),
|
||||
pack('quillrat', 2),
|
||||
]
|
||||
|
||||
const allocation = allocatePacksToRooms(packs, rooms, 12345)
|
||||
|
||||
// Safe room (id: 0) must receive NO normal packs
|
||||
expect(allocation.get(rooms[0]!)).toHaveLength(0)
|
||||
|
||||
// Remaining packs must be distributed between room 1 and room 2
|
||||
const room1Count = allocation.get(rooms[1]!)?.length ?? 0
|
||||
const room2Count = allocation.get(rooms[2]!)?.length ?? 0
|
||||
expect(room1Count + room2Count).toBe(4)
|
||||
expect(room1Count).toBeGreaterThan(0)
|
||||
expect(room2Count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('balances pack counts across candidate rooms based on room area', () => {
|
||||
// Room Small: 10x10 cells (area 100)
|
||||
// Room Large: 20x20 cells (area 400 - 4x larger)
|
||||
const roomSmall: MapRoom = { id: 'small', x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const roomLarge: MapRoom = { id: 'large', x: 800, y: 0, width: 1600, height: 1600, cellX: 10, cellY: 0, cellsX: 20, cellsY: 20 }
|
||||
const rooms = [roomSmall, roomLarge]
|
||||
|
||||
const packs = Array.from({ length: 10 }, (_, i) => pack(`mob_${String(i)}`, 3))
|
||||
const allocation = allocatePacksToRooms(packs, rooms, 42)
|
||||
|
||||
const smallCount = allocation.get(roomSmall)?.length ?? 0
|
||||
const largeCount = allocation.get(roomLarge)?.length ?? 0
|
||||
|
||||
expect(smallCount + largeCount).toBe(10)
|
||||
// Large room has 4x area, so it should receive significantly more packs without overcrowding small room
|
||||
expect(largeCount).toBeGreaterThan(smallCount * 2)
|
||||
})
|
||||
|
||||
it('routes landmark SuperUnique packs with landmarkTile to covering room', () => {
|
||||
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1920, height: 1920, cellX: 0, cellY: 0, cellsX: 24, cellsY: 24 }
|
||||
const roomB: MapRoom = { id: 'B', x: 1920, y: 0, width: 1920, height: 1920, cellX: 24, cellY: 0, cellsX: 24, cellsY: 24 }
|
||||
const rooms = [roomA, roomB]
|
||||
|
||||
// Bishibosh at landmark tile (tileX: 30, tileY: 10) falls inside room B (cellX in [24, 48))
|
||||
const bishiboshPack = pack('bishibosh', 5, {
|
||||
superUniqueId: 'Bishibosh',
|
||||
landmarkTile: { tileX: 30, tileY: 10 },
|
||||
})
|
||||
const normalPack = pack('fallen', 3)
|
||||
|
||||
const allocation = allocatePacksToRooms([bishiboshPack, normalPack], rooms, 100)
|
||||
|
||||
const roomBPacks = allocation.get(roomB) ?? []
|
||||
expect(roomBPacks).toContain(bishiboshPack)
|
||||
})
|
||||
|
||||
it('routes landmark SuperUnique packs with fixedPosition to covering room', () => {
|
||||
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const roomB: MapRoom = { id: 'B', x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const rooms = [roomA, roomB]
|
||||
|
||||
// Rakanishu fixed at world pixel coordinates (1500, 500) inside room B
|
||||
const rakanishuPack = pack('rakanishu', 4, {
|
||||
superUniqueId: 'Rakanishu',
|
||||
fixedPosition: { x: 1500, y: 500 },
|
||||
})
|
||||
|
||||
const allocation = allocatePacksToRooms([rakanishuPack], rooms)
|
||||
expect(allocation.get(roomB)).toContain(rakanishuPack)
|
||||
expect(allocation.get(roomA)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is deterministic with a given seed', () => {
|
||||
const rooms: MapRoom[] = [
|
||||
{ id: 1, x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 2, x: 800, y: 0, width: 800, height: 800, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 3, x: 1600, y: 0, width: 800, height: 800, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
]
|
||||
const packs = Array.from({ length: 9 }, (_, i) => pack(`mob_${String(i)}`, 2))
|
||||
|
||||
const run1 = allocatePacksToRooms(packs, rooms, 9999)
|
||||
const run2 = allocatePacksToRooms(packs, rooms, 9999)
|
||||
|
||||
for (const room of rooms) {
|
||||
const p1 = run1.get(room)!.map(p => p.members[0]?.id)
|
||||
const p2 = run2.get(room)!.map(p => p.members[0]?.id)
|
||||
expect(p1).toEqual(p2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnPacksInRoom', () => {
|
||||
it('places monster pack strictly within room boundaries', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'test_room',
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 10,
|
||||
cellY: 10,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
const testPack = pack('fallen', 6)
|
||||
|
||||
const placed = spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(placed).toBe(6)
|
||||
expect(world.monsters).toHaveLength(6)
|
||||
|
||||
// Verify every monster is strictly inside room bounds
|
||||
for (const monster of world.monsters) {
|
||||
expect(monster.x).toBeGreaterThanOrEqual(room.x)
|
||||
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
|
||||
expect(monster.y).toBeGreaterThanOrEqual(room.y)
|
||||
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('respects terrain collisions and does not spill through walls', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'walled_room',
|
||||
x: 500,
|
||||
y: 500,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 5,
|
||||
cellY: 5,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
// Vertical solid wall slicing through x in [680, 750]
|
||||
const walledTerrain: CombatTerrain = {
|
||||
overlap: (x: number) => (x >= 680 && x <= 750 ? 1 : 0),
|
||||
}
|
||||
const testPack = pack('zombie', 8)
|
||||
|
||||
const placed = spawnPacksInRoom(world, testPack, room, walledTerrain)
|
||||
expect(placed).toBeGreaterThan(0)
|
||||
|
||||
for (const monster of world.monsters) {
|
||||
// Must never land on solid wall
|
||||
expect(walledTerrain.overlap(monster.x, monster.y)).toBe(0)
|
||||
expect(monster.x < 680 || monster.x > 750).toBe(true)
|
||||
// Must stay inside room
|
||||
expect(monster.x).toBeGreaterThanOrEqual(room.x)
|
||||
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
|
||||
expect(monster.y).toBeGreaterThanOrEqual(room.y)
|
||||
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('clusters minions tightly around leader within PACK_RADIUS_PX', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'cluster_room',
|
||||
x: 2000,
|
||||
y: 2000,
|
||||
width: 800,
|
||||
height: 800,
|
||||
cellX: 20,
|
||||
cellY: 20,
|
||||
cellsX: 10,
|
||||
cellsY: 10,
|
||||
}
|
||||
const testPack = pack('fallen', 7)
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(7)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
const minions = world.monsters.slice(1)
|
||||
|
||||
for (const minion of minions) {
|
||||
const dist = Math.hypot(minion.x - leader.x, minion.y - leader.y)
|
||||
expect(dist).toBeLessThanOrEqual(PACK_RADIUS_PX)
|
||||
}
|
||||
})
|
||||
|
||||
it('positions leader near interior center of room on clear ground', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'center_test',
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 10,
|
||||
cellY: 10,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
const testPack = pack('boss', 1)
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(1)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
const roomCenterX = room.x + room.width / 2 // 1200
|
||||
const roomCenterY = room.y + room.height / 2 // 1200
|
||||
|
||||
// Leader should land right at or very close to center
|
||||
expect(Math.abs(leader.x - roomCenterX)).toBeLessThan(16)
|
||||
expect(Math.abs(leader.y - roomCenterY)).toBeLessThan(16)
|
||||
})
|
||||
|
||||
it('places fixedPosition SuperUnique at or near designated landmark coordinates', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'landmark_room',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
cellsX: 10,
|
||||
cellsY: 10,
|
||||
}
|
||||
const testPack = pack('treehead', 1, {
|
||||
superUniqueId: 'Treehead WoodFist',
|
||||
fixedPosition: { x: 350, y: 450 },
|
||||
})
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(1)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
expect(leader.x).toBe(350)
|
||||
expect(leader.y).toBe(450)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnMonsterPacksByRooms', () => {
|
||||
it('populates world with all packs distributed across partitioned rooms', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const rooms = partitionMapIntoRooms(48, 48, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
const packs = [
|
||||
pack('fallen', 4),
|
||||
pack('zombie', 3),
|
||||
pack('skeleton', 5),
|
||||
]
|
||||
|
||||
const placed = spawnMonsterPacksByRooms(world, packs, rooms, OPEN, { seed: 101 })
|
||||
expect(placed).toBe(12)
|
||||
expect(world.monsters).toHaveLength(12)
|
||||
|
||||
// Verify every monster lies within at least one room's bounding box
|
||||
for (const m of world.monsters) {
|
||||
const inAnyRoom = rooms.some(
|
||||
r => m.x >= r.x && m.x <= r.x + r.width && m.y >= r.y && m.y <= r.y + r.height,
|
||||
)
|
||||
expect(inAnyRoom).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('maintains backwards compatibility when fallback around coordinates are passed', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const packs = [pack('fallen', 3)]
|
||||
|
||||
const placed = spawnMonsterPacksByRooms(
|
||||
world,
|
||||
packs,
|
||||
{ x: 500, y: 500 },
|
||||
OPEN,
|
||||
{ spread: 300 },
|
||||
)
|
||||
expect(placed).toBe(3)
|
||||
expect(world.monsters).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,571 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createWorld, damageMonster, tickCombat } from '../src/game/combat.ts'
|
||||
import type { CombatOptions, CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts'
|
||||
import { MonsterStreamingManager, distanceToRoom } from '../src/game/monster-streaming.ts'
|
||||
import type { StreamingRoomDef, StreamingRoomState } from '../src/game/monster-streaming.ts'
|
||||
|
||||
/** Open ground everywhere. */
|
||||
const OPEN: CombatTerrain = { overlap: () => 0 }
|
||||
|
||||
/** Minimal map provider for engine testing. */
|
||||
const OPEN_MAP: WorldMapProvider = {
|
||||
widthPx: 10000,
|
||||
heightPx: 10000,
|
||||
overlap: () => 0,
|
||||
}
|
||||
|
||||
/** Helper to construct test monster stats. */
|
||||
function stats(id: string, hp = 30, damage = 5): MonsterStats {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
hp,
|
||||
damage,
|
||||
cooldownTicks: 25,
|
||||
reach: 40,
|
||||
aggroRadius: 200,
|
||||
speed: 100,
|
||||
xp: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to construct a test monster pack. */
|
||||
function pack(id: string, count: number, hp = 30): MonsterPack {
|
||||
return {
|
||||
members: Array.from({ length: count }, (_, i) => stats(id, hp + i)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Default combat options for simulation. */
|
||||
const DEFAULT_COMBAT_OPTIONS: CombatOptions = {
|
||||
playerSpeed: 100,
|
||||
playerReach: 40,
|
||||
playerCooldownTicks: 25,
|
||||
playerDamage: 10,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 100,
|
||||
}
|
||||
|
||||
describe('distanceToRoom', () => {
|
||||
const bounds = { minX: 1000, minY: 1000, maxX: 2000, maxY: 2000 }
|
||||
|
||||
it('returns 0 when point is inside room bounds', () => {
|
||||
expect(distanceToRoom(1500, 1500, bounds)).toBe(0)
|
||||
expect(distanceToRoom(1000, 1000, bounds)).toBe(0)
|
||||
expect(distanceToRoom(2000, 2000, bounds)).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates orthogonal distance correctly', () => {
|
||||
expect(distanceToRoom(500, 1500, bounds)).toBe(500)
|
||||
expect(distanceToRoom(2600, 1500, bounds)).toBe(600)
|
||||
expect(distanceToRoom(1500, 300, bounds)).toBe(700)
|
||||
expect(distanceToRoom(1500, 2800, bounds)).toBe(800)
|
||||
})
|
||||
|
||||
it('calculates diagonal distance to closest corner', () => {
|
||||
// Distance from (700, 600) to (1000, 1000) is hypot(300, 400) = 500
|
||||
expect(distanceToRoom(700, 600, bounds)).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Lazy Population (bPopulated)', () => {
|
||||
it('populates only spawn neighborhood rooms on initialization, leaving distant rooms unpopulated', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 3)],
|
||||
},
|
||||
{
|
||||
id: 'adjacent_room',
|
||||
bounds: { minX: 600, minY: 0, maxX: 1000, maxY: 400 }, // distance = 500 <= 1600
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room_1',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, // distance ~4100 > 1600
|
||||
packs: [pack('quillrat1', 4)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room_2',
|
||||
bounds: { minX: 6000, minY: 6000, maxX: 6500, maxY: 6500 }, // distance ~8300 > 1600
|
||||
packs: [pack('skeleton1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Only spawn_room (3) and adjacent_room (2) are populated
|
||||
expect(world.monsters).toHaveLength(5)
|
||||
|
||||
const [spawnRoom, adjRoom, distRoom1, distRoom2] = mgr.rooms as [
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
]
|
||||
|
||||
expect(spawnRoom.bPopulated).toBe(true)
|
||||
expect(spawnRoom.active).toBe(true)
|
||||
expect(spawnRoom.monsterIndices).toEqual([0, 1, 2])
|
||||
|
||||
expect(adjRoom.bPopulated).toBe(true)
|
||||
expect(adjRoom.active).toBe(true)
|
||||
expect(adjRoom.monsterIndices).toEqual([3, 4])
|
||||
|
||||
// Distant rooms must NOT be populated and must have 0 monster indices
|
||||
expect(distRoom1.bPopulated).toBe(false)
|
||||
expect(distRoom1.active).toBe(false)
|
||||
expect(distRoom1.monsterIndices).toHaveLength(0)
|
||||
|
||||
expect(distRoom2.bPopulated).toBe(false)
|
||||
expect(distRoom2.active).toBe(false)
|
||||
expect(distRoom2.monsterIndices).toHaveLength(0)
|
||||
|
||||
// Active monsters are awake
|
||||
expect(world.monsters.every(m => !m.sleeping)).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers room activation and population when player approaches', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
expect(world.monsters).toHaveLength(2)
|
||||
expect(mgr.rooms[1]!.bPopulated).toBe(false)
|
||||
|
||||
// Move player close to distant room (distance to [3000, 3000] is ~282px <= 1600)
|
||||
world.player.x = 2800
|
||||
world.player.y = 2800
|
||||
mgr.update(world.player.x, world.player.y)
|
||||
|
||||
const distantRoom = mgr.rooms[1]!
|
||||
expect(distantRoom.bPopulated).toBe(true)
|
||||
expect(distantRoom.active).toBe(true)
|
||||
expect(distantRoom.monsterIndices).toEqual([2, 3, 4])
|
||||
expect(world.monsters).toHaveLength(5)
|
||||
|
||||
// Distant monsters are active and not sleeping
|
||||
for (const idx of distantRoom.monsterIndices) {
|
||||
expect(world.monsters[idx]!.sleeping).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('invokes onSpawnRoom callback exactly once when a room is populated', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const onSpawnRoom = vi.fn()
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_a',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_b',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
onSpawnRoom,
|
||||
})
|
||||
|
||||
// room_a spawned on init
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(1)
|
||||
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_a', bPopulated: true }))
|
||||
|
||||
// Move closer to room_b
|
||||
mgr.update(3000, 3000)
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
|
||||
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_b', bPopulated: true }))
|
||||
|
||||
// Repeated updates do NOT trigger onSpawnRoom again
|
||||
mgr.update(3000, 3000)
|
||||
mgr.update(0, 0)
|
||||
mgr.update(3000, 3000)
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Active / Sleeping Room Streaming', () => {
|
||||
it('marks living monsters in distant rooms as sleeping and skips their AI in tickMonsters', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_far',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Populate room_far
|
||||
mgr.update(3100, 3100)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(mgr.rooms[1]!.active).toBe(true)
|
||||
|
||||
// Monsters in room_spawn (indices 0, 1) are now sleeping
|
||||
const sleepingMonster = world.monsters[0]!
|
||||
expect(sleepingMonster.sleeping).toBe(true)
|
||||
|
||||
// Set cooldown and hitFlash on sleeping monster to verify tickMonsters skips it completely
|
||||
sleepingMonster.cooldown = 15
|
||||
sleepingMonster.hitFlash = 3
|
||||
const initialMonsterX = sleepingMonster.x
|
||||
const initialMonsterY = sleepingMonster.y
|
||||
|
||||
// Advance simulation tick: sleeping monster must skip all AI and timers
|
||||
tickCombat(
|
||||
world,
|
||||
{ movement: { x: 0, y: 0 }, attack: false },
|
||||
DEFAULT_COMBAT_OPTIONS,
|
||||
OPEN,
|
||||
[0, 100, 200],
|
||||
)
|
||||
|
||||
expect(sleepingMonster.cooldown).toBe(15) // Cooldown preserved without decrementing
|
||||
expect(sleepingMonster.hitFlash).toBe(3) // hitFlash preserved without decrementing
|
||||
expect(sleepingMonster.x).toBe(initialMonsterX)
|
||||
expect(sleepingMonster.y).toBe(initialMonsterY)
|
||||
|
||||
// Active monsters in room_far (e.g. index 2) are not sleeping
|
||||
const activeMonster = world.monsters[2]!
|
||||
expect(activeMonster.sleeping).toBe(false)
|
||||
})
|
||||
|
||||
it('wakes sleeping monsters when player re-enters activationRadius', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_far',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Move player far away: room_spawn deactivates
|
||||
mgr.update(4000, 4000)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(world.monsters[0]!.sleeping).toBe(true)
|
||||
|
||||
// Move player back: room_spawn wakes up
|
||||
mgr.update(100, 100)
|
||||
expect(mgr.rooms[0]!.active).toBe(true)
|
||||
expect(world.monsters[0]!.sleeping).toBe(false)
|
||||
expect(world.monsters[1]!.sleeping).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves monster damage, cooldowns, and positions across sleeping and wake cycles', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2, 50)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
const target = world.monsters[0]!
|
||||
const initialHp = target.hp
|
||||
damageMonster(world, 0, 18) // HP becomes initialHp - 18
|
||||
target.cooldown = 14
|
||||
const preservedX = target.x
|
||||
const preservedY = target.y
|
||||
|
||||
// Deactivate room
|
||||
mgr.update(4000, 4000)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(target.sleeping).toBe(true)
|
||||
|
||||
// Simulate several ticks while monster is sleeping
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
tickCombat(
|
||||
world,
|
||||
{ movement: { x: 0, y: 0 }, attack: false },
|
||||
DEFAULT_COMBAT_OPTIONS,
|
||||
OPEN,
|
||||
[0, 100, 200],
|
||||
)
|
||||
}
|
||||
|
||||
// Wake room back up
|
||||
mgr.update(100, 100)
|
||||
expect(mgr.rooms[0]!.active).toBe(true)
|
||||
expect(target.sleeping).toBe(false)
|
||||
expect(target.hp).toBe(initialHp - 18)
|
||||
expect(target.cooldown).toBe(14)
|
||||
expect(target.x).toBe(preservedX)
|
||||
expect(target.y).toBe(preservedY)
|
||||
})
|
||||
|
||||
it('respects hysteresis between activationRadius and deactivationRadius', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 1)],
|
||||
},
|
||||
{
|
||||
id: 'room_mid',
|
||||
bounds: { minX: 2000, minY: 0, maxX: 2200, maxY: 300 }, // distance from (100, 100) is 1900
|
||||
packs: [pack('zombie1', 1)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
const roomSpawn = mgr.rooms[0]!
|
||||
const roomMid = mgr.rooms[1]!
|
||||
|
||||
// Initial state: player at 100, 100
|
||||
// roomSpawn is active (dist = 0 <= 1600)
|
||||
// roomMid is at dist 1900 (between 1600 and 2400), so it stays unpopulated & inactive
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
expect(roomMid.bPopulated).toBe(false)
|
||||
expect(roomMid.active).toBe(false)
|
||||
|
||||
// Player moves to dist 2000 from roomSpawn:
|
||||
// roomSpawn was already active, so it STAYS active (dist 2000 <= 2400)
|
||||
mgr.update(2100, 100)
|
||||
// Now player is inside roomMid! (x: 2100 is between 2000 and 2200)
|
||||
expect(roomMid.bPopulated).toBe(true)
|
||||
expect(roomMid.active).toBe(true)
|
||||
// roomSpawn distance is 2100 - 300 = 1800 <= 2400, so it remains active
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
|
||||
// Player moves farther to x: 2800 (distance from roomSpawn: 2500 > 2400)
|
||||
mgr.update(2800, 100)
|
||||
expect(roomSpawn.active).toBe(false)
|
||||
|
||||
// Player steps back to x: 2300 (distance from roomSpawn: 2000, between 1600 and 2400)
|
||||
// roomSpawn was sleeping, so it STAYS sleeping until <= 1600!
|
||||
mgr.update(2300, 100)
|
||||
expect(roomSpawn.active).toBe(false)
|
||||
|
||||
// Player steps closer to x: 1800 (distance from roomSpawn: 1500 <= 1600)
|
||||
// roomSpawn wakes up!
|
||||
mgr.update(1800, 100)
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Idempotency & Non-Respawn', () => {
|
||||
it('never re-populates a room once bPopulated is true: dead monsters stay dead when returning', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_a',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 3)],
|
||||
},
|
||||
{
|
||||
id: 'room_b',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3400, maxY: 3400 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
expect(world.monsters).toHaveLength(3)
|
||||
|
||||
// Approach room_b to populate it
|
||||
mgr.update(3200, 3200)
|
||||
expect(world.monsters).toHaveLength(6)
|
||||
const roomB = mgr.rooms[1]!
|
||||
expect(roomB.bPopulated).toBe(true)
|
||||
|
||||
// Kill all monsters in room_b
|
||||
for (const idx of roomB.monsterIndices) {
|
||||
damageMonster(world, idx, 9999)
|
||||
expect(world.monsters[idx]!.state).toBe('dead')
|
||||
}
|
||||
|
||||
const totalMonstersBeforeLeave = world.monsters.length
|
||||
expect(totalMonstersBeforeLeave).toBe(6)
|
||||
|
||||
// Player leaves room_b far away
|
||||
mgr.update(100, 100)
|
||||
expect(roomB.active).toBe(false)
|
||||
|
||||
// Player returns to room_b
|
||||
mgr.update(3200, 3200)
|
||||
expect(roomB.active).toBe(true)
|
||||
expect(roomB.bPopulated).toBe(true)
|
||||
|
||||
// Verification: NO new monsters are spawned
|
||||
expect(world.monsters).toHaveLength(totalMonstersBeforeLeave)
|
||||
for (const idx of roomB.monsterIndices) {
|
||||
expect(world.monsters[idx]!.state).toBe('dead')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GameEngine Integration with MonsterStreamingManager', () => {
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [stats('fallen1'), stats('zombie1')],
|
||||
xpTable: [0, 0, 10, 30, 60],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: DEFAULT_COMBAT_OPTIONS,
|
||||
talkRadius: 50,
|
||||
pickupRadius: 50,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
monsterCount: 0,
|
||||
}
|
||||
|
||||
it('automatically initializes MonsterStreamingManager when roomPacks are provided', () => {
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
roomPacks: rooms,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager).toBeDefined()
|
||||
expect(engine.streamingManager?.rooms).toHaveLength(2)
|
||||
// Only spawn room monsters are created initially
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
expect(engine.streamingManager?.rooms[0]!.bPopulated).toBe(true)
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
|
||||
})
|
||||
|
||||
it('calls streamingManager.update in GameEngine.tick to stream rooms as player moves', () => {
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 1200, minY: 100, maxX: 1500, maxY: 500 }, // distance ~700 from (500, 100)
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
roomPacks: rooms,
|
||||
streamingOptions: {
|
||||
activationRadius: 800,
|
||||
deactivationRadius: 1400,
|
||||
},
|
||||
})
|
||||
|
||||
// Initially at (100, 100), distance to distant_room is 1100 > 800 -> unpopulated
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
|
||||
|
||||
// Teleport player closer to distant room
|
||||
engine.world.player.x = 800
|
||||
engine.world.player.y = 100
|
||||
|
||||
// Next engine.tick should update streaming manager and populate distant room
|
||||
engine.tick({
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
digits: [],
|
||||
saving: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(true)
|
||||
expect(engine.world.monsters).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('accepts pre-constructed streamingManager in GameEngineOptions', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_1',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
]
|
||||
const streamingManager = new MonsterStreamingManager(world, OPEN, rooms)
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
streamingManager,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager).toBe(streamingManager)
|
||||
expect(engine.world).toBe(world)
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
generatePreset,
|
||||
generateChaosSanctuary,
|
||||
CHAOS_SANCTUARY_LEVEL_ID,
|
||||
CHAOS_SANCTUARY_SIZE_X,
|
||||
CHAOS_SANCTUARY_SIZE_Y,
|
||||
CHAOS_SANCTUARY_PLACEMENTS,
|
||||
} from "../src/game/preset.ts"
|
||||
import type { PresetPiece } from "../src/game/preset.ts"
|
||||
import { generateWilderness } from "../src/game/wilderness.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(width: number, height: number, style = 1): 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: 2, sequence: 0, style, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 2, sequence: 0, style, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 4,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Preset Map Pipeline (DrlgType 2)", () => {
|
||||
test("generatePreset generates Level 108 Chaos Sanctuary stitching 7 cross-wing presets on 120x120 canvas", () => {
|
||||
const names = [
|
||||
"Act 4 - Diablo Arm N",
|
||||
"Act 4 - Diablo Arm W",
|
||||
"Act 4 - Diablo Heart",
|
||||
"Act 4 - Diablo Arm E",
|
||||
"Act 4 - Diablo Arm S",
|
||||
"Act 4 - Diablo Entry",
|
||||
"Act 4 - Bridge 2",
|
||||
]
|
||||
const pieces: PresetPiece[] = names.map((name, idx) => {
|
||||
const baseDs1 = makeMockDs1(24, 24, 10 + idx)
|
||||
const ds1: Ds1 = {
|
||||
...baseDs1,
|
||||
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
|
||||
}
|
||||
return { name, levels: [ds1] }
|
||||
})
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: CHAOS_SANCTUARY_LEVEL_ID,
|
||||
levelName: "Chaos Sanctuary",
|
||||
levelTypeName: "Act 4 - Lava",
|
||||
sizeX: CHAOS_SANCTUARY_SIZE_X,
|
||||
sizeY: CHAOS_SANCTUARY_SIZE_Y,
|
||||
seed: 108,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(120)
|
||||
expect(result.level.height).toBe(120)
|
||||
expect(result.level.act).toBe(4)
|
||||
expect(result.stats.substitutedClusters).toBe(7)
|
||||
expect(result.stats.substitutions.length).toBe(7)
|
||||
expect(result.level.objects.length).toBe(7)
|
||||
expect(result.stats.groundCells).toBeGreaterThan(0)
|
||||
|
||||
// Verify object placement offsets (5 sub-tiles per cell)
|
||||
// Arm N at (48, 0): object at (48*5 + 5, 0*5 + 5) = (245, 5)
|
||||
const armNObj = result.level.objects.find(o => o.id === 200)
|
||||
expect(armNObj).toBeDefined()
|
||||
expect(armNObj?.x).toBe(48 * 5 + 5)
|
||||
expect(armNObj?.y).toBe(0 * 5 + 5)
|
||||
|
||||
// Arm W at (24, 24): object at (24*5 + 5, 24*5 + 5) = (125, 125)
|
||||
const armWObj = result.level.objects.find(o => o.id === 201)
|
||||
expect(armWObj).toBeDefined()
|
||||
expect(armWObj?.x).toBe(24 * 5 + 5)
|
||||
expect(armWObj?.y).toBe(24 * 5 + 5)
|
||||
|
||||
// Heart at (48, 24): object at (48*5 + 5, 24*5 + 5) = (245, 125)
|
||||
const heartObj = result.level.objects.find(o => o.id === 202)
|
||||
expect(heartObj).toBeDefined()
|
||||
expect(heartObj?.x).toBe(48 * 5 + 5)
|
||||
expect(heartObj?.y).toBe(24 * 5 + 5)
|
||||
|
||||
// Bridge 2 at (48, 96): object at (48*5 + 5, 96*5 + 5) = (245, 485)
|
||||
const bridgeObj = result.level.objects.find(o => o.id === 206)
|
||||
expect(bridgeObj).toBeDefined()
|
||||
expect(bridgeObj?.x).toBe(48 * 5 + 5)
|
||||
expect(bridgeObj?.y).toBe(96 * 5 + 5)
|
||||
|
||||
// Verify tile cell content stamped at origins
|
||||
expect(result.level.cells[0]?.[48]?.floors[0]?.style).toBe(10) // Arm N style
|
||||
expect(result.level.cells[24]?.[24]?.floors[0]?.style).toBe(11) // Arm W style
|
||||
expect(result.level.cells[24]?.[48]?.floors[0]?.style).toBe(12) // Heart style
|
||||
expect(result.level.cells[96]?.[48]?.floors[0]?.style).toBe(16) // Bridge style
|
||||
})
|
||||
|
||||
test("generatePreset dispatches by levelName when levelId is not 108", () => {
|
||||
const pieces: PresetPiece[] = CHAOS_SANCTUARY_PLACEMENTS.map((item, idx) => ({
|
||||
name: item.name,
|
||||
levels: [makeMockDs1(24, 24, 5)],
|
||||
}))
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 0,
|
||||
levelName: "Act 4 - Chaos Sanctuary",
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(120)
|
||||
expect(result.level.height).toBe(120)
|
||||
expect(result.stats.substitutedClusters).toBe(7)
|
||||
})
|
||||
|
||||
test("generateChaosSanctuary matches piece names flexibly (tokens, stripped prefixes, filenames)", () => {
|
||||
// Pieces with alternative naming styles (e.g. without 'Act 4 - ', or file paths)
|
||||
const pieces: PresetPiece[] = [
|
||||
{ name: "Diablo Arm N", levels: [makeMockDs1(24, 24, 1)] },
|
||||
{ name: "data\\global\\tiles\\act4\\diablo\\ArmW.ds1", levels: [makeMockDs1(24, 24, 2)] },
|
||||
{ name: "Act 4 - Diablo Heart", levels: [makeMockDs1(24, 24, 3)] },
|
||||
{ name: "Diablo Arm E", levels: [makeMockDs1(24, 24, 4)] },
|
||||
{ name: "Arm S", levels: [makeMockDs1(24, 24, 5)] },
|
||||
{ name: "Diablo Entry", levels: [makeMockDs1(24, 24, 6)] },
|
||||
{ name: "Bridge 2", levels: [makeMockDs1(24, 24, 7)] },
|
||||
]
|
||||
|
||||
const result = generateChaosSanctuary({
|
||||
levelId: 108,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.stats.substitutedClusters).toBe(7)
|
||||
expect(result.level.width).toBe(120)
|
||||
expect(result.level.height).toBe(120)
|
||||
})
|
||||
|
||||
test("generatePreset handles standalone single-DS1 preset levels", () => {
|
||||
const baseDs1 = makeMockDs1(40, 40, 7)
|
||||
const mockDs1: Ds1 = {
|
||||
...baseDs1,
|
||||
objects: [{ type: 2, id: 99, x: 10, y: 10, flags: 0 }],
|
||||
}
|
||||
const pieces: PresetPiece[] = [
|
||||
{
|
||||
name: "Tristram",
|
||||
levels: [mockDs1],
|
||||
},
|
||||
]
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 38,
|
||||
levelName: "Tristram",
|
||||
sizeX: 40,
|
||||
sizeY: 40,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(40)
|
||||
expect(result.level.height).toBe(40)
|
||||
expect(result.level.objects.length).toBe(1)
|
||||
expect(result.stats.substitutedClusters).toBe(1)
|
||||
})
|
||||
|
||||
test("generatePreset supports custom piece placements", () => {
|
||||
const pieces: PresetPiece[] = [
|
||||
{ name: "RoomA", levels: [makeMockDs1(20, 20, 1)] },
|
||||
{ name: "RoomB", levels: [makeMockDs1(20, 20, 2)] },
|
||||
]
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 999,
|
||||
sizeX: 60,
|
||||
sizeY: 60,
|
||||
pieces,
|
||||
placements: [
|
||||
{ name: "RoomA", x: 0, y: 0 },
|
||||
{ name: "RoomB", x: 30, y: 30 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(60)
|
||||
expect(result.level.height).toBe(60)
|
||||
expect(result.stats.substitutedClusters).toBe(2)
|
||||
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1)
|
||||
expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2)
|
||||
})
|
||||
|
||||
test("wilderness generator no longer hardcodes Level 108 Chaos Sanctuary special case", () => {
|
||||
// Wilderness generator does not hijack Level 108 anymore; without special preset routing,
|
||||
// it will not produce the 7-cluster Chaos Sanctuary layout.
|
||||
const names = [
|
||||
"Act 4 - Diablo Arm N",
|
||||
"Act 4 - Diablo Arm W",
|
||||
"Act 4 - Diablo Heart",
|
||||
"Act 4 - Diablo Arm E",
|
||||
"Act 4 - Diablo Arm S",
|
||||
"Act 4 - Diablo Entry",
|
||||
"Act 4 - Bridge 2",
|
||||
]
|
||||
const pieces = names.map(name => ({
|
||||
name,
|
||||
border: false,
|
||||
levels: [makeMockDs1(24, 24)],
|
||||
}))
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 108,
|
||||
levelName: "Chaos Sanctuary",
|
||||
levelTypeName: "Act 4 - Lava",
|
||||
sizeX: 120,
|
||||
sizeY: 120,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 108,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
// Previously this returned substitutedClusters: 7 via stampAct4ChaosSanctuary.
|
||||
// Now wilderness treats it as regular wilderness, not the 7-preset Chaos Sanctuary.
|
||||
expect(result.stats.substitutedClusters).not.toBe(7)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,556 @@
|
|||
import { describe, test, expect } from 'vitest'
|
||||
import { SpriteRenderer, RendererError, type AtlasHandle } from '../src/render/renderer.ts'
|
||||
import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/render/cull.ts'
|
||||
import { countLoadedPages } from '../src/scene/act-scene.ts'
|
||||
|
||||
interface MockResource {
|
||||
id: number
|
||||
type: string
|
||||
}
|
||||
|
||||
function createMockCanvas(): {
|
||||
canvas: any
|
||||
stats: {
|
||||
createdTextures: Set<MockResource>
|
||||
deletedTextures: Set<MockResource>
|
||||
createdBuffers: Set<MockResource>
|
||||
deletedBuffers: Set<MockResource>
|
||||
createdVAOs: Set<MockResource>
|
||||
deletedVAOs: Set<MockResource>
|
||||
createdPrograms: Set<MockResource>
|
||||
deletedPrograms: Set<MockResource>
|
||||
createdShaders: Set<MockResource>
|
||||
deletedShaders: Set<MockResource>
|
||||
eventListeners: Map<string, Set<EventListener>>
|
||||
glDrawCalls: number
|
||||
shaderSources: string[]
|
||||
}
|
||||
setContextLost: (lost: boolean) => void
|
||||
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
|
||||
} {
|
||||
let nextId = 1
|
||||
let contextLost = false
|
||||
|
||||
const stats = {
|
||||
createdTextures: new Set<MockResource>(),
|
||||
deletedTextures: new Set<MockResource>(),
|
||||
createdBuffers: new Set<MockResource>(),
|
||||
deletedBuffers: new Set<MockResource>(),
|
||||
createdVAOs: new Set<MockResource>(),
|
||||
deletedVAOs: new Set<MockResource>(),
|
||||
createdPrograms: new Set<MockResource>(),
|
||||
deletedPrograms: new Set<MockResource>(),
|
||||
createdShaders: new Set<MockResource>(),
|
||||
deletedShaders: new Set<MockResource>(),
|
||||
eventListeners: new Map<string, Set<EventListener>>(),
|
||||
glDrawCalls: 0,
|
||||
shaderSources: [] as string[],
|
||||
}
|
||||
|
||||
const gl: any = {
|
||||
VERTEX_SHADER: 35633,
|
||||
FRAGMENT_SHADER: 35632,
|
||||
COMPILE_STATUS: 35713,
|
||||
LINK_STATUS: 35714,
|
||||
MAX_TEXTURE_SIZE: 3379,
|
||||
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
||||
TEXTURE_2D: 3553,
|
||||
RGBA: 6408,
|
||||
R8: 33321,
|
||||
RED: 6403,
|
||||
UNSIGNED_BYTE: 5121,
|
||||
UNSIGNED_INT: 5125,
|
||||
FLOAT: 5126,
|
||||
ARRAY_BUFFER: 34962,
|
||||
ELEMENT_ARRAY_BUFFER: 34963,
|
||||
DYNAMIC_DRAW: 35048,
|
||||
STATIC_DRAW: 35044,
|
||||
TRIANGLES: 4,
|
||||
TEXTURE_MIN_FILTER: 10241,
|
||||
TEXTURE_MAG_FILTER: 10240,
|
||||
TEXTURE_WRAP_S: 10242,
|
||||
TEXTURE_WRAP_T: 10243,
|
||||
CLAMP_TO_EDGE: 33071,
|
||||
NEAREST: 9728,
|
||||
UNPACK_ALIGNMENT: 3317,
|
||||
BLEND: 3042,
|
||||
SRC_ALPHA: 770,
|
||||
ONE_MINUS_SRC_ALPHA: 771,
|
||||
DEPTH_TEST: 2929,
|
||||
COLOR_BUFFER_BIT: 16384,
|
||||
TEXTURE0: 33984,
|
||||
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 720,
|
||||
|
||||
isContextLost: () => contextLost,
|
||||
|
||||
createProgram: () => {
|
||||
const res: MockResource = { id: nextId++, type: 'program' }
|
||||
stats.createdPrograms.add(res)
|
||||
return res
|
||||
},
|
||||
deleteProgram: (p: MockResource) => { stats.deletedPrograms.add(p) },
|
||||
attachShader: () => {},
|
||||
detachShader: () => {},
|
||||
linkProgram: () => {},
|
||||
getProgramParameter: () => true,
|
||||
getProgramInfoLog: () => '',
|
||||
useProgram: () => {},
|
||||
|
||||
createShader: (type: number) => {
|
||||
const res: MockResource = { id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }
|
||||
stats.createdShaders.add(res)
|
||||
return res
|
||||
},
|
||||
shaderSource: (_shader: MockResource, source: string) => { stats.shaderSources.push(source) },
|
||||
compileShader: () => {},
|
||||
getShaderParameter: () => true,
|
||||
getShaderInfoLog: () => '',
|
||||
deleteShader: (s: MockResource) => { stats.deletedShaders.add(s) },
|
||||
|
||||
createVertexArray: () => {
|
||||
const res: MockResource = { id: nextId++, type: 'vao' }
|
||||
stats.createdVAOs.add(res)
|
||||
return res
|
||||
},
|
||||
deleteVertexArray: (vao: MockResource) => { stats.deletedVAOs.add(vao) },
|
||||
bindVertexArray: () => {},
|
||||
|
||||
createBuffer: () => {
|
||||
const res: MockResource = { id: nextId++, type: 'buffer' }
|
||||
stats.createdBuffers.add(res)
|
||||
return res
|
||||
},
|
||||
deleteBuffer: (b: MockResource) => { stats.deletedBuffers.add(b) },
|
||||
bindBuffer: () => {},
|
||||
bufferData: () => {},
|
||||
bufferSubData: () => {},
|
||||
|
||||
getAttribLocation: (_p: any, name: string) => (
|
||||
name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4
|
||||
),
|
||||
enableVertexAttribArray: () => {},
|
||||
vertexAttribPointer: () => {},
|
||||
|
||||
createTexture: () => {
|
||||
const res: MockResource = { id: nextId++, type: 'texture' }
|
||||
stats.createdTextures.add(res)
|
||||
return res
|
||||
},
|
||||
deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) },
|
||||
bindTexture: () => {},
|
||||
texParameteri: () => {},
|
||||
texImage2D: () => {},
|
||||
pixelStorei: () => {},
|
||||
|
||||
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
||||
getUniformLocation: (_p: any, name: string) => ({ name }),
|
||||
uniform2f: () => {},
|
||||
uniform1f: () => {},
|
||||
uniform1i: () => {},
|
||||
uniform1iv: () => {},
|
||||
|
||||
enable: () => {},
|
||||
disable: () => {},
|
||||
blendFunc: () => {},
|
||||
viewport: () => {},
|
||||
clearColor: () => {},
|
||||
clear: () => {},
|
||||
activeTexture: () => {},
|
||||
drawArrays: () => { stats.glDrawCalls += 1 },
|
||||
drawElements: () => { stats.glDrawCalls += 1 },
|
||||
}
|
||||
|
||||
const eventListeners = stats.eventListeners
|
||||
|
||||
const canvas: any = {
|
||||
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
if (!eventListeners.has(type)) eventListeners.set(type, new Set())
|
||||
eventListeners.get(type)!.add(listener)
|
||||
},
|
||||
removeEventListener: (type: string, listener: EventListener) => {
|
||||
eventListeners.get(type)?.delete(listener)
|
||||
},
|
||||
width: 1280,
|
||||
height: 720,
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 720,
|
||||
}
|
||||
|
||||
return {
|
||||
canvas,
|
||||
stats,
|
||||
setContextLost: (lost: boolean) => { contextLost = lost },
|
||||
dispatchMockEvent: (type: string) => {
|
||||
let defaultPrevented = false
|
||||
const event: any = {
|
||||
type,
|
||||
preventDefault: () => { defaultPrevented = true },
|
||||
}
|
||||
const set = eventListeners.get(type)
|
||||
if (set) {
|
||||
for (const listener of set) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
return { defaultPrevented }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createAtlasPage(renderer: SpriteRenderer, width = 32, height = 32): AtlasHandle {
|
||||
return renderer.addAtlas({ pixels: new Uint8ClampedArray(width * height * 4), width, height })
|
||||
}
|
||||
|
||||
describe('多纹理合批与 Draw Call 预算 (方案 B / Issue #16)', () => {
|
||||
test('多图集页关卡在画家顺序重度交错下维持 Draw Call <= 1', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
|
||||
// 典型多图集关卡构成:
|
||||
// 地面(2页)+ 墙体(2页)+ 屋顶(1页)+ 物件(1页)+ 角色(1页)+ 怪物(2页)= 9页
|
||||
const pages = Array.from({ length: 9 }, () => createAtlasPage(renderer))
|
||||
const frame = { x: 0, y: 0, width: 32, height: 32 }
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
const initialCalls = mock.stats.glDrawCalls
|
||||
|
||||
// 1. 地面层(400 个四边形在 page 0 和 page 1 交替)
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
renderer.draw(frame, i * 16, 0, { atlas: pages[i % 2] })
|
||||
}
|
||||
// 2. 墙体与物件深度归并层(600 个四边形在 pages 2, 3, 5 频繁交替)
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
const p = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
|
||||
renderer.draw(frame, i * 16, 50, { atlas: pages[p] })
|
||||
}
|
||||
// 3. 实体层:玩家与怪物(150 个四边形在 pages 6, 7, 8 频繁交替)
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
renderer.draw(frame, i * 20, 100, { atlas: pages[6 + (i % 3)] })
|
||||
}
|
||||
// 4. 屋顶层(200 个四边形在 page 4)
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
renderer.draw(frame, i * 16, 150, { atlas: pages[4] })
|
||||
}
|
||||
|
||||
renderer.flush()
|
||||
|
||||
expect(renderer.quadsSubmitted).toBe(1350)
|
||||
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
|
||||
expect(renderer.drawCalls).toBe(1)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('精灵纹理与纯色矩形混合绘制不打断合批(单次 Draw Call)', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const pageA = createAtlasPage(renderer)
|
||||
const pageB = createAtlasPage(renderer)
|
||||
const frame = { x: 0, y: 0, width: 16, height: 16 }
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
const initialCalls = mock.stats.glDrawCalls
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
renderer.draw(frame, i * 8, 0, { atlas: i % 2 === 0 ? pageA : pageB })
|
||||
renderer.drawSolid(i * 8, 20, 10, 10, [1, 0.5, 0, 1])
|
||||
}
|
||||
|
||||
renderer.flush()
|
||||
|
||||
expect(renderer.quadsSubmitted).toBe(100)
|
||||
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
|
||||
expect(renderer.drawCalls).toBe(1)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('超出 GPU 纹理单元上限时严格按需断批,保持最小 Draw Call 开销', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const units = renderer.textureUnits
|
||||
expect(units).toBeGreaterThanOrEqual(8)
|
||||
|
||||
// 创建 units + 3 个不同纹理页
|
||||
const totalPages = units + 3
|
||||
const pages = Array.from({ length: totalPages }, () => createAtlasPage(renderer))
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
const initialCalls = mock.stats.glDrawCalls
|
||||
|
||||
// 前 units 个页在 batch 1
|
||||
for (let i = 0; i < units; i += 1) {
|
||||
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
|
||||
}
|
||||
// 接下来 3 个页触发断批,进入 batch 2
|
||||
for (let i = units; i < totalPages; i += 1) {
|
||||
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
|
||||
}
|
||||
|
||||
renderer.flush()
|
||||
|
||||
const issuedCalls = mock.stats.glDrawCalls - initialCalls
|
||||
expect(issuedCalls).toBe(2)
|
||||
expect(renderer.drawCalls).toBe(2)
|
||||
expect(renderer.drawCalls).toBeLessThanOrEqual(Math.ceil(totalPages / units))
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('begin() 重置每帧计数器,防止跨帧累加污染性能监控', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const page = createAtlasPage(renderer)
|
||||
const frame = { x: 0, y: 0, width: 10, height: 10 }
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
renderer.draw(frame, 0, 0, { atlas: page })
|
||||
renderer.flush()
|
||||
expect(renderer.drawCalls).toBe(1)
|
||||
expect(renderer.quadsSubmitted).toBe(1)
|
||||
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
expect(renderer.drawCalls).toBe(0)
|
||||
expect(renderer.quadsSubmitted).toBe(0)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('视口剔除与剔除率守卫 (方案 A / Issue #16)', () => {
|
||||
test('大关卡 (80x80 = 6400 四边形) 视口剔除率稳定超过 80%', () => {
|
||||
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
|
||||
const cols = 80
|
||||
const rows = 80
|
||||
for (let y = 0; y < rows; y += 1) {
|
||||
for (let x = 0; x < cols; x += 1) {
|
||||
quads.push({
|
||||
x: x * 80,
|
||||
y: y * 40,
|
||||
frame: { width: 80, height: 40 },
|
||||
})
|
||||
}
|
||||
}
|
||||
expect(quads.length).toBe(6400)
|
||||
|
||||
const bounds = buildBounds(quads)
|
||||
// 相机放置于地图中央 (3200, 1600),视口 1280x720,缩放 1.0
|
||||
const view = viewportRect(3200, 1600, 1280, 720, 1.0)
|
||||
|
||||
let visible = 0
|
||||
let culled = 0
|
||||
const visibleIndices: number[] = []
|
||||
|
||||
for (let i = 0; i < quads.length; i += 1) {
|
||||
if (intersects(bounds, i, view)) {
|
||||
visible += 1
|
||||
visibleIndices.push(i)
|
||||
} else {
|
||||
culled += 1
|
||||
}
|
||||
}
|
||||
|
||||
const cullingRatio = culled / quads.length
|
||||
expect(visible + culled).toBe(quads.length)
|
||||
// 80x80 关卡在 1280x720 视口下实测剔除率约为 94%
|
||||
expect(cullingRatio).toBeGreaterThan(0.80)
|
||||
expect(cullingRatio).toBeGreaterThanOrEqual(0.88)
|
||||
|
||||
// 严格断言画家算法顺序:保留的四边形相对下标必须单调递增,不能错乱
|
||||
for (let i = 1; i < visibleIndices.length; i += 1) {
|
||||
expect(visibleIndices[i]).toBeGreaterThan(visibleIndices[i - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
test('放大 (zoom = 2.0) 时视口变小,剔除率进一步提高', () => {
|
||||
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
|
||||
for (let y = 0; y < 60; y += 1) {
|
||||
for (let x = 0; x < 60; x += 1) {
|
||||
quads.push({ x: x * 80, y: y * 40, frame: { width: 80, height: 40 } })
|
||||
}
|
||||
}
|
||||
const bounds = buildBounds(quads)
|
||||
const view1x = viewportRect(2400, 1200, 1280, 720, 1.0)
|
||||
const view2x = viewportRect(2400, 1200, 1280, 720, 2.0)
|
||||
|
||||
let culled1x = 0
|
||||
let culled2x = 0
|
||||
for (let i = 0; i < quads.length; i += 1) {
|
||||
if (!intersects(bounds, i, view1x)) culled1x += 1
|
||||
if (!intersects(bounds, i, view2x)) culled2x += 1
|
||||
}
|
||||
|
||||
const ratio1x = culled1x / quads.length
|
||||
const ratio2x = culled2x / quads.length
|
||||
|
||||
expect(ratio1x).toBeGreaterThan(0.80)
|
||||
expect(ratio2x).toBeGreaterThan(ratio1x)
|
||||
})
|
||||
|
||||
test('边界临界条件:搭界不剔除、屏外大精灵不剔除', () => {
|
||||
const view = viewportRect(1000, 1000, 800, 600, 1)
|
||||
// view: left: 1000 - 400 - 32 = 568, right: 1432, top: 1000 - 300 - 32 = 668, bottom: 1332
|
||||
|
||||
// 1. 完全屏外
|
||||
const farAway = buildBounds([{ x: 0, y: 0, frame: { width: 10, height: 10 } }])
|
||||
expect(intersects(farAway, 0, view)).toBe(false)
|
||||
|
||||
// 2. 恰好搭在边界边缘(利用 CULL_MARGIN 余量)
|
||||
const borderQuad = buildBounds([{ x: 570, y: 700, frame: { width: 10, height: 10 } }])
|
||||
expect(intersects(borderQuad, 0, view)).toBe(true)
|
||||
|
||||
// 3. 锚点在屏外上方 (x: 1000, y: 0),但精灵尺寸达 200x800,覆盖到底部
|
||||
const tallSprite = buildBounds([{ x: 1000, y: 0, frame: { width: 200, height: 800 } }])
|
||||
expect(intersects(tallSprite, 0, view)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HUD 动态度量与真实性能指标', () => {
|
||||
test('HUD 数据源自渲染器实时测量值,而非硬编码假值', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const page = createAtlasPage(renderer)
|
||||
const frame = { x: 0, y: 0, width: 16, height: 16 }
|
||||
|
||||
// Frame 1 模拟:提交 150 个四边形,剔除 1850 个,耗时真实记录
|
||||
const start1 = performance.now()
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
renderer.draw(frame, i * 4, 0, { atlas: page })
|
||||
}
|
||||
renderer.flush()
|
||||
const renderMs1 = performance.now() - start1
|
||||
|
||||
const state1 = {
|
||||
drawCalls: renderer.drawCalls,
|
||||
quadsDrawn: renderer.quadsSubmitted,
|
||||
culledDraws: 1850,
|
||||
renderMs: renderMs1,
|
||||
}
|
||||
|
||||
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
|
||||
|
||||
expect(state1.drawCalls).toBe(1)
|
||||
expect(state1.quadsDrawn).toBe(150)
|
||||
expect(state1.culledDraws).toBe(1850)
|
||||
expect(state1.renderMs).toBeGreaterThanOrEqual(0)
|
||||
expect(hudText1).toContain('150 图元 / 1 批次(剔除 1850)')
|
||||
|
||||
// Frame 2 模拟:多图集导致 2 次 Draw Call,提交 300 个四边形,剔除 3700 个
|
||||
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => createAtlasPage(renderer))
|
||||
const start2 = performance.now()
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 250; i += 1) {
|
||||
renderer.draw(frame, i * 4, 0, { atlas: overPages[i % renderer.textureUnits] })
|
||||
}
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
renderer.draw(frame, i * 4, 50, { atlas: overPages[renderer.textureUnits] })
|
||||
}
|
||||
renderer.flush()
|
||||
const renderMs2 = performance.now() - start2
|
||||
|
||||
const state2 = {
|
||||
drawCalls: renderer.drawCalls,
|
||||
quadsDrawn: renderer.quadsSubmitted,
|
||||
culledDraws: 3700,
|
||||
renderMs: renderMs2,
|
||||
}
|
||||
|
||||
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
|
||||
|
||||
// 验证不是硬编码的常量
|
||||
expect(state2.drawCalls).toBe(2)
|
||||
expect(state2.quadsDrawn).toBe(300)
|
||||
expect(state2.culledDraws).toBe(3700)
|
||||
expect(hudText2).toContain('300 图元 / 2 批次(剔除 3700)')
|
||||
expect(state2.drawCalls).not.toBe(state1.drawCalls)
|
||||
expect(state2.quadsDrawn).not.toBe(state1.quadsDrawn)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('零分配顶点写入与 GPU 资源生命周期审计 (方案 C & 方案 D/E/G 护栏)', () => {
|
||||
test('动态顶点缓冲容量自动翻倍,并保全已有几何数据', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const page = createAtlasPage(renderer)
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
// 初始容量 8192,这里提交 10,000 个四边形触发 grow()
|
||||
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||
for (let i = 0; i < 10000; i += 1) {
|
||||
renderer.draw(frame, i % 100, i % 100, { atlas: page })
|
||||
}
|
||||
expect(renderer.quadsSubmitted).toBe(10000)
|
||||
|
||||
renderer.flush()
|
||||
expect(renderer.drawCalls).toBe(1)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('countLoadedPages 统计加载页数零临时数组分配', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
const page1 = createAtlasPage(renderer)
|
||||
const page2 = createAtlasPage(renderer)
|
||||
|
||||
const pages = [page1, null, page2, undefined, null]
|
||||
expect(countLoadedPages(pages as any)).toBe(2)
|
||||
|
||||
const allNull = [null, null, null]
|
||||
expect(countLoadedPages(allNull)).toBe(0)
|
||||
|
||||
const empty: any[] = []
|
||||
expect(countLoadedPages(empty)).toBe(0)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('WebGL 着色器与纹理生命周期完整释放,无显存泄漏', () => {
|
||||
const mock = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(mock.canvas)
|
||||
|
||||
expect(mock.stats.createdShaders.size).toBe(2)
|
||||
expect(mock.stats.deletedShaders.size).toBe(2)
|
||||
expect(mock.stats.createdPrograms.size).toBe(1)
|
||||
expect(mock.stats.createdTextures.size).toBe(3) // atlas + white + palette
|
||||
|
||||
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
|
||||
expect(mock.stats.createdTextures.size).toBe(4)
|
||||
|
||||
// 删除单个 atlas
|
||||
renderer.deleteAtlas(extra)
|
||||
expect(mock.stats.deletedTextures.has(extra.texture as any)).toBe(true)
|
||||
|
||||
// 释放整个渲染器
|
||||
renderer.dispose()
|
||||
expect(renderer.isDisposed).toBe(true)
|
||||
expect(mock.stats.deletedTextures.size).toBe(4)
|
||||
expect(mock.stats.deletedBuffers.size).toBe(2) // vertex + index buffer
|
||||
expect(mock.stats.deletedVAOs.size).toBe(1)
|
||||
expect(mock.stats.deletedPrograms.size).toBe(1)
|
||||
})
|
||||
|
||||
test('上下文丢失时安全降级与清理', () => {
|
||||
const mock = createMockCanvas()
|
||||
let lostTriggered = false
|
||||
const renderer = new SpriteRenderer(mock.canvas, {
|
||||
onContextLost: () => { lostTriggered = true },
|
||||
})
|
||||
|
||||
const eventResult = mock.dispatchMockEvent('webglcontextlost')
|
||||
expect(eventResult.defaultPrevented).toBe(true)
|
||||
expect(lostTriggered).toBe(true)
|
||||
|
||||
mock.setContextLost(true)
|
||||
// 在上下文已丢失状态下 dispose 不应报错
|
||||
expect(() => { renderer.dispose() }).not.toThrow()
|
||||
expect(renderer.isDisposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
SpriteRenderer,
|
||||
vertexShaderSource,
|
||||
fragmentShaderSource,
|
||||
type AtlasHandle,
|
||||
} from '../src/render/renderer.ts'
|
||||
|
||||
interface MockResource {
|
||||
id: number
|
||||
type: string
|
||||
}
|
||||
|
||||
function createMockCanvas(hasTexStorage = true): {
|
||||
canvas: any
|
||||
gl: any
|
||||
stats: {
|
||||
createdTextures: Set<MockResource>
|
||||
deletedTextures: Set<MockResource>
|
||||
texStorage2DCalls: Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>
|
||||
texSubImage2DCalls: Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>
|
||||
texImage2DCalls: Array<any>
|
||||
uniformCameraCalls: number
|
||||
uniformViewportCalls: number
|
||||
uniformZoomCalls: number
|
||||
uniformUploadCalls: number
|
||||
shaderSources: string[]
|
||||
}
|
||||
} {
|
||||
let nextId = 1
|
||||
const stats = {
|
||||
createdTextures: new Set<MockResource>(),
|
||||
deletedTextures: new Set<MockResource>(),
|
||||
texStorage2DCalls: [] as Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>,
|
||||
texSubImage2DCalls: [] as Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>,
|
||||
texImage2DCalls: [] as Array<any>,
|
||||
uniformCameraCalls: 0,
|
||||
uniformViewportCalls: 0,
|
||||
uniformZoomCalls: 0,
|
||||
uniformUploadCalls: 0,
|
||||
shaderSources: [] as string[],
|
||||
}
|
||||
|
||||
const gl: any = {
|
||||
VERTEX_SHADER: 35633,
|
||||
FRAGMENT_SHADER: 35632,
|
||||
COMPILE_STATUS: 35713,
|
||||
LINK_STATUS: 35714,
|
||||
MAX_TEXTURE_SIZE: 3379,
|
||||
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
||||
TEXTURE_2D: 3553,
|
||||
RGBA: 6408,
|
||||
RGBA8: 32856,
|
||||
R8: 33321,
|
||||
RED: 6403,
|
||||
UNSIGNED_BYTE: 5121,
|
||||
UNSIGNED_INT: 5125,
|
||||
FLOAT: 5126,
|
||||
ARRAY_BUFFER: 34962,
|
||||
ELEMENT_ARRAY_BUFFER: 34963,
|
||||
DYNAMIC_DRAW: 35048,
|
||||
STATIC_DRAW: 35044,
|
||||
TRIANGLES: 4,
|
||||
TEXTURE_MIN_FILTER: 10241,
|
||||
TEXTURE_MAG_FILTER: 10240,
|
||||
TEXTURE_WRAP_S: 10242,
|
||||
TEXTURE_WRAP_T: 10243,
|
||||
CLAMP_TO_EDGE: 33071,
|
||||
NEAREST: 9728,
|
||||
UNPACK_ALIGNMENT: 3317,
|
||||
BLEND: 3042,
|
||||
SRC_ALPHA: 770,
|
||||
ONE_MINUS_SRC_ALPHA: 771,
|
||||
DEPTH_TEST: 2929,
|
||||
COLOR_BUFFER_BIT: 16384,
|
||||
TEXTURE0: 33984,
|
||||
|
||||
drawingBufferWidth: 800,
|
||||
drawingBufferHeight: 600,
|
||||
|
||||
isContextLost: () => false,
|
||||
|
||||
createProgram: () => ({ id: nextId++, type: 'program' }),
|
||||
deleteProgram: () => {},
|
||||
attachShader: () => {},
|
||||
detachShader: () => {},
|
||||
linkProgram: () => {},
|
||||
getProgramParameter: () => true,
|
||||
getProgramInfoLog: () => '',
|
||||
useProgram: () => {},
|
||||
|
||||
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vs' : 'fs' }),
|
||||
shaderSource: (_shader: any, source: string) => { stats.shaderSources.push(source) },
|
||||
compileShader: () => {},
|
||||
getShaderParameter: () => true,
|
||||
getShaderInfoLog: () => '',
|
||||
deleteShader: () => {},
|
||||
|
||||
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
|
||||
deleteVertexArray: () => {},
|
||||
bindVertexArray: () => {},
|
||||
|
||||
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
|
||||
deleteBuffer: () => {},
|
||||
bindBuffer: () => {},
|
||||
bufferData: () => {},
|
||||
bufferSubData: () => {},
|
||||
|
||||
getAttribLocation: (_p: any, name: string) => (
|
||||
name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4
|
||||
),
|
||||
enableVertexAttribArray: () => {},
|
||||
vertexAttribPointer: () => {},
|
||||
|
||||
createTexture: () => {
|
||||
const res: MockResource = { id: nextId++, type: 'texture' }
|
||||
stats.createdTextures.add(res)
|
||||
return res
|
||||
},
|
||||
deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) },
|
||||
bindTexture: () => {},
|
||||
texParameteri: () => {},
|
||||
pixelStorei: () => {},
|
||||
|
||||
texImage2D: (...args: any[]) => {
|
||||
stats.texImage2DCalls.push(args)
|
||||
},
|
||||
texSubImage2D: (
|
||||
target: number,
|
||||
level: number,
|
||||
xoffset: number,
|
||||
yoffset: number,
|
||||
width: number,
|
||||
height: number,
|
||||
format: number,
|
||||
type: number,
|
||||
data: any,
|
||||
) => {
|
||||
stats.texSubImage2DCalls.push({ target, level, xoffset, yoffset, width, height, format, type, data })
|
||||
},
|
||||
|
||||
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
|
||||
getUniformLocation: (_p: any, name: string) => ({ name }),
|
||||
|
||||
uniform2f: (loc: any, x: number, y: number) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
|
||||
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
|
||||
},
|
||||
uniform1f: (loc: any, x: number) => {
|
||||
stats.uniformUploadCalls += 1
|
||||
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
|
||||
},
|
||||
uniform1i: () => {},
|
||||
uniform1iv: () => {},
|
||||
|
||||
enable: () => {},
|
||||
disable: () => {},
|
||||
blendFunc: () => {},
|
||||
viewport: () => {},
|
||||
clearColor: () => {},
|
||||
clear: () => {},
|
||||
activeTexture: () => {},
|
||||
drawArrays: () => {},
|
||||
drawElements: () => {},
|
||||
}
|
||||
|
||||
if (hasTexStorage) {
|
||||
gl.texStorage2D = (target: number, levels: number, internalformat: number, width: number, height: number) => {
|
||||
stats.texStorage2DCalls.push({ target, levels, internalformat, width, height })
|
||||
}
|
||||
}
|
||||
|
||||
const canvas: any = {
|
||||
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
drawingBufferWidth: 800,
|
||||
drawingBufferHeight: 600,
|
||||
}
|
||||
|
||||
return { canvas, gl, stats }
|
||||
}
|
||||
|
||||
describe('WebGL2 SpriteRenderer Performance Tuning (Issue #20)', () => {
|
||||
describe('Immutable Texture Storage (texStorage2D + texSubImage2D)', () => {
|
||||
test('allocates whiteTexture and paletteTexture with texStorage2D in constructor', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
|
||||
expect(stats.texStorage2DCalls.length).toBeGreaterThanOrEqual(2)
|
||||
// whiteTexture: 1x1 RGBA8
|
||||
const whiteStorage = stats.texStorage2DCalls.find(call => call.width === 1 && call.height === 1)
|
||||
expect(whiteStorage).toBeDefined()
|
||||
expect(whiteStorage?.internalformat).toBe(32856) // gl.RGBA8
|
||||
expect(whiteStorage?.levels).toBe(1)
|
||||
|
||||
// paletteTexture: 256x5 RGBA8
|
||||
const palStorage = stats.texStorage2DCalls.find(call => call.width === 256 && call.height === 5)
|
||||
expect(palStorage).toBeDefined()
|
||||
expect(palStorage?.internalformat).toBe(32856) // gl.RGBA8
|
||||
|
||||
// Both should have data uploaded via texSubImage2D
|
||||
expect(stats.texSubImage2DCalls.length).toBeGreaterThanOrEqual(2)
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('setAtlas allocates immutable storage via texStorage2D and uploads via texSubImage2D', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
const storageCallsBefore = stats.texStorage2DCalls.length
|
||||
const subCallsBefore = stats.texSubImage2DCalls.length
|
||||
|
||||
const atlas = {
|
||||
pixels: new Uint8ClampedArray(128 * 64 * 4),
|
||||
width: 128,
|
||||
height: 64,
|
||||
groups: [],
|
||||
}
|
||||
renderer.setAtlas(atlas)
|
||||
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
||||
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
||||
expect(lastStorage?.width).toBe(128)
|
||||
expect(lastStorage?.height).toBe(64)
|
||||
expect(lastStorage?.internalformat).toBe(32856) // gl.RGBA8
|
||||
|
||||
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
||||
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
|
||||
expect(lastSub?.width).toBe(128)
|
||||
expect(lastSub?.height).toBe(64)
|
||||
|
||||
// Re-uploading atlas with SAME dimensions reuses the immutable storage
|
||||
renderer.setAtlas(atlas)
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1) // no extra texStorage2D
|
||||
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2) // extra texSubImage2D
|
||||
|
||||
// Uploading atlas with DIFFERENT dimensions deletes old texture and allocates new storage
|
||||
const resizedAtlas = {
|
||||
pixels: new Uint8ClampedArray(256 * 256 * 4),
|
||||
width: 256,
|
||||
height: 256,
|
||||
groups: [],
|
||||
}
|
||||
renderer.setAtlas(resizedAtlas)
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
|
||||
const resizedStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
||||
expect(resizedStorage?.width).toBe(256)
|
||||
expect(resizedStorage?.height).toBe(256)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('addAtlas and createAtlas allocate immutable storage via texStorage2D', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
const storageCallsBefore = stats.texStorage2DCalls.length
|
||||
const subCallsBefore = stats.texSubImage2DCalls.length
|
||||
|
||||
const handle1 = renderer.addAtlas({
|
||||
pixels: new Uint8ClampedArray(32 * 32 * 4),
|
||||
width: 32,
|
||||
height: 32,
|
||||
})
|
||||
expect(handle1.width).toBe(32)
|
||||
expect(handle1.height).toBe(32)
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
||||
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
||||
|
||||
const handle2 = renderer.createAtlas({
|
||||
pixels: new Uint8ClampedArray(64 * 32 * 4),
|
||||
width: 64,
|
||||
height: 32,
|
||||
})
|
||||
expect(handle2.width).toBe(64)
|
||||
expect(handle2.height).toBe(32)
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
|
||||
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('addIndexedAtlas allocates R8 single-channel texture via texStorage2D', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
const storageCallsBefore = stats.texStorage2DCalls.length
|
||||
const subCallsBefore = stats.texSubImage2DCalls.length
|
||||
|
||||
const handle = renderer.addIndexedAtlas(new Uint8Array(64 * 64), 64, 64)
|
||||
expect(handle.indexed).toBe(true)
|
||||
expect(handle.width).toBe(64)
|
||||
expect(handle.height).toBe(64)
|
||||
|
||||
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
|
||||
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
|
||||
expect(lastStorage?.width).toBe(64)
|
||||
expect(lastStorage?.height).toBe(64)
|
||||
expect(lastStorage?.internalformat).toBe(33321) // gl.R8
|
||||
|
||||
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
|
||||
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
|
||||
expect(lastSub?.format).toBe(6403) // gl.RED
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('gracefully falls back to texImage2D when texStorage2D is unavailable', () => {
|
||||
const { canvas, stats } = createMockCanvas(false) // without texStorage2D
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
expect(stats.texImage2DCalls.length).toBeGreaterThan(0)
|
||||
|
||||
const atlas = {
|
||||
pixels: new Uint8ClampedArray(16 * 16 * 4),
|
||||
width: 16,
|
||||
height: 16,
|
||||
groups: [],
|
||||
}
|
||||
renderer.setAtlas(atlas)
|
||||
renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
|
||||
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
||||
expect(stats.texImage2DCalls.length).toBeGreaterThanOrEqual(4)
|
||||
renderer.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Uniform Caching & Dirty Checking', () => {
|
||||
test('only issues uniform uploads when camera, viewport, or zoom values change', () => {
|
||||
const { canvas, gl, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
const frame = { x: 0, y: 0, width: 8, height: 8 }
|
||||
|
||||
// Frame 1: Initial flush
|
||||
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(1)
|
||||
expect(stats.uniformViewportCalls).toBe(1)
|
||||
expect(stats.uniformZoomCalls).toBe(1)
|
||||
|
||||
// Frame 2: Same camera, same viewport, same zoom -> 0 uniform uploads
|
||||
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(1)
|
||||
expect(stats.uniformViewportCalls).toBe(1)
|
||||
expect(stats.uniformZoomCalls).toBe(1)
|
||||
|
||||
// Frame 3: Multiple flushes within the same frame -> 0 redundant uploads
|
||||
renderer.draw(frame, 8, 8)
|
||||
renderer.flush()
|
||||
renderer.draw(frame, 16, 16)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(1)
|
||||
expect(stats.uniformViewportCalls).toBe(1)
|
||||
expect(stats.uniformZoomCalls).toBe(1)
|
||||
|
||||
// Frame 4: Camera changes -> only camera uniform uploaded
|
||||
renderer.begin({ x: 60, y: 100, zoom: 1.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(2)
|
||||
expect(stats.uniformViewportCalls).toBe(1)
|
||||
expect(stats.uniformZoomCalls).toBe(1)
|
||||
|
||||
// Frame 5: Zoom changes -> only zoom uniform uploaded
|
||||
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(2)
|
||||
expect(stats.uniformViewportCalls).toBe(1)
|
||||
expect(stats.uniformZoomCalls).toBe(2)
|
||||
|
||||
// Frame 6: Viewport changes -> only viewport uniform uploaded
|
||||
gl.drawingBufferWidth = 1024
|
||||
gl.drawingBufferHeight = 768
|
||||
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
|
||||
renderer.draw(frame, 0, 0)
|
||||
renderer.flush()
|
||||
|
||||
expect(stats.uniformCameraCalls).toBe(2)
|
||||
expect(stats.uniformViewportCalls).toBe(2)
|
||||
expect(stats.uniformZoomCalls).toBe(2)
|
||||
|
||||
renderer.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configurable Shader Discard / Alpha Optimization', () => {
|
||||
test('fragmentShaderSource generates discard when alphaDiscard is true', () => {
|
||||
const src = fragmentShaderSource(8, { alphaDiscard: true })
|
||||
expect(src).toContain('if (idx == 0) discard;')
|
||||
expect(src).toContain('if (texel.a == 0.0) discard;')
|
||||
})
|
||||
|
||||
test('fragmentShaderSource omits discard when alphaDiscard is false', () => {
|
||||
const src = fragmentShaderSource(8, { alphaDiscard: false })
|
||||
expect(src).not.toContain('discard')
|
||||
expect(src).toContain('texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);')
|
||||
expect(src).toContain('outColor = texel * v_tint;')
|
||||
})
|
||||
|
||||
test('SpriteRenderer defaults to alphaDiscard = true', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas)
|
||||
expect(renderer.alphaDiscard).toBe(true)
|
||||
|
||||
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
expect(fs).toContain('discard;')
|
||||
renderer.dispose()
|
||||
})
|
||||
|
||||
test('SpriteRenderer respects alphaDiscard = false option', () => {
|
||||
const { canvas, stats } = createMockCanvas()
|
||||
const renderer = new SpriteRenderer(canvas, { alphaDiscard: false })
|
||||
expect(renderer.alphaDiscard).toBe(false)
|
||||
|
||||
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
|
||||
expect(fs).not.toContain('discard')
|
||||
expect(fs).toContain('outColor = texel * v_tint;')
|
||||
renderer.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getRoofBounds,
|
||||
getCombinedRoofBounds,
|
||||
isPointInRoofBounds,
|
||||
boundsOverlap,
|
||||
getPlayerBounds,
|
||||
isPlayerOverlappingRoofBounds,
|
||||
isPlayerInRoofCell,
|
||||
isPlayerInRoofSubTile,
|
||||
isPlayerUnderRoof,
|
||||
stepRoofAlpha,
|
||||
clusterRoofs,
|
||||
RoofFadeController,
|
||||
DEFAULT_MIN_ROOF_ALPHA,
|
||||
DEFAULT_MAX_ROOF_ALPHA,
|
||||
DEFAULT_ROOF_FADE_DURATION_MS,
|
||||
type RoofDrawable,
|
||||
} from '../src/render/roofs.ts'
|
||||
|
||||
/** Helper to create a minimal roof tile. */
|
||||
function makeRoof(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
cellX?: number,
|
||||
cellY?: number,
|
||||
): RoofDrawable {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
frame: { width, height },
|
||||
...(cellX !== undefined ? { cellX } : {}),
|
||||
...(cellY !== undefined ? { cellY } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Roof Bounding Detection', () => {
|
||||
it('computes correct bounds for a single roof tile', () => {
|
||||
const roof = makeRoof(100, 200, 80, 60)
|
||||
const bounds = getRoofBounds(roof)
|
||||
expect(bounds).toEqual({
|
||||
minX: 100,
|
||||
minY: 200,
|
||||
maxX: 180,
|
||||
maxY: 260,
|
||||
})
|
||||
})
|
||||
|
||||
it('computes correct bounds with negative coordinates and offsets', () => {
|
||||
const roof = makeRoof(-50, -30, 100, 70)
|
||||
const bounds = getRoofBounds(roof)
|
||||
expect(bounds).toEqual({
|
||||
minX: -50,
|
||||
minY: -30,
|
||||
maxX: 50,
|
||||
maxY: 40,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for combined bounds of empty roof list', () => {
|
||||
expect(getCombinedRoofBounds([])).toBeNull()
|
||||
})
|
||||
|
||||
it('computes combined bounding box enclosing multiple roof tiles', () => {
|
||||
const roofs = [
|
||||
makeRoof(100, 150, 40, 40),
|
||||
makeRoof(130, 180, 50, 30),
|
||||
makeRoof(80, 200, 30, 60),
|
||||
]
|
||||
const combined = getCombinedRoofBounds(roofs)
|
||||
expect(combined).toEqual({
|
||||
minX: 80,
|
||||
minY: 150,
|
||||
maxX: 180, // 130 + 50
|
||||
maxY: 260, // 80..110, 200 + 60
|
||||
})
|
||||
})
|
||||
|
||||
it('detects whether a 2D point is inside roof bounds', () => {
|
||||
const bounds = { minX: 50, minY: 100, maxX: 150, maxY: 200 }
|
||||
|
||||
// Inside
|
||||
expect(isPointInRoofBounds(100, 150, bounds)).toBe(true)
|
||||
|
||||
// Boundaries (inclusive)
|
||||
expect(isPointInRoofBounds(50, 100, bounds)).toBe(true)
|
||||
expect(isPointInRoofBounds(150, 200, bounds)).toBe(true)
|
||||
|
||||
// Outside
|
||||
expect(isPointInRoofBounds(49, 150, bounds)).toBe(false)
|
||||
expect(isPointInRoofBounds(151, 150, bounds)).toBe(false)
|
||||
expect(isPointInRoofBounds(100, 99, bounds)).toBe(false)
|
||||
expect(isPointInRoofBounds(100, 201, bounds)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects bounding box overlap and non-overlap', () => {
|
||||
const boxA = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
|
||||
|
||||
// Fully overlapping
|
||||
expect(boundsOverlap(boxA, { minX: 20, minY: 20, maxX: 80, maxY: 80 })).toBe(true)
|
||||
// Partially overlapping
|
||||
expect(boundsOverlap(boxA, { minX: 50, minY: 50, maxX: 150, maxY: 150 })).toBe(true)
|
||||
// Touching edge
|
||||
expect(boundsOverlap(boxA, { minX: 100, minY: 0, maxX: 200, maxY: 100 })).toBe(true)
|
||||
// Separated in X
|
||||
expect(boundsOverlap(boxA, { minX: 101, minY: 0, maxX: 200, maxY: 100 })).toBe(false)
|
||||
// Separated in Y
|
||||
expect(boundsOverlap(boxA, { minX: 0, minY: 101, maxX: 100, maxY: 200 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Player-Under-Roof Condition', () => {
|
||||
const roof = makeRoof(100, 100, 80, 80, 5, 10)
|
||||
|
||||
describe('bounding box overlap', () => {
|
||||
it('detects player point inside roof bounds', () => {
|
||||
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 140 }, roof)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects player sprite overlapping lower edge of roof', () => {
|
||||
// Player feet at y=190, sprite extends upward to y=142, overlapping roof [100..180]
|
||||
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 190 }, roof)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when player is far away from roof', () => {
|
||||
expect(isPlayerOverlappingRoofBounds({ x: 500, y: 500 }, roof)).toBe(false)
|
||||
expect(isPlayerOverlappingRoofBounds({ x: 20, y: 50 }, roof)).toBe(false)
|
||||
})
|
||||
|
||||
it('supports explicit player bounding box', () => {
|
||||
expect(isPlayerOverlappingRoofBounds({ left: 110, top: 120, right: 130, bottom: 160 }, roof)).toBe(true)
|
||||
expect(isPlayerOverlappingRoofBounds({ left: 300, top: 300, right: 320, bottom: 340 }, roof)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cell and sub-tile coverage', () => {
|
||||
it('matches player cell coordinates with roof tile cell', () => {
|
||||
expect(isPlayerInRoofCell({ x: 5, y: 10 }, roof)).toBe(true)
|
||||
expect(isPlayerInRoofCell({ x: 4, y: 10 }, roof)).toBe(false)
|
||||
expect(isPlayerInRoofCell({ x: 5, y: 9 }, roof)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches player cell against an array of roof tiles', () => {
|
||||
const roofTiles = [
|
||||
makeRoof(0, 0, 80, 40, 5, 10),
|
||||
makeRoof(0, 0, 80, 40, 5, 11),
|
||||
makeRoof(0, 0, 80, 40, 6, 10),
|
||||
]
|
||||
expect(isPlayerInRoofCell({ x: 5, y: 11 }, roofTiles)).toBe(true)
|
||||
expect(isPlayerInRoofCell({ x: 6, y: 10 }, roofTiles)).toBe(true)
|
||||
expect(isPlayerInRoofCell({ x: 7, y: 12 }, roofTiles)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects player sub-tile coverage (5x5 sub-tiles per cell)', () => {
|
||||
// Cell (5, 10) corresponds to sub-tiles [25..29, 50..54]
|
||||
expect(isPlayerInRoofSubTile({ x: 25, y: 50 }, roof)).toBe(true)
|
||||
expect(isPlayerInRoofSubTile({ x: 29, y: 54 }, roof)).toBe(true)
|
||||
expect(isPlayerInRoofSubTile({ x: 27, y: 52 }, roof)).toBe(true)
|
||||
|
||||
// Adjacent sub-tile in cell (4, 10) or (5, 9)
|
||||
expect(isPlayerInRoofSubTile({ x: 24, y: 50 }, roof)).toBe(false)
|
||||
expect(isPlayerInRoofSubTile({ x: 25, y: 49 }, roof)).toBe(false)
|
||||
})
|
||||
|
||||
it('combines cell coverage and bounding box in isPlayerUnderRoof', () => {
|
||||
// Cell matches even if screen point is outside
|
||||
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 5, y: 10 })).toBe(true)
|
||||
|
||||
// Bounding box matches even if cell is different
|
||||
expect(isPlayerUnderRoof({ x: 140, y: 140 }, roof, { x: 99, y: 99 })).toBe(true)
|
||||
|
||||
// Neither matches
|
||||
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 99, y: 99 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shelter clustering', () => {
|
||||
it('groups adjacent roof tiles into a single shelter cluster', () => {
|
||||
const akaraTent = [
|
||||
makeRoof(100, 100, 80, 80, 10, 10),
|
||||
makeRoof(140, 120, 80, 80, 10, 11),
|
||||
makeRoof(120, 140, 80, 80, 11, 10),
|
||||
]
|
||||
const clusters = clusterRoofs(akaraTent)
|
||||
expect(clusters).toHaveLength(1)
|
||||
expect(clusters[0]!.roofs).toHaveLength(3)
|
||||
expect(clusters[0]!.cells.has('10,10')).toBe(true)
|
||||
expect(clusters[0]!.cells.has('10,11')).toBe(true)
|
||||
expect(clusters[0]!.cells.has('11,10')).toBe(true)
|
||||
})
|
||||
|
||||
it('separates distant roof groups into distinct shelters', () => {
|
||||
const akaraTent = [
|
||||
makeRoof(100, 100, 80, 80, 10, 10),
|
||||
makeRoof(140, 120, 80, 80, 10, 11),
|
||||
]
|
||||
const charsiForge = [
|
||||
makeRoof(800, 800, 80, 80, 50, 50),
|
||||
makeRoof(840, 820, 80, 80, 50, 51),
|
||||
]
|
||||
const clusters = clusterRoofs([...akaraTent, ...charsiForge])
|
||||
expect(clusters).toHaveLength(2)
|
||||
|
||||
const clusterA = clusters.find(c => c.cells.has('10,10'))!
|
||||
const clusterB = clusters.find(c => c.cells.has('50,50'))!
|
||||
expect(clusterA.roofs).toHaveLength(2)
|
||||
expect(clusterB.roofs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roof Opacity Fade', () => {
|
||||
describe('stepRoofAlpha', () => {
|
||||
it('fades down towards minAlpha over durationMs', () => {
|
||||
// 200ms total duration, from 1.0 to 0.25
|
||||
const start = 1.0
|
||||
const target = 0.25
|
||||
|
||||
// After 50ms (25% progress) -> 1.0 - 0.75 * 0.25 = 0.8125
|
||||
const step1 = stepRoofAlpha(start, target, 50, 200, 0.25, 1.0)
|
||||
expect(step1).toBeCloseTo(0.8125, 4)
|
||||
|
||||
// After 100ms (50% progress) -> 1.0 - 0.75 * 0.5 = 0.625
|
||||
const step2 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
|
||||
expect(step2).toBeCloseTo(0.625, 4)
|
||||
|
||||
// After 200ms (100% progress) -> exactly 0.25
|
||||
const step3 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
|
||||
expect(step3).toBe(0.25)
|
||||
|
||||
// Clamps and does not overshoot below minAlpha
|
||||
const step4 = stepRoofAlpha(0.25, target, 100, 200, 0.25, 1.0)
|
||||
expect(step4).toBe(0.25)
|
||||
})
|
||||
|
||||
it('fades up towards maxAlpha when exiting shelter', () => {
|
||||
const start = 0.25
|
||||
const target = 1.0
|
||||
|
||||
// After 100ms -> halfway back to 1.0 (0.625)
|
||||
const step1 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
|
||||
expect(step1).toBeCloseTo(0.625, 4)
|
||||
|
||||
// After 200ms -> full 1.0
|
||||
const step2 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
|
||||
expect(step2).toBe(1.0)
|
||||
|
||||
// Clamps at maxAlpha
|
||||
const step3 = stepRoofAlpha(1.0, target, 50, 200, 0.25, 1.0)
|
||||
expect(step3).toBe(1.0)
|
||||
})
|
||||
|
||||
it('smoothly reverses direction mid-fade without discontinuities', () => {
|
||||
// Player enters shelter, fades down partially to 0.70
|
||||
let alpha = 1.0
|
||||
alpha = stepRoofAlpha(alpha, 0.25, 80, 200, 0.25, 1.0)
|
||||
expect(alpha).toBeCloseTo(0.70, 4)
|
||||
|
||||
// Player immediately steps back out: smoothly fades back up towards 1.0
|
||||
alpha = stepRoofAlpha(alpha, 1.0, 40, 200, 0.25, 1.0)
|
||||
expect(alpha).toBeGreaterThan(0.70)
|
||||
expect(alpha).toBeCloseTo(0.85, 4)
|
||||
|
||||
// Reaches 1.0
|
||||
alpha = stepRoofAlpha(alpha, 1.0, 100, 200, 0.25, 1.0)
|
||||
expect(alpha).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns currentAlpha when deltaMs is zero or negative', () => {
|
||||
expect(stepRoofAlpha(0.5, 0.25, 0, 200)).toBe(0.5)
|
||||
expect(stepRoofAlpha(0.5, 0.25, -10, 200)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('snaps immediately to target when durationMs is zero', () => {
|
||||
expect(stepRoofAlpha(1.0, 0.25, 16, 0)).toBe(0.25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoofFadeController', () => {
|
||||
const akaraTent = [
|
||||
makeRoof(100, 100, 80, 80, 10, 10),
|
||||
makeRoof(140, 120, 80, 80, 10, 11),
|
||||
]
|
||||
const charsiForge = [
|
||||
makeRoof(800, 800, 80, 80, 50, 50),
|
||||
]
|
||||
|
||||
it('initializes all roofs to full opacity (1.0)', () => {
|
||||
const controller = new RoofFadeController([...akaraTent, ...charsiForge])
|
||||
expect(controller.getAlpha(0)).toBe(1.0)
|
||||
expect(controller.getAlpha(1)).toBe(1.0)
|
||||
expect(controller.getAlpha(2)).toBe(1.0)
|
||||
expect(controller.overallAlpha).toBe(1.0)
|
||||
expect(controller.isPlayerUnderAnyRoof).toBe(false)
|
||||
})
|
||||
|
||||
it('fades only the shelter the player is under', () => {
|
||||
const controller = new RoofFadeController([...akaraTent, ...charsiForge], {
|
||||
fadeDurationMs: 200,
|
||||
minAlpha: 0.25,
|
||||
})
|
||||
|
||||
// Player stands inside Akara's tent at cell (10, 10)
|
||||
const playerPos = { x: 140, y: 140 }
|
||||
const playerCell = { x: 10, y: 10 }
|
||||
|
||||
// Advance by 100ms (halfway)
|
||||
controller.update(playerPos, 100, playerCell)
|
||||
|
||||
expect(controller.isPlayerUnderAnyRoof).toBe(true)
|
||||
// Akara's tent tiles (indices 0 and 1) fade in unison
|
||||
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
|
||||
expect(controller.getAlpha(1)).toBeCloseTo(0.625, 4)
|
||||
|
||||
// Charsi's forge (index 2) remains fully opaque
|
||||
expect(controller.getAlpha(2)).toBe(1.0)
|
||||
|
||||
// Advance by another 100ms (reaches minAlpha 0.25)
|
||||
controller.update(playerPos, 100, playerCell)
|
||||
expect(controller.getAlpha(0)).toBe(0.25)
|
||||
expect(controller.getAlpha(1)).toBe(0.25)
|
||||
expect(controller.getAlpha(2)).toBe(1.0)
|
||||
expect(controller.overallAlpha).toBe(0.25)
|
||||
})
|
||||
|
||||
it('smoothly restores full opacity when player exits shelter', () => {
|
||||
const controller = new RoofFadeController(akaraTent, {
|
||||
fadeDurationMs: 200,
|
||||
minAlpha: 0.25,
|
||||
})
|
||||
|
||||
// Player inside -> fully faded
|
||||
controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 })
|
||||
expect(controller.getAlpha(0)).toBe(0.25)
|
||||
|
||||
// Player exits shelter to town square
|
||||
const outsidePos = { x: 500, y: 500 }
|
||||
const outsideCell = { x: 25, y: 25 }
|
||||
|
||||
// 100ms elapsed
|
||||
controller.update(outsidePos, 100, outsideCell)
|
||||
expect(controller.isPlayerUnderAnyRoof).toBe(false)
|
||||
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
|
||||
|
||||
// 200ms elapsed -> fully restored
|
||||
controller.update(outsidePos, 100, outsideCell)
|
||||
expect(controller.getAlpha(0)).toBe(1.0)
|
||||
expect(controller.overallAlpha).toBe(1.0)
|
||||
})
|
||||
|
||||
it('handles empty roof list safely', () => {
|
||||
const controller = new RoofFadeController([])
|
||||
controller.update({ x: 100, y: 100 }, 16)
|
||||
expect(controller.getAlpha(0)).toBe(1.0)
|
||||
expect(controller.overallAlpha).toBe(1.0)
|
||||
expect(controller.isPlayerUnderAnyRoof).toBe(false)
|
||||
})
|
||||
|
||||
it('allows manual alpha override via setAlpha', () => {
|
||||
const controller = new RoofFadeController(akaraTent)
|
||||
controller.setAlpha(0.2)
|
||||
expect(controller.getAlpha(0)).toBe(0.2)
|
||||
expect(controller.getAlpha(1)).toBe(0.2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
findMatchingVariant,
|
||||
getDefaultTownVariant,
|
||||
oppositeSide,
|
||||
variantOrientation,
|
||||
} from '../src/game/variants.ts'
|
||||
|
||||
describe('固定地图变体与朝向匹配 (Issue #42)', () => {
|
||||
test('解析变体方向标识符', () => {
|
||||
expect(variantOrientation({ label: '1-act-1-town-townn1' })).toBe('north')
|
||||
expect(variantOrientation({ label: '1-act-1-town-towne1' })).toBe('east')
|
||||
expect(variantOrientation({ label: '1-act-1-town-towns1' })).toBe('south')
|
||||
expect(variantOrientation({ label: '1-act-1-town-townw1' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutN.ds1' })).toBe('north')
|
||||
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutW.ds1' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courte' })).toBe('east')
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtn' })).toBe('north')
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtw' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ label: '8-act-1-cave-1-var1' })).toBeUndefined()
|
||||
})
|
||||
|
||||
test('朝向互逆函数计算', () => {
|
||||
expect(oppositeSide('north')).toBe('south')
|
||||
expect(oppositeSide('south')).toBe('north')
|
||||
expect(oppositeSide('east')).toBe('west')
|
||||
expect(oppositeSide('west')).toBe('east')
|
||||
})
|
||||
|
||||
test('依据相邻区域出口朝向自动匹配互逆的城镇变体', () => {
|
||||
const act1TownVariants = [
|
||||
{ label: '1-act-1-town-townn1', levelId: 1 },
|
||||
{ label: '1-act-1-town-towne1', levelId: 1 },
|
||||
{ label: '1-act-1-town-towns1', levelId: 1 },
|
||||
{ label: '1-act-1-town-townw1', levelId: 1 },
|
||||
]
|
||||
|
||||
// 鲜血荒地(Level 2)在北侧大门离开(fromSide: 'north'),进入城镇要求城镇出口在南侧('south')
|
||||
const matchedSouth = findMatchingVariant(act1TownVariants, oppositeSide('north'))
|
||||
expect(matchedSouth?.label).toBe('1-act-1-town-towns1')
|
||||
|
||||
// 若鲜血荒地在西侧大门离开(fromSide: 'west'),进入城镇要求城镇出口在东侧('east')
|
||||
const matchedEast = findMatchingVariant(act1TownVariants, oppositeSide('west'))
|
||||
expect(matchedEast?.label).toBe('1-act-1-town-towne1')
|
||||
|
||||
// 鲁高因(Level 40)变体匹配
|
||||
const act2TownVariants = [
|
||||
{ label: '40-act-2-town-lutw', ds1: 'LutW.ds1', levelId: 40 },
|
||||
{ label: '40-act-2-town-lutn', ds1: 'LutN.ds1', levelId: 40 },
|
||||
]
|
||||
|
||||
// 碎石荒地在南侧离开(fromSide: 'south'),进入鲁高因要求北门('north')
|
||||
const matchedLutN = findMatchingVariant(act2TownVariants, oppositeSide('south'))
|
||||
expect(matchedLutN?.label).toBe('40-act-2-town-lutn')
|
||||
})
|
||||
|
||||
test('默认城镇变体与生成预置缝合对齐', () => {
|
||||
const act1TownVariants = [
|
||||
{ label: '1-act-1-town-townn1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-towne1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-towns1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-townw1', slug: '1-act-1-town', act: 1 },
|
||||
]
|
||||
|
||||
// Act 1 鲜血荒地默认采用 Town S Transition(在荒地北边界),因此默认城镇应选择 TownS1
|
||||
const defaultAct1 = getDefaultTownVariant(act1TownVariants, 1)
|
||||
expect(defaultAct1?.label).toBe('1-act-1-town-towns1')
|
||||
|
||||
const act2TownVariants = [
|
||||
{ label: '40-act-2-town-lutw', slug: '40-act-2-town', act: 2 },
|
||||
{ label: '40-act-2-town-lutn', slug: '40-act-2-town', act: 2 },
|
||||
]
|
||||
// Act 2 碎石荒地默认在南边界衔接城镇,因此默认鲁高因应选择 LutN
|
||||
const defaultAct2 = getDefaultTownVariant(act2TownVariants, 2)
|
||||
expect(defaultAct2?.label).toBe('40-act-2-town-lutn')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
generateWilderness,
|
||||
getPerimeterOpenings,
|
||||
UNIMPLEMENTED_PASSES,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import type { WildernessPiece } from '../src/game/wilderness.ts'
|
||||
import type {
|
||||
Act2TopographyStats,
|
||||
Act3TopographyStats,
|
||||
Act4TopographyStats,
|
||||
Act5TopographyStats,
|
||||
} from '../src/game/wilderness-acts.ts'
|
||||
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
|
||||
|
||||
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean = false): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const isGap = hasGapInWalls && x === Math.floor(width / 2)
|
||||
row.push({
|
||||
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 2, sequence: 0, style: 1, 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 makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
|
||||
const borderDs1 = makeMockDs1(8, 8, false)
|
||||
const openBorderDs1 = makeMockDs1(8, 8, true)
|
||||
return [
|
||||
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
|
||||
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
|
||||
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
|
||||
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
|
||||
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
|
||||
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
|
||||
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
|
||||
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
|
||||
]
|
||||
}
|
||||
|
||||
describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
|
||||
test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => {
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel')
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel')
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel')
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel')
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
|
||||
expect(UNIMPLEMENTED_PASSES).toEqual([
|
||||
'DRLGVER_CreateVertices',
|
||||
'DRLGOUTPLACE_CreateLevelConnections',
|
||||
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
||||
])
|
||||
})
|
||||
|
||||
describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => {
|
||||
test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => {
|
||||
const pieces = makeMockBorderPieces('Act 2')
|
||||
const result = generateWilderness({
|
||||
levelId: 41,
|
||||
levelName: 'Rocky Waste',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 41001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act2Topography).toBeDefined()
|
||||
const topo = result.stats.act2Topography as Act2TopographyStats
|
||||
expect(topo.sandTrailCells).toBeGreaterThan(50)
|
||||
expect(topo.canyonWalls).toBeGreaterThan(20)
|
||||
|
||||
let foundSandTrail = false
|
||||
let foundCanyonWall = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true
|
||||
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true
|
||||
}
|
||||
}
|
||||
expect(foundSandTrail).toBe(true)
|
||||
expect(foundCanyonWall).toBe(true)
|
||||
})
|
||||
|
||||
test('Far Oasis (43) generates lush water oasis pools', () => {
|
||||
const pieces = makeMockBorderPieces('Act 2')
|
||||
const result = generateWilderness({
|
||||
levelId: 43,
|
||||
levelName: 'Far Oasis',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 43001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act2Topography).toBeDefined()
|
||||
const topo = result.stats.act2Topography as Act2TopographyStats
|
||||
expect(topo.oasisPoolCells).toBeGreaterThan(15)
|
||||
|
||||
let foundOasisWater = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true
|
||||
}
|
||||
}
|
||||
expect(foundOasisWater).toBe(true)
|
||||
})
|
||||
|
||||
test('Valley of Snakes (45) creates narrow canyon gorge', () => {
|
||||
const pieces = makeMockBorderPieces('Act 2')
|
||||
const result = generateWilderness({
|
||||
levelId: 45,
|
||||
levelName: 'Valley of Snakes',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 45001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act2Topography).toBeDefined()
|
||||
const topo = result.stats.act2Topography as Act2TopographyStats
|
||||
expect(topo.canyonWalls).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => {
|
||||
const pieces = makeMockBorderPieces('Act 2')
|
||||
expect(getPerimeterOpenings(46, 10, 10).size).toBe(0)
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 46,
|
||||
levelName: 'Canyon of the Magi',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 46001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act2Topography).toBeDefined()
|
||||
const topo = result.stats.act2Topography as Act2TopographyStats
|
||||
expect(topo.tombEntrances).toBe(7)
|
||||
|
||||
// Verify central Arcane Sanctuary warp platform (portal/dais)
|
||||
let foundCentralDais = false
|
||||
for (let y = 35; y <= 45; y += 1) {
|
||||
for (let x = 35; x <= 45; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 3)) foundCentralDais = true
|
||||
}
|
||||
}
|
||||
expect(foundCentralDais).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => {
|
||||
test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => {
|
||||
const tailDs1 = makeMockDs1(64, 32)
|
||||
const headDs1 = makeMockDs1(64, 32)
|
||||
const clearingDs1 = makeMockDs1(32, 32)
|
||||
const riverDs1 = makeMockDs1(32, 32)
|
||||
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] },
|
||||
{ name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] },
|
||||
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] },
|
||||
{ name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 76,
|
||||
levelName: 'Spider Forest',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 76001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act3Topography).toBeDefined()
|
||||
const topo = result.stats.act3Topography as Act3TopographyStats
|
||||
expect(topo.riverBridges).toBeGreaterThanOrEqual(2)
|
||||
expect(topo.junglePathCells).toBeGreaterThan(20)
|
||||
|
||||
// Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel
|
||||
let foundBridgePlank = false
|
||||
for (let y = 70; y <= 90; y += 1) {
|
||||
for (let x = 32; x < 60; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true
|
||||
}
|
||||
}
|
||||
expect(foundBridgePlank).toBe(true)
|
||||
})
|
||||
|
||||
test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => {
|
||||
const bridgeDs1 = makeMockDs1(48, 16)
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 82,
|
||||
levelName: 'Kurast Causeway',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sizeX: 48,
|
||||
sizeY: 16,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 82001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act3Topography).toBeDefined()
|
||||
const topo = result.stats.act3Topography as Act3TopographyStats
|
||||
expect(topo.causewayCells).toBeGreaterThan(50)
|
||||
expect(topo.canalCells).toBeGreaterThan(100)
|
||||
|
||||
// Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2)
|
||||
let foundWalkway = false
|
||||
let foundBalustrade = false
|
||||
let foundCanalWater = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundWalkway = true
|
||||
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundBalustrade = true
|
||||
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
|
||||
}
|
||||
}
|
||||
expect(foundWalkway).toBe(true)
|
||||
expect(foundBalustrade).toBe(true)
|
||||
expect(foundCanalWater).toBe(true)
|
||||
})
|
||||
|
||||
test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => {
|
||||
const pieces = makeMockBorderPieces('Act 3')
|
||||
const result = generateWilderness({
|
||||
levelId: 79,
|
||||
levelName: 'Kurast Bazaar',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 79001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act3Topography).toBeDefined()
|
||||
const topo = result.stats.act3Topography as Act3TopographyStats
|
||||
expect(topo.causewayCells).toBeGreaterThan(50)
|
||||
expect(topo.canalCells).toBeGreaterThan(30)
|
||||
expect(topo.riverBridges).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => {
|
||||
test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => {
|
||||
const pieces = makeMockBorderPieces('Act 4')
|
||||
const result = generateWilderness({
|
||||
levelId: 104,
|
||||
levelName: 'Outer Steppes',
|
||||
levelTypeName: 'Act 4 - Mesa',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 104001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act4Topography).toBeDefined()
|
||||
const topo = result.stats.act4Topography as Act4TopographyStats
|
||||
expect(topo.lavaChasmCells).toBeGreaterThan(100)
|
||||
expect(topo.rockyBridges).toBeGreaterThan(5)
|
||||
|
||||
// Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2)
|
||||
let foundLava = false
|
||||
let foundRockyBridge = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 20 && f.sequence === 3 && f.prop1 === 2)) foundLava = true
|
||||
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
|
||||
}
|
||||
}
|
||||
expect(foundLava).toBe(true)
|
||||
expect(foundRockyBridge).toBe(true)
|
||||
})
|
||||
|
||||
test('Plains of Despair (105) creates Izual prison plateau', () => {
|
||||
const pieces = makeMockBorderPieces('Act 4')
|
||||
const result = generateWilderness({
|
||||
levelId: 105,
|
||||
levelName: 'Plains of Despair',
|
||||
levelTypeName: 'Act 4 - Mesa',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 105001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act4Topography).toBeDefined()
|
||||
const topo = result.stats.act4Topography as Act4TopographyStats
|
||||
expect(topo.plateauCells).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => {
|
||||
test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => {
|
||||
const pieces = makeMockBorderPieces('Act 5')
|
||||
const result = generateWilderness({
|
||||
levelId: 111,
|
||||
levelName: 'Frigid Highlands',
|
||||
levelTypeName: 'Act 5 - Barricade',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 111001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act5Topography).toBeDefined()
|
||||
const topo = result.stats.act5Topography as Act5TopographyStats
|
||||
expect(topo.barricadeWalls).toBeGreaterThan(40)
|
||||
expect(topo.trenchCells).toBeGreaterThan(30)
|
||||
|
||||
// Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 5, prop1 194)
|
||||
let foundBarricadeWall = false
|
||||
let foundTrench = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true
|
||||
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundTrench = true
|
||||
}
|
||||
}
|
||||
expect(foundBarricadeWall).toBe(true)
|
||||
expect(foundTrench).toBe(true)
|
||||
})
|
||||
|
||||
test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => {
|
||||
const pieces = makeMockBorderPieces('Act 5')
|
||||
const result = generateWilderness({
|
||||
levelId: 112,
|
||||
levelName: 'Arreat Plateau',
|
||||
levelTypeName: 'Act 5 - Barricade',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 112001,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.act5Topography).toBeDefined()
|
||||
const topo = result.stats.act5Topography as Act5TopographyStats
|
||||
expect(topo.frozenLakeCells).toBeGreaterThan(20)
|
||||
|
||||
let foundIceFloor = false
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true
|
||||
}
|
||||
}
|
||||
expect(foundIceFloor).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -244,50 +244,14 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(result.stats.substitutedClusters).toBe(15)
|
||||
expect(result.level.width).toBe(240)
|
||||
expect(result.level.height).toBe(48)
|
||||
expect(result.level.objects.length).toBe(15)
|
||||
expect(result.level.objects.filter(o => o.type === 2).length).toBe(15)
|
||||
expect(result.level.objects.length).toBe(17)
|
||||
|
||||
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
|
||||
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
|
||||
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
|
||||
})
|
||||
|
||||
test("Act 4 Chaos Sanctuary (Level 108) stamps 7 cross-wing presets on 120x120 canvas", () => {
|
||||
const names = [
|
||||
"Act 4 - Diablo Arm N",
|
||||
"Act 4 - Diablo Arm W",
|
||||
"Act 4 - Diablo Heart",
|
||||
"Act 4 - Diablo Arm E",
|
||||
"Act 4 - Diablo Arm S",
|
||||
"Act 4 - Diablo Entry",
|
||||
"Act 4 - Bridge 2",
|
||||
]
|
||||
const pieces: WildernessPiece[] = names.map((name, idx) => {
|
||||
const baseDs1 = makeMockDs1(25, 25, false)
|
||||
const ds1: Ds1 = {
|
||||
...baseDs1,
|
||||
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
|
||||
}
|
||||
return { name, border: false, levels: [ds1] }
|
||||
})
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 108,
|
||||
levelName: "Chaos Sanctuary",
|
||||
levelTypeName: "Act 4 - Lava",
|
||||
sizeX: 120,
|
||||
sizeY: 120,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 108,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(120)
|
||||
expect(result.level.height).toBe(120)
|
||||
expect(result.stats.substitutedClusters).toBe(7)
|
||||
expect(result.level.objects.length).toBe(7)
|
||||
})
|
||||
|
||||
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
|
||||
const names = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,512 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
fillGround,
|
||||
sampleGroundTile,
|
||||
deriveGroundTilesFromDt1Libraries,
|
||||
deriveGroundTiles,
|
||||
deriveGroundTile,
|
||||
cellHash,
|
||||
smoothNoise2D,
|
||||
createCanvas,
|
||||
generateWilderness,
|
||||
CANONICAL_GROUND_POOLS,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type {
|
||||
WeightedGroundTile,
|
||||
WildernessPiece,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(
|
||||
width: number,
|
||||
height: number,
|
||||
floorStyle = 0,
|
||||
floorSequence = 0,
|
||||
customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined,
|
||||
): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const custom = customFloor?.(x, y)
|
||||
const style = custom ? custom.style : floorStyle
|
||||
const sequence = custom ? custom.sequence : floorSequence
|
||||
row.push({
|
||||
walls: [],
|
||||
floors: [{ prop1: 2, sequence, style, 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 makeMockDt1(tiles: Partial<Dt1Tile>[]): Dt1 {
|
||||
return {
|
||||
warnings: [],
|
||||
versionMajor: 7,
|
||||
versionMinor: 6,
|
||||
tiles: tiles.map(t => ({
|
||||
direction: 0,
|
||||
height: -80,
|
||||
width: 160,
|
||||
type: t.type ?? 0,
|
||||
style: t.style ?? 0,
|
||||
sequence: t.sequence ?? 0,
|
||||
materialFlags: 0,
|
||||
roofHeight: 0,
|
||||
animated: t.animated ?? 0,
|
||||
rarityFrameIndex: t.rarityFrameIndex ?? 1,
|
||||
subTileFlags: [],
|
||||
minBlockY: 0,
|
||||
bitmapHeight: 80,
|
||||
blocks: [],
|
||||
...t,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTestPieces(): WildernessPiece[] {
|
||||
return [
|
||||
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
|
||||
]
|
||||
}
|
||||
|
||||
describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
||||
describe("PRNG & Noise Primitives", () => {
|
||||
test("cellHash produces deterministic values in [0, 1)", () => {
|
||||
const v1 = cellHash(10, 20, 12345)
|
||||
const v2 = cellHash(10, 20, 12345)
|
||||
const v3 = cellHash(10, 20, 99999)
|
||||
const v4 = cellHash(11, 20, 12345)
|
||||
|
||||
expect(v1).toBe(v2)
|
||||
expect(v1).toBeGreaterThanOrEqual(0)
|
||||
expect(v1).toBeLessThan(1)
|
||||
expect(v1).not.toBe(v3)
|
||||
expect(v1).not.toBe(v4)
|
||||
})
|
||||
|
||||
test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => {
|
||||
const n0 = smoothNoise2D(10, 10, 8, 42)
|
||||
const n1 = smoothNoise2D(10.1, 10.1, 8, 42)
|
||||
const nFar = smoothNoise2D(50, 50, 8, 42)
|
||||
|
||||
expect(n0).toBeGreaterThanOrEqual(0)
|
||||
expect(n0).toBeLessThanOrEqual(1)
|
||||
// Small step produces small difference
|
||||
expect(Math.abs(n1 - n0)).toBeLessThan(0.1)
|
||||
expect(nFar).toBeGreaterThanOrEqual(0)
|
||||
expect(nFar).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Single-Tile Fallback (Backward Compatibility)", () => {
|
||||
test("fillGround with a single tile object fills entire area uniformly", () => {
|
||||
const canvas = createCanvas(20, 20, 1, 1, 0)
|
||||
const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20)
|
||||
|
||||
expect(written).toBe(400)
|
||||
for (let y = 0; y < 20; y += 1) {
|
||||
for (let x = 0; x < 20; x += 1) {
|
||||
const floor = canvas.cells[y]![x]!.floors[0]!
|
||||
expect(floor.style).toBe(3)
|
||||
expect(floor.sequence).toBe(7)
|
||||
expect(floor.hidden).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("fillGround with a 1-element array fills entire area uniformly", () => {
|
||||
const canvas = createCanvas(15, 15, 1, 1, 0)
|
||||
const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15)
|
||||
|
||||
expect(written).toBe(225)
|
||||
for (let y = 0; y < 15; y += 1) {
|
||||
for (let x = 0; x < 15; x += 1) {
|
||||
const floor = canvas.cells[y]![x]!.floors[0]!
|
||||
expect(floor.style).toBe(1)
|
||||
expect(floor.sequence).toBe(4)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("sampleGroundTile with a single tile returns it directly", () => {
|
||||
const single = { style: 2, sequence: 5 }
|
||||
expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single)
|
||||
|
||||
const singleArr = [{ style: 4, sequence: 9, weight: 1 }]
|
||||
expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multi-Tile Ground Blending & Weight Adherence", () => {
|
||||
const candidates: readonly WeightedGroundTile[] = [
|
||||
{ style: 0, sequence: 0, weight: 70 },
|
||||
{ style: 0, sequence: 1, weight: 20 },
|
||||
{ style: 0, sequence: 2, weight: 10 },
|
||||
]
|
||||
|
||||
test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => {
|
||||
const width = 60
|
||||
const height = 60
|
||||
const totalCells = width * height
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
|
||||
fillGround(canvas, candidates, 0, 0, width, height, {
|
||||
seed: 424242,
|
||||
mode: "spatial",
|
||||
spatialScale: 6,
|
||||
spatialCoherence: 0.6,
|
||||
})
|
||||
|
||||
const counts = new Map<number, number>()
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
|
||||
counts.set(seq, (counts.get(seq) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// All candidate sequences must be present
|
||||
expect(counts.has(0)).toBe(true)
|
||||
expect(counts.has(1)).toBe(true)
|
||||
expect(counts.has(2)).toBe(true)
|
||||
|
||||
const c0 = counts.get(0)! / totalCells
|
||||
const c1 = counts.get(1)! / totalCells
|
||||
const c2 = counts.get(2)! / totalCells
|
||||
|
||||
// Expected: ~70% sequence 0, ~20% sequence 1, ~10% sequence 2
|
||||
// Spatial noise percentile ranking guarantees global proportions very close to exact weights
|
||||
expect(c0).toBeGreaterThanOrEqual(0.60)
|
||||
expect(c0).toBeLessThanOrEqual(0.80)
|
||||
expect(c1).toBeGreaterThanOrEqual(0.12)
|
||||
expect(c1).toBeLessThanOrEqual(0.28)
|
||||
expect(c2).toBeGreaterThanOrEqual(0.04)
|
||||
expect(c2).toBeLessThanOrEqual(0.18)
|
||||
})
|
||||
|
||||
test("fillGround in CDF mode generates all candidate tiles and respects weight ratios", () => {
|
||||
const width = 60
|
||||
const height = 60
|
||||
const totalCells = width * height
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
|
||||
fillGround(canvas, candidates, 0, 0, width, height, {
|
||||
seed: 88888,
|
||||
mode: "cdf",
|
||||
})
|
||||
|
||||
const counts = new Map<number, number>()
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
|
||||
counts.set(seq, (counts.get(seq) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
expect(counts.has(0)).toBe(true)
|
||||
expect(counts.has(1)).toBe(true)
|
||||
expect(counts.has(2)).toBe(true)
|
||||
|
||||
const c0 = counts.get(0)! / totalCells
|
||||
const c1 = counts.get(1)! / totalCells
|
||||
const c2 = counts.get(2)! / totalCells
|
||||
|
||||
// Law of large numbers for 3600 samples: within +-8% of true probabilities
|
||||
expect(c0).toBeGreaterThanOrEqual(0.62)
|
||||
expect(c0).toBeLessThanOrEqual(0.78)
|
||||
expect(c1).toBeGreaterThanOrEqual(0.14)
|
||||
expect(c1).toBeLessThanOrEqual(0.26)
|
||||
expect(c2).toBeGreaterThanOrEqual(0.05)
|
||||
expect(c2).toBeLessThanOrEqual(0.16)
|
||||
})
|
||||
|
||||
test("spatial mode creates contiguous natural patches (higher neighborhood correlation than random)", () => {
|
||||
const width = 40
|
||||
const height = 40
|
||||
const canvasSpatial = createCanvas(width, height, 1, 1, 0)
|
||||
const canvasCdf = createCanvas(width, height, 1, 1, 0)
|
||||
|
||||
fillGround(canvasSpatial, candidates, 0, 0, width, height, { seed: 100, mode: "spatial", spatialScale: 6 })
|
||||
fillGround(canvasCdf, candidates, 0, 0, width, height, { seed: 100, mode: "cdf" })
|
||||
|
||||
// Count horizontal neighbor matches: how often cell(x, y) has the same sequence as cell(x+1, y)
|
||||
let spatialMatches = 0
|
||||
let cdfMatches = 0
|
||||
let totalPairs = 0
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width - 1; x += 1) {
|
||||
totalPairs += 1
|
||||
if (canvasSpatial.cells[y]![x]!.floors[0]!.sequence === canvasSpatial.cells[y]![x + 1]!.floors[0]!.sequence) {
|
||||
spatialMatches += 1
|
||||
}
|
||||
if (canvasCdf.cells[y]![x]!.floors[0]!.sequence === canvasCdf.cells[y]![x + 1]!.floors[0]!.sequence) {
|
||||
cdfMatches += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spatial coherent noise should have significantly higher neighbor continuity than independent CDF noise
|
||||
const spatialCorr = spatialMatches / totalPairs
|
||||
const cdfCorr = cdfMatches / totalPairs
|
||||
|
||||
expect(spatialCorr).toBeGreaterThan(cdfCorr)
|
||||
expect(spatialCorr).toBeGreaterThan(0.60)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deterministic PRNG Consistency", () => {
|
||||
test("same seed produces byte-for-byte identical tile layouts", () => {
|
||||
const candidates: readonly WeightedGroundTile[] = [
|
||||
{ style: 0, sequence: 0, weight: 60 },
|
||||
{ style: 0, sequence: 1, weight: 30 },
|
||||
{ style: 0, sequence: 2, weight: 10 },
|
||||
]
|
||||
|
||||
const canvasA = createCanvas(25, 25, 1, 1, 0)
|
||||
const canvasB = createCanvas(25, 25, 1, 1, 0)
|
||||
|
||||
fillGround(canvasA, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
|
||||
fillGround(canvasB, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
|
||||
|
||||
for (let y = 0; y < 25; y += 1) {
|
||||
for (let x = 0; x < 25; x += 1) {
|
||||
const floorA = canvasA.cells[y]![x]!.floors[0]!
|
||||
const floorB = canvasB.cells[y]![x]!.floors[0]!
|
||||
expect(floorA.style).toBe(floorB.style)
|
||||
expect(floorA.sequence).toBe(floorB.sequence)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("different seeds produce different ground layouts", () => {
|
||||
const candidates: readonly WeightedGroundTile[] = [
|
||||
{ style: 0, sequence: 0, weight: 50 },
|
||||
{ style: 0, sequence: 1, weight: 50 },
|
||||
]
|
||||
|
||||
const canvasA = createCanvas(20, 20, 1, 1, 0)
|
||||
const canvasB = createCanvas(20, 20, 1, 1, 0)
|
||||
|
||||
fillGround(canvasA, candidates, 0, 0, 20, 20, { seed: 111 })
|
||||
fillGround(canvasB, candidates, 0, 0, 20, 20, { seed: 999 })
|
||||
|
||||
let differences = 0
|
||||
for (let y = 0; y < 20; y += 1) {
|
||||
for (let x = 0; x < 20; x += 1) {
|
||||
if (canvasA.cells[y]![x]!.floors[0]!.sequence !== canvasB.cells[y]![x]!.floors[0]!.sequence) {
|
||||
differences += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(differences).toBeGreaterThan(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DT1 Library Derivation (deriveGroundTilesFromDt1Libraries)", () => {
|
||||
test("filters libraries strictly according to dwDt1Mask", () => {
|
||||
const lib0 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 50 },
|
||||
])
|
||||
const lib1 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||
])
|
||||
const lib2 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 2, rarityFrameIndex: 10 },
|
||||
])
|
||||
|
||||
const libraries = [lib0, lib1, lib2]
|
||||
|
||||
// Mask 0b001 = 1 (only lib 0)
|
||||
const onlyLib0 = deriveGroundTilesFromDt1Libraries(libraries, 1)
|
||||
expect(onlyLib0).toHaveLength(1)
|
||||
expect(onlyLib0[0]).toMatchObject({ style: 0, sequence: 0, weight: 50 })
|
||||
|
||||
// Mask 0b101 = 5 (lib 0 and lib 2, skipping lib 1)
|
||||
const lib0and2 = deriveGroundTilesFromDt1Libraries(libraries, 5)
|
||||
expect(lib0and2).toHaveLength(2)
|
||||
expect(lib0and2.map(t => t.sequence)).toEqual([0, 2])
|
||||
|
||||
// Mask 0b000 = 0 (no libraries active)
|
||||
const empty = deriveGroundTilesFromDt1Libraries(libraries, 0)
|
||||
expect(empty).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("filters out non-floor tiles (type !== 0) and weights by rarityFrameIndex", () => {
|
||||
const dt1 = makeMockDt1([
|
||||
// Floor tiles (type === 0)
|
||||
{ type: 0, style: 1, sequence: 0, rarityFrameIndex: 60 },
|
||||
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 40 },
|
||||
// Same style/seq variant in library: weights should accumulate
|
||||
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 10 },
|
||||
// Wall tile (type === 1) - must be excluded
|
||||
{ type: 1, style: 1, sequence: 0, rarityFrameIndex: 99 },
|
||||
// Roof tile (type === 2) - must be excluded
|
||||
{ type: 2, style: 1, sequence: 0, rarityFrameIndex: 99 },
|
||||
])
|
||||
|
||||
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
// Expected ordering: highest weight first
|
||||
// seq 0 has 60, seq 1 has 40 + 10 = 50
|
||||
expect(result[0]).toMatchObject({ style: 1, sequence: 0, weight: 60 })
|
||||
expect(result[1]).toMatchObject({ style: 1, sequence: 1, weight: 50 })
|
||||
})
|
||||
|
||||
test("handles empty libraries or libraries with no floor tiles gracefully", () => {
|
||||
expect(deriveGroundTilesFromDt1Libraries([], 0xff)).toEqual([])
|
||||
|
||||
const noFloors = makeMockDt1([{ type: 1, style: 0, sequence: 0 }])
|
||||
expect(deriveGroundTilesFromDt1Libraries([noFloors], 1)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Piece Floor Derivation (deriveGroundTiles & deriveGroundTile)", () => {
|
||||
test("derives candidate ground tiles with frequencies from interior floor cells", () => {
|
||||
// Create a piece with interior floor tiles:
|
||||
// 8x8 piece has interior from x=1..6, y=1..6 (36 interior cells)
|
||||
// Set cells x=2..4, y=2..4 to sequence 1 (9 cells)
|
||||
const level = makeMockDs1(8, 8, 0, 0, (x, y) => {
|
||||
if (x >= 2 && x <= 4 && y >= 2 && y <= 4) {
|
||||
return { style: 0, sequence: 1 }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const pieces: WildernessPiece[] = [{ name: "Test Piece", border: true, levels: [level] }]
|
||||
const tiles = deriveGroundTiles(pieces)
|
||||
|
||||
expect(tiles.length).toBe(2)
|
||||
// Sequence 0 is the majority, sequence 1 has 9 cells
|
||||
expect(tiles[0]!.sequence).toBe(0)
|
||||
expect(tiles[1]!.sequence).toBe(1)
|
||||
expect(tiles[1]!.weight).toBe(9)
|
||||
|
||||
// deriveGroundTile should return the most frequent interior tile
|
||||
const primary = deriveGroundTile(pieces)
|
||||
expect(primary).toEqual({ style: 0, sequence: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateWilderness Integration", () => {
|
||||
test("generates wilderness with explicit groundTiles blend", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const candidateTiles: readonly WeightedGroundTile[] = [
|
||||
{ style: 0, sequence: 0, weight: 70 },
|
||||
{ style: 0, sequence: 1, weight: 30 },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor Blend Test",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 123456,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
groundTiles: candidateTiles,
|
||||
})
|
||||
|
||||
expect(result.stats.groundTiles).toBeDefined()
|
||||
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
|
||||
expect(statsTiles).toHaveLength(2)
|
||||
|
||||
// Verify level cells contain both sequences
|
||||
let seq0Count = 0
|
||||
let seq1Count = 0
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const floor = result.level.cells[y]![x]!.floors[0]
|
||||
if (floor && !floor.hidden) {
|
||||
if (floor.sequence === 0) seq0Count += 1
|
||||
if (floor.sequence === 1) seq1Count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(seq0Count).toBeGreaterThan(0)
|
||||
expect(seq1Count).toBeGreaterThan(0)
|
||||
expect(seq0Count).toBeGreaterThan(seq1Count)
|
||||
})
|
||||
|
||||
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor Single Tile",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 654321,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
groundTile: { style: 0, sequence: 0 },
|
||||
})
|
||||
|
||||
expect(result.stats.groundTile).toEqual({ style: 0, sequence: 0 })
|
||||
})
|
||||
|
||||
test("generates wilderness with dt1Libraries deriving ground tiles automatically", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const dt1 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||
])
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor DT1 Test",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 777777,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
dt1Libraries: [dt1],
|
||||
})
|
||||
|
||||
expect(result.stats.groundTiles).toBeDefined()
|
||||
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
|
||||
expect(statsTiles).toHaveLength(2)
|
||||
expect(statsTiles[0]!.sequence).toBe(0)
|
||||
expect(statsTiles[1]!.sequence).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
UNIMPLEMENTED_PASSES,
|
||||
buildJungleTopology,
|
||||
DRLG_GenerateJungles,
|
||||
type WildernessPiece,
|
||||
type WildernessRequest,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
|
||||
/**
|
||||
* Creates a mock DS1 piece of arbitrary size (e.g. 32x32 for jungle macro-cells, 64x32 for head/tail).
|
||||
*/
|
||||
function makeMockDs1(width: number, height: number, isWater = false): 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: [],
|
||||
floors: [
|
||||
{
|
||||
prop1: isWater ? 194 : 2,
|
||||
sequence: 0,
|
||||
style: isWater ? 1 : 0,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 3,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete mock suite of Act 3 Jungle pieces.
|
||||
*/
|
||||
function makeMockJunglePieces(): WildernessPiece[] {
|
||||
const pieces: WildernessPiece[] = []
|
||||
|
||||
// 1. Head (TransL.ds1, 64x32) and Tail (TravL.ds1, 64x32)
|
||||
pieces.push({
|
||||
name: 'Act 3 - Jungle Head',
|
||||
border: false,
|
||||
levels: [makeMockDs1(64, 32)],
|
||||
})
|
||||
pieces.push({
|
||||
name: 'Act 3 - Jungle Tail',
|
||||
border: false,
|
||||
levels: [makeMockDs1(64, 32)],
|
||||
})
|
||||
|
||||
// 2. Wooden Bridge (Act 3 - Bridge, 32x32)
|
||||
pieces.push({
|
||||
name: 'Act 3 - Bridge',
|
||||
border: false,
|
||||
levels: [makeMockDs1(32, 32)],
|
||||
})
|
||||
|
||||
// 3. Directional River Presets (32x32)
|
||||
const riverTokens = [
|
||||
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
|
||||
'NSE', 'NSW', 'NEW', 'SEW', 'NSEW',
|
||||
'NS W', 'NS E', 'EW N', 'EW S',
|
||||
]
|
||||
for (const token of riverTokens) {
|
||||
pieces.push({
|
||||
name: `Act 3 - Jungle ${token}`,
|
||||
border: false,
|
||||
levels: [makeMockDs1(32, 32, true)],
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Clearings for each of the 3 jungle areas (32x32)
|
||||
const clearingThemes = ['Webby', 'Boggy', 'Pygmy']
|
||||
const openings = ['', ' E', ' W', ' EW', ' NE', ' NW', ' SE', ' SW']
|
||||
|
||||
for (const theme of clearingThemes) {
|
||||
for (const opening of openings) {
|
||||
pieces.push({
|
||||
name: `Act 3 - Clearing ${theme}${opening}`,
|
||||
border: false,
|
||||
levels: [makeMockDs1(32, 32)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
describe('Act 3 Jungle Branching Topology (buildJungleTopology)', () => {
|
||||
test('guarantees Flayer Jungle (78) is always reachable from Spider Forest (76)', () => {
|
||||
// Test across 100 random seeds
|
||||
for (let seed = 1; seed <= 100; seed += 1) {
|
||||
const topology = buildJungleTopology(seed)
|
||||
|
||||
expect(topology.zones).toHaveLength(3)
|
||||
const spider = topology.zones.find(z => z.levelId === 76)!
|
||||
const marsh = topology.zones.find(z => z.levelId === 77)!
|
||||
const flayer = topology.zones.find(z => z.levelId === 78)!
|
||||
|
||||
expect(spider.name).toBe('Spider Forest')
|
||||
expect(marsh.name).toBe('Great Marsh')
|
||||
expect(flayer.name).toBe('Flayer Jungle')
|
||||
|
||||
// Check graph reachability: BFS from 76 to 78
|
||||
const visited = new Set<number>([76])
|
||||
const queue = [76]
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!
|
||||
const outgoing = topology.connections.filter(c => c.fromLevelId === curr)
|
||||
for (const conn of outgoing) {
|
||||
if (!visited.has(conn.toLevelId)) {
|
||||
visited.add(conn.toLevelId)
|
||||
queue.push(conn.toLevelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(visited.has(78)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('exhibits both Great Marsh skip active and inactive across different seeds', () => {
|
||||
let skipsSeen = 0
|
||||
let noSkipsSeen = 0
|
||||
let marshDeadEndsSeen = 0
|
||||
let marshThroughwaysSeen = 0
|
||||
|
||||
for (let seed = 1; seed <= 100; seed += 1) {
|
||||
const topology = buildJungleTopology(seed)
|
||||
if (topology.hasMarshSkip) {
|
||||
skipsSeen += 1
|
||||
const directSkipConn = topology.connections.find(
|
||||
c => c.fromLevelId === 76 && c.toLevelId === 78,
|
||||
)
|
||||
expect(directSkipConn).toBeDefined()
|
||||
expect(directSkipConn!.connectionType).toBe('skip')
|
||||
} else {
|
||||
noSkipsSeen += 1
|
||||
// Without skip, Great Marsh must connect forward to Flayer Jungle
|
||||
const marshToFlayer = topology.connections.find(
|
||||
c => c.fromLevelId === 77 && c.toLevelId === 78,
|
||||
)
|
||||
expect(marshToFlayer).toBeDefined()
|
||||
expect(topology.marshIsDeadEnd).toBe(false)
|
||||
}
|
||||
|
||||
if (topology.marshIsDeadEnd) {
|
||||
marshDeadEndsSeen += 1
|
||||
expect(topology.hasMarshSkip).toBe(true)
|
||||
} else {
|
||||
marshThroughwaysSeen += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(skipsSeen).toBeGreaterThan(0)
|
||||
expect(noSkipsSeen).toBeGreaterThan(0)
|
||||
expect(marshDeadEndsSeen).toBeGreaterThan(0)
|
||||
expect(marshThroughwaysSeen).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DRLG_GenerateJungles River Network Generation', () => {
|
||||
test('generates meandering river paths that wander across columns', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const request: WildernessRequest = {
|
||||
levelId: 76,
|
||||
levelName: 'Spider Forest',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 42,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
}
|
||||
|
||||
const result = generateWilderness(request)
|
||||
const layout = result.stats.jungleLayout as any
|
||||
expect(layout).toBeDefined()
|
||||
expect(layout.riverNetwork).toBeDefined()
|
||||
|
||||
const { meanderingPath, meanderCount } = layout.riverNetwork
|
||||
expect(meanderingPath.length).toBeGreaterThanOrEqual(6)
|
||||
expect(meanderCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Verify that the river visits multiple columns
|
||||
const columnsVisited = new Set(meanderingPath.map((p: any) => p.col))
|
||||
expect(columnsVisited.size).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('places river forks / tributary junctions with branching presets', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const request: WildernessRequest = {
|
||||
levelId: 77,
|
||||
levelName: 'Great Marsh',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 12345,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
}
|
||||
|
||||
const result = generateWilderness(request)
|
||||
const layout = result.stats.jungleLayout as any
|
||||
expect(layout.riverNetwork.forks.length).toBeGreaterThan(0)
|
||||
|
||||
const fork = layout.riverNetwork.forks[0]!
|
||||
expect(fork.row).toBeGreaterThanOrEqual(1)
|
||||
expect(fork.row).toBeLessThanOrEqual(4)
|
||||
expect(result.stats.riverForks).toBe(layout.riverNetwork.forks.length)
|
||||
})
|
||||
|
||||
test('identifies and creates river island clearings flanked by river channels', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const request: WildernessRequest = {
|
||||
levelId: 76,
|
||||
levelName: 'Spider Forest',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 999,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
}
|
||||
|
||||
const result = generateWilderness(request)
|
||||
const layout = result.stats.jungleLayout as any
|
||||
expect(layout.riverNetwork.islands.length).toBeGreaterThan(0)
|
||||
|
||||
const island = layout.riverNetwork.islands[0]!
|
||||
expect(island.col).toBeGreaterThanOrEqual(0)
|
||||
expect(island.row).toBeGreaterThanOrEqual(1)
|
||||
expect(result.stats.riverIslands).toBe(layout.riverNetwork.islands.length)
|
||||
|
||||
// Check that clearings array flags the island
|
||||
const islandClearing = layout.riverNetwork.clearings.find(
|
||||
(c: any) => c.col === island.col && c.row === island.row,
|
||||
)
|
||||
expect(islandClearing).toBeDefined()
|
||||
expect(islandClearing.isIsland).toBe(true)
|
||||
})
|
||||
|
||||
test('places wooden bridges crossing river channels to connect islands and trails', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const request: WildernessRequest = {
|
||||
levelId: 78,
|
||||
levelName: 'Flayer Jungle',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 777,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
}
|
||||
|
||||
const result = generateWilderness(request)
|
||||
const layout = result.stats.jungleLayout as any
|
||||
expect(layout.riverNetwork.bridges.length).toBeGreaterThan(0)
|
||||
|
||||
// Bridge recorded in stats.substitutions and special presets
|
||||
const bridgeSub = (result.stats.substitutions as any[]).find(
|
||||
s => s.name === 'Act 3 - Bridge',
|
||||
)
|
||||
expect(bridgeSub).toBeDefined()
|
||||
expect(bridgeSub.enabled).toBe(true)
|
||||
expect(result.stats.bridgesPlaced).toBe(layout.riverNetwork.bridges.length)
|
||||
|
||||
// Bridge coordinates must align to grid cells
|
||||
for (const bridge of layout.riverNetwork.bridges) {
|
||||
expect(bridge.x).toBe(bridge.col * 32)
|
||||
expect(bridge.y).toBe(bridge.row * 32)
|
||||
expect(bridge.pieceName).toBe('Act 3 - Bridge')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('End-to-End generateWilderness Integration for Act 3 Jungles', () => {
|
||||
test('DRLG_GenerateJungles is removed from UNIMPLEMENTED_PASSES', () => {
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
|
||||
})
|
||||
|
||||
test('generates Spider Forest (76) with Webby clearings and complete jungle metadata', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 76,
|
||||
levelName: 'Spider Forest',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 555,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(64)
|
||||
expect(result.level.height).toBe(192)
|
||||
expect(result.level.act).toBe(3)
|
||||
|
||||
const subs = result.stats.substitutions as any[]
|
||||
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Webby'))).toBe(true)
|
||||
expect(subs.some(s => s.name === 'Act 3 - Jungle Tail')).toBe(true)
|
||||
expect(subs.some(s => s.name === 'Act 3 - Jungle Head')).toBe(true)
|
||||
expect(subs.some(s => s.name === 'Act 3 - Bridge')).toBe(true)
|
||||
|
||||
expect(result.stats.jungleLayout).toBeDefined()
|
||||
expect(result.stats.jungleTopology).toBeDefined()
|
||||
})
|
||||
|
||||
test('generates Great Marsh (77) with Boggy clearings', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 77,
|
||||
levelName: 'Great Marsh',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 888,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const subs = result.stats.substitutions as any[]
|
||||
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Boggy'))).toBe(true)
|
||||
})
|
||||
|
||||
test('generates Flayer Jungle (78) with Pygmy clearings', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 78,
|
||||
levelName: 'Flayer Jungle',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 999,
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const subs = result.stats.substitutions as any[]
|
||||
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Pygmy'))).toBe(true)
|
||||
})
|
||||
|
||||
test('supports wider 3-column (96x192) jungle layouts', () => {
|
||||
const pieces = makeMockJunglePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 76,
|
||||
levelName: 'Spider Forest',
|
||||
levelTypeName: 'Act 3 - Jungle',
|
||||
seed: 1234,
|
||||
sizeX: 96,
|
||||
sizeY: 192,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(96)
|
||||
expect(result.level.height).toBe(192)
|
||||
|
||||
const layout = result.stats.jungleLayout as any
|
||||
expect(layout.gridColumns).toBe(3)
|
||||
expect(layout.gridRows).toBe(6)
|
||||
expect(layout.riverNetwork.meanderCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createCanvas,
|
||||
stampAct1DirtRoads,
|
||||
stampAct1InteriorStoneWalls,
|
||||
stampAct2DesertRoads,
|
||||
stampAct2SandstoneBerms,
|
||||
stampAct3KurastAvenues,
|
||||
stampAct3KurastBalustrades,
|
||||
stampAct5SnowRoads,
|
||||
stampAct5FrozenTrailDivides,
|
||||
stampOutdoorRoads,
|
||||
stampOutdoorDividers,
|
||||
generateWilderness,
|
||||
TILES_PER_BLOCK,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
|
||||
|
||||
function makeMockDs1(width: number, height: number): 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: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 2, sequence: 0, style: 1, 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 countFloorTiles(canvas: Canvas, style: number, prop1: number): number {
|
||||
let count = 0
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
const row = canvas.cells[y]
|
||||
if (!row) continue
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const cell = row[x]
|
||||
if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function countWallTiles(canvas: Canvas, style: number, prop1: number): number {
|
||||
let count = 0
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
const row = canvas.cells[y]
|
||||
if (!row) continue
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const cell = row[x]
|
||||
if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
||||
const gridW = 10
|
||||
const gridH = 10
|
||||
const width = gridW * TILES_PER_BLOCK
|
||||
const height = gridH * TILES_PER_BLOCK
|
||||
const inX = Math.floor(gridW / 2)
|
||||
const outY = Math.floor(gridH / 2)
|
||||
|
||||
describe('Act 2 Desert Roads & Sandstone Berms', () => {
|
||||
it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(42)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 },
|
||||
]
|
||||
|
||||
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const roadFloors = countFloorTiles(canvas, 1, 194)
|
||||
expect(roadFloors).toBeGreaterThan(50)
|
||||
|
||||
// Verify the main highway corridor has caravan road floors
|
||||
const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
|
||||
expect(highwayTile).toBeDefined()
|
||||
expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true)
|
||||
|
||||
// Verify spur path connected towards the Tomb preset
|
||||
let spurFound = false
|
||||
for (let y = 30; y <= 34; y += 1) {
|
||||
for (let x = 38; x <= 44; x += 1) {
|
||||
const c = canvas.cells[y]?.[x]
|
||||
if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) {
|
||||
spurFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(spurFound).toBe(true)
|
||||
})
|
||||
|
||||
it('stampAct2SandstoneBerms paints sandstone berms with style: 1, prop1: 129, corner joins, and road archway breaks', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(42)
|
||||
const placedRects: PlacedPresetRect[] = []
|
||||
|
||||
// First stamp roads so the divider can create 3-tile archway breaks across them
|
||||
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const bermWalls = countWallTiles(canvas, 1, 129)
|
||||
expect(bermWalls).toBeGreaterThan(20)
|
||||
|
||||
// Berms should not block the road where the road crosses the divider
|
||||
// Highway crosses dividing lines at (inX * 8 + 4, outY * 8 + 4)
|
||||
const crossRoad = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
|
||||
expect(crossRoad?.walls.some(w => w.style === 1 && w.prop1 === 129)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Act 3 Kurast Avenues & Balustrades', () => {
|
||||
it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(100)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 },
|
||||
]
|
||||
|
||||
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const avenueFloors = countFloorTiles(canvas, 1, 194)
|
||||
expect(avenueFloors).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('stampAct3KurastBalustrades paints balustrades with style: 1, prop1: 129', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(100)
|
||||
const placedRects: PlacedPresetRect[] = []
|
||||
|
||||
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const balustradeWalls = countWallTiles(canvas, 1, 129)
|
||||
expect(balustradeWalls).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Act 5 Snow Roads & Frozen Trail Divides', () => {
|
||||
it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(555)
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 },
|
||||
]
|
||||
|
||||
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const snowRoadFloors = countFloorTiles(canvas, 1, 194)
|
||||
expect(snowRoadFloors).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('stampAct5FrozenTrailDivides paints frozen trail divides with style: 2, prop1: 129', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(555)
|
||||
const placedRects: PlacedPresetRect[] = []
|
||||
|
||||
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
const frozenDivides = countWallTiles(canvas, 2, 129)
|
||||
expect(frozenDivides).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => {
|
||||
it('dispatches Act 2 Desert based on levelId 41', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(123)
|
||||
stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
|
||||
stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
|
||||
|
||||
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('dispatches Act 3 Kurast based on levelId 80', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(123)
|
||||
stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
|
||||
stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
|
||||
|
||||
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('dispatches Act 5 Snow based on levelId 111', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(123)
|
||||
stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
|
||||
stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
|
||||
|
||||
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('dispatches Act 1 Dirt Roads based on levelId 3', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(123)
|
||||
stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
|
||||
stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
|
||||
|
||||
expect(countFloorTiles(canvas, 5, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('dispatches based on levelTypeName when levelId is generic', () => {
|
||||
const c2 = createCanvas(width, height, 1, 1, 0)
|
||||
stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
|
||||
stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
|
||||
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
|
||||
|
||||
const c3 = createCanvas(width, height, 1, 1, 0)
|
||||
stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
|
||||
stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
|
||||
expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(c3, 1, 129)).toBeGreaterThan(15)
|
||||
|
||||
const c5 = createCanvas(width, height, 1, 1, 0)
|
||||
stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
|
||||
stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
|
||||
expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(c5, 2, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('dispatches based on act number fallback', () => {
|
||||
const c2 = createCanvas(width, height, 1, 1, 0)
|
||||
stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
|
||||
stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
|
||||
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('strictly skips Cow Level (levelId 39)', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(42)
|
||||
stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
|
||||
stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
|
||||
|
||||
expect(countFloorTiles(canvas, 1, 194)).toBe(0)
|
||||
expect(countFloorTiles(canvas, 5, 194)).toBe(0)
|
||||
expect(countWallTiles(canvas, 1, 129)).toBe(0)
|
||||
expect(countWallTiles(canvas, 2, 129)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateWilderness Integration for Acts 2, 3, 5', () => {
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const borderPiece: WildernessPiece = {
|
||||
name: 'Act 2 - Desert Border 1',
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
|
||||
it('generates Act 2 Rocky Waste (Level 41) with caravan routes and sandstone berms', () => {
|
||||
const result = generateWilderness({
|
||||
levelId: 41,
|
||||
levelName: 'Rocky Waste',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 77777,
|
||||
pieces: [borderPiece],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.act).toBe(2)
|
||||
let caravanRoads = 0
|
||||
let sandstoneBerms = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) caravanRoads += 1
|
||||
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(caravanRoads).toBeGreaterThan(30)
|
||||
expect(sandstoneBerms).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and balustrades', () => {
|
||||
const act3Border: WildernessPiece = {
|
||||
name: 'Act 3 - Kurast Border',
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
const result = generateWilderness({
|
||||
levelId: 80,
|
||||
levelName: 'Kurast Bazaar',
|
||||
levelTypeName: 'Act 3 - Kurast',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 88888,
|
||||
pieces: [act3Border],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.act).toBe(3)
|
||||
let avenues = 0
|
||||
let balustrades = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) avenues += 1
|
||||
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) balustrades += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(avenues).toBeGreaterThan(30)
|
||||
expect(balustrades).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('generates Act 5 Frigid Highlands (Level 111) with snow roads and frozen trail divides', () => {
|
||||
const act5Border: WildernessPiece = {
|
||||
name: 'Act 5 - Barricade Border',
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
const result = generateWilderness({
|
||||
levelId: 111,
|
||||
levelName: 'Frigid Highlands',
|
||||
levelTypeName: 'Act 5 - Barricade',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 99999,
|
||||
pieces: [act5Border],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.act).toBe(5)
|
||||
let snowRoads = 0
|
||||
let frozenDivides = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1
|
||||
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(snowRoads).toBeGreaterThan(30)
|
||||
expect(frozenDivides).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('generates Cow Level (Level 39) without roads or stone divider networks', () => {
|
||||
const cowBorder: WildernessPiece = {
|
||||
name: 'Act 1 - Wild Border 1',
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
const result = generateWilderness({
|
||||
levelId: 39,
|
||||
levelName: 'Moo Moo Farm',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 12345,
|
||||
pieces: [cowBorder],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
let roadFloors = 0
|
||||
let dividerWalls = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1
|
||||
if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(roadFloors).toBe(0)
|
||||
expect(dividerWalls).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
generateWilderness,
|
||||
UNIMPLEMENTED_PASSES,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type { WildernessPiece } from "../src/game/wilderness.ts"
|
||||
import {
|
||||
ACT5_SIEGE_STRIP_NAMES,
|
||||
type SiegeTopographyStats,
|
||||
} from "../src/game/wilderness-siege.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockStripDs1(
|
||||
width: number,
|
||||
height: number,
|
||||
tagSequence: number,
|
||||
): 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: [],
|
||||
floors: [
|
||||
{
|
||||
prop1: 2,
|
||||
sequence: x === 0 && y === 0 ? tagSequence : 0,
|
||||
style: 0,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 5,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockSiegePieces(): WildernessPiece[] {
|
||||
return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({
|
||||
name,
|
||||
border: false,
|
||||
levels: [makeMockStripDs1(16, 48, 100 + idx)],
|
||||
}))
|
||||
}
|
||||
|
||||
describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => {
|
||||
test("DRLGOUTSIEGE_InitAct5OutdoorLevel is removed from UNIMPLEMENTED_PASSES", () => {
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain(
|
||||
"DRLGOUTSIEGE_InitAct5OutdoorLevel",
|
||||
)
|
||||
})
|
||||
|
||||
test("generates 240x48 Bloody Foothills canvas with 15 sequential strips from West to East", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 12345,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(240)
|
||||
expect(result.level.height).toBe(48)
|
||||
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
|
||||
expect(result.stats.substitutedClusters).toBe(15)
|
||||
|
||||
// Strip 0 (Siege To Barricade) is at x=0, Strip 14 (Siege To Town) is at x=224
|
||||
expect(result.level.cells[0]![0]!.floors[0]!.sequence).toBe(100)
|
||||
expect(result.level.cells[0]![224]!.floors[0]!.sequence).toBe(114)
|
||||
})
|
||||
|
||||
test("supports dynamic strip preset level variants selected by seed", () => {
|
||||
const pieces: WildernessPiece[] = ACT5_SIEGE_STRIP_NAMES.map((name, idx) => {
|
||||
// Provide 3 different DS1 variants with distinct floor tags at (0,1)
|
||||
const varA = makeMockStripDs1(16, 48, 100 + idx)
|
||||
const varB = makeMockStripDs1(16, 48, 200 + idx)
|
||||
const varC = makeMockStripDs1(16, 48, 300 + idx)
|
||||
;(varA.cells[1]![0]!.floors as any)[0].sequence = 1
|
||||
;(varB.cells[1]![0]!.floors as any)[0].sequence = 2
|
||||
;(varC.cells[1]![0]!.floors as any)[0].sequence = 3
|
||||
|
||||
return {
|
||||
name,
|
||||
border: false,
|
||||
levels: [varA, varB, varC],
|
||||
}
|
||||
})
|
||||
|
||||
const runA = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const runB = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 999999,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
// Compare variant selections across strips
|
||||
const tagsA = [16, 32, 48, 64, 80, 96, 112].map(
|
||||
x => runA.level.cells[1]![x]!.floors[0]!.sequence,
|
||||
)
|
||||
const tagsB = [16, 32, 48, 64, 80, 96, 112].map(
|
||||
x => runB.level.cells[1]![x]!.floors[0]!.sequence,
|
||||
)
|
||||
|
||||
// At least one strip variant differs between the two seeds
|
||||
expect(tagsA).not.toEqual(tagsB)
|
||||
})
|
||||
|
||||
test("supports dynamic strip selection with named variant pieces", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
// Replace Strip 5 with a named variant piece
|
||||
const strip5Idx = pieces.findIndex(p => p.name === "Act 5 - Siege Strip 5")
|
||||
pieces[strip5Idx] = {
|
||||
name: "Act 5 - Siege Strip 5 Variant Bravo",
|
||||
border: false,
|
||||
levels: [makeMockStripDs1(16, 48, 777)],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 777,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
// Strip 5 is at slot index 9 (x = 9 * 16 = 144)
|
||||
expect(result.level.cells[0]![144]!.floors[0]!.sequence).toBe(777)
|
||||
})
|
||||
|
||||
test("generates battlefield trenches with cleared wall channels", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats.trenchCells).toBeGreaterThan(60)
|
||||
|
||||
// Scan level for trench floor cells (style: 5, prop1: 194)
|
||||
let foundTrenchCells = 0
|
||||
let wallsClearedInTrenches = true
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
const hasTrenchFloor = cell.floors.some(
|
||||
f => f.style === 5 && f.prop1 === 194,
|
||||
)
|
||||
if (hasTrenchFloor) {
|
||||
foundTrenchCells += 1
|
||||
// Trenches must be cleared of blocking walls
|
||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
|
||||
wallsClearedInTrenches = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundTrenchCells).toBe(stats.trenchCells)
|
||||
expect(wallsClearedInTrenches).toBe(true)
|
||||
})
|
||||
|
||||
test("generates breached wooden barricades with splintered end-caps and open breach gaps", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||
expect(stats.barricadeWalls).toBeGreaterThan(30)
|
||||
|
||||
// Check for barricade walls (style: 2, prop1: 129)
|
||||
let foundBarricadeWalls = 0
|
||||
let foundSplinteredEndCaps = 0
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
const barricadeWall = cell.walls.find(
|
||||
w => !w.hidden && w.style === 2 && w.prop1 === 129,
|
||||
)
|
||||
if (barricadeWall) {
|
||||
foundBarricadeWalls += 1
|
||||
if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) {
|
||||
foundSplinteredEndCaps += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundBarricadeWalls).toBe(stats.barricadeWalls)
|
||||
// Splintered end-caps (seq: 1/2) must flank the breaches
|
||||
expect(foundSplinteredEndCaps).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("generates catapult defense platforms with parapets", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||
expect(stats.catapultPlatforms).toBe(3)
|
||||
|
||||
// Scan for catapult platform floors (style: 4, prop1: 2)
|
||||
let platformFloorCount = 0
|
||||
let platformParapetCount = 0
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) {
|
||||
platformFloorCount += 1
|
||||
}
|
||||
if (cell.walls.some(w => !w.hidden && w.style === 4 && w.prop1 === 2)) {
|
||||
platformParapetCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(platformFloorCount).toBeGreaterThan(30)
|
||||
expect(platformParapetCount).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
test("generates fort ruins interspersed between siege strips", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||
expect(stats.fortRuins).toBe(4)
|
||||
|
||||
// Scan for fort ruin flagstone floors (style: 1, prop1: 2) and stone walls (style: 1, prop1: 129)
|
||||
let ruinFloorCount = 0
|
||||
let ruinWallCount = 0
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.floors.some(f => f.style === 1 && f.prop1 === 2)) {
|
||||
ruinFloorCount += 1
|
||||
}
|
||||
if (cell.walls.some(w => !w.hidden && w.style === 1 && w.prop1 === 129)) {
|
||||
ruinWallCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(ruinFloorCount).toBeGreaterThan(40)
|
||||
expect(ruinWallCount).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
test("enforces progressive frontline fortification density from East (Harrogath) to West (Barricade)", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||
const { westFrontlineObstacles, midFieldObstacles, eastRearObstacles } =
|
||||
stats.frontlineDensity
|
||||
|
||||
// West Frontline (Shenk / Arreat Barricade) > Mid Field > East Rear (Harrogath)
|
||||
expect(westFrontlineObstacles).toBeGreaterThan(midFieldObstacles)
|
||||
expect(midFieldObstacles).toBeGreaterThanOrEqual(eastRearObstacles)
|
||||
expect(eastRearObstacles).toBeGreaterThan(0)
|
||||
|
||||
expect(westFrontlineObstacles).toBe(10)
|
||||
expect(midFieldObstacles).toBe(6)
|
||||
expect(eastRearObstacles).toBe(3)
|
||||
})
|
||||
|
||||
test("preserves traversable central thoroughfare along y=24 from Harrogath exit to Barricade entrance", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
// Walk horizontally across y = 24 from East (x = 239) to West (x = 0)
|
||||
const yCorridor = 24
|
||||
let blockedCells = 0
|
||||
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[yCorridor]![x]!
|
||||
const hasBlockingWall = cell.walls.some(
|
||||
w => !w.hidden && w.prop1 !== 0,
|
||||
)
|
||||
if (hasBlockingWall) {
|
||||
blockedCells += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Central line along y = 24 should have 0 blocking walls
|
||||
expect(blockedCells).toBe(0)
|
||||
})
|
||||
|
||||
test("records notes and telemetry in result.stats", () => {
|
||||
const pieces = createMockSiegePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const notes = result.stats.notes as string[]
|
||||
expect(notes).toContain("generated dynamic siege trenches and fortifications")
|
||||
expect(result.stats.siegeTopography).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
generateWilderness,
|
||||
buildLandmarkEntitiesFromTables,
|
||||
spawnLandmarkEntities,
|
||||
CANONICAL_LANDMARK_ENTITIES,
|
||||
type LandmarkEntityDef,
|
||||
type WildernessPiece,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import {
|
||||
planLevelMonsters,
|
||||
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
|
||||
CANONICAL_ELITE_MODIFIERS,
|
||||
} from "../src/game/monsters.ts"
|
||||
import { parseTable } from "../src/game/acts.ts"
|
||||
import type { D2Table } from "../src/game/acts.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function mockTable(tsv: string): D2Table {
|
||||
const encoder = new TextEncoder()
|
||||
return parseTable(encoder.encode(tsv.trim()))
|
||||
}
|
||||
|
||||
function makeMockDs1(width: number, height: number): 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: [],
|
||||
floors: [{ prop1: 1, sequence: 0, style: 1, 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,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
||||
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
|
||||
test("contains all 25 canonical landmark entities across Acts 1-5", () => {
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(25)
|
||||
|
||||
// Act 1 (6 entities: 5 SuperUniques + 1 NPC)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Flavie" && e.levelIds.includes(3))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bishibosh" && e.levelIds.includes(3))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Rakanishu" && e.levelIds.includes(4))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Treehead WoodFist" && e.levelIds.includes(5))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Blood Raven" && e.levelIds.includes(17))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "The Countess" && e.levelIds.includes(25))).toBe(true)
|
||||
|
||||
// Act 2 (5 entities)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true)
|
||||
|
||||
// Act 3 (4 entities)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true)
|
||||
|
||||
// Act 4 (3 entities: 2 SuperUniques + 1 NPC)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true)
|
||||
|
||||
// Act 5 (7 entities)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildLandmarkEntitiesFromTables", () => {
|
||||
test("returns canonical definitions when no tables are supplied", () => {
|
||||
const entities = buildLandmarkEntitiesFromTables()
|
||||
expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length)
|
||||
expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name)
|
||||
})
|
||||
|
||||
test("overrides boss modifiers and properties from SuperUniques.txt", () => {
|
||||
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
|
||||
const superUniquesTsv = [
|
||||
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
|
||||
"Bishibosh\tfallen2\t8\t9\t0\t2\t5\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||
"Rakanishu\tfallen3\t17\t6\t0\t3\t6\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||
].join("\n")
|
||||
|
||||
const suTable = mockTable(superUniquesTsv)
|
||||
const entities = buildLandmarkEntitiesFromTables(suTable)
|
||||
|
||||
const bishibosh = entities.find(e => e.id === "Bishibosh")
|
||||
expect(bishibosh).toBeDefined()
|
||||
expect(bishibosh?.monsterId).toBe("fallen2")
|
||||
expect(bishibosh?.modifiers).toEqual(["magicresistant", "fireenchant"])
|
||||
expect(bishibosh?.minionCount).toEqual([2, 5])
|
||||
|
||||
const rakanishu = entities.find(e => e.id === "Rakanishu")
|
||||
expect(rakanishu).toBeDefined()
|
||||
expect(rakanishu?.monsterId).toBe("fallen3")
|
||||
expect(rakanishu?.modifiers).toEqual(["lightenchant", "fast"])
|
||||
expect(rakanishu?.minionCount).toEqual([3, 6])
|
||||
})
|
||||
|
||||
test("resolves objectId from MonPreset.txt", () => {
|
||||
const monPresetTsv = [
|
||||
"Act\tPlace\tComponent\tObjectId",
|
||||
"1\tCairn Stones\t1\t105",
|
||||
"4\tChaos Sanctuary\t1\t350",
|
||||
].join("\n")
|
||||
|
||||
const mpTable = mockTable(monPresetTsv)
|
||||
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
|
||||
|
||||
const rakanishu = entities.find(e => e.name === "Rakanishu")
|
||||
expect(rakanishu?.objectId).toBe(105)
|
||||
|
||||
const hadriel = entities.find(e => e.name === "Hadriel")
|
||||
expect(hadriel?.objectId).toBe(350)
|
||||
})
|
||||
})
|
||||
|
||||
describe("spawnLandmarkEntities in Wilderness Generator", () => {
|
||||
test("places Rakanishu centered on Cairn Stones preset piece", () => {
|
||||
const cairnDs1 = makeMockDs1(16, 16)
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Cairn Stones", border: false, levels: [cairnDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
specialPresets: ["Act 1 - Cairn Stones"],
|
||||
})
|
||||
|
||||
expect(result.stats.landmarkEntities).toBeDefined()
|
||||
const rakanishu = result.stats.landmarkEntities?.find(e => e.name === "Rakanishu")
|
||||
expect(rakanishu).toBeDefined()
|
||||
expect(rakanishu?.levelId).toBe(4)
|
||||
|
||||
// Verify subtile coordinates: tileX * 5 + 2
|
||||
expect(rakanishu?.subtileX).toBe(rakanishu!.tileX * 5 + 2)
|
||||
expect(rakanishu?.subtileY).toBe(rakanishu!.tileY * 5 + 2)
|
||||
|
||||
// Verify canvas.objects contains the Type 1 entity
|
||||
const obj = result.level.objects.find(
|
||||
o => o.type === 1 && o.x === rakanishu!.subtileX && o.y === rakanishu!.subtileY
|
||||
)
|
||||
expect(obj).toBeDefined()
|
||||
expect(obj?.id).toBe(rakanishu?.objectId)
|
||||
})
|
||||
|
||||
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
|
||||
const stripDs1 = makeMockDs1(16, 48)
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 110,
|
||||
levelName: "Bloody Foothills",
|
||||
levelTypeName: "Act 5 - Siege",
|
||||
sizeX: 240,
|
||||
sizeY: 48,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 110,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const landmarks = result.stats.landmarkEntities ?? []
|
||||
const dacFarren = landmarks.find(e => e.name === "Dac Farren")
|
||||
const shenk = landmarks.find(e => e.name === "Shenk the Overseer")
|
||||
|
||||
expect(dacFarren).toBeDefined()
|
||||
expect(shenk).toBeDefined()
|
||||
expect(dacFarren?.objectId).toBe(87)
|
||||
expect(shenk?.objectId).toBe(88)
|
||||
|
||||
// Both must be recorded in canvas.objects as type 1
|
||||
const dacObj = result.level.objects.find(
|
||||
o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY
|
||||
)
|
||||
const shenkObj = result.level.objects.find(
|
||||
o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY
|
||||
)
|
||||
expect(dacObj).toBeDefined()
|
||||
expect(shenkObj).toBeDefined()
|
||||
})
|
||||
|
||||
test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => {
|
||||
const csDs1 = makeMockDs1(25, 25)
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 108,
|
||||
levelName: "Chaos Sanctuary",
|
||||
levelTypeName: "Act 4 - Lava",
|
||||
sizeX: 120,
|
||||
sizeY: 120,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 108,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel")
|
||||
expect(hadriel).toBeDefined()
|
||||
expect(hadriel?.objectId).toBe(86)
|
||||
expect(hadriel?.role).toBe("npc_guard")
|
||||
|
||||
const obj = result.level.objects.find(
|
||||
o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY
|
||||
)
|
||||
expect(obj).toBeDefined()
|
||||
expect(obj?.id).toBe(86)
|
||||
})
|
||||
|
||||
test("supports customLandmarks override in WildernessRequest", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const customLandmarks: LandmarkEntityDef[] = [
|
||||
{
|
||||
id: "CustomBoss",
|
||||
name: "Test Custom Boss",
|
||||
role: "superunique",
|
||||
act: 1,
|
||||
levelIds: [4],
|
||||
monsterId: "fallen1",
|
||||
defaultLandmark: "Custom Landmark",
|
||||
modifiers: ["lightenchant", "fast"],
|
||||
minionCount: [4, 6],
|
||||
minionMonsterId: "fallen1",
|
||||
objectId: 999,
|
||||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 123,
|
||||
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||
substitutions: [],
|
||||
customLandmarks,
|
||||
})
|
||||
|
||||
expect(result.stats.landmarkEntities?.length).toBe(1)
|
||||
const custom = result.stats.landmarkEntities?.[0]
|
||||
expect(custom?.name).toBe("Test Custom Boss")
|
||||
expect(custom?.tileX).toBe(40)
|
||||
expect(custom?.tileY).toBe(40)
|
||||
expect(custom?.objectId).toBe(999)
|
||||
})
|
||||
|
||||
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const result = generateWilderness({
|
||||
levelId: 5, // Dark Wood (Treehead WoodFist)
|
||||
levelName: "Dark Wood",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 100,
|
||||
sizeY: 100,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 99,
|
||||
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const treehead = result.stats.landmarkEntities?.find(e => e.name === "Treehead WoodFist")
|
||||
expect(treehead).toBeDefined()
|
||||
expect(treehead?.tileX).toBeGreaterThanOrEqual(1)
|
||||
expect(treehead?.tileX).toBeLessThan(99)
|
||||
expect(treehead?.tileY).toBeGreaterThanOrEqual(1)
|
||||
expect(treehead?.tileY).toBeLessThan(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => {
|
||||
test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => {
|
||||
const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura")
|
||||
expect(aura).toBeDefined()
|
||||
expect(aura?.label).toBe("Aura Enchanted")
|
||||
expect(aura?.id).toBe(30)
|
||||
})
|
||||
|
||||
test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => {
|
||||
// Act 1
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess
|
||||
|
||||
// Act 2
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin
|
||||
|
||||
// Act 3
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina
|
||||
|
||||
// Act 4
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto
|
||||
|
||||
// Act 5
|
||||
const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk
|
||||
expect(Array.isArray(act5Bloody)).toBe(true)
|
||||
expect((act5Bloody as readonly unknown[]).length).toBe(2)
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw
|
||||
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak
|
||||
})
|
||||
|
||||
test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => {
|
||||
const levelsTxt = mockTable([
|
||||
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
||||
"110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1",
|
||||
].join("\n"))
|
||||
const monstatsTxt = mockTable([
|
||||
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
||||
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
|
||||
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
|
||||
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
|
||||
].join("\n"))
|
||||
const monlvlTxt = mockTable([
|
||||
"Level\tAC\tTH\tHP\tDM\tXP",
|
||||
"35\t100\t100\t100\t100\t100",
|
||||
].join("\n"))
|
||||
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||
|
||||
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal")
|
||||
|
||||
expect(plan.superUniques).toContain("Dac Farren")
|
||||
expect(plan.superUniques).toContain("Shenk the Overseer")
|
||||
|
||||
const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren")
|
||||
const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer")
|
||||
|
||||
expect(dacPack).toBeDefined()
|
||||
expect(shenkPack).toBeDefined()
|
||||
expect(dacPack?.members[0]?.rank).toBe("unique")
|
||||
expect(shenkPack?.members[0]?.rank).toBe("unique")
|
||||
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
|
||||
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
generateWilderness,
|
||||
OUTDOOR_WAYPOINT_LEVELS,
|
||||
actOfLevel,
|
||||
ACT_POPULATION_CONFIG,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let isWall: boolean
|
||||
if (isOpen) {
|
||||
// Clearings have open interior floor with perimeter walls
|
||||
isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls))
|
||||
} else {
|
||||
isWall = !(hasGapInWalls && x === Math.floor(width / 2))
|
||||
}
|
||||
row.push({
|
||||
walls: isWall ? [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [],
|
||||
floors: [{ prop1: 2, 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 makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] {
|
||||
const pieces: WildernessPiece[] = []
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
pieces.push({
|
||||
name: `${prefix} Border ${i}`,
|
||||
border: true,
|
||||
levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)],
|
||||
})
|
||||
}
|
||||
pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] })
|
||||
pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] })
|
||||
if (landmarkName) {
|
||||
pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] })
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function makeAct3JunglePieces(): WildernessPiece[] {
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
||||
{ name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
||||
{ name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
||||
{ name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
||||
{ name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
||||
{ name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] },
|
||||
]
|
||||
return pieces
|
||||
}
|
||||
|
||||
describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => {
|
||||
test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => {
|
||||
expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1)
|
||||
expect(actOfLevel(43, "Act 2 - Desert")).toBe(2)
|
||||
expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3)
|
||||
expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4)
|
||||
expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5)
|
||||
|
||||
// Falls back to numeric levelId ranges when levelTypeName is generic
|
||||
expect(actOfLevel(4, undefined)).toBe(1)
|
||||
expect(actOfLevel(42, undefined)).toBe(2)
|
||||
expect(actOfLevel(76, undefined)).toBe(3)
|
||||
expect(actOfLevel(107, undefined)).toBe(4)
|
||||
expect(actOfLevel(117, undefined)).toBe(5)
|
||||
})
|
||||
|
||||
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
|
||||
// Act 1
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP)
|
||||
|
||||
// Act 2
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP)
|
||||
|
||||
// Act 3
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal
|
||||
|
||||
// Act 4
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP)
|
||||
|
||||
// Act 5
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP)
|
||||
})
|
||||
|
||||
test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1")
|
||||
const result = generateWilderness({
|
||||
levelId: 43,
|
||||
levelName: "Far Oasis",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 8,
|
||||
subTheme: 0,
|
||||
seed: 0x434343,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(1)
|
||||
expect(result.stats.waypointTile).toBeDefined()
|
||||
expect(result.stats.waypointTile).not.toBeNull()
|
||||
|
||||
// Waypoint object for Act 2 is ID 10
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10)
|
||||
expect(wpObj).toBeDefined()
|
||||
|
||||
// Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126)
|
||||
const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
|
||||
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id))
|
||||
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Well for Act 2 is ID 234
|
||||
const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234)
|
||||
expect(wellObj).toBeDefined()
|
||||
})
|
||||
|
||||
test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
||||
const result = generateWilderness({
|
||||
levelId: 41,
|
||||
levelName: "Rocky Waste",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 8,
|
||||
subTheme: 0,
|
||||
seed: 0x414141,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(0)
|
||||
expect(result.stats.waypointTile).toBeNull()
|
||||
})
|
||||
|
||||
test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => {
|
||||
const pieces = makeAct3JunglePieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 78,
|
||||
levelName: "Flayer Jungle",
|
||||
levelTypeName: "Act 3 - Jungle",
|
||||
sizeX: 64,
|
||||
sizeY: 192,
|
||||
subType: 10,
|
||||
subTheme: 0,
|
||||
seed: 0x787878,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(1)
|
||||
expect(result.stats.waypointTile).toBeDefined()
|
||||
expect(result.stats.waypointTile).not.toBeNull()
|
||||
|
||||
// Waypoint object for Act 3 is ID 1
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1)
|
||||
expect(wpObj).toBeDefined()
|
||||
|
||||
// Waypoint is inside the clearing column (x < 32)
|
||||
const wpTile = result.stats.waypointTile as { x: number; y: number }
|
||||
expect(wpTile.x).toBeLessThan(32)
|
||||
|
||||
// Act 3 shrines spawned
|
||||
const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool)
|
||||
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id))
|
||||
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 4 - Mesa")
|
||||
const result = generateWilderness({
|
||||
levelId: 106,
|
||||
levelName: "City of the Damned",
|
||||
levelTypeName: "Act 4 - Mesa",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 12,
|
||||
subTheme: 0,
|
||||
seed: 0x106106,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(1)
|
||||
expect(result.stats.waypointTile).toBeDefined()
|
||||
|
||||
// Act 4 waypoint id is 0
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0)
|
||||
expect(wpObj).toBeDefined()
|
||||
|
||||
// Outer Steppes (104) has no waypoint
|
||||
const steppesResult = generateWilderness({
|
||||
levelId: 104,
|
||||
levelName: "Outer Steppes",
|
||||
levelTypeName: "Act 4 - Mesa",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 12,
|
||||
subTheme: 0,
|
||||
seed: 0x104104,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
expect(steppesResult.stats.waypointsSpawned).toBe(0)
|
||||
expect(steppesResult.stats.waypointTile).toBeNull()
|
||||
})
|
||||
|
||||
test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1")
|
||||
const result = generateWilderness({
|
||||
levelId: 112,
|
||||
levelName: "Arreat Plateau",
|
||||
levelTypeName: "Act 5 - Barricade",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 14,
|
||||
subTheme: 0,
|
||||
seed: 0x112112,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(1)
|
||||
expect(result.stats.waypointTile).toBeDefined()
|
||||
|
||||
// Act 5 waypoint id is 12
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12)
|
||||
expect(wpObj).toBeDefined()
|
||||
|
||||
// Act 5 shrines spawned
|
||||
const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool)
|
||||
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id))
|
||||
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 96,
|
||||
sizeY: 96,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x8899aabb,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const shrineIds = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
||||
const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id))
|
||||
|
||||
// If multiple shrines are spawned, check pairwise distance >= 7
|
||||
if (shrines.length >= 2) {
|
||||
for (let i = 0; i < shrines.length; i += 1) {
|
||||
for (let j = i + 1; j < shrines.length; j += 1) {
|
||||
const s1 = shrines[i]!
|
||||
const s2 = shrines[j]!
|
||||
const dist = Math.hypot(
|
||||
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
|
||||
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
|
||||
)
|
||||
expect(dist).toBeGreaterThanOrEqual(7)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
|
||||
// Use multiple seeds to observe companion spawning (60% probability)
|
||||
let foundClusteredCompanion = false
|
||||
const act1ChestsAndScatter = new Set([
|
||||
...ACT_POPULATION_CONFIG[1].chestPool,
|
||||
...ACT_POPULATION_CONFIG[1].scatterPool,
|
||||
])
|
||||
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
||||
|
||||
for (let s = 0; s < 10; s += 1) {
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x5000 + s * 17,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const shrines = result.level.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
|
||||
const companions = result.level.objects.filter(o => o.type === 2 && act1ChestsAndScatter.has(o.id))
|
||||
|
||||
for (const shrine of shrines) {
|
||||
const sx = Math.floor(shrine.x / 5)
|
||||
const sy = Math.floor(shrine.y / 5)
|
||||
for (const comp of companions) {
|
||||
const cx = Math.floor(comp.x / 5)
|
||||
const cy = Math.floor(comp.y / 5)
|
||||
const dist = Math.hypot(cx - sx, cy - sy)
|
||||
if (dist >= 1.9 && dist <= 4.5) {
|
||||
foundClusteredCompanion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundClusteredCompanion) break
|
||||
}
|
||||
if (foundClusteredCompanion) break
|
||||
}
|
||||
|
||||
expect(foundClusteredCompanion).toBe(true)
|
||||
})
|
||||
|
||||
test("shrineSubstitutions scales shrine count based on LvlSub max", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
||||
const mockShrineSub: WildernessSubstitution = {
|
||||
name: "Act 2 - Shrines",
|
||||
type: 8,
|
||||
gridSize: 1,
|
||||
bordType: 0,
|
||||
dt1Mask: 0,
|
||||
prob: [100, 100, 100, 100, 100],
|
||||
trials: [1, 1, 1, 1, 1],
|
||||
max: [4, 4, 4, 4, 4],
|
||||
levels: [],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 43,
|
||||
levelName: "Far Oasis",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 96,
|
||||
sizeY: 96,
|
||||
subType: 8,
|
||||
subTheme: 0,
|
||||
seed: 0x777777,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
shrineSubstitutions: [mockShrineSub],
|
||||
})
|
||||
|
||||
// With max = 4 plus healing well, shrinesSpawned should scale up (>= 2)
|
||||
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("Determinism: same seed produces identical waypoint and shrine layouts", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1")
|
||||
const seed = 0xabcdef12
|
||||
|
||||
const run1 = generateWilderness({
|
||||
levelId: 43,
|
||||
levelName: "Far Oasis",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 8,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const run2 = generateWilderness({
|
||||
levelId: 43,
|
||||
levelName: "Far Oasis",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 8,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned)
|
||||
expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile)
|
||||
expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned)
|
||||
expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned)
|
||||
expect(run1.level.objects).toEqual(run2.level.objects)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue