714 lines
26 KiB
TypeScript
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
|
|
}
|