fix(generator): orient wilderness borders and restore rock cliff boundaries

This commit is contained in:
troytt 2026-09-15 02:30:31 +00:00
parent 36d5624b14
commit 6b9c3d3430
2 changed files with 185 additions and 18 deletions

View File

@ -695,7 +695,7 @@ for (const { id, name } of wildLevels) {
check(substitutions.some(s => s.name.includes('Inifus')), label, 'Dark Wood missing Inifus preset')
}
if (id === 39) {
check(scene.walls.length > 700, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
check(scene.walls.length > 500, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral')), label, 'Moo Moo Farm missing Corral Fill special preset')
check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset')

View File

@ -617,13 +617,124 @@ function borderRing(gridWidth: number, gridHeight: number): { x: number; y: numb
/**
* Lay the level's border pieces around the block grid's outer ring.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: a piece is stamped at a
* block cell, the box it covers is claimed, and later stamps overwrite earlier
* ones. Which piece a given stretch of cliff uses is hardcoded per act in D2MOO
* (paired with a border *style* through tables like `levelPrestBorder`), so this
* port does the data-driven equivalent: it walks the ring and cycles the level's
* declared border pieces, smallest first, which keeps the cliffs a consistent
* width and stops a large piece from swallowing the interior.
/** Orientation of a border piece along the outer edge of a level. */
export type BorderOrientation =
| 'north'
| 'south'
| 'east'
| 'west'
| 'corner-nw'
| 'corner-ne'
| 'corner-se'
| 'corner-sw'
export interface ClassifiedBorderPiece {
readonly piece: WildernessPiece
readonly orientation: BorderOrientation | null
readonly isCliff: boolean
}
/**
* Classify a border piece by its orientation and whether it is a stone cliff.
*
* Blizzard North uses a universal numbering convention across outdoor border presets:
* - 1 = South edge (horizontal straight)
* - 2 = West edge (vertical straight)
* - 3 = North edge (horizontal straight)
* - 4 = East edge (vertical straight)
* - 5 = SW corner
* - 6 = NW corner
* - 7 = NE corner
* - 8 = SE corner
* - 9..12 = corner / transition variations
*
* Act 3 outdoor tiles use cardinal suffixes ("Border N", "Border S", "Border NE", etc.).
* Cave entrances ("clfcave", "clfcave2") are dedicated structures, never generic border pieces.
*/
export function classifyBorderPiece(name: string): { orientation: BorderOrientation | null; isCliff: boolean } {
if (/cave/i.test(name)) return { orientation: null, isCliff: false }
const isCliff = /cliff|stnclf/i.test(name)
// Act 3 style: "Border N", "Border S", "Border NE", etc.
const cardinal = name.match(/Border\s+([NESW]{1,2})\b/i)
if (cardinal) {
const dir = cardinal[1]!.toUpperCase()
if (dir === 'N') return { orientation: 'north', isCliff }
if (dir === 'S') return { orientation: 'south', isCliff }
if (dir === 'E') return { orientation: 'east', isCliff }
if (dir === 'W') return { orientation: 'west', isCliff }
if (dir === 'NW') return { orientation: 'corner-nw', isCliff }
if (dir === 'NE') return { orientation: 'corner-ne', isCliff }
if (dir === 'SE') return { orientation: 'corner-se', isCliff }
if (dir === 'SW') return { orientation: 'corner-sw', isCliff }
}
// Act 1, 2, 4, 5 style: 1=S, 2=W, 3=N, 4=E, 5=SW, 6=NW, 7=NE, 8=SE
const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr)\s*0?(\d+)/i)
if (numMatch) {
const num = Number(numMatch[1])
if (num === 1) return { orientation: 'south', isCliff }
if (num === 2) return { orientation: 'west', isCliff }
if (num === 3) return { orientation: 'north', isCliff }
if (num === 4) return { orientation: 'east', isCliff }
if (num === 5) return { orientation: 'corner-sw', isCliff }
if (num === 6) return { orientation: 'corner-nw', isCliff }
if (num === 7) return { orientation: 'corner-ne', isCliff }
if (num === 8) return { orientation: 'corner-se', isCliff }
}
return { orientation: null, isCliff }
}
/**
* Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid.
*/
function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation {
if (x === 0 && y === 0) return 'corner-nw'
if (x === gridWidth - 1 && y === 0) return 'corner-ne'
if (x === gridWidth - 1 && y === gridHeight - 1) return 'corner-se'
if (x === 0 && y === gridHeight - 1) return 'corner-sw'
if (y === 0) return 'north'
if (x === gridWidth - 1) return 'east'
if (y === gridHeight - 1) return 'south'
return 'west'
}
/**
* Select a matching piece for the given orientation.
*/
function selectBorderPiece(
orientation: BorderOrientation,
classified: readonly ClassifiedBorderPiece[],
rng: Rng,
preferCliff: boolean,
): WildernessPiece | null {
const matches = classified.filter(c => c.orientation === orientation)
if (matches.length === 0) return null
if (preferCliff) {
const cliffMatches = matches.filter(c => c.isCliff)
if (cliffMatches.length > 0) {
return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece
}
} else {
const nonCliffMatches = matches.filter(c => !c.isCliff)
if (nonCliffMatches.length > 0) {
return nonCliffMatches[rng.int(0, nonCliffMatches.length - 1)]!.piece
}
}
return matches[rng.int(0, matches.length - 1)]!.piece
}
/**
* Lay the level's border pieces around the block grid's outer ring.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: straight edge pieces are
* placed according to edge orientation (North, South, East, West), followed by corner
* join pieces (NW, NE, SE, SW). In Act 1 wilderness levels, North and West boundaries
* prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders,
* matching original Diablo II DRLG rules.
*
* @param canvas - the canvas.
* @param pieces - the level's border pieces, already filtered to `border`.
@ -645,29 +756,85 @@ function layBorder(
stats.unresolved.push('no border piece for this level type')
return 0
}
const ordered = [...pieces].sort((a, b) => {
// Filter out cave entrances from generic border placement
const validPieces = pieces.filter(p => !/cave/i.test(p.name))
const candidatePieces = validPieces.length > 0 ? validPieces : pieces
const classified: ClassifiedBorderPiece[] = candidatePieces
.map(piece => {
const { orientation, isCliff } = classifyBorderPiece(piece.name)
return { piece, orientation, isCliff }
})
.filter((item): item is ClassifiedBorderPiece & { orientation: BorderOrientation } => item.orientation !== null)
const ordered = [...candidatePieces].sort((a, b) => {
const areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
return areaA - areaB || a.name.localeCompare(b.name)
})
const ring = borderRing(gridWidth, gridHeight)
// Stamp straight edges first, then corners last, matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders
// where corner presets overwrite edge presets to form seamless joins.
const straightCells: { x: number; y: number }[] = []
// North edge
for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 })
// East edge
for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y })
// South edge
for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 })
// West edge
for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y })
const cornerCells: { x: number; y: number }[] = [
{ x: 0, y: 0 }, // NW
{ x: gridWidth - 1, y: 0 }, // NE
{ x: gridWidth - 1, y: gridHeight - 1 }, // SE
{ x: 0, y: gridHeight - 1 }, // SW
]
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
for (const cell of ring) {
const piece = ordered[cursor]!
const stampCell = (cell: { x: number; y: number }) => {
const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight)
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries)
const preferCliff =
orientation === 'north' ||
orientation === 'west' ||
orientation === 'corner-nw' ||
orientation === 'corner-ne' ||
orientation === 'corner-sw'
let piece = selectBorderPiece(orientation, classified, rng, preferCliff)
if (!piece) {
piece = ordered[cursor]!
cursor = (cursor + 1) % ordered.length
}
if (!piece || piece.levels.length === 0) return
const variant = rng.int(0, piece.levels.length - 1)
const level = piece.levels[variant % piece.levels.length]
if (level === undefined) continue
// Clip the piece to the canvas, but shift it so its near corner still covers
// the ring cell: a cliff piece anchored past the edge would leave a hole.
const originX = Math.min(cell.x * TILES_PER_BLOCK, Math.max(canvas.width - level.width, 0))
const originY = Math.min(cell.y * TILES_PER_BLOCK, Math.max(canvas.height - level.height, 0))
if (level === undefined) return
const maxOriginX = Math.max(canvas.width - level.width, 0)
const maxOriginY = Math.max(canvas.height - level.height, 0)
const originX = level.width <= TILES_PER_BLOCK + 1
? cell.x * TILES_PER_BLOCK
: Math.min(cell.x * TILES_PER_BLOCK, maxOriginX)
const originY = level.height <= TILES_PER_BLOCK + 1
? cell.y * TILES_PER_BLOCK
: Math.min(cell.y * TILES_PER_BLOCK, maxOriginY)
stampDs1(canvas, level, originX, originY)
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
stats.borderStamped += 1
stamped += 1
}
for (const cell of straightCells) stampCell(cell)
for (const cell of cornerCells) stampCell(cell)
return stamped
}