diablo2-web/src/game/wilderness-jungles.ts

714 lines
26 KiB
TypeScript

/**
* Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`).
*
* In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77),
* and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets.
* Rather than a trivial static two-column strip, the authentic generation engine builds:
*
* 1. Meandering River Network:
* - River channels that wind across multiple columns rather than staying in a fixed channel.
* - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`).
* 2. River Forks / Junctions:
* - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`).
* 3. River Island Clearings:
* - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves.
* 4. Wooden Bridges:
* - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks.
* 5. Branching Multi-Zone Topology:
* - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78).
* - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle,
* allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh
* serves as the mandatory throughway.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, PlacedPresetRect, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
/** Connection between two Act 3 zones. */
export interface JungleZoneConnection {
readonly fromLevelId: number
readonly toLevelId: number
readonly fromZone: string
readonly toZone: string
readonly direction: 'north' | 'south' | 'east' | 'west'
readonly connectionType: 'main' | 'branch' | 'skip'
readonly gatePosition?: { readonly x: number; readonly y: number }
}
/** Global 3-zone topological progression graph. */
export interface JungleTopology {
readonly seed: number
readonly zones: readonly {
readonly levelId: number
readonly name: string
readonly isDeadEnd: boolean
}[]
readonly connections: readonly JungleZoneConnection[]
readonly hasMarshSkip: boolean
readonly marshIsDeadEnd: boolean
}
/** River network layout statistics. */
export interface JungleRiverNetwork {
readonly meanderingPath: readonly { readonly col: number; readonly row: number }[]
readonly meanderCount: number
readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[]
readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[]
}
/** Complete layout statistics for an Act 3 Jungle level. */
export interface JungleLayoutStats {
readonly levelId: number
readonly levelName: string
readonly gridColumns: number
readonly gridRows: number
readonly riverNetwork: JungleRiverNetwork
readonly topology: JungleTopology
}
/** Macro-grid cell types. */
export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail'
/** Macro-grid cell representation. */
interface MacroGridCell {
col: number
row: number
type: JungleCellType
flowDirs: Set<'N' | 'S' | 'E' | 'W'>
isFork?: boolean
isIsland?: boolean
pieceName?: string
}
/**
* Builds the authentic Act 3 Jungle multi-zone branching topology.
*
* In Diablo II:
* - Spider Forest (76) begins at Kurast Docks (75).
* - Spider Forest always connects to Great Marsh (77).
* - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip).
* - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle.
* - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability).
*/
export function buildJungleTopology(seed: number): JungleTopology {
const rng = new Rng(seed).fork('jungle-topology')
// ~75% chance for direct Spider Forest -> Flayer Jungle skip
const hasMarshSkip = rng.int(0, 3) !== 0
// If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end.
const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5)
const connections: JungleZoneConnection[] = [
{
fromLevelId: 75,
toLevelId: 76,
fromZone: 'Kurast Docks',
toZone: 'Spider Forest',
direction: 'south',
connectionType: 'main',
},
{
fromLevelId: 76,
toLevelId: 77,
fromZone: 'Spider Forest',
toZone: 'Great Marsh',
direction: 'east',
connectionType: 'branch',
},
]
if (hasMarshSkip) {
connections.push({
fromLevelId: 76,
toLevelId: 78,
fromZone: 'Spider Forest',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: 'skip',
})
}
if (!marshIsDeadEnd) {
connections.push({
fromLevelId: 77,
toLevelId: 78,
fromZone: 'Great Marsh',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: hasMarshSkip ? 'branch' : 'main',
})
}
connections.push({
fromLevelId: 78,
toLevelId: 79,
fromZone: 'Flayer Jungle',
toZone: 'Lower Kurast',
direction: 'north',
connectionType: 'main',
})
const zones = [
{ levelId: 76, name: 'Spider Forest', isDeadEnd: false },
{ levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd },
{ levelId: 78, name: 'Flayer Jungle', isDeadEnd: false },
]
return {
seed,
zones,
connections,
hasMarshSkip,
marshIsDeadEnd,
}
}
/**
* Finds the most suitable directional river piece from available pieces.
*/
function pickRiverPiece(
pieces: readonly WildernessPiece[],
flowDirs: Set<'N' | 'S' | 'E' | 'W'>,
rng: Rng,
): WildernessPiece | null {
const riverPieces = 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,
)
if (riverPieces.length === 0) return null
const requiredTokens = Array.from(flowDirs)
const tokenStr = requiredTokens.sort().join('')
// 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.)
const exactMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
const tokens = rest.split(/\s+/)
return tokens.some(t => t.toUpperCase() === tokenStr)
})
if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]!
// 2. Subset / superset match containing all required directions
const supersetMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
return requiredTokens.every(d => rest.includes(d))
})
if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]!
// 3. Fallback to any river piece
return riverPieces[rng.int(0, riverPieces.length - 1)]!
}
/**
* Finds the most suitable clearing piece matching the Act 3 sub-theme.
*/
function pickClearingPiece(
pieces: readonly WildernessPiece[],
levelId: number,
preferredOpening: 'E' | 'W' | 'N' | 'S' | null,
rng: Rng,
): WildernessPiece | null {
const prefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0)
if (pool.length === 0) {
pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0)
}
if (pool.length === 0) return null
if (preferredOpening) {
const openingMatches = pool.filter(p => p.name.includes(preferredOpening))
if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]!
}
return pool[rng.int(0, pool.length - 1)]!
}
/**
* Stamps wooden bridge floor tiles directly across the water channel.
*/
function synthesizeBridgePlanks(
canvas: Canvas,
bridgeX: number,
bridgeY: number,
orientation: 'horizontal' | 'vertical',
bridgeDs1?: Ds1,
): void {
const makeBridgeFloor = (): Ds1Floor => ({
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// A 4-tile wide plank walkway bridging across the 32-tile river channel
if (orientation === 'horizontal') {
for (let dy = 14; dy <= 17; dy += 1) {
for (let dx = 0; dx < 32; dx += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
} else {
for (let dx = 14; dx <= 17; dx += 1) {
for (let dy = 0; dy < 32; dy += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
}
}
/**
* Measure what the jungle pass actually painted, cell by cell.
*
* The counters used to be literals (`junglePathCells: 100`), so a pass that
* stamped nothing — no clearing pieces in the MPQ, a canvas too small for the
* macro-grid — still reported a healthy-looking jungle. Counting the finished
* canvas means an empty level reports zeros and the gap is visible.
*
* A cell counts as painted when it carries a visible, non-blank floor. Bridge
* planks (style 4, prop1 2) are skipped here: they are decks laid *over* the
* water and are reported as bridges, not as water or as ground.
*
* @param canvas - the level being stamped.
* @param grid - the macro-grid that decided which 32x32 block is river.
* @returns the painted ground and water cell counts.
*/
function measureJungleTopography(
canvas: Canvas,
grid: readonly (readonly MacroGridCell[])[],
): { canalCells: number; junglePathCells: number } {
let canalCells = 0
let junglePathCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
const gridRow = grid[Math.floor(y / 32)]
if (gridRow === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell === undefined) continue
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
if (floor.style === 4 && floor.prop1 === 2) continue
const gridCell = gridRow[Math.floor(x / 32)]
if (gridCell === undefined) continue
if (gridCell.type === 'river') canalCells += 1
else junglePathCells += 1
}
}
return { canalCells, junglePathCells }
}
/**
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
*
* Corresponds to Blizzard DRLGOUTJUNG_BuildJungle (D2Common.0x6FD7FC20).
*
* In Diablo II 1.13c / D2MOO, DRLGOUTJUNG_BuildJungle assembles the 32x32 macro-grid for
* Spider Forest (76), Great Marsh (77), and Flayer Jungle (78), stamping head/tail presets
* (LVLPREST_ACT3_JUNGLE_HEAD / LVLPREST_ACT3_JUNGLE_TAIL) and assigning river/clearing presets.
* This TypeScript implementation reconstructs that behavior with:
* - Meandering river paths wandering across columns.
* - River forks splitting into secondary tributary channels.
* - Island clearings surrounded by river channels.
* - Wooden bridges crossing river channels to connect trails and islands.
* - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78).
*/
export function DRLG_GenerateJungles(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
placedRects?: PlacedPresetRect[],
): Set<string> {
const placed = new Set<string>()
const numCols = Math.max(2, Math.floor(canvas.width / 32))
const numRows = Math.max(3, Math.floor(canvas.height / 32))
// 1. Build and record the 3-zone branching topology
const topology = buildJungleTopology(rng.seed)
stats.jungleTopology = topology
// 2. Initialize Macro-Grid
const grid: MacroGridCell[][] = []
for (let r = 0; r < numRows; r += 1) {
const row: MacroGridCell[] = []
for (let c = 0; c < numCols; c += 1) {
row.push({
col: c,
row: r,
type: 'clearing',
flowDirs: new Set(),
})
}
grid.push(row)
}
// Row 0 is Tail, Row numRows - 1 is Head
for (let c = 0; c < numCols; c += 1) {
grid[0]![c]!.type = 'tail'
grid[numRows - 1]![c]!.type = 'head'
}
// 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0)
// Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout)
const startCol = Math.min(1, numCols - 1)
const endCol = Math.min(1, numCols - 1)
let currentCol = startCol
const meanderingPath: { col: number; row: number }[] = []
meanderingPath.push({ col: currentCol, row: numRows - 1 })
grid[numRows - 1]![currentCol]!.flowDirs.add('N')
let meanderCount = 0
// Target row to initiate column meander
const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2))
for (let r = numRows - 2; r >= 1; r -= 1) {
let nextCol = currentCol
// Meander to adjacent column when at target row or on random roll
if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) {
if (numCols === 2) {
nextCol = 1 - currentCol
} else {
const step = rng.chance(0.5) ? -1 : 1
nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step))
}
}
if (nextCol !== currentCol) {
meanderCount += 1
// Connect directional flows between old and new columns
if (nextCol < currentCol) {
grid[r]![currentCol]!.flowDirs.add('W')
grid[r]![nextCol]!.flowDirs.add('E')
} else {
grid[r]![currentCol]!.flowDirs.add('E')
grid[r]![nextCol]!.flowDirs.add('W')
}
currentCol = nextCol
}
grid[r]![currentCol]!.type = 'river'
grid[r]![currentCol]!.flowDirs.add('S')
grid[r]![currentCol]!.flowDirs.add('N')
meanderingPath.push({ col: currentCol, row: r })
}
// Ensure tail connection
if (currentCol !== endCol) {
meanderCount += 1
if (endCol < currentCol) {
grid[1]![currentCol]!.flowDirs.add('W')
grid[1]![endCol]!.flowDirs.add('E')
} else {
grid[1]![currentCol]!.flowDirs.add('E')
grid[1]![endCol]!.flowDirs.add('W')
}
currentCol = endCol
}
grid[0]![endCol]!.flowDirs.add('S')
meanderingPath.push({ col: endCol, row: 0 })
// 4. Generate River Fork / Tributary Branch
const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow))
const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]!
const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1)
const forks: { col: number; row: number; pieceName?: string }[] = []
mainCellAtFork.isFork = true
if (forkCol < mainCellAtFork.col) {
mainCellAtFork.flowDirs.add('W')
grid[forkRow]![forkCol]!.flowDirs.add('E')
} else {
mainCellAtFork.flowDirs.add('E')
grid[forkRow]![forkCol]!.flowDirs.add('W')
}
grid[forkRow]![forkCol]!.type = 'river'
grid[forkRow]![forkCol]!.flowDirs.add('N')
grid[forkRow]![forkCol]!.flowDirs.add('S')
forks.push({ col: mainCellAtFork.col, row: forkRow })
// 5. Identify River Island Clearings
// Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing
const islands: { col: number; row: number; pieceName?: string }[] = []
const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = []
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
if (cell.type !== 'clearing') continue
let riverNeighbors = 0
if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1
if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1
if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1
if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1
if (riverNeighbors >= 2) {
cell.isIsland = true
islands.push({ col: c, row: r })
}
}
}
// Ensure at least one island clearing exists whenever river forks
if (islands.length === 0 && numRows >= 4) {
const candidateR = forkRow === 1 ? 2 : 1
const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1
const candidateCell = grid[candidateR]?.[candidateC]
if (candidateCell && candidateCell.type === 'clearing') {
candidateCell.isIsland = true
islands.push({ col: candidateC, row: candidateR })
}
}
// 6. Wooden Bridges Crossing River Channels
const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = []
const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge')
// Bridge placement for island clearings
for (const island of islands) {
// Find adjacent river cell to span across
let bridgeCol = island.col
let bridgeRow = island.row
let orientation: 'horizontal' | 'vertical' = 'horizontal'
if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') {
bridgeCol = island.col - 1
orientation = 'horizontal'
} else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') {
bridgeCol = island.col + 1
orientation = 'horizontal'
} else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') {
bridgeRow = island.row - 1
orientation = 'vertical'
} else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') {
bridgeRow = island.row + 1
orientation = 'vertical'
}
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
placedRects?.push({ x: bx + offsetX, y: by + offsetY, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, orientation, bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Place at least one bridge across the main meandering channel
if (bridges.length === 0) {
const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1
const bridgeRow = Math.floor(numRows / 2)
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
stampDs1Fn(canvas, bDs1, bx, by)
placedRects?.push({ x: bx, y: by, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, 'horizontal', bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// 7. Stamp Jungle Head and Tail
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1Fn(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: 0, w: tailPiece.levels[0].width, h: tailPiece.levels[0].height, name: tailPiece.name })
}
if (headPiece?.levels[0]) {
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: (numRows - 1) * 32, w: headPiece.levels[0].width, h: headPiece.levels[0].height, name: headPiece.name })
}
// 8. Stamp Interior River and Clearing Presets
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
const x = c * 32
const y = r * 32
if (cell.type === 'river') {
const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng)
if (riverPiece?.levels[0]) {
const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = riverPiece.name
placed.add(riverPiece.name)
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: riverPiece.name })
}
} else if (cell.type === 'clearing') {
const preferredOpening =
c < numCols - 1 && grid[r]![c + 1]!.type === 'river'
? 'E'
: c > 0 && grid[r]![c - 1]!.type === 'river'
? 'W'
: null
const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng)
if (clearingPiece?.levels[0]) {
const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = clearingPiece.name
placed.add(clearingPiece.name)
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: clearingPiece.name })
clearings.push({
col: c,
row: r,
pieceName: clearingPiece.name,
isIsland: cell.isIsland ?? false,
})
}
}
}
}
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
for (const b of bridges) {
if (bridgePiece?.levels[0]) {
const ds1 = bridgePiece.levels[0]
stampDs1Fn(canvas, ds1, b.x, b.y)
placedRects?.push({ x: b.x, y: b.y, w: ds1.width, h: ds1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal', bridgePiece?.levels[0])
}
// 10. Record Layout Statistics
const riverNetwork: JungleRiverNetwork = {
meanderingPath,
meanderCount,
forks,
islands,
bridges,
clearings,
}
const levelName =
levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle'
stats.jungleLayout = {
levelId,
levelName,
gridColumns: numCols,
gridRows: numRows,
riverNetwork,
topology,
}
// Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography
let canonicalCrossings = 0
for (const by of [78, 142]) {
let plankCells = 0
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) {
const row = canvas.cells[y]
if (row?.[x]) {
;(row[x]!.floors as Ds1Floor[])[0] = {
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
}
plankCells += 1
}
}
}
}
// A crossing off the bottom of a short canvas paints nothing, so it is not
// a crossing. Counting the attempt is how `riverBridges` used to claim 2.
if (plankCells > 0) canonicalCrossings += 1
}
const measured = measureJungleTopography(canvas, grid)
stats.act3Topography = {
// Causeways are a Kurast feature; the jungle pass carves none, and 0 here
// is a measurement, not a placeholder.
causewayCells: 0,
riverBridges: bridges.length + canonicalCrossings,
canalCells: measured.canalCells,
junglePathCells: measured.junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(bridges.length + canonicalCrossings)} river bridges, ` +
`${String(measured.junglePathCells)} jungle ground cells, ${String(measured.canalCells)} river water cells`,
)
stats.bridgesPlaced = bridges.length
stats.riverIslands = islands.length
stats.riverForks = forks.length
stats.meanderCount = meanderCount
return placed
}