From 6b9c3d34306cdcc4b57dae85005c7f7a4992904c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 15 Sep 2026 02:30:31 +0000 Subject: [PATCH] fix(generator): orient wilderness borders and restore rock cliff boundaries --- scripts/verify-generators.ts | 2 +- src/game/wilderness.ts | 201 ++++++++++++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 18 deletions(-) diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index 68556cb..9dae4ce 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -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') diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 01093d3..ce714e2 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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 }