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')
|
check(substitutions.some(s => s.name.includes('Inifus')), label, 'Dark Wood missing Inifus preset')
|
||||||
}
|
}
|
||||||
if (id === 39) {
|
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 }[]
|
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('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')
|
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.
|
* Lay the level's border pieces around the block grid's outer ring.
|
||||||
*
|
*
|
||||||
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: a piece is stamped at a
|
/** Orientation of a border piece along the outer edge of a level. */
|
||||||
* block cell, the box it covers is claimed, and later stamps overwrite earlier
|
export type BorderOrientation =
|
||||||
* ones. Which piece a given stretch of cliff uses is hardcoded per act in D2MOO
|
| 'north'
|
||||||
* (paired with a border *style* through tables like `levelPrestBorder`), so this
|
| 'south'
|
||||||
* port does the data-driven equivalent: it walks the ring and cycles the level's
|
| 'east'
|
||||||
* declared border pieces, smallest first, which keeps the cliffs a consistent
|
| 'west'
|
||||||
* width and stops a large piece from swallowing the interior.
|
| '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 canvas - the canvas.
|
||||||
* @param pieces - the level's border pieces, already filtered to `border`.
|
* @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')
|
stats.unresolved.push('no border piece for this level type')
|
||||||
return 0
|
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 areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y
|
||||||
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
|
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
|
||||||
return areaA - areaB || a.name.localeCompare(b.name)
|
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 cursor = rng.int(0, ordered.length - 1)
|
||||||
let stamped = 0
|
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
|
cursor = (cursor + 1) % ordered.length
|
||||||
|
}
|
||||||
|
if (!piece || piece.levels.length === 0) return
|
||||||
|
|
||||||
const variant = rng.int(0, piece.levels.length - 1)
|
const variant = rng.int(0, piece.levels.length - 1)
|
||||||
const level = piece.levels[variant % piece.levels.length]
|
const level = piece.levels[variant % piece.levels.length]
|
||||||
if (level === undefined) continue
|
if (level === undefined) return
|
||||||
// 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 maxOriginX = Math.max(canvas.width - level.width, 0)
|
||||||
const originX = Math.min(cell.x * TILES_PER_BLOCK, Math.max(canvas.width - level.width, 0))
|
const maxOriginY = Math.max(canvas.height - level.height, 0)
|
||||||
const originY = Math.min(cell.y * TILES_PER_BLOCK, 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)
|
stampDs1(canvas, level, originX, originY)
|
||||||
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
|
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
|
||||||
stats.borderStamped += 1
|
stats.borderStamped += 1
|
||||||
stamped += 1
|
stamped += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const cell of straightCells) stampCell(cell)
|
||||||
|
for (const cell of cornerCells) stampCell(cell)
|
||||||
|
|
||||||
return stamped
|
return stamped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue