fix(generator): orient wilderness borders and restore rock cliff boundaries
This commit is contained in:
parent
36d5624b14
commit
6b9c3d3430
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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]!
|
||||
cursor = (cursor + 1) % ordered.length
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue