fix(map-gen): assemble Act 5 Bloody Foothills 15 siege strips, Barricade fortifications & Act 1 stone walls/dirt roads (Issue #35)

This commit is contained in:
troytt 2026-09-16 03:06:48 +00:00
parent dcd3da0a22
commit 25ae7289ee
3 changed files with 484 additions and 9 deletions

View File

@ -589,10 +589,9 @@ function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]>
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, levels, border: /border|cliff/i.test(name) })
pieces.push({ name, levels, border: /border|cliff|ravine/i.test(name) })
}
return pieces
})()
@ -975,9 +974,10 @@ async function bakeDs1Variant(
}
const filterLevel = process.env.FILTER_LEVEL
const filterLevels = filterLevel !== undefined ? new Set(filterLevel.split(',').map(s => s.trim())) : null
for (const entry of LEVELS) {
if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) continue
if (filterLevels !== null && !filterLevels.has(String(entry.levelId))) continue
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
@ -1109,7 +1109,9 @@ if (filterLevel !== undefined) {
}
}
await writeFile(indexPath, JSON.stringify(index, null, 1))
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
if (!process.env.SKIP_ENTITIES) {
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
}
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)

View File

@ -1087,65 +1087,98 @@ function applySubstitutions(
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
2: [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
3: [
'Act 1 - Cave Entrance',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
4: [
'Act 1 - Cairn Stones',
'Act 1 - Camp',
'Act 1 - Cave Entrance',
'Act 1 - Tower Tome',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Camp',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
5: [
'Act 1 - Inifus',
'Act 1 - Cave Entrance',
'Act 1 - Ruin',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Tree Fill',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 2',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
6: [
'Act 1 - Tower 1',
'Act 1 - Cave Entrance',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
7: [
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Fallen Camp 2',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
17: [
'Act 1 - Graveyard',
],
39: [
'Act 1 - Bivouac',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Corral Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
@ -1156,6 +1189,34 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
111: [
'Act 5 - Barricade Waypoint Dirt',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Prison 1',
'Act 5 - Barricade Prison 2',
'Act 5 - Barricade Prison 3',
'Act 5 - Barricade 1',
'Act 5 - Barricade 2',
'Act 5 - Barricade 3',
'Act 5 - Barricade 4',
'Act 5 - Barricade Building',
'Act 5 - Barricade Filler Treasure',
'Act 5 - Barricade Ruins N',
'Act 5 - Barricade Ruins W',
],
112: [
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Snow Unique',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade 4 Snow',
'Act 5 - Barricade Snow Lake 1',
'Act 5 - Barricade Snow Lake 2',
'Act 5 - Barricade Ruins N Treasure',
'Act 5 - Barricade Ruins W Treasure',
],
}
/**
@ -1316,6 +1377,220 @@ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: n
* level, because an empty map silently baked into an asset pack is worse than a
* crash.
*/
/**
* Assemble the 15 sequential 16x48 siege strips for Act 5 - Siege 1 (Bloody Foothills, LevelId 110).
*
* In Diablo II (`DRLGOUTSIEGE_InitAct5Siege`), Bloody Foothills is a 240x48 uphill battleground
* constructed from 15 sequential 16x48 DS1 strips: `Siege To Barricade` at x=0 (West/North exit to
* Frigid Highlands), `Siege Strip 13` (Shenk the Overseer's boss platform) at x=16, down through
* `Siege Strip 1` at x=208, and `Siege To Town` (Harrogath gate) at x=224.
*/
function stampAct5SiegeStrips(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const stripOrder = [
'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',
]
const placed = new Set<string>()
for (let i = 0; i < stripOrder.length; i += 1) {
const name = stripOrder[i]!
const piece = pieces.find(p => p.name === name)
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, i * 16, 0)
placed.add(name)
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit,
* and interior landmarks in Act 1 wilderness levels (LevelId 2..7).
*/
function stampAct1DirtRoads(canvas: Canvas, rng: Rng): void {
const roadFloor: Ds1Floor = {
prop1: 194,
sequence: 0,
style: 5,
unknown1: 0,
unknown2: 0,
hidden: false,
}
const paintRoadCell = (cx: number, cy: number): void => {
for (let dy = 0; dy <= 1; dy += 1) {
for (let dx = 0; dx <= 1; dx += 1) {
const x = cx + dx
const y = cy + dy
if (x < 8 || x >= canvas.width - 8 || y < 8 || y >= canvas.height - 8) continue
const cell = canvas.cells[y]?.[x]
if (!cell) continue
// Do not overwrite cells that already carry walls or water/bridge floors
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) continue
const existingFloor = cell.floors[0]
if (existingFloor && existingFloor.style !== 0) continue
;(cell.floors as Ds1Floor[])[0] = roadFloor
}
}
}
const connectPoints = (x0: number, y0: number, x1: number, y1: number): void => {
let curX = x0
let curY = y0
let steps = 0
const maxSteps = canvas.width + canvas.height + 40
while ((Math.abs(curX - x1) > 1 || Math.abs(curY - y1) > 1) && steps < maxSteps) {
paintRoadCell(curX, curY)
const dx = x1 - curX
const dy = y1 - curY
if (Math.abs(dx) > Math.abs(dy)) {
curX += dx > 0 ? 1 : -1
if (rng.int(0, 3) === 0 && dy !== 0) curY += dy > 0 ? 1 : -1
} else {
curY += dy > 0 ? 1 : -1
if (rng.int(0, 3) === 0 && dx !== 0) curX += dx > 0 ? 1 : -1
}
steps += 1
}
paintRoadCell(x1, y1)
}
const hubX = Math.floor(canvas.width / 2) + rng.int(-5, 5)
const hubY = Math.floor(canvas.height / 2) + rng.int(-5, 5)
const southX = Math.floor(canvas.width / 2)
const southY = canvas.height - 9
const eastX = canvas.width - 9
const eastY = Math.floor(canvas.height / 2)
const nwX = Math.floor(canvas.width * 0.28)
const nwY = Math.floor(canvas.height * 0.28)
const swX = Math.floor(canvas.width * 0.28)
const swY = Math.floor(canvas.height * 0.72)
connectPoints(southX, southY, hubX, hubY)
connectPoints(hubX, hubY, eastX, eastY)
connectPoints(hubX, hubY, nwX, nwY)
connectPoints(hubX, hubY, swX, swY)
}
/**
* Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1
* wilderness levels (LevelId 2..7), creating classic stone-walled courtyards and meadows with
* archway gate openings where roads pass through.
*/
function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeight: number, rng: Rng): void {
const ewWall = (seq: number): Ds1Wall => ({
prop1: 129,
sequence: seq % 3,
style: 2,
type: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
const nsWall = (seq: number): Ds1Wall => ({
prop1: 129,
sequence: seq % 3,
style: 2,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
const cornerPillar: Ds1Wall = {
prop1: 129,
sequence: 0,
style: 2,
type: 3,
unknown1: 0,
unknown2: 0,
hidden: false,
}
const canPlaceWall = (x: number, y: number): boolean => {
if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return false
const cell = canvas.cells[y]?.[x]
if (!cell) return false
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
// Leave gate openings wherever a dirt road (`style === 5`) or water crosses
if (cell.floors.some(f => !f.hidden && f.style !== 0)) return false
return true
}
const placeWall = (x: number, y: number, wall: Ds1Wall): void => {
const cell = canvas.cells[y]?.[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
}
// Primary horizontal (East-West) stone wall divider near the middle of the map
const midBlockY = Math.floor(gridHeight / 2) + (rng.int(0, 1) === 0 ? -1 : 1)
const wallY = Math.max(16, Math.min(canvas.height - 16, midBlockY * TILES_PER_BLOCK))
// Primary vertical (North-South) stone wall divider
const midBlockX = Math.floor(gridWidth / 2) + (rng.int(0, 1) === 0 ? -1 : 1)
const wallX = Math.max(16, Math.min(canvas.width - 16, midBlockX * TILES_PER_BLOCK))
// Draw East-West stone wall line with periodic gate gaps
let prevPlacedEW = false
for (let x = 11; x < canvas.width - 11; x += 1) {
const isGate = (x % 18) >= 7 && (x % 18) <= 10
if (isGate || Math.abs(x - wallX) <= 1 || !canPlaceWall(x, wallY)) {
if (prevPlacedEW && canPlaceWall(x - 1, wallY)) {
placeWall(x - 1, wallY, cornerPillar)
}
prevPlacedEW = false
continue
}
if (!prevPlacedEW) {
placeWall(x, wallY, cornerPillar)
} else {
placeWall(x, wallY, ewWall(x))
}
prevPlacedEW = true
}
// Draw North-South stone wall line with periodic gate gaps
let prevPlacedNS = false
for (let y = 11; y < canvas.height - 11; y += 1) {
const isGate = (y % 18) >= 7 && (y % 18) <= 10
if (isGate || Math.abs(y - wallY) <= 1 || !canPlaceWall(wallX, y)) {
if (prevPlacedNS && canPlaceWall(wallX, y - 1)) {
placeWall(wallX, y - 1, cornerPillar)
}
prevPlacedNS = false
continue
}
if (!prevPlacedNS) {
placeWall(wallX, y, cornerPillar)
} else {
placeWall(wallX, y, nsWall(y))
}
prevPlacedNS = true
}
}
export function generateWilderness(request: WildernessRequest): WildernessResult {
const where = `level ${String(request.levelId)} (${request.levelName})`
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
@ -1421,8 +1696,73 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
}
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
const borderPieces = request.pieces.filter(piece => piece.border)
const fillPieces = request.pieces.filter(piece => !piece.border)
// Special case: Act 5 Bloody Foothills (LevelId 110 / Act 5 - Siege 1) is assembled
// from 15 sequential 16x48 siege strips rather than perimeter borders + random scatter.
if (request.levelId === 110 || request.levelTypeName === 'Act 5 - Siege') {
const placedSiege = stampAct5SiegeStrips(canvas, request.pieces, stats)
let finalGroundCells = 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)) finalGroundCells += 1
}
}
stats.groundCells = finalGroundCells
return {
level: {
version: version === 0 ? 18 : version,
width: canvas.width,
height: canvas.height,
act,
substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: 0,
groundTile,
groundCells: stats.groundCells,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
substitutions: stats.substitutions,
substitutedClusters: placedSiege.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
},
}
}
// Filter pieces for Act 5 Barricade dirt (111) vs snow (112)
let levelPieces = request.pieces
if (request.levelId === 111) {
levelPieces = request.pieces.filter(p => !/snow/i.test(p.name))
} else if (request.levelId === 112) {
const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name))
if (snowOnly.length > 0) levelPieces = snowOnly
}
const borderPieces = levelPieces.filter(piece => piece.border)
const fillPieces = levelPieces.filter(piece => !piece.border)
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats, request.levelId)
let specialPresetNames = request.specialPresets
@ -1432,20 +1772,30 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
const hasRiver = rng.int(0, 1) === 0
specialPresetNames = hasRiver
? [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
: [
'Act 1 - DOE Entrance',
'Act 1 - Pond',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
@ -1455,6 +1805,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
}
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
// interior stone wall divider lines across the meadow.
if (request.levelId >= 2 && request.levelId <= 7) {
stampAct1DirtRoads(canvas, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, rng)
}
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]

View File

@ -134,4 +134,120 @@ describe("Wilderness Features & Border Rules", () => {
expect(result.level.width).toBe(80)
expect(result.level.height).toBe(80)
})
test("Act 1 Wilderness generates interior stone walls and dirt road network", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1],
}
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 99999,
pieces: [piece],
substitutions: [],
})
let stoneWallCount = 0
let dirtRoadCount = 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.walls.some(w => w.style === 2 && w.prop1 === 129)) {
stoneWallCount += 1
}
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) {
dirtRoadCount += 1
}
}
}
expect(stoneWallCount).toBeGreaterThan(20)
expect(dirtRoadCount).toBeGreaterThan(30)
})
test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => {
const stripNames = [
"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",
]
const pieces: WildernessPiece[] = stripNames.map((name, idx) => {
const baseDs1 = makeMockDs1(17, 49, false)
// Create a copy with tagged floor sequence at (0,0) and one object
const cells = baseDs1.cells.map((row, y) =>
row.map((cell, x) =>
x === 0 && y === 0
? {
...cell,
floors: [{ ...cell.floors[0], sequence: idx + 10 }],
}
: cell,
),
)
const ds1: Ds1 = {
...baseDs1,
cells,
objects: [
{
type: 2,
id: 100 + idx,
x: 5,
y: 5,
flags: 0,
},
],
}
return {
name,
border: false,
levels: [ds1],
}
})
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
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)
// 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)
})
})