fix(map-gen): fix Act 1 road/stone wall connectivity, Blood Moor river+bridge layout & Act 3 jungle river alignment

This commit is contained in:
troytt 2026-09-16 04:31:10 +00:00
parent 602565030f
commit 1f2f19641f
2 changed files with 489 additions and 190 deletions

View File

@ -769,8 +769,17 @@ export function selectBorderVariant(
isOpen: boolean, isOpen: boolean,
rng: Rng, rng: Rng,
): Ds1 { ): Ds1 {
const closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation)) // In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4),
const openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation)) // slots 0..2 are closed borders (a/b/c) and slots 3+ are open exit/gate borders (o/oe).
let closedLevels: readonly Ds1[]
let openLevels: readonly Ds1[]
if (piece.levels.length >= 4) {
closedLevels = piece.levels.slice(0, 3)
openLevels = piece.levels.slice(3)
} else {
closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation))
openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation))
}
if (isOpen && openLevels.length > 0) { if (isOpen && openLevels.length > 0) {
return openLevels[rng.int(0, openLevels.length - 1)]! return openLevels[rng.int(0, openLevels.length - 1)]!
@ -797,17 +806,9 @@ export function getPerimeterOpenings(
return openings return openings
} }
if (levelId === 2) { // Act 1 wilderness levels (2..7) & Act 4 Mesa wilderness levels (104..106):
// Blood Moor: exactly 1 entrance (South edge) and 1 exit (East edge) // entrance on South edge, exit on East edge
const inX = Math.floor(gridWidth / 2) if ((levelId >= 2 && levelId <= 7) || (levelId >= 104 && levelId <= 106)) {
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
// Act 1 wilderness levels (3..7): entrance on South, exit on East
if (levelId >= 3 && levelId <= 7) {
const inX = Math.floor(gridWidth / 2) const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1)) openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2) const outY = Math.floor(gridHeight / 2)
@ -1311,6 +1312,97 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
* @param stats - report collector. * @param stats - report collector.
* @returns set of piece names that were placed. * @returns set of piece names that were placed.
*/ */
/** Bounding rectangle of a placed special preset in canvas tile coordinates. */
export interface PlacedPresetRect {
readonly name: string
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
/**
* Stamp a continuous 2-block-wide (16-tile) North-South river stream across the level with
* a 2-block wooden bridge crossing at `by = outY` where the main East-West road passes.
*/
function stampAct1RiverAndBridge(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
occupied: boolean[][],
placedPieces: Set<string>,
placedRects: PlacedPresetRect[],
stats: WildernessStats,
): boolean {
const upperPiece = pieces.find(p => p.name === 'Act 1 - River Upper' || p.name.startsWith('Act 1 - River Upper'))
const lowerPiece = pieces.find(p => p.name === 'Act 1 - River Lower' || p.name.startsWith('Act 1 - River Lower'))
const bridgePiece = pieces.find(p => p.name === 'Act 1 - Bridge' || p.name.startsWith('Act 1 - Bridge'))
if (!upperPiece?.levels[0] || !lowerPiece?.levels[0] || !bridgePiece?.levels[0]) return false
// Place the 2-column vertical river stream just East of the central crossroads (e.g. blocks 6 & 7 on 10x10 grid)
const riverBlockX = Math.min(gridWidth - 3, inX + 1)
if (riverBlockX < 1 || riverBlockX + 1 >= gridWidth - 1) return false
for (let by = 1; by <= gridHeight - 2; by += 1) {
const tileX0 = riverBlockX * TILES_PER_BLOCK
const tileX1 = (riverBlockX + 1) * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
if (by === outY) {
// Main East-West road crossing: stamp BridgeU (west half) + BridgeL (east half)
const bridgeU = bridgePiece.levels[0]!
const bridgeL = bridgePiece.levels[1] ?? bridgePiece.levels[0]!
stampDs1(canvas, bridgeU, tileX0, tileY)
stampDs1(canvas, bridgeL, tileX1, tileY)
} else if (by === 1) {
// North river source/mouth (URiverN + LRiverN)
const uNorth = upperPiece.levels[1] ?? upperPiece.levels[0]!
const lNorth = lowerPiece.levels[1] ?? lowerPiece.levels[0]!
stampDs1(canvas, uNorth, tileX0, tileY)
stampDs1(canvas, lNorth, tileX1, tileY)
} else if (by === gridHeight - 2) {
// South river mouth (URiverS + LRiverS)
const uSouth = upperPiece.levels[2] ?? upperPiece.levels[0]!
const lSouth = lowerPiece.levels[2] ?? lowerPiece.levels[0]!
stampDs1(canvas, uSouth, tileX0, tileY)
stampDs1(canvas, lSouth, tileX1, tileY)
} else {
// Center continuous river channel (URiverC + LRiverC)
stampDs1(canvas, upperPiece.levels[0]!, tileX0, tileY)
stampDs1(canvas, lowerPiece.levels[0]!, tileX1, tileY)
}
occupied[by]![riverBlockX] = true
occupied[by]![riverBlockX + 1] = true
}
for (const name of [upperPiece.name, lowerPiece.name, bridgePiece.name]) {
placedPieces.add(name)
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
}
placedRects.push({
name: 'Act 1 - River',
x: riverBlockX * TILES_PER_BLOCK,
y: TILES_PER_BLOCK,
w: 2 * TILES_PER_BLOCK,
h: (gridHeight - 2) * TILES_PER_BLOCK,
})
return true
}
/**
* Scatter special presets across the level's interior.
*
* Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and
* `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates
* (excluding the outer 1-block border and any reserved main road corridors) are
* shuffled deterministically with the level's RNG, and each preset in sequence
* scans for the first free block rectangle where it can fit without overlapping
* already-occupied blocks or the perimeter.
*/
function spawnSpecialPresets( function spawnSpecialPresets(
canvas: Canvas, canvas: Canvas,
pieces: readonly WildernessPiece[], pieces: readonly WildernessPiece[],
@ -1319,12 +1411,14 @@ function spawnSpecialPresets(
gridHeight: number, gridHeight: number,
rng: Rng, rng: Rng,
stats: WildernessStats, stats: WildernessStats,
): Set<string> { reservedBlocks?: ReadonlySet<string>,
): { placedPieces: Set<string>; placedRects: PlacedPresetRect[] } {
const innerWidth = gridWidth - 2 const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2 const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>() const placedPieces = new Set<string>()
if (totalSlots <= 0 || presetNames.length === 0) return placedPieces const placedRects: PlacedPresetRect[] = []
if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects }
const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
for (let x = 0; x < gridWidth; x += 1) { for (let x = 0; x < gridWidth; x += 1) {
@ -1335,8 +1429,52 @@ function spawnSpecialPresets(
occupied[y]![0] = true occupied[y]![0] = true
occupied[y]![gridWidth - 1] = true occupied[y]![gridWidth - 1] = true
} }
if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) {
for (const key of reservedBlocks) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
if (occupied[by]?.[bx] !== undefined) {
occupied[by]![bx] = true
}
}
}
for (const presetName of presetNames) { // Handle continuous river + bridge layout for Blood Moor when requested on standard grids
let remainingPresets = [...presetNames]
const wantsRiver =
gridWidth >= 8 &&
gridHeight >= 6 &&
remainingPresets.some(n => n.startsWith('Act 1 - River Upper')) &&
remainingPresets.some(n => n.startsWith('Act 1 - River Lower')) &&
remainingPresets.some(n => n.startsWith('Act 1 - Bridge'))
if (wantsRiver) {
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
if (
stampAct1RiverAndBridge(
canvas,
pieces,
gridWidth,
gridHeight,
inX,
outY,
occupied,
placedPieces,
placedRects,
stats,
)
) {
remainingPresets = remainingPresets.filter(
n =>
!n.startsWith('Act 1 - River Upper') &&
!n.startsWith('Act 1 - River Lower') &&
!n.startsWith('Act 1 - Bridge'),
)
}
}
for (const presetName of remainingPresets) {
const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName)) const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName))
if (!piece || piece.levels.length === 0) continue if (!piece || piece.levels.length === 0) continue
@ -1376,7 +1514,9 @@ function spawnSpecialPresets(
} }
if (blocked) continue if (blocked) continue
stampDs1(canvas, ds1, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) const tileX = bx * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
stampDs1(canvas, ds1, tileX, tileY)
for (let dy = 0; dy < blocksY; dy += 1) { for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) { for (let dx = 0; dx < blocksX; dx += 1) {
@ -1391,6 +1531,13 @@ function spawnSpecialPresets(
clusters: 1, clusters: 1,
}) })
placedPieces.add(piece.name) placedPieces.add(piece.name)
placedRects.push({
name: piece.name,
x: tileX,
y: tileY,
w: ds1.width,
h: ds1.height,
})
placed = true placed = true
break break
} }
@ -1400,7 +1547,7 @@ function spawnSpecialPresets(
} }
} }
return placedPieces return { placedPieces, placedRects }
} }
/* ------------------------------------------------------------------------- * /* ------------------------------------------------------------------------- *
@ -1409,17 +1556,6 @@ function spawnSpecialPresets(
/** /**
* Derive the block grid for a level whose `Levels.txt` size is unset. * Derive the block grid for a level whose `Levels.txt` size is unset.
*
* `Act 5 - Barricade 1` and `2` ship `SizeX = SizeY = -1`. The engine does not
* read a size for them: `DRLGOUTSIEGE_ConnectBarricadeAndSiege` positions the
* barricade level against the siege level and sizes it from the barricade piece
* itself. The barricade piece is the `LvlSub` row with `GridSize = 2` whose name
* is `Barricade`, so its DS1's extent is the level's extent — that is what this
* reproduces.
*
* @param request - the request.
* @returns the size in cells and where it came from.
* @throws when no barricade piece is available to size the level from.
*/ */
function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } { function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } {
if (request.sizeOverride !== undefined) { if (request.sizeOverride !== undefined) {
@ -1433,30 +1569,8 @@ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: n
return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' } return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' }
} }
/**
* Generate one wilderness level.
*
* The pipeline follows `DRLGOUTDOORS_GenerateLevel`: derive the block grid from
* the level's declared size, lay the ground, walk the outer ring stamping the
* border pieces, then scatter the `LvlSub` substitutions according to
* `SubType`/`SubTheme`. What it does **not** do is the per-act work that decides
* the level's outline and where it meets its neighbours — see
* {@link UNIMPLEMENTED_PASSES} and the module header.
*
* @param request - the real table data for one `DrlgType == 3` level.
* @returns the synthesized map plus a report of what was done.
* @throws when the level cannot be built: a size that yields no usable grid, or
* an unset size with nothing to derive one from. The message always names the
* 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). * 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( function stampAct5SiegeStrips(
canvas: Canvas, canvas: Canvas,
@ -1571,6 +1685,13 @@ function stampAct3KurastCauseway(
/** /**
* Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78). * Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78).
*
* In `TravL.ds1` (`Jungle Tail` at `y = 0..31`) and `TransL.ds1` (`Jungle Head` at `y = 160..191`),
* the Kurast river channel flows through the East column (`x = 32..63`, water at `x = 38..60`).
* Placing North-South river presets with West path openings (`Act 3 - Jungle NS W` / `RivNSoW`) in the
* East column (`x = 32`) aligns the river channel (`32 + [5..28] = [37..60]`) seamlessly from `y = 0`
* to `y = 192`, while placing Clearing presets that open East in the West column (`x = 0`) connects
* every clearing across `x = 32` directly to the main riverside jungle trail.
*/ */
function stampAct3Jungle( function stampAct3Jungle(
canvas: Canvas, canvas: Canvas,
@ -1599,145 +1720,190 @@ function stampAct3Jungle(
: levelId === 77 : levelId === 77
? 'Act 3 - Clearing Boggy' ? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy' : 'Act 3 - Clearing Pygmy'
const clearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0) const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
const riverPieces = pieces.filter( // Prefer clearings that open East (` E` or ` EW` or ` SE` or ` NE`) to meet the East river column
const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name))
const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces
const allRiverPieces = pieces.filter(
p => p =>
p.name.startsWith('Act 3 - Jungle ') && p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' && p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' && p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0, p.levels.length > 0,
) )
// Prefer North-South river segments that open West (`NS W` / `NS`) to align with Tail/Head at x=32
const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name))
const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces
for (let row = 0; row < 4; row += 1) { for (let row = 0; row < 4; row += 1) {
const y = 32 + row * 32 const y = 32 + row * 32
const riverOnLeft = (row + rng.int(0, 1)) % 2 === 0 // West column (x = 0): Jungle Clearing opening East
for (let col = 0; col < 2; col += 1) { if (clearingPool.length > 0) {
const x = col * 32 const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
const isRiverSlot = (col === 0 && riverOnLeft) || (col === 1 && !riverOnLeft) const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
const pool = isRiverSlot || clearingPieces.length === 0 ? riverPieces : clearingPieces stampDs1(canvas, ds1, 0, y)
if (pool.length === 0) continue placed.add(chosenClearing.name)
const chosen = pool[rng.int(0, pool.length - 1)]! stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! }
stampDs1(canvas, ds1, x, y) // East column (x = 32): Continuous North-South River channel
placed.add(chosen.name) if (riverPool.length > 0) {
stats.substitutions.push({ name: chosen.name, role: 'object', enabled: true, clusters: 1 }) const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]!
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
stampDs1(canvas, ds1, 32, y)
placed.add(chosenRiver.name)
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
} }
} }
return placed return placed
} }
/** /**
* Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit, * Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`),
* and interior landmarks in Act 1 wilderness levels (LevelId 2..7). * the Central Crossroads (`hubX, hubY`), the River Bridge (if present), and the East Border Gate
* (`Bord4o.ds1` at `y = outY*8 + 3..5`), plus 2-tile-wide spur paths to every spawned landmark.
*/ */
function stampAct1DirtRoads(canvas: Canvas, rng: Rng): void { function stampAct1DirtRoads(
const roadFloor: Ds1Floor = { canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
const makeRoadFloor = (): Ds1Floor => ({
prop1: 194, prop1: 194,
sequence: 0, sequence: rng.int(0, 6) === 0 ? 25 : 0,
style: 5, style: 5,
unknown1: 0, unknown1: 0,
unknown2: 0, unknown2: 0,
hidden: false, hidden: false,
})
const isInsideProtectedPreset = (x: number, y: number): boolean => {
for (const r of placedRects) {
if (r.name === 'Act 1 - River') continue
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
return true
}
}
return false
} }
const paintRoadCell = (cx: number, cy: number): void => { const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => {
for (let dy = 0; dy <= 1; dy += 1) { if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return
for (let dx = 0; dx <= 1; dx += 1) { if (isInsideProtectedPreset(x, y)) return
const x = cx + dx const cell = canvas.cells[y]?.[x]
const y = cy + dy if (!cell) return
if (x < 8 || x >= canvas.width - 8 || y < 8 || y >= canvas.height - 8) continue const existingFloor = cell.floors[0]
const cell = canvas.cells[y]?.[x] // Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`)
if (!cell) continue if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) {
// Do not overwrite cells that already carry walls or water/bridge floors return
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) continue }
const existingFloor = cell.floors[0] if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
if (existingFloor && existingFloor.style !== 0) continue if (clearStrayClutter) {
;(cell.floors as Ds1Floor[])[0] = roadFloor // Clear random LvlSub bush/tree clutter on the main highway spine
;(cell.walls as Ds1Wall[]).length = 0
} else {
return
} }
} }
;(cell.floors as Ds1Floor[])[0] = makeRoadFloor()
}
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3
const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3
const northEnd = Math.max(12, Math.floor(canvas.height * 0.2))
const westEnd = Math.max(12, Math.floor(canvas.width * 0.2))
// 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`)
for (let y = northEnd; y <= southGateY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintRoadTile(hubX + dx, y, true)
}
} }
const connectPoints = (x0: number, y0: number, x1: number, y1: number): void => { // 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`)
for (let x = westEnd; x <= eastGateX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
paintRoadTile(x, hubY + dy, true)
}
}
// 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway
const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => {
let curX = x0 let curX = x0
let curY = y0 let curY = y0
let steps = 0 let steps = 0
const maxSteps = canvas.width + canvas.height + 40 const maxSteps = canvas.width + canvas.height
while ((Math.abs(curX - x1) > 1 || Math.abs(curY - y1) > 1) && steps < maxSteps) { while ((curX !== x1 || curY !== y1) && steps < maxSteps) {
paintRoadCell(curX, curY) for (let dy = 0; dy <= 1; dy += 1) {
const dx = x1 - curX for (let dx = 0; dx <= 1; dx += 1) {
const dy = y1 - curY paintRoadTile(curX + dx, curY + dy, false)
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
} }
if (curX !== x1) curX += x1 > curX ? 1 : -1
else if (curY !== y1) curY += y1 > curY ? 1 : -1
steps += 1 steps += 1
} }
paintRoadCell(x1, y1)
} }
const hubX = Math.floor(canvas.width / 2) + rng.int(-5, 5) for (const r of placedRects) {
const hubY = Math.floor(canvas.height / 2) + rng.int(-5, 5) if (
const southX = Math.floor(canvas.width / 2) r.name === 'Act 1 - River' ||
const southY = canvas.height - 9 /Stone Fill|Swamp Fill|Tree Fill/i.test(r.name)
const eastX = canvas.width - 9 ) {
const eastY = Math.floor(canvas.height / 2) continue
const nwX = Math.floor(canvas.width * 0.28) }
const nwY = Math.floor(canvas.height * 0.28) const centerX = Math.floor(r.x + r.w / 2)
const swX = Math.floor(canvas.width * 0.28) const centerY = Math.floor(r.y + r.h / 2)
const swY = Math.floor(canvas.height * 0.72) // Pick the edge of the preset closest to the main crossroads
const distToNS = Math.abs(centerX - hubX)
connectPoints(southX, southY, hubX, hubY) const distToEW = Math.abs(centerY - hubY)
connectPoints(hubX, hubY, eastX, eastY) if (distToNS <= distToEW) {
connectPoints(hubX, hubY, nwX, nwY) const doorX = centerX < hubX ? r.x + r.w : r.x - 1
connectPoints(hubX, hubY, swX, swY) const doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY))
paintSpurSegment(doorX, doorY, hubX, doorY)
} else {
const doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX))
const doorY = centerY < hubY ? r.y + r.h : r.y - 1
paintSpurSegment(doorX, doorY, doorX, hubY)
}
}
} }
/** /**
* Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1 * 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 * wilderness levels (LevelId 2..7), using exact Blizzard tile sequences:
* archway gate openings where roads pass through. * - Continuous East-West wall: `style=2, sequence=0, type=2` (`s2.q0.t2`)
* - Continuous North-South wall: `style=2, sequence=0, type=1` (`s2.q0.t1`)
* - Corner intersection: `style=2, sequence=0, type=3` (`s2.q0.t3`)
* - East-West gate end-caps: `sequence=1, type=2` (West/Right-of-gap) & `sequence=2, type=2` (East/Left-of-gap)
* - North-South gate end-caps: `sequence=1, type=1` (North/Bottom-of-gap) & `sequence=2, type=1` (South/Top-of-gap)
*/ */
function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeight: number, rng: Rng): void { function stampAct1InteriorStoneWalls(
const ewWall = (seq: number): Ds1Wall => ({ canvas: Canvas,
prop1: 129, gridWidth: number,
sequence: seq % 3, gridHeight: number,
style: 2, inX: number,
type: 2, outY: number,
unknown1: 0, placedRects: readonly PlacedPresetRect[],
unknown2: 0, rng: Rng,
hidden: false, ): void {
}) if (gridWidth < 8 || gridHeight < 8) return
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 => { const makeWall = (type: number, sequence: number): Ds1Wall => ({
if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return false prop1: 129,
const cell = canvas.cells[y]?.[x] sequence,
if (!cell) return false style: 2,
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false type,
// Leave gate openings wherever a dirt road (`style === 5`) or water crosses unknown1: 0,
if (cell.floors.some(f => !f.hidden && f.style !== 0)) return false unknown2: 0,
return true hidden: false,
} })
const placeWall = (x: number, y: number, wall: Ds1Wall): void => { const placeWall = (x: number, y: number, wall: Ds1Wall): void => {
const cell = canvas.cells[y]?.[x] const cell = canvas.cells[y]?.[x]
@ -1750,50 +1916,98 @@ function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeig
} }
} }
// Primary horizontal (East-West) stone wall divider near the middle of the map // Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine
const midBlockY = Math.floor(gridHeight / 2) + (rng.int(0, 1) === 0 ? -1 : 1) const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2
const wallY = Math.max(16, Math.min(canvas.height - 16, midBlockY * TILES_PER_BLOCK)) const offsetBlockX = rng.int(0, 1) === 0 ? -2 : 2
const wallY = Math.max(16, Math.min(canvas.height - 16, (outY + offsetBlockY) * TILES_PER_BLOCK))
const wallX = Math.max(16, Math.min(canvas.width - 16, (inX + offsetBlockX) * TILES_PER_BLOCK))
// Primary vertical (North-South) stone wall divider const isCellBlockedForWall = (x: number, y: number): boolean => {
const midBlockX = Math.floor(gridWidth / 2) + (rng.int(0, 1) === 0 ? -1 : 1) if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return true
const wallX = Math.max(16, Math.min(canvas.width - 16, midBlockX * TILES_PER_BLOCK)) // Leave 2-tile margin around all placed special presets (houses, caves, rivers, corrals)
for (const r of placedRects) {
// Draw East-West stone wall line with periodic gate gaps if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) {
let prevPlacedEW = false return true
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) { // Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes
placeWall(x, wallY, cornerPillar) for (let dy = -1; dy <= 1; dy += 1) {
} else { for (let dx = -1; dx <= 1; dx += 1) {
placeWall(x, wallY, ewWall(x)) const c = canvas.cells[y + dy]?.[x + dx]
if (!c) continue
if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 4 || f.style === 2 || f.style === 3))) {
return true
}
}
} }
prevPlacedEW = true const cell = canvas.cells[y]?.[x]
if (!cell) return true
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return true
return false
} }
// Draw North-South stone wall line with periodic gate gaps // Build valid tile masks along wallY (EW) and wallX (NS), inserting periodic pasture gates
let prevPlacedNS = false const validEW: boolean[] = Array.from({ length: canvas.width }, (_, x) => {
for (let y = 11; y < canvas.height - 11; y += 1) { const isPastureGate = (x % 24) >= 11 && (x % 24) <= 13 && Math.abs(x - wallX) > 3
const isGate = (y % 18) >= 7 && (y % 18) <= 10 return !isPastureGate && !isCellBlockedForWall(x, wallY)
if (isGate || Math.abs(y - wallY) <= 1 || !canPlaceWall(wallX, y)) { })
if (prevPlacedNS && canPlaceWall(wallX, y - 1)) { const validNS: boolean[] = Array.from({ length: canvas.height }, (_, y) => {
placeWall(wallX, y - 1, cornerPillar) const isPastureGate = (y % 24) >= 11 && (y % 24) <= 13 && Math.abs(y - wallY) > 3
return !isPastureGate && !isCellBlockedForWall(wallX, y)
})
const extractRuns = (mask: readonly boolean[]): Array<{ start: number; end: number }> => {
const runs: Array<{ start: number; end: number }> = []
let start = -1
for (let i = 0; i < mask.length; i += 1) {
if (mask[i]) {
if (start === -1) start = i
} else if (start !== -1) {
if (i - start >= 4) runs.push({ start, end: i - 1 })
start = -1
} }
prevPlacedNS = false
continue
} }
if (!prevPlacedNS) { if (start !== -1 && mask.length - start >= 4) {
placeWall(wallX, y, cornerPillar) runs.push({ start, end: mask.length - 1 })
} else { }
placeWall(wallX, y, nsWall(y)) return runs
}
const ewRuns = extractRuns(validEW)
const nsRuns = extractRuns(validNS)
const ewCoversCorner = ewRuns.some(r => wallX >= r.start && wallX <= r.end)
const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end)
const hasCornerJoin = ewCoversCorner && nsCoversCorner
// Stamp East-West stone wall segments (`type = 2`)
for (const run of ewRuns) {
for (let x = run.start; x <= run.end; x += 1) {
if (x === wallX && hasCornerJoin) {
placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`)
} else if (x === run.start) {
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`s2.q1.t2`)
} else if (x === run.end) {
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`s2.q2.t2`)
} else {
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Stone Wall (`s2.q0.t2`)
}
}
}
// Stamp North-South stone wall segments (`type = 1`)
for (const run of nsRuns) {
for (let y = run.start; y <= run.end; y += 1) {
if (y === wallY && hasCornerJoin) {
placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`)
} else if (y === run.start) {
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`s2.q1.t1`)
} else if (y === run.end) {
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`s2.q2.t1`)
} else {
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Stone Wall (`s2.q0.t1`)
}
} }
prevPlacedNS = true
} }
} }
@ -2034,15 +2248,25 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? [] specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
} }
} }
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats) const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and const reservedRoadBlocks = new Set<string>()
// interior stone wall divider lines across the meadow.
if (request.levelId >= 2 && request.levelId <= 7) { if (request.levelId >= 2 && request.levelId <= 7) {
stampAct1DirtRoads(canvas, rng) for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, rng) for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`)
} }
const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets(
canvas,
fillPieces,
specialPresetNames,
gridWidth,
gridHeight,
rng,
stats,
reservedRoadBlocks,
)
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme))) 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 objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])] const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
@ -2058,6 +2282,14 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
rng, rng,
stats, stats,
) )
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
// interior stone wall divider lines across the meadow after random clutter substitutions.
if (request.levelId >= 2 && request.levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
}
for (const piece of fillPieces) { for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) { if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`) stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)

View File

@ -325,4 +325,71 @@ describe("Wilderness Features & Border Rules", () => {
expect(result.stats.substitutedClusters).toBe(6) expect(result.stats.substitutedClusters).toBe(6)
expect(result.level.objects.length).toBe(6) expect(result.level.objects.length).toBe(6)
}) })
test("Act 1 Blood Moor (Level 2) connects South/East border gates with 3-tile road and uses authentic stone wall sequences", () => {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
const presetDs1 = makeMockDs1(16, 16, false)
const riverDs1 = makeMockDs1(8, 8, false)
const pieces: WildernessPiece[] = [
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [borderDs1] },
{ name: "Act 1 - DOE Entrance", border: false, levels: [presetDs1] },
{ name: "Act 1 - River Upper", border: false, levels: [riverDs1, riverDs1, riverDs1] },
{ name: "Act 1 - River Lower", border: false, levels: [riverDs1, riverDs1, riverDs1] },
{ name: "Act 1 - Bridge", border: false, levels: [riverDs1, riverDs1] },
{ name: "Act 1 - Cottages 1", border: false, levels: [presetDs1] },
]
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: -1,
subTheme: -1,
seed: 1234,
pieces,
substitutions: [],
specialPresets: [
"Act 1 - DOE Entrance",
"Act 1 - River Upper",
"Act 1 - River Lower",
"Act 1 - Bridge",
"Act 1 - Cottages 1",
],
})
// 1. Verify 3-tile wide main highway at central crossroads (44, 44)
expect(result.level.cells[44]![44]!.floors[0]!.style).toBe(5)
expect(result.level.cells[44]![43]!.floors[0]!.style).toBe(5)
expect(result.level.cells[44]![45]!.floors[0]!.style).toBe(5)
// 2. Verify South border gate road connection at (44, 75) and East border gate connection at (75, 44)
expect(result.level.cells[75]![44]!.floors[0]!.style).toBe(5)
expect(result.level.cells[44]![75]!.floors[0]!.style).toBe(5)
// 3. Verify interior stone wall segments (style=2, prop1=129) use continuous sequence=0 and end-caps sequence=1/2
let foundContinuousEW = false
let foundEndCapEW = false
for (let y = 10; y < 70; y += 1) {
for (let x = 10; x < 70; x += 1) {
const wall = result.level.cells[y]![x]!.walls.find(w => w.style === 2 && w.prop1 === 129)
if (wall && wall.type === 2) {
if (wall.sequence === 0) foundContinuousEW = true
if (wall.sequence === 1 || wall.sequence === 2) foundEndCapEW = true
}
}
}
expect(foundContinuousEW).toBe(true)
expect(foundEndCapEW).toBe(true)
})
}) })