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:
parent
602565030f
commit
1f2f19641f
|
|
@ -769,8 +769,17 @@ export function selectBorderVariant(
|
|||
isOpen: boolean,
|
||||
rng: Rng,
|
||||
): Ds1 {
|
||||
const closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation))
|
||||
const openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation))
|
||||
// In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4),
|
||||
// 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) {
|
||||
return openLevels[rng.int(0, openLevels.length - 1)]!
|
||||
|
|
@ -797,17 +806,9 @@ export function getPerimeterOpenings(
|
|||
return openings
|
||||
}
|
||||
|
||||
if (levelId === 2) {
|
||||
// Blood Moor: exactly 1 entrance (South edge) and 1 exit (East edge)
|
||||
const inX = Math.floor(gridWidth / 2)
|
||||
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) {
|
||||
// Act 1 wilderness levels (2..7) & Act 4 Mesa wilderness levels (104..106):
|
||||
// entrance on South edge, exit on East edge
|
||||
if ((levelId >= 2 && levelId <= 7) || (levelId >= 104 && levelId <= 106)) {
|
||||
const inX = Math.floor(gridWidth / 2)
|
||||
openings.add(key(inX, gridHeight - 1))
|
||||
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.
|
||||
* @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(
|
||||
canvas: Canvas,
|
||||
pieces: readonly WildernessPiece[],
|
||||
|
|
@ -1319,12 +1411,14 @@ function spawnSpecialPresets(
|
|||
gridHeight: number,
|
||||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
): Set<string> {
|
||||
reservedBlocks?: ReadonlySet<string>,
|
||||
): { placedPieces: Set<string>; placedRects: PlacedPresetRect[] } {
|
||||
const innerWidth = gridWidth - 2
|
||||
const innerHeight = gridHeight - 2
|
||||
const totalSlots = innerWidth * innerHeight
|
||||
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))
|
||||
for (let x = 0; x < gridWidth; x += 1) {
|
||||
|
|
@ -1335,8 +1429,52 @@ function spawnSpecialPresets(
|
|||
occupied[y]![0] = 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))
|
||||
if (!piece || piece.levels.length === 0) continue
|
||||
|
||||
|
|
@ -1376,7 +1514,9 @@ function spawnSpecialPresets(
|
|||
}
|
||||
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 dx = 0; dx < blocksX; dx += 1) {
|
||||
|
|
@ -1391,6 +1531,13 @@ function spawnSpecialPresets(
|
|||
clusters: 1,
|
||||
})
|
||||
placedPieces.add(piece.name)
|
||||
placedRects.push({
|
||||
name: piece.name,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
w: ds1.width,
|
||||
h: ds1.height,
|
||||
})
|
||||
placed = true
|
||||
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.
|
||||
*
|
||||
* `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 } {
|
||||
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' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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(
|
||||
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).
|
||||
*
|
||||
* 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(
|
||||
canvas: Canvas,
|
||||
|
|
@ -1599,145 +1720,190 @@ function stampAct3Jungle(
|
|||
: levelId === 77
|
||||
? 'Act 3 - Clearing Boggy'
|
||||
: 'Act 3 - Clearing Pygmy'
|
||||
const clearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
|
||||
const riverPieces = pieces.filter(
|
||||
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
|
||||
// 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.name.startsWith('Act 3 - Jungle ') &&
|
||||
p.name !== 'Act 3 - Jungle Head' &&
|
||||
p.name !== 'Act 3 - Jungle Tail' &&
|
||||
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) {
|
||||
const y = 32 + row * 32
|
||||
const riverOnLeft = (row + rng.int(0, 1)) % 2 === 0
|
||||
for (let col = 0; col < 2; col += 1) {
|
||||
const x = col * 32
|
||||
const isRiverSlot = (col === 0 && riverOnLeft) || (col === 1 && !riverOnLeft)
|
||||
const pool = isRiverSlot || clearingPieces.length === 0 ? riverPieces : clearingPieces
|
||||
if (pool.length === 0) continue
|
||||
const chosen = pool[rng.int(0, pool.length - 1)]!
|
||||
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
|
||||
stampDs1(canvas, ds1, x, y)
|
||||
placed.add(chosen.name)
|
||||
stats.substitutions.push({ name: chosen.name, role: 'object', enabled: true, clusters: 1 })
|
||||
// West column (x = 0): Jungle Clearing opening East
|
||||
if (clearingPool.length > 0) {
|
||||
const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
|
||||
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
|
||||
stampDs1(canvas, ds1, 0, y)
|
||||
placed.add(chosenClearing.name)
|
||||
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
|
||||
}
|
||||
// East column (x = 32): Continuous North-South River channel
|
||||
if (riverPool.length > 0) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit,
|
||||
* and interior landmarks in Act 1 wilderness levels (LevelId 2..7).
|
||||
* Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`),
|
||||
* 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 {
|
||||
const roadFloor: Ds1Floor = {
|
||||
function stampAct1DirtRoads(
|
||||
canvas: Canvas,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: readonly PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
): void {
|
||||
const makeRoadFloor = (): Ds1Floor => ({
|
||||
prop1: 194,
|
||||
sequence: 0,
|
||||
sequence: rng.int(0, 6) === 0 ? 25 : 0,
|
||||
style: 5,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
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 => {
|
||||
for (let dy = 0; dy <= 1; dy += 1) {
|
||||
for (let dx = 0; dx <= 1; dx += 1) {
|
||||
const x = cx + dx
|
||||
const y = cy + dy
|
||||
if (x < 8 || x >= canvas.width - 8 || y < 8 || y >= canvas.height - 8) continue
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
// Do not overwrite cells that already carry walls or water/bridge floors
|
||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) continue
|
||||
const existingFloor = cell.floors[0]
|
||||
if (existingFloor && existingFloor.style !== 0) continue
|
||||
;(cell.floors as Ds1Floor[])[0] = roadFloor
|
||||
const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => {
|
||||
if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return
|
||||
if (isInsideProtectedPreset(x, y)) return
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return
|
||||
const existingFloor = cell.floors[0]
|
||||
// Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`)
|
||||
if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) {
|
||||
return
|
||||
}
|
||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
|
||||
if (clearStrayClutter) {
|
||||
// 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 curY = y0
|
||||
let steps = 0
|
||||
const maxSteps = canvas.width + canvas.height + 40
|
||||
while ((Math.abs(curX - x1) > 1 || Math.abs(curY - y1) > 1) && steps < maxSteps) {
|
||||
paintRoadCell(curX, curY)
|
||||
const dx = x1 - curX
|
||||
const dy = y1 - curY
|
||||
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
|
||||
const maxSteps = canvas.width + canvas.height
|
||||
while ((curX !== x1 || curY !== y1) && steps < maxSteps) {
|
||||
for (let dy = 0; dy <= 1; dy += 1) {
|
||||
for (let dx = 0; dx <= 1; dx += 1) {
|
||||
paintRoadTile(curX + dx, curY + dy, false)
|
||||
}
|
||||
}
|
||||
if (curX !== x1) curX += x1 > curX ? 1 : -1
|
||||
else if (curY !== y1) curY += y1 > curY ? 1 : -1
|
||||
steps += 1
|
||||
}
|
||||
paintRoadCell(x1, y1)
|
||||
}
|
||||
|
||||
const hubX = Math.floor(canvas.width / 2) + rng.int(-5, 5)
|
||||
const hubY = Math.floor(canvas.height / 2) + rng.int(-5, 5)
|
||||
const southX = Math.floor(canvas.width / 2)
|
||||
const southY = canvas.height - 9
|
||||
const eastX = canvas.width - 9
|
||||
const eastY = Math.floor(canvas.height / 2)
|
||||
const nwX = Math.floor(canvas.width * 0.28)
|
||||
const nwY = Math.floor(canvas.height * 0.28)
|
||||
const swX = Math.floor(canvas.width * 0.28)
|
||||
const swY = Math.floor(canvas.height * 0.72)
|
||||
|
||||
connectPoints(southX, southY, hubX, hubY)
|
||||
connectPoints(hubX, hubY, eastX, eastY)
|
||||
connectPoints(hubX, hubY, nwX, nwY)
|
||||
connectPoints(hubX, hubY, swX, swY)
|
||||
for (const r of placedRects) {
|
||||
if (
|
||||
r.name === 'Act 1 - River' ||
|
||||
/Stone Fill|Swamp Fill|Tree Fill/i.test(r.name)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const centerX = Math.floor(r.x + r.w / 2)
|
||||
const centerY = Math.floor(r.y + r.h / 2)
|
||||
// Pick the edge of the preset closest to the main crossroads
|
||||
const distToNS = Math.abs(centerX - hubX)
|
||||
const distToEW = Math.abs(centerY - hubY)
|
||||
if (distToNS <= distToEW) {
|
||||
const doorX = centerX < hubX ? r.x + r.w : r.x - 1
|
||||
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
|
||||
* wilderness levels (LevelId 2..7), creating classic stone-walled courtyards and meadows with
|
||||
* archway gate openings where roads pass through.
|
||||
* wilderness levels (LevelId 2..7), using exact Blizzard tile sequences:
|
||||
* - 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 {
|
||||
const ewWall = (seq: number): Ds1Wall => ({
|
||||
prop1: 129,
|
||||
sequence: seq % 3,
|
||||
style: 2,
|
||||
type: 2,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
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,
|
||||
}
|
||||
function stampAct1InteriorStoneWalls(
|
||||
canvas: Canvas,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
inX: number,
|
||||
outY: number,
|
||||
placedRects: readonly PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
): void {
|
||||
if (gridWidth < 8 || gridHeight < 8) return
|
||||
|
||||
const canPlaceWall = (x: number, y: number): boolean => {
|
||||
if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return false
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return false
|
||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
|
||||
// Leave gate openings wherever a dirt road (`style === 5`) or water crosses
|
||||
if (cell.floors.some(f => !f.hidden && f.style !== 0)) return false
|
||||
return true
|
||||
}
|
||||
const makeWall = (type: number, sequence: number): Ds1Wall => ({
|
||||
prop1: 129,
|
||||
sequence,
|
||||
style: 2,
|
||||
type,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
})
|
||||
|
||||
const placeWall = (x: number, y: number, wall: Ds1Wall): void => {
|
||||
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
|
||||
const midBlockY = Math.floor(gridHeight / 2) + (rng.int(0, 1) === 0 ? -1 : 1)
|
||||
const wallY = Math.max(16, Math.min(canvas.height - 16, midBlockY * TILES_PER_BLOCK))
|
||||
// Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine
|
||||
const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2
|
||||
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 midBlockX = Math.floor(gridWidth / 2) + (rng.int(0, 1) === 0 ? -1 : 1)
|
||||
const wallX = Math.max(16, Math.min(canvas.width - 16, midBlockX * TILES_PER_BLOCK))
|
||||
|
||||
// Draw East-West stone wall line with periodic gate gaps
|
||||
let prevPlacedEW = false
|
||||
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)
|
||||
const isCellBlockedForWall = (x: number, y: number): boolean => {
|
||||
if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return true
|
||||
// Leave 2-tile margin around all placed special presets (houses, caves, rivers, corrals)
|
||||
for (const r of placedRects) {
|
||||
if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) {
|
||||
return true
|
||||
}
|
||||
prevPlacedEW = false
|
||||
continue
|
||||
}
|
||||
if (!prevPlacedEW) {
|
||||
placeWall(x, wallY, cornerPillar)
|
||||
} else {
|
||||
placeWall(x, wallY, ewWall(x))
|
||||
// Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
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
|
||||
let prevPlacedNS = false
|
||||
for (let y = 11; y < canvas.height - 11; y += 1) {
|
||||
const isGate = (y % 18) >= 7 && (y % 18) <= 10
|
||||
if (isGate || Math.abs(y - wallY) <= 1 || !canPlaceWall(wallX, y)) {
|
||||
if (prevPlacedNS && canPlaceWall(wallX, y - 1)) {
|
||||
placeWall(wallX, y - 1, cornerPillar)
|
||||
// Build valid tile masks along wallY (EW) and wallX (NS), inserting periodic pasture gates
|
||||
const validEW: boolean[] = Array.from({ length: canvas.width }, (_, x) => {
|
||||
const isPastureGate = (x % 24) >= 11 && (x % 24) <= 13 && Math.abs(x - wallX) > 3
|
||||
return !isPastureGate && !isCellBlockedForWall(x, wallY)
|
||||
})
|
||||
const validNS: boolean[] = Array.from({ length: canvas.height }, (_, y) => {
|
||||
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) {
|
||||
placeWall(wallX, y, cornerPillar)
|
||||
} else {
|
||||
placeWall(wallX, y, nsWall(y))
|
||||
if (start !== -1 && mask.length - start >= 4) {
|
||||
runs.push({ start, end: mask.length - 1 })
|
||||
}
|
||||
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] ?? []
|
||||
}
|
||||
}
|
||||
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
|
||||
|
||||
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
|
||||
// interior stone wall divider lines across the meadow.
|
||||
const inX = Math.floor(gridWidth / 2)
|
||||
const outY = Math.floor(gridHeight / 2)
|
||||
const reservedRoadBlocks = new Set<string>()
|
||||
if (request.levelId >= 2 && request.levelId <= 7) {
|
||||
stampAct1DirtRoads(canvas, rng)
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, rng)
|
||||
for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`)
|
||||
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 objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
|
||||
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
|
||||
|
|
@ -2058,6 +2282,14 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
rng,
|
||||
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) {
|
||||
if (!placedSpecialPieces.has(piece.name)) {
|
||||
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
|
||||
|
|
|
|||
|
|
@ -325,4 +325,71 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(result.stats.substitutedClusters).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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue