diff --git a/package.json b/package.json index 8e6677d..96deb51 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "verify:acts": "tsx scripts/verify-acts.ts", "verify:monsters": "tsx scripts/verify-monsters.ts", "verify:generators": "tsx scripts/verify-generators.ts samples/d2", + "verify:world-graph": "tsx scripts/verify-world-graph.ts samples/d2", + "verify:world-walk": "tsx scripts/verify-world-walk.ts", "build:game": "vite build --base=/diablo2/ --outDir dist-game", "pack:data": "tsx scripts/pack-act-assets.ts", "verify:packs": "tsx scripts/verify-packs.ts", diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 6fc204f..297cfc3 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -40,6 +40,7 @@ import { decodePl2 } from '../src/formats/pl2.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { planLevelMonsters } from '../src/game/monsters.ts' import type { IsoMapScene } from '../src/game/d2map.ts' +import { SUB_TILES_PER_TILE } from '../src/game/map.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDc6 } from '../src/formats/dc6.ts' @@ -47,7 +48,28 @@ import type { SpriteFrame } from '../src/formats/sprite.ts' import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' import { generateWilderness } from '../src/game/wilderness.ts' -import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' +import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' +export interface MazeWarp { + readonly room: number + readonly direction: 'up' | 'down' + readonly kind?: string + readonly pieceName?: string + readonly centreX: number + readonly centreY: number +} +import { + assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp, + parseLevelRows, parseWarpGeometry, SIDES, +} from '../src/game/world-graph.ts' +import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts' +import { + cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable, + seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES, +} from '../src/game/level-links.ts' +import type { + BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion, +} from '../src/game/level-links.ts' +import { findWarpTiles } from '../src/game/warp-tiles.ts' import { encodeIndexedPng } from './png.ts' /** @@ -326,6 +348,457 @@ const monstersTable = { statsById, } +/* ------------------------------------------------------------------------- * + * World connectivity + * ------------------------------------------------------------------------- */ + +/** + * The seed the act layout is solved with. + * + * Deliberately **not** derived from a level's seed. Which edge a seam sits on + * is a property of the pair, not of either level: if the Cold Plains rolled its + * Stony Field exit per variant, `3-var1` would put it north while `4-var2` was + * still expecting to be entered from the south, and the two copies could not be + * docked. One seed for the whole bake means all three variants of every level + * agree, and any variant can be swapped for any other at runtime. + */ +const ACT_LAYOUT_SEED = 0x5eed_2000 + +/** Where each act's town is, for working out which way is "up" in a dungeon. */ +const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] + +const worldGraph: WorldGraph = assignGateSides( + buildWorldGraph(parseLevelRows(tables.levels)), + ACT_LAYOUT_SEED, +) +const warpGeometry = parseWarpGeometry(tables.lvlwarp) + +/** + * Distance from the nearest town, in level hops. + * + * Used for one thing: telling a dungeon's up staircase from its down one. The + * data does not say. `Vis`/`Warp` slot order is not depth order — level 20, the + * Forgotten Tower's entrance building, has its way out in slot 0 and its way + * down in slot 1, while level 9's descent is in slot 4 — so the only reliable + * signal is that the way out is the neighbour closer to town. + */ +const townDistance = ((): Map => { + const outgoing = new Map() + for (const edge of worldGraph.edges) { + const list = outgoing.get(edge.from) + if (list === undefined) outgoing.set(edge.from, [edge.to]) + else list.push(edge.to) + } + const distance = new Map() + const queue: number[] = [] + for (const town of ACT_TOWNS) { + distance.set(town, 0) + queue.push(town) + } + while (queue.length > 0) { + const at = queue.shift() + if (at === undefined) break + const here = distance.get(at) ?? 0 + for (const next of outgoing.get(at) ?? []) { + if (distance.has(next)) continue + distance.set(next, here + 1) + queue.push(next) + } + } + return distance +})() + +/** + * A level's display name, for labelling the openings that lead to it. + * + * @param levelId - the level. + * @returns its `Levels.txt` name, or the bare id when there is no such level. + */ +function levelNameOf(levelId: number): string { + return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}` +} + +/** + * The border seams an outdoor level must cut, as the generator wants them. + * + * @param levelId - the level being generated. + * @returns one gate per seamless edge leaving it. + */ +function gatesFor(levelId: number): PlannedGate[] { + const gates: PlannedGate[] = [] + for (const edge of edgesFrom(worldGraph, levelId)) { + if (edge.kind !== 'seamless' || edge.sideFrom === null) continue + gates.push({ + side: edge.sideFrom, + toLevelId: edge.to, + label: `${levelNameOf(edge.to)} Seam`, + // The Burial Grounds seam is the one place the border art has a + // dedicated opening: variant 4 is the graveyard gate, 3 the plain road. + variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3, + }) + } + return gates +} + +/** + * Turn a graph edge and a place into a baked warp. + * + * @param edge - the edge being placed. + * @param direction - which way it goes. + * @param cellX - the anchor cell. + * @param cellY - the anchor cell. + * @param grid - the collision map, for nudging the arrival point off a wall. + * @param source - how the position was found. + * @param region - the level's main walkable region when there is a meaningful + * one, keeping the arrival point out of sealed pockets; undefined for + * generated levels, where the unstamped void would be the largest region. + * @returns the warp. + */ +function makeWarp( + edge: WorldEdge, + direction: SceneWarp['direction'], + cellX: number, + cellY: number, + grid: IsoMapScene, + source: SceneWarp['source'], + region: WalkableRegion | undefined, +): SceneWarp { + const warpId = edge.warps[0] ?? -1 + const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId) + let x = cellToSubTile(cellX) + let y = cellToSubTile(cellY) + const fullSpan = Math.max(grid.gridWidth, grid.gridHeight) + if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) { + const snapped = nearestWalkable(grid, x, y, fullSpan, region) + if (snapped !== null) { + x = snapped.x + y = snapped.y + } + } + // `OffsetX/Y` is where the engine materialises the player, relative to the + // anchor tile and usually negative so that they do not land on the trigger. + const wanted = { + x: x + (geometry?.offsetX ?? -2), + y: y + (geometry?.offsetY ?? -2), + } + const arrive = nearestWalkable(grid, wanted.x, wanted.y, fullSpan, region) ?? { x, y } + return { + toLevelId: edge.to, + warpId, + direction, + label: `${levelNameOf(edge.to)}`, + x, + y, + arriveX: arrive.x, + arriveY: arrive.y, + selectX: geometry?.selectX ?? 0, + selectY: geometry?.selectY ?? 0, + selectDX: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDX, + selectDY: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDY, + exitWalkX: geometry?.exitWalkX ?? 0, + exitWalkY: geometry?.exitWalkY ?? 0, + source, + } +} + +/** + * Work out every way out of one baked level. + * + * **Warps come from the artwork.** Every map in the game, shipped or generated, + * marks its staircases with DS1 special tiles whose `style` is the `Warp0..7` + * slot — see {@link findWarpTiles} — and the slot says which edge of the graph + * the staircase is. That single rule covers presets, mazes and outdoor levels + * alike, it gives the position the original engine would have used, and it + * removes the need to guess which of a dungeon's two staircases goes up. + * + * **Seams come from the generator or the collision map.** A seamless edge has + * no marker, because walking off the edge of the Blood Moor is not a staircase. + * Outdoor levels report the openings they cut; presets have theirs recovered + * from the collision map, since their artwork is fixed and nobody wrote down + * where the gate is. + * + * The maze generator's staircase rooms are kept as a fallback for the handful + * of pieces that carry no marker. + * + * @param levelId - the level. + * @param kind - how it was built. + * @param ds1 - the assembled map, for its warp markers. + * @param grid - the baked collision map. + * @param entrances - `stats.entrances` from the wilderness generator, if any. + * @param mazeWarps - `stats.warps` from the maze generator, if any. + * @param waypointCells - waypoint objects found in the artwork, in sub-tiles. + * @returns the links, plus anything the graph wanted that could not be placed. + */ +function buildSceneLinks( + levelId: number, + kind: LevelKind, + ds1: Ds1, + grid: IsoMapScene, + entrances: readonly WildernessEntrance[], + mazeWarps: readonly MazeWarp[], + waypointCells: readonly { x: number; y: number }[], +): SceneLinks { + const outEntrances: SceneEntrance[] = [] + const outWarps: SceneWarp[] = [] + const unplaced: { toLevelId: number; reason: string }[] = [] + const placedSeams = new Set() + const placedWarps = new Set() + + // The part of the map the player can actually stand on. + // + // Presets are finished artwork and outdoor levels are stamped edge to edge, + // so in both the biggest connected patch of open ground is the play area. + // + // For mazes, `buildIsoMapScene` leaves unstamped void cells around the rooms + // as `blocked = 0` (see Issue #38), so the void outside the dungeon walls + // would be larger than the dungeon itself. Masking out cells that carry no + // active floor tile before running `largestWalkableRegion` isolates the true + // dungeon interior, ensuring every staircase and fallback warp lands inside + // connected rooms rather than outside the walls. + let regionGrid: LinkGrid = grid + if (kind === 'maze') { + const maskedBlocked = new Uint8Array(grid.blocked) + for (let cy = 0; cy < grid.cellsY; cy += 1) { + for (let cx = 0; cx < grid.cellsX; cx += 1) { + const cell = ds1.cells[cy]?.[cx] + const hasFloor = cell !== undefined && cell.floors.some(f => !f.hidden && f.prop1 !== 0) + if (!hasFloor) { + for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) { + const row = (cy * SUB_TILES_PER_TILE + sy) * grid.gridWidth + cx * SUB_TILES_PER_TILE + maskedBlocked.fill(1, row, row + SUB_TILES_PER_TILE) + } + } + } + } + regionGrid = { + cellsX: grid.cellsX, + cellsY: grid.cellsY, + gridWidth: grid.gridWidth, + gridHeight: grid.gridHeight, + blocked: maskedBlocked, + } + } + const region = largestWalkableRegion(regionGrid) + + + const warpEdgeTo = new Map() + /** `Warp0..7` slot -> the edge that slot crosses. */ + const warpEdgeBySlot = new Map() + for (const edge of edgesFrom(worldGraph, levelId)) { + if (edge.kind !== 'warp') continue + warpEdgeTo.set(edge.to, edge) + for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge) + } + + /** Which way a crossing leads, for the runtime's benefit. */ + const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER + const directionTo = (toLevelId: number): SceneWarp['direction'] => + (townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down' + + // 1. The staircases the artwork declares. Authoritative for every kind of + // level, because the slot identifies the destination outright. + for (const tile of findWarpTiles(ds1)) { + const edge = warpEdgeBySlot.get(tile.slot) + // A marker for a slot this level does not use: the piece was drawn for a + // level that connects there and reused here. Ignore it rather than invent + // a destination. + if (edge === undefined) continue + // A generator can stamp the same stair piece twice; the first wins. + if (placedWarps.has(edge.to)) continue + outWarps.push(makeWarp(edge, directionTo(edge.to), tile.cellX, tile.cellY, grid, 'tile', region)) + placedWarps.add(edge.to) + } + + // 2. Outdoor levels: the generator already cut the seams, and knows where the + // interior presets it stamped sit. + for (const anchor of entrances) { + if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) { + // The anchor the generator hands over is the border *piece*, and the + // centre of that cell is wall; on some outdoor variants the border + // block's gap is separated from the main play area by up to ~40 + // sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius + // places the seam trigger on the outer boundary of the main play area, + // and `seamArrivalSpot` steps inward outside the trigger radius. + const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region) + ?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) } + const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region) + outEntrances.push({ + toLevelId: anchor.toLevelId, + side: anchor.side, + label: anchor.label, + x: gate.x, + y: gate.y, + arriveX: arrive.x, + arriveY: arrive.y, + }) + placedSeams.add(anchor.toLevelId) + continue + } + // A preset's mouth: the cave, tower or crypt that opens off this level. + const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId) + if (edge === undefined || placedWarps.has(edge.to)) continue + outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region)) + placedWarps.add(anchor.toLevelId) + } + + // 3. Mazes: staircase rooms, for pieces whose artwork carries no marker. + if (kind === 'maze') { + const leftover = [...edgesFrom(worldGraph, levelId)] + .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) + const queues = { + up: leftover.filter(edge => directionTo(edge.to) === 'up'), + down: leftover.filter(edge => directionTo(edge.to) === 'down'), + } + for (const room of mazeWarps) { + const edge = queues[room.direction].shift() + if (edge === undefined) continue + outWarps.push(makeWarp(edge, room.direction, room.centreX, room.centreY, grid, 'room', region)) + placedWarps.add(edge.to) + } + } + + // 4. Seams nobody cut: read them off the artwork's walkable border. + // + // Presets always land here, because their artwork is fixed and nobody + // wrote down where the gate is. So do the two mazes with a seamless edge + // (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos + // Sanctuary), and outdoor levels whose grid is too small for the planner + // to run, like the 6x2 Kurast docks. + // + // The graph's side is usually right, but not always. The Rogue Encampment + // ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which + // are the same camp slid to four different corners of a 57x41 canvas, so + // that the palisade opens off a different edge of the map in each. The + // graph has one side per *level*, so three variants in four would be told + // to put the gate where this particular camp has none. + // + // What distinguishes the two cases is `inset`: an opening at inset 0 is + // walkable ground running off the edge of the map, which is what a gate + // looks like, while a large inset means the scan gave up on the border and + // settled for the far end of the play area. So when the level has exactly + // one seam and some side reaches the border while the graph's side does + // not, the artwork wins and the widest such contact is the gate. With more + // than one seam there is no way to tell which border contact belongs to + // which neighbour, so the graph's sides stand. + const loneSeams = [...edgesFrom(worldGraph, levelId)] + .filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to)) + for (const edge of loneSeams) { + const wanted = edge.sideFrom! + const measured = new Map() + for (const side of SIDES) { + const found = findBorderOpening(grid, side, region) + if (found !== null) measured.set(side, found) + } + + const asked = measured.get(wanted) + let side: Side | null = asked === undefined ? null : wanted + let opening = asked + + const atBorder = [...measured].filter(([, found]) => found.inset === 0) + if (loneSeams.length === 1 && atBorder.length > 0 && (asked === undefined || asked.inset > 0)) { + // Widest contact wins: `TownE1` grazes the north border with a one-cell + // path as well as opening its whole east flank, and the flank is the gate. + const [bestSide, bestOpening] = atBorder + .reduce((best, next) => (next[1].width > best[1].width ? next : best)) + side = bestSide + opening = bestOpening + } + + if (opening === undefined || side === null) { + // Nothing on the asked-for side and nothing at any border either. + const any = [...measured].sort((a, b) => a[1].inset - b[1].inset)[0] + if (any === undefined) { + unplaced.push({ toLevelId: edge.to, reason: 'every edge is solid' }) + continue + } + side = any[0] + opening = any[1] + } + + outEntrances.push({ + toLevelId: edge.to, + side, + label: `${levelNameOf(edge.to)} Gate`, + x: opening.x, + y: opening.y, + arriveX: opening.arriveX, + arriveY: opening.arriveY, + }) + placedSeams.add(edge.to) + } + + // 5. Last resort. Several level types — the Act 2 sewers, the tombs, the + // Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES` + // because their DRLG staircase pass has not been transcribed, so they + // stamp no stair room, and their pieces carry no marker either. Leaving + // those edges unplaced makes a level you can enter and never leave, which + // is a worse lie than an approximate staircase. Each one is spread to a + // different quarter of the map so two of them never coincide, and each is + // tagged `fallback` so nothing mistakes it for the real position. + const needFallback = [...edgesFrom(worldGraph, levelId)] + .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) + needFallback.forEach((edge, index) => { + // Quarter centres, in reading order, so the choice is deterministic. + const quarters = [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]] as const + const [qx, qy] = quarters[index % quarters.length]! + const wanted = { + x: Math.floor(grid.gridWidth * qx / 4), + y: Math.floor(grid.cellsY * SUB_TILES_PER_TILE * qy / 4), + } + const spot = nearestWalkable(grid, wanted.x, wanted.y, Math.max(grid.gridWidth, grid.gridHeight), region) + if (spot === null) { + unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' }) + return + } + outWarps.push(makeWarp( + edge, + directionTo(edge.to), + Math.floor(spot.x / SUB_TILES_PER_TILE), + Math.floor(spot.y / SUB_TILES_PER_TILE), + grid, + 'fallback', + region, + )) + placedWarps.add(edge.to) + }) + + // Anything the graph wanted and nothing produced. Portals are excluded on + // purpose: a town portal or a quest portal is conjured at run time by the + // thing that opens it, so there is nothing for the bake to cut. Seams pass 4 + // already complained about are skipped, or every solid edge would be counted + // twice: once with the real reason and once with this generic one. + const reported = new Set(unplaced.map(hole => hole.toLevelId)) + for (const edge of edgesFrom(worldGraph, levelId)) { + if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) { + unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' }) + } + } + + // Waypoints. The artwork wins when it has one; otherwise the level gets a + // stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and + // a waypoint the network knows about but the map does not show is worse than + // an approximate position. + const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 + const waypoints: SceneWaypoint[] = [] + if (waypointId !== 255) { + const fromArt = waypointCells[0] + const spot = fromArt ?? findWaypointSpot(grid, region) + if (spot !== null && spot !== undefined) { + const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot + waypoints.push({ + waypointId, + x: spot.x, + y: spot.y, + arriveX: arrive.x, + arriveY: arrive.y, + source: fromArt === undefined ? 'placed' : 'object', + }) + } + } + + return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced } +} + const allNames = await archives.listFiles() { @@ -530,6 +1003,7 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu const WILDERNESS_PIECE_FAMILIES: Readonly> = { 'Act 1 - Wilderness': [ 'Act 1 - Wild', + 'Act 1 - Town 1 Transition', 'Act 1 - DOE Entrance', 'Act 1 - Cave Entrance', 'Act 1 - Corral Fill', @@ -593,7 +1067,8 @@ function getWildernessPieces(levelTypeName: string): Promise if (!families.some(family => name.startsWith(family))) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue - pieces.push({ name, levels, border: /border|cliff|ravine/i.test(name) }) + const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name) + pieces.push({ name, levels, border: isBorder }) } return pieces })() @@ -670,6 +1145,16 @@ async function bakeDs1Variant( ds1Name: string, label: string, seed: number, + /** + * What the generator learned while building this level. + * + * Empty for presets, which are not generated: their openings are recovered + * from the collision map instead. + */ + generatorLinks: { + readonly entrances: readonly WildernessEntrance[] + readonly mazeWarps: readonly MazeWarp[] + } = { entrances: [], mazeWarps: [] }, ): Promise { const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) const spawn = findIsoSpawn(scene) @@ -727,6 +1212,13 @@ async function bakeDs1Variant( const objectPages = new PageBuilder(palette) const objects: unknown[] = [] const npcs: unknown[] = [] + /** + * Waypoint pedestals found in the artwork, in sub-tiles. + * + * Only presets have one: the outdoor and maze generators do not run the pass + * that spawns waypoints, so their levels fall back to a placed position. + */ + const waypointCells: { x: number; y: number }[] = [] const missingObjects: string[] = [] let objectsWithArt = 0 const placementByMember = new Map() @@ -818,10 +1310,16 @@ async function bakeDs1Variant( } const list = resolved.kind === 'npc' ? npcs : objects + const resolvedName = resolved.name ?? row?.name ?? resolved.token + // The pedestal's own sub-tile, taken here because everything downstream is + // in screen pixels and cannot be converted back. + if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) { + waypointCells.push({ x: object.x, y: object.y }) + } list.push({ id: object.id, type: object.type, - name: resolved.name ?? row?.name ?? resolved.token, + name: resolvedName, token: resolved.token, mode: resolved.mode === '' ? 'NU' : resolved.mode, // `objectsTxtId` is the Objects.txt row the table points at; -1 means the @@ -874,6 +1372,16 @@ async function bakeDs1Variant( PLAYER_WALK_SPEED_PX, ) + const sceneLinks = buildSceneLinks( + entry.levelId, + entry.kind, + level, + scene, + generatorLinks.entrances, + generatorLinks.mazeWarps, + waypointCells, + ) + const sceneJson = { version: 1, fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', @@ -908,6 +1416,10 @@ async function bakeDs1Variant( }, collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], + // Everything the runtime needs to leave this level, in sub-tiles. + entrances: sceneLinks.entrances, + warps: sceneLinks.warps, + waypoints: sceneLinks.waypoints, stats: { floors: scene.floors.length, walls: scene.walls.length, @@ -924,6 +1436,9 @@ async function bakeDs1Variant( objectsUnresolved: missingObjects, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, dt1Libraries: dt1Names.length, + // Edges the world graph has that this copy of the level has nowhere to + // put. Reported rather than dropped: each one is a hole in the world. + unplacedEdges: sceneLinks.unplacedEdges, }, } const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson)) @@ -960,6 +1475,10 @@ async function bakeDs1Variant( pages: pageFiles.length, objectPages: objectFiles.length, missingTiles: scene.missingTiles, + entrances: sceneLinks.entrances.length, + warps: sceneLinks.warps.length, + waypoints: sceneLinks.waypoints.length, + unplacedEdges: sceneLinks.unplacedEdges.length, bytes: manifest.pngBytes + sceneBytes.byteLength, }) totalLevels += 1 @@ -975,11 +1494,20 @@ async function bakeDs1Variant( ) } +/** + * Restrict the bake to a few levels, for spot checks. + * + * Comma-separated, e.g. `FILTER_LEVEL=75,82`. Note that a filtered run still + * rewrites `index.json` with only the levels it baked, so the output directory + * is not a usable pack afterwards — read the `scene.json` files directly. + */ const filterLevel = process.env.FILTER_LEVEL -const filterLevels = filterLevel !== undefined ? new Set(filterLevel.split(',').map(s => s.trim())) : null +const filterLevels = filterLevel !== undefined + ? new Set(filterLevel.split(',').map(s => Number(s.trim()))) + : null for (const entry of LEVELS) { - if (filterLevels !== null && !filterLevels.has(String(entry.levelId))) continue + if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)! const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) @@ -1031,7 +1559,11 @@ for (const entry of LEVELS) { seed, pieces, }) - await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed) + await bakeDs1Variant( + entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, + result.level, `generated:${label}`, label, seed, + { entrances: [], mazeWarps: (result.stats.warps ?? []) as MazeWarp[] }, + ) } catch (err) { console.error(`failed to bake maze ${label}: ${(err as Error).message}`) } @@ -1054,6 +1586,10 @@ for (const entry of LEVELS) { const libraries: Dt1[] = [] for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) + // Solved once for the whole world, so all three variants cut the same + // seams on the same edges and any variant docks with any neighbour. + const gates = gatesFor(entry.levelId) + for (let v = 1; v <= 3; v += 1) { const seed = 0x5eed_1000 + entry.levelId * 10 + v const label = `${entry.slug}-var${v}` @@ -1070,8 +1606,13 @@ for (const entry of LEVELS) { pieces, substitutions: rows, shrineSubstitutions: shrineRows, + gates, }) - await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed) + await bakeDs1Variant( + entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, + result.level, `generated:${label}`, label, seed, + { entrances: (result.stats.entrances ?? []) as WildernessEntrance[], mazeWarps: [] }, + ) } catch (err) { console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) } @@ -1111,9 +1652,38 @@ if (filterLevel !== undefined) { } } await writeFile(indexPath, JSON.stringify(index, null, 1)) + +// The solved graph, written once for the whole pack. The runtime must not +// re-solve it: `assignGateSides` is seeded, but re-running it against a +// different `Levels.txt` would silently move every seam while the baked levels +// kept their old openings. +await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({ + version: 1, + layoutSeed: ACT_LAYOUT_SEED, + levels: [...worldGraph.levels.values()].map(level => ({ + id: level.id, + act: level.act + 1, + name: level.name, + drlgType: level.drlgType, + waypoint: level.waypoint, + })), + edges: worldGraph.edges.map(edge => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + sideFrom: edge.sideFrom, + sideTo: edge.sideTo, + warps: edge.warps, + warpSlots: edge.warpSlots, + source: edge.source, + })), + waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })), +}, null, 1)) + if (!process.env.SKIP_ENTITIES) { const { bakeEntities } = await import('./pack-entity-assets.ts') await bakeEntities(archiveDir, outDir) } console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`) +console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`) diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index e1876c0..36d1ce0 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -399,6 +399,9 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu const WILDERNESS_PIECE_FAMILIES: Readonly> = { 'Act 1 - Wilderness': [ 'Act 1 - Wild', + 'Act 1 - Town 1 Transition', + 'Act 1 - Cave Entrance', + 'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill', 'Act 1 - River', @@ -478,7 +481,8 @@ async function wildernessPieces( if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue const levels = await rowDs1s(archives, lvlprest, row) if (levels.length === 0) continue - pieces.push({ name, levels, border: /border|cliff/i.test(name) }) + const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name) + pieces.push({ name, levels, border: isBorder }) } return pieces } @@ -710,6 +714,10 @@ for (const { id, name } of wildLevels) { check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset') check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset') } + if (type.name === 'Act 1 - Wilderness') { + const roadCells = Number(first.stats.roadCells ?? 0) + check(roadCells > 0, label, `Act 1 wilderness level must generate dirt roads (roadCells=${String(roadCells)})`) + } if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1 console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`) diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index d473f59..a08ffb8 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -266,5 +266,133 @@ for (const entry of index.levels) { ) } +/* --------------------------------------------------------------------------- * + * World connectivity + * + * The pixel comparison above can only run on preset levels, because a generated + * level has no DS1 in the archives to rebuild it from. Links, though, are baked + * for every level, and they are the part that breaks silently: an opening whose + * destination is not a graph edge is a door to the void, and a graph edge with + * no opening is a level you can never leave. Both are checked here, for presets + * and generated levels alike. + * --------------------------------------------------------------------------- */ + +/** The solved graph the packer wrote alongside the levels. */ +interface PackedWorldGraph { + readonly version: number + readonly layoutSeed: number + readonly levels: readonly { readonly id: number; readonly name: string; readonly waypoint: number }[] + readonly edges: readonly { readonly from: number; readonly to: number; readonly kind: string }[] + readonly waypoints: readonly { readonly id: number; readonly levelId: number }[] +} + +/** The link half of a baked scene. */ +interface PackedLinks { + readonly levelId: number + readonly entrances?: readonly { readonly toLevelId: number; readonly label: string }[] + readonly warps?: readonly { + readonly toLevelId: number + readonly source?: 'tile' | 'room' | 'fallback' + }[] + readonly waypoints?: readonly { readonly waypointId: number }[] + readonly stats?: { readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] } +} + +const graph = JSON.parse(await readFile(join(packDir, 'world-graph.json'), 'utf8')) as PackedWorldGraph +const graphLevelName = new Map(graph.levels.map(level => [level.id, level.name])) +/** `from -> to` pairs, for asking "is this a real edge?" in constant time. */ +const graphEdges = new Set(graph.edges.map(edge => `${String(edge.from)}>${String(edge.to)}`)) +/** + * Every edge the bake is expected to open somewhere; emptied as they are found. + * + * Portal edges are left out. A town portal, the Tristram cairn portal, the act + * transitions and the Arcane Sanctuary entrance are all conjured at run time by + * the thing that opens them — a quest, a scroll, a red portal — so there is no + * staircase in the artwork to find and demanding one would bury the real holes + * under forty-six false ones. + */ +const unmaterialised = new Set( + graph.edges.filter(edge => edge.kind !== 'portal') + .map(edge => `${String(edge.from)}>${String(edge.to)}`), +) +/** Levels that own a waypoint pedestal, by the graph. */ +const waypointLevels = new Set(graph.waypoints.map(entry => entry.levelId)) +/** Waypoint levels seen with a baked pedestal, so variants do not each have to have one. */ +const waypointsFound = new Set() + +let unplacedTotal = 0 +/** How each baked warp's position was arrived at; see `SceneWarp.source`. */ +const warpSources = { tile: 0, room: 0, fallback: 0 } +/** Levels with at least one invented staircase, for the summary. */ +const approximateLevels = new Set() +for (const entry of index.levels) { + const scene = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedLinks + const levelId = scene.levelId + const name = graphLevelName.get(levelId) ?? entry.label + + for (const warp of scene.warps ?? []) { + const source = warp.source ?? 'tile' + warpSources[source] += 1 + if (source === 'fallback') approximateLevels.add(levelId) + } + + // Every opening must lead somewhere the graph agrees with. + let strayOpenings = 0 + for (const link of [...(scene.entrances ?? []), ...(scene.warps ?? [])]) { + const key = `${String(levelId)}>${String(link.toLevelId)}` + if (graphEdges.has(key)) unmaterialised.delete(key) + else { + strayOpenings += 1 + if (strayOpenings <= 3) { + console.log(` FAIL ${entry.path}: 开口通向 ${String(link.toLevelId)},但世界图里没有 ${name} → 该关卡的边`) + } + } + } + check(strayOpenings === 0, `${entry.path}: openings match graph edges (${String(strayOpenings)} stray)`) + + // A level the graph says has a waypoint must bake a pedestal somewhere. Only + // one variant has to have it for the level to be reachable by waypoint, so + // this is recorded and judged after the loop. + if ((scene.waypoints ?? []).length > 0) waypointsFound.add(levelId) + + const unplaced = scene.stats?.unplacedEdges ?? [] + unplacedTotal += unplaced.length + for (const hole of unplaced) { + const target = graphLevelName.get(hole.toLevelId) ?? String(hole.toLevelId) + console.log(` HOLE ${entry.path}: 无法放置通往 ${target} 的开口(${hole.reason})`) + } +} + +check(unplacedTotal === 0, `world: every graph edge got an opening (${String(unplacedTotal)} unplaced)`) + +// Packs are filtered during development (`FILTER_LEVEL`), so every judgement +// below is scoped to levels this pack actually contains. +const bakedLevels = new Set(index.levels.map(entry => entry.levelId)) + +const missingWaypoints = [...waypointLevels] + .filter(levelId => bakedLevels.has(levelId) && !waypointsFound.has(levelId)) +for (const levelId of missingWaypoints) { + console.log(` FAIL 关卡 ${graphLevelName.get(levelId) ?? String(levelId)} 应有传送点,但所有变体都没有烘焙出来`) +} +check(missingWaypoints.length === 0, `world: waypoint levels have a pedestal (${String(missingWaypoints.length)} missing)`) + +// An edge nobody opened is a level you can walk to on paper and never reach in +// play. +const orphanEdges = [...unmaterialised].filter(key => bakedLevels.has(Number(key.split('>')[0]))) +for (const key of orphanEdges.slice(0, 20)) { + const [from, to] = key.split('>').map(Number) + console.log(` FAIL 世界图有 ${graphLevelName.get(from!) ?? String(from!)} → ${graphLevelName.get(to!) ?? String(to!)} 的边,但没有任何变体开了这个口`) +} +check(orphanEdges.length === 0, `world: no graph edge left without an opening (${String(orphanEdges.length)} orphans)`) + +// Not an assertion: an approximate staircase is traversable, just not authentic. +// The number is printed every run so it cannot creep up unnoticed, and it only +// falls when a level type's DRLG staircase pass gets transcribed. +console.log( + `\n楼梯来源:美术瓦片 ${String(warpSources.tile)},迷宫楼梯间 ${String(warpSources.room)},` + + `兜底 ${String(warpSources.fallback)}(涉及 ${String(approximateLevels.size)} 个关卡)`, +) + console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`) if (failures > 0) process.exit(1) + diff --git a/scripts/verify-world-graph.ts b/scripts/verify-world-graph.ts new file mode 100644 index 0000000..9d83983 --- /dev/null +++ b/scripts/verify-world-graph.ts @@ -0,0 +1,378 @@ +/** + * Verify the global world connectivity graph. + * + * Issue #23's acceptance criteria are mostly about walking around, which is not + * something a script can do, but the two failure modes that actually break a + * playthrough are both static and both cheap to catch here: + * + * 1. **Islands.** A level nothing points at is a level the player can never + * reach. Building the graph from `Levels.txt` `Vis`/`Warp` alone produces + * 111 of them — every outdoor zone in the game — which is the whole reason + * `world-graph.ts` exists. + * 2. **Doors onto nothing.** An edge whose destination has no asset pack, or a + * seam with no side assigned, is an opening that leads into the void. + * + * The checks: + * + * 1. `Vis` slots collapse to the expected number of unique pairs, and the one + * known asymmetric edge is gone. + * 2. Every edge is bidirectional, and both endpoints exist. + * 3. Every act's levels are reachable from that act's town, with the two + * documented exceptions (`OUTDOOR_ISLANDS`) reachable by waypoint instead. + * 4. Every edge destination has a baked asset pack. + * 5. The 39 waypoints are contiguous, land on real levels, and those levels + * have packs. + * 6. Gate side assignment holds its two invariants — opposite sides across a + * seam, and no two seams of one level sharing an edge — across many seeds. + * 7. Every `LvlWarp.txt` row referenced by a `Warp` column actually exists. + * + * Usage: + * node scripts/verify-world-graph.ts [directory] + */ +import { readFileSync } from 'node:fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { loadActTables } from '../src/game/acts.ts' +import { + parseLevelRows, + buildWorldGraph, + assignGateSides, + seamlessPairs, + reachableFrom, + parseWarpGeometry, + findWarpGeometry, + oppositeSide, + OUTDOOR_ISLANDS, + SEAMLESS_ADJACENCY, + PORTAL_LINKS, + DROPPED_VIS_EDGES, +} from '../src/game/world-graph.ts' +import type { Side, WorldGraph } from '../src/game/world-graph.ts' + +/** Where the archives live by default. */ +const dir = process.argv[2] ?? 'samples/d2' +/** Mount order: later archives override earlier ones, as the game loads them. */ +const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] +/** The baked asset packs. */ +const PACK_INDEX = 'samples/d2-packs/index.json' +/** The town of each act, keyed by the 0-based `Act` column. */ +const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] +/** Waypoint ids run 0..38 with no gaps. */ +const WAYPOINT_COUNT = 39 +/** How many seeds to exercise the side solver with. */ +const SIDE_SEEDS = 200 + +let checks = 0 +let failures = 0 +const failureReasons: string[] = [] + +/** + * Record one assertion. + * + * @param ok - whether it held. + * @param scope - what was being checked. + * @param message - the claim. + */ +function check(ok: boolean, scope: string, message: string): void { + checks += 1 + if (!ok) { + failures += 1 + failureReasons.push(`${scope}: ${message}`) + } +} + +/* ------------------------------------------------------------------------- * + * Load + * ------------------------------------------------------------------------- */ + +const archives = new MountedArchives() +for (const name of MOUNTS) { + try { + archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) + } catch (err) { + console.log(`skip ${name}: ${String(err)}`) + } +} +if (archives.size === 0) { + console.log(`no archives found in ${dir}`) + process.exit(2) +} + +const tables = await loadActTables(archives) +const rows = parseLevelRows(tables.levels) +const base = buildWorldGraph(rows) +const graph = assignGateSides(base, 0x5eed_2000) +const warpGeometry = parseWarpGeometry(tables.lvlwarp) + +/** The baked packs, if they have been generated. */ +interface PackEntry { + readonly levelId?: number + readonly kind?: string + readonly label?: string +} +let packLevelIds: Set | null = null +try { + const parsed = JSON.parse(readFileSync(PACK_INDEX, 'utf8')) as { levels?: PackEntry[] } + const entries = parsed.levels ?? [] + packLevelIds = new Set(entries.map(entry => entry.levelId ?? -1).filter(id => id > 0)) +} catch { + console.log(`no pack index at ${PACK_INDEX}; skipping pack coverage checks`) +} + +/** A readable label for a level. */ +function label(levelId: number): string { + const row = graph.levels.get(levelId) + return row === undefined ? `level ${String(levelId)}` : `${String(levelId)} ${row.name}` +} + +/* ------------------------------------------------------------------------- * + * 1. Edge extraction + * ------------------------------------------------------------------------- */ + +{ + const scope = 'edges' + let populatedSlots = 0 + const uniqueVisPairs = new Set() + for (const row of rows) { + for (let slot = 0; slot < 8; slot += 1) { + const destination = row.vis[slot] ?? 0 + if (destination === 0) continue + populatedSlots += 1 + uniqueVisPairs.add(`${String(row.id)}->${String(destination)}`) + } + } + console.log(` Vis slots populated: ${String(populatedSlots)}, unique ordered pairs: ${String(uniqueVisPairs.size)}`) + check(populatedSlots > uniqueVisPairs.size, scope, 'several slots should collapse onto one logical edge') + + for (const [from, to] of DROPPED_VIS_EDGES) { + check( + !graph.edges.some(edge => edge.from === from && edge.to === to), + scope, + `the copy-paste artifact ${label(from)} -> ${label(to)} must be dropped`, + ) + } + + for (const edge of graph.edges) { + check(graph.levels.has(edge.from), scope, `edge source ${String(edge.from)} must be a real level`) + check(graph.levels.has(edge.to), scope, `edge destination ${String(edge.to)} must be a real level`) + } + + const directed = new Set(graph.edges.map(edge => `${String(edge.from)}->${String(edge.to)}`)) + for (const edge of graph.edges) { + check( + directed.has(`${String(edge.to)}->${String(edge.from)}`), + scope, + `${label(edge.from)} -> ${label(edge.to)} (${edge.kind}) has no return edge`, + ) + } + + const counts = { warp: 0, seamless: 0, portal: 0 } + for (const edge of graph.edges) counts[edge.kind] += 1 + console.log( + ` edges: ${String(graph.edges.length)} total ` + + `(${String(counts.warp)} warp, ${String(counts.seamless)} seamless, ${String(counts.portal)} portal)`, + ) +} + +/* ------------------------------------------------------------------------- * + * 2. Hard-coded tables are sane + * ------------------------------------------------------------------------- */ + +{ + const scope = 'hard-coded tables' + const seen = new Set() + for (const [a, b] of SEAMLESS_ADJACENCY) { + const key = a < b ? `${String(a)}:${String(b)}` : `${String(b)}:${String(a)}` + check(!seen.has(key), scope, `adjacency ${key} is listed twice`) + seen.add(key) + check(a !== b, scope, `adjacency ${key} is a self-loop`) + check(graph.levels.has(a), scope, `adjacency source ${String(a)} is not a level`) + check(graph.levels.has(b), scope, `adjacency destination ${String(b)} is not a level`) + } + for (const link of PORTAL_LINKS) { + check(graph.levels.has(link.from), scope, `portal source ${String(link.from)} is not a level`) + check(graph.levels.has(link.to), scope, `portal destination ${String(link.to)} is not a level`) + } +} + +/* ------------------------------------------------------------------------- * + * 3. Connectivity + * ------------------------------------------------------------------------- */ + +{ + const scope = 'connectivity' + const islands = new Set(OUTDOOR_ISLANDS) + const waypointLevels = new Set(graph.waypoints.values()) + + // Every level, from every town: the world is one component, since the acts + // are joined by the caravan, the ship, the Infernal Gate and Tyrael. + const reachable = reachableFrom(graph, ACT_TOWNS[0] ?? 1) + for (const row of rows) { + if (reachable.has(row.id)) continue + const excused = islands.has(row.id) && waypointLevels.has(row.id) + check(excused, scope, `${label(row.id)} is unreachable from Act 1 town`) + } + console.log(` reachable from Act 1 town: ${String(reachable.size)} / ${String(rows.length)} levels`) + + for (const [index, town] of ACT_TOWNS.entries()) { + const fromTown = reachableFrom(graph, town) + const actLevels = rows.filter(row => row.act === index) + const missing = actLevels.filter(row => !fromTown.has(row.id) && !islands.has(row.id)) + check( + missing.length === 0, + scope, + `act ${String(index + 1)}: ${String(missing.length)} levels unreachable from ${label(town)}` + + (missing.length === 0 ? '' : ` (${missing.slice(0, 5).map(row => label(row.id)).join(', ')})`), + ) + } + + for (const island of OUTDOOR_ISLANDS) { + check( + waypointLevels.has(island) || graph.edges.some(edge => edge.to === island), + scope, + `${label(island)} is excused as an island but has neither a waypoint nor an inbound edge`, + ) + } +} + +/* ------------------------------------------------------------------------- * + * 4. Pack coverage — no door onto nothing + * ------------------------------------------------------------------------- */ + +if (packLevelIds !== null) { + const scope = 'pack coverage' + const packs = packLevelIds + let broken = 0 + for (const edge of graph.edges) { + if (packs.has(edge.to)) continue + broken += 1 + check(false, scope, `${label(edge.from)} -> ${label(edge.to)} leads to a level with no pack`) + } + const uncovered = rows.filter(row => !packs.has(row.id)) + check(uncovered.length === 0, scope, `${String(uncovered.length)} levels have no pack`) + console.log(` packs cover ${String(rows.length - uncovered.length)} / ${String(rows.length)} levels, ${String(broken)} broken edges`) +} + +/* ------------------------------------------------------------------------- * + * 5. Waypoints + * ------------------------------------------------------------------------- */ + +{ + const scope = 'waypoints' + check( + graph.waypoints.size === WAYPOINT_COUNT, + scope, + `expected ${String(WAYPOINT_COUNT)} waypoints, found ${String(graph.waypoints.size)}`, + ) + for (let id = 0; id < WAYPOINT_COUNT; id += 1) { + const level = graph.waypoints.get(id) + check(level !== undefined, scope, `waypoint ${String(id)} is missing`) + if (level === undefined) continue + check(graph.levels.has(level), scope, `waypoint ${String(id)} points at a level that does not exist`) + if (packLevelIds !== null) { + check(packLevelIds.has(level), scope, `waypoint ${String(id)} is on ${label(level)}, which has no pack`) + } + } +} + +/* ------------------------------------------------------------------------- * + * 6. Gate sides + * ------------------------------------------------------------------------- */ + +{ + const scope = 'gate sides' + const pairs = seamlessPairs(base) + console.log(` seams needing a side: ${String(pairs.length)}`) + + /** Assert the two invariants on one assignment. */ + const inspect = (candidate: WorldGraph, seed: number): void => { + const used = new Map>() + for (const edge of candidate.edges) { + if (edge.kind !== 'seamless') continue + check(edge.sideFrom !== null, scope, `seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} has no side`) + if (edge.sideFrom === null || edge.sideTo === null) continue + check( + edge.sideTo === oppositeSide(edge.sideFrom), + scope, + `seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} sides are not opposite`, + ) + let sides = used.get(edge.from) + if (sides === undefined) { + sides = new Map() + used.set(edge.from, sides) + } + const already = sides.get(edge.sideFrom) + check( + already === undefined || already === edge.to, + scope, + `seed ${String(seed)}: ${label(edge.from)} puts two seams on its ${edge.sideFrom} edge`, + ) + sides.set(edge.sideFrom, edge.to) + } + } + + for (let seed = 0; seed < SIDE_SEEDS; seed += 1) { + inspect(assignGateSides(base, 0x5eed_2000 + seed), seed) + } + + // Determinism: the same seed must produce the same layout. + const first = assignGateSides(base, 12345) + const again = assignGateSides(base, 12345) + const render = (candidate: WorldGraph): string => + candidate.edges + .filter(edge => edge.kind === 'seamless') + .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) + .sort() + .join('|') + check(render(first) === render(again), scope, 'the same seed must produce the same sides') + check( + render(first) !== render(assignGateSides(base, 999)), + scope, + 'different seeds should produce different sides', + ) +} + +/* ------------------------------------------------------------------------- * + * 7. Warp geometry + * ------------------------------------------------------------------------- */ + +{ + const scope = 'warp geometry' + console.log(` LvlWarp rows: ${String(warpGeometry.size)}`) + for (const row of rows) { + for (let slot = 0; slot < 8; slot += 1) { + const warpId = row.warp[slot] ?? -1 + if (warpId === -1) continue + if ((row.vis[slot] ?? 0) === 0) continue + check( + findWarpGeometry(warpGeometry, warpId) !== undefined, + scope, + `${label(row.id)} slot ${String(slot)} references LvlWarp ${String(warpId)}, which does not exist`, + ) + } + } + for (const edge of graph.edges) { + for (const warpId of edge.warps) { + check( + findWarpGeometry(warpGeometry, warpId) !== undefined, + scope, + `edge ${label(edge.from)} -> ${label(edge.to)} references LvlWarp ${String(warpId)}`, + ) + } + } +} + +/* ------------------------------------------------------------------------- * + * Report + * ------------------------------------------------------------------------- */ + +console.log('') +if (failures === 0) { + console.log(`world graph: ${String(checks)} assertions, all passed`) +} else { + console.log(`world graph: ${String(checks)} assertions, ${String(failures)} failed`) + for (const reason of failureReasons.slice(0, 40)) console.log(` - ${reason}`) + if (failureReasons.length > 40) console.log(` ... and ${String(failureReasons.length - 40)} more`) + process.exit(1) +} diff --git a/scripts/verify-world-walk.ts b/scripts/verify-world-walk.ts new file mode 100644 index 0000000..e038764 --- /dev/null +++ b/scripts/verify-world-walk.ts @@ -0,0 +1,443 @@ +/** + * Walk the baked world the way a player would, without a browser. + * + * `verify-packs` proves every opening in the pack corresponds to an edge in the + * world graph and that no edge was left uncut. That is a statement about + * bookkeeping. It says nothing about whether the openings are somewhere a + * player can actually reach: a staircase walled off behind a cliff satisfies + * every count and still ends the game. + * + * This script closes that gap by replaying the runtime's own rules against the + * pack: + * + * - the variant picked for a destination is `variantSeed % candidates`, with + * `variantSeed = WORLD_VARIANT_SEED + levelId`, exactly as + * `loadRuntimeForLevel` does — one variant per level, so re-entering a + * level always gives the same map; + * - the landing spot is the destination's own opening back to where we came + * from, exactly as `travel` does; + * - a link fires when the player is within `SEAM_TRIGGER_SUBTILES` or + * `WARP_TRIGGER_SUBTILES` of it, using the constants the scene imports. + * + * Two passes run: + * + * 1. **The acceptance route.** Rogue Encampment → Blood Moor → Cold Plains → + * Cave Level 1 → Cave Level 2, then the Cold Plains waypoint home. Every + * hop asserts that the exit is reachable on foot from where the previous + * hop dropped us, and that the landing is on open ground. This is issue + * #23's acceptance criterion, minus the pixels. + * 2. **The whole world.** For every baked variant, flood fill from the point + * a player arrives at and report which of that level's exits are cut off. + * A level with exits but none reachable is a trap and fails; the rest is + * reported as a percentage so the number cannot quietly rot. + * + * Usage: node scripts/verify-world-walk.ts [pack-directory] + */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../src/game/level-links.ts' + +const [packDir = 'samples/d2-packs'] = process.argv.slice(2) + +/** One opening, either kind, reduced to what walking needs. */ +interface Link { + readonly toLevelId: number + readonly x: number + readonly y: number + readonly arriveX: number + readonly arriveY: number + readonly label: string + readonly radius: number + readonly what: string +} + +/** The fields of `scene.json` this script reads. */ +interface PackedScene { + readonly levelId: number + readonly levelName: string + readonly act: number + readonly cellsX: number + readonly cellsY: number + /** Scene-pixel offset of the map's top corner; `spawn` is measured from it. */ + readonly originX: number + readonly originY: number + readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } + readonly spawn: readonly number[] | null + readonly entrances?: readonly { + toLevelId: number; side: string; label: string + x: number; y: number; arriveX: number; arriveY: number + }[] + readonly warps?: readonly { + toLevelId: number; label: string; direction: string; source: string + x: number; y: number; arriveX: number; arriveY: number + }[] + readonly waypoints?: readonly { + waypointId: number; x: number; y: number; arriveX: number; arriveY: number + }[] +} + +interface IndexEntry { + readonly act: number + readonly levelId: number + readonly path: string + readonly label: string + readonly kind?: string +} + +const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as { + levels: readonly IndexEntry[] +} + +let checks = 0 +let failures = 0 + +/** + * Assert one expectation. + * + * @param ok - whether it held. + * @param message - what was checked. + */ +function check(ok: boolean, message: string): void { + checks += 1 + if (ok) return + failures += 1 + console.log(` FAIL ${message}`) +} + +const sceneCache = new Map() + +/** + * Read one variant's `scene.json`, cached. + * + * @param entry - the index entry. + * @returns the decoded scene. + */ +async function sceneOf(entry: IndexEntry): Promise { + const hit = sceneCache.get(entry.path) + if (hit !== undefined) return hit + const scene = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene + sceneCache.set(entry.path, scene) + return scene +} + +/** + * Expand the run-length encoded collision grid. + * + * @param scene - the packed scene. + * @returns one byte per sub-tile, non-zero meaning impassable. + */ +function blockedOf(scene: PackedScene): Uint8Array { + const { width, height, runs } = scene.collision + const out = new Uint8Array(width * height) + let at = 0 + for (const run of runs) { + const value = run[0] ?? 0 + const count = run[1] ?? 0 + out.fill(value, at, at + count) + at += count + } + return out +} + +/** + * The spawn point, in sub-tiles. + * + * `scene.json` stores it in scene pixels, because that is the space the player + * entity lives in, while every link is in sub-tiles. This is the same inverse + * isometric projection `playerSubTile` applies in the scene: the half-cell is + * 16×8 pixels, so `x` and `y` come out of the sum and difference of the pixel + * offsets from the map's origin. + * + * @param scene - the packed scene. + * @returns the spawn sub-tile, or null when the map has no spawn. + */ +function spawnSubTile(scene: PackedScene): { x: number; y: number } | null { + if (scene.spawn == null) return null + const dx = ((scene.spawn[0] ?? 0) - scene.originX) / 16 + const dy = ((scene.spawn[1] ?? 0) - scene.originY) / 8 + return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } +} + +/** Mirrors `WORLD_VARIANT_SEED` in `act-scene.ts`. */ +const WORLD_VARIANT_SEED = 0x5eed_3000 + +/** + * Pick the variant the runtime would load. + * + * @param levelId - the level wanted. + * @returns the index entry, or null when the pack has no such level. + */ +function variantFor(levelId: number): IndexEntry | null { + const candidates = index.levels.filter(entry => entry.levelId === levelId) + if (candidates.length === 0) return null + return candidates[Math.abs(WORLD_VARIANT_SEED + levelId) % candidates.length] ?? null +} + +/** Every way out of a level, with the radius that fires it. */ +function linksOf(scene: PackedScene): Link[] { + const out: Link[] = [] + for (const entrance of scene.entrances ?? []) { + out.push({ ...entrance, radius: SEAM_TRIGGER_SUBTILES, what: `接缝(${entrance.side})` }) + } + for (const warp of scene.warps ?? []) { + out.push({ ...warp, radius: WARP_TRIGGER_SUBTILES, what: `传送门(${warp.direction}/${warp.source})` }) + } + return out +} + +/** + * Flood fill the walkable sub-tiles reachable from a point. + * + * Four-connected, because the engine's feet box slides along axes and a + * diagonal squeeze between two blocked corners is not something a player can + * actually walk through. + * + * @param scene - the packed scene, for its grid size. + * @param blocked - the expanded collision grid. + * @param from - the starting sub-tile. + * @returns a mask of reachable sub-tiles, empty when the start is blocked. + */ +function reachable(scene: PackedScene, blocked: Uint8Array, from: { x: number; y: number }): Uint8Array { + const width = scene.collision.width + const height = scene.collision.height + const seen = new Uint8Array(width * height) + const at = (x: number, y: number): number => y * width + x + if (from.x < 0 || from.y < 0 || from.x >= width || from.y >= height) return seen + if (blocked[at(from.x, from.y)] !== 0) return seen + + // An explicit stack rather than recursion: the Nihlathak maps are 425×425, + // which is deep enough to blow the call stack. + const stack = [at(from.x, from.y)] + seen[stack[0]!] = 1 + while (stack.length > 0) { + const here = stack.pop()! + const x = here % width + const y = (here - x) / width + const neighbours = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]] as const + for (const [nx, ny] of neighbours) { + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue + const next = at(nx, ny) + if (seen[next] === 1 || blocked[next] !== 0) continue + seen[next] = 1 + stack.push(next) + } + } + return seen +} + +/** + * Whether a link can be triggered from somewhere in a reachable region. + * + * The anchor itself is usually blocked — a staircase is scenery — so what + * matters is whether any walkable sub-tile inside the trigger box is reachable. + * + * @param scene - the packed scene. + * @param mask - the reachable mask from `reachable`. + * @param link - the link to test. + * @returns true when the player can stand somewhere that fires it. + */ +function canTrigger(scene: PackedScene, mask: Uint8Array, link: Link): boolean { + const width = scene.collision.width + const height = scene.collision.height + for (let dy = -link.radius; dy <= link.radius; dy += 1) { + for (let dx = -link.radius; dx <= link.radius; dx += 1) { + const x = link.x + dx + const y = link.y + dy + if (x < 0 || y < 0 || x >= width || y >= height) continue + if (mask[y * width + x] === 1) return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Pass 1: the acceptance route. +// --------------------------------------------------------------------------- + +/** One hop of the scripted walk. */ +interface Hop { + readonly to: number + /** How the player leaves: a link on the map, or the waypoint network. */ + readonly via: 'link' | 'waypoint' + readonly note: string +} + +/** + * Issue #23's acceptance walk. + * + * The Den of Evil is deliberately not used for the descent: it is a single + * level with nothing below it, so Cold Plains → Cave Level 1 → Cave Level 2 is + * the shortest route that actually exercises a multi-level dungeon. + */ +const ROUTE: readonly Hop[] = [ + { to: 2, via: 'link', note: '罗格营地 → 鲜血荒地(接缝)' }, + { to: 3, via: 'link', note: '鲜血荒地 → 冰冷高原(接缝)' }, + { to: 9, via: 'link', note: '冰冷高原 → 洞穴一层(洞口)' }, + { to: 13, via: 'link', note: '洞穴一层 → 洞穴二层(下行楼梯)' }, +] + +console.log('=== 验收路线 ===') + +let entry = variantFor(1) +check(entry !== null, '资源包里有罗格营地(关卡 1)') +let scene = entry === null ? null : await sceneOf(entry) +let here = scene === null ? { x: 0, y: 0 } : spawnSubTile(scene) ?? { x: 0, y: 0 } +/** Waypoints the walk switched on, mirroring `WaypointNetwork.activate`. */ +const activated = new Map() + +for (const hop of ROUTE) { + if (scene === null || entry === null) break + const fromLevelId = scene.levelId + const blocked = blockedOf(scene) + const mask = reachable(scene, blocked, here) + const reach = mask.reduce((sum, byte) => sum + byte, 0) + + // Standing on a waypoint switches it on, which is how the trip home works. + // The network stores the pedestal's *arrival* sub-tile, not its anchor: the + // anchor is the pedestal itself and is usually solid. + for (const waypoint of scene.waypoints ?? []) { + const asLink: Link = { + ...waypoint, toLevelId: -1, label: '', radius: WARP_TRIGGER_SUBTILES, what: '', + } + if (!canTrigger(scene, mask, asLink)) continue + activated.set(waypoint.waypointId, { levelId: fromLevelId, x: waypoint.arriveX, y: waypoint.arriveY }) + } + + const link = linksOf(scene).find(candidate => candidate.toLevelId === hop.to) + check(link !== undefined, `${hop.note}:${scene.levelName} 有通往 ${String(hop.to)} 的出口`) + if (link === undefined) break + check( + canTrigger(scene, mask, link), + `${hop.note}:出口「${link.label}」${link.what} 在 (${String(link.x)},${String(link.y)}) 可从落脚点 (${String(here.x)},${String(here.y)}) 走到` + + `(连通区域 ${String(reach)} 子格)`, + ) + + const nextEntry = variantFor(hop.to) + check(nextEntry !== null, `${hop.note}:资源包里有目的地 ${String(hop.to)}`) + if (nextEntry === null) break + const nextScene = await sceneOf(nextEntry) + const back = linksOf(nextScene).find(candidate => candidate.toLevelId === fromLevelId) + check(back !== undefined, `${hop.note}:${nextScene.levelName} 有回到 ${String(fromLevelId)} 的对侧开口`) + const landing = back === undefined + ? { + x: Math.floor(nextScene.cellsX * 5 / 2), + y: Math.floor(nextScene.cellsY * 5 / 2), + } + : { x: back.arriveX, y: back.arriveY } + const nextBlocked = blockedOf(nextScene) + const inBounds = landing.x >= 0 && landing.y >= 0 + && landing.x < nextScene.collision.width && landing.y < nextScene.collision.height + check( + inBounds && nextBlocked[landing.y * nextScene.collision.width + landing.x] === 0, + `${hop.note}:落脚点 (${String(landing.x)},${String(landing.y)}) 在 ${nextScene.levelName} 是空地`, + ) + console.log(` ${hop.note} → ${nextEntry.label},落脚 (${String(landing.x)},${String(landing.y)})`) + + entry = nextEntry + scene = nextScene + here = landing +} + +// The trip home. The waypoint we want is Cold Plains', switched on while +// walking through it; the scene's own rule is "go to this act's town". +console.log('=== 传送点回城 ===') +const townSite = [...activated.values()].find(site => site.levelId === 1) +const coldPlains = [...activated.entries()].find(([, site]) => site.levelId === 3) +check(coldPlains !== undefined, '路过冰冷高原时激活了它的传送点') +// Act 1's town has no waypoint pedestal of its own in the pack when the player +// has never stood on one, so the network falls back to the town's own site. +const townEntry = variantFor(1) +check(townEntry !== null, '资源包里有罗格营地') +if (townEntry !== null) { + const townScene = await sceneOf(townEntry) + const townWaypoint = (townScene.waypoints ?? [])[0] + check(townWaypoint !== undefined, '罗格营地烘焙出了传送点底座') + const landing = townSite ?? (townWaypoint === undefined + ? null + : { levelId: 1, x: townWaypoint.arriveX, y: townWaypoint.arriveY }) + if (landing !== null) { + const townBlocked = blockedOf(townScene) + check( + townBlocked[landing.y * townScene.collision.width + landing.x] === 0, + `回城落脚点 (${String(landing.x)},${String(landing.y)}) 是空地`, + ) + const mask = reachable(townScene, townBlocked, landing) + const out = linksOf(townScene).find(link => link.toLevelId === 2) + check(out !== undefined, '罗格营地仍有通往鲜血荒地的出口') + if (out !== undefined) { + check(canTrigger(townScene, mask, out), '回城之后还能再走出营地大门') + } + } +} + +// --------------------------------------------------------------------------- +// Pass 2: the whole world. +// --------------------------------------------------------------------------- + +console.log('=== 全量可达性 ===') + +/** + * The question asked here is "arrive through this opening, can you leave by + * that one". + * + * Starting from the map's spawn instead would be wrong for generated levels: + * `findIsoSpawn` picks any open sub-tile, and on a maze the biggest expanse of + * open sub-tiles is the unstamped void outside the dungeon, because + * `buildIsoMapScene` only marks a sub-tile solid when a tile's flags say so and + * a cell with no tiles at all has no flags. An arrival point, by contrast, is + * always placed next to a real staircase, so it is somewhere the player can + * genuinely be. + */ +let variantsWithExits = 0 +let pairs = 0 +let reachablePairs = 0 +const traps: string[] = [] +const partial: string[] = [] + +for (const candidate of index.levels) { + const packed = await sceneOf(candidate) + const links = linksOf(packed) + if (links.length === 0) continue + variantsWithExits += 1 + const blocked = blockedOf(packed) + + let worst = Number.MAX_SAFE_INTEGER + let stranded = false + for (const arrival of links) { + const mask = reachable(packed, blocked, { x: arrival.arriveX, y: arrival.arriveY }) + let got = 0 + for (const other of links) { + if (other === arrival) continue + pairs += 1 + if (canTrigger(packed, mask, other)) { got += 1; reachablePairs += 1 } + } + // A single-exit level cannot strand anyone: you came in that way, so you + // can leave that way. Only count levels that have somewhere else to go. + if (links.length > 1) { + worst = Math.min(worst, got) + if (got === 0) stranded = true + } + } + if (stranded) traps.push(`${candidate.label}(${String(links.length)} 个出口,某个入口进来后一个都走不到)`) + else if (links.length > 1 && worst < links.length - 1) { + partial.push(`${candidate.label} 最差 ${String(worst)}/${String(links.length - 1)}`) + } + + // Freeing the cache keeps a 365-map sweep inside a sane heap. + sceneCache.delete(candidate.path) +} + +for (const trap of traps.slice(0, 40)) console.log(` 困死 ${trap}`) +if (traps.length > 40) console.log(` …以及另外 ${String(traps.length - 40)} 张`) +if (partial.length > 0) { + console.log(` 部分可达 ${String(partial.length)} 张:${partial.slice(0, 10).join(',')}${partial.length > 10 ? ' …' : ''}`) +} +console.log( + ` ${String(variantsWithExits)} 张有出口的地图,出入口配对可达 ${String(reachablePairs)}/${String(pairs)}` + + `(${((reachablePairs / Math.max(1, pairs)) * 100).toFixed(1)}%),困死 ${String(traps.length)} 张`, +) +check(traps.length === 0, `存在 ${String(traps.length)} 张困死地图`) +check(reachablePairs === pairs, `全图出入口配对可达率不足 100% (${String(reachablePairs)}/${String(pairs)})`) + +console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过`) +if (failures > 0) process.exitCode = 1 diff --git a/src/game/acts.ts b/src/game/acts.ts index 18d0227..25ada82 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -114,6 +114,19 @@ export interface ActTables { readonly monumod: D2Table /** `SuperUniques.txt` — the named bosses, e.g. Bishibosh, Rakanishu. */ readonly superuniques: D2Table + /** + * `LvlWarp.txt`: the geometry of every stair, cave mouth and door. + * + * Referenced by `Levels.txt` `Warp0..7` — and only by those columns. + * `LevelWarp` is *not* an index into this table; it holds a string-table key + * for the hover text (`"To The Blood Moor"`). + * + * Rows must be keyed on `(Id, Direction)`, not on `Id` alone: ids 71, 73, 74, + * 81 and 82 each appear twice, once with `Direction = l` and once with `r`, + * for the Act 5 barricades. There is also one `Expansion` separator row with + * no usable data. + */ + readonly lvlwarp: D2Table } /** Everything needed to place and render one level. */ @@ -139,6 +152,28 @@ export interface LevelInfo { readonly sizeY: number /** Palette member path. */ readonly paletteName: string + /** + * `Vis0..7`: the level reachable through each warp slot, 0 when unused. + * + * Only half the world's connectivity. Outdoor levels that simply abut each + * other — town to Blood Moor, Blood Moor to Cold Plains — are absent from + * these columns entirely; see `world-graph.ts`. + */ + readonly vis: readonly number[] + /** + * `Warp0..7`: the `LvlWarp.txt` row for each slot, -1 when the slot has no + * warp tile. + * + * A slot with a `Vis` but no `Warp` is an opening the player walks through + * rather than clicks. + */ + readonly warp: readonly number[] + /** `Waypoint`, 0..38, or 255 when the level has none. */ + readonly waypoint: number + /** `Position`; 1 marks a level that something teleports into. */ + readonly position: number + /** `Portal`. */ + readonly portal: number } /** @@ -172,6 +207,7 @@ export async function loadActTables(archives: MountedArchives): Promise= grid.gridWidth || y >= grid.gridHeight) return false + return grid.blocked[y * grid.gridWidth + x] === 0 +} + +/** + * A subset of a level's walkable sub-tiles, one byte each. + * + * Used to mean "the part of the map the player can actually get to", so that a + * puddle of open ground outside the town wall is not mistaken for a gate. + */ +export type WalkableRegion = Uint8Array + +/** + * The biggest connected patch of open ground in a level. + * + * Maps are not one connected space. A town's artwork leaves walkable sub-tiles + * in the moat outside its wall; a cave's rock has pockets the layout never + * joins up. Anything placed in one of those is a link the player can see on the + * minimap and never touch, which is exactly the failure this pass exists to + * prevent. Taking the largest component is a blunt rule, but on every map + * measured the main play area dwarfs the leftovers by an order of magnitude. + * + * Four-connected, matching the engine's axis-aligned feet box: a diagonal + * squeeze between two blocked corners is not somewhere a player can walk. + * + * @param grid - the collision map. + * @returns one byte per sub-tile, 1 inside the main region. + */ +export function largestWalkableRegion(grid: LinkGrid): WalkableRegion { + const width = grid.gridWidth + const height = grid.gridHeight + const component = new Int32Array(width * height).fill(-1) + const sizes: number[] = [] + // An explicit stack rather than recursion: the biggest maps are 425x425, + // which is deep enough to overflow the call stack. + const stack: number[] = [] + + for (let seed = 0; seed < component.length; seed += 1) { + if (component[seed] !== -1 || grid.blocked[seed] !== 0) continue + const label = sizes.length + let size = 0 + component[seed] = label + stack.push(seed) + while (stack.length > 0) { + const here = stack.pop()! + size += 1 + const x = here % width + const y = (here - x) / width + if (x > 0) { const n = here - 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } } + if (x < width - 1) { const n = here + 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } } + if (y > 0) { const n = here - width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } } + if (y < height - 1) { const n = here + width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } } + } + sizes.push(size) + } + + let best = -1 + let bestSize = 0 + sizes.forEach((size, label) => { if (size > bestSize) { bestSize = size; best = label } }) + + const region = new Uint8Array(component.length) + if (best < 0) return region + for (let at = 0; at < component.length; at += 1) if (component[at] === best) region[at] = 1 + return region +} + +/** + * Whether a sub-tile is inside a region, or walkable when there is no region. + * + * @param grid - the collision map. + * @param region - the region to test against, or undefined for "anywhere open". + * @param x - sub-tile x. + * @param y - sub-tile y. + * @returns true when the sub-tile qualifies. + */ +function inRegion(grid: LinkGrid, region: WalkableRegion | undefined, x: number, y: number): boolean { + if (!isWalkable(grid, x, y)) return false + return region === undefined || region[y * grid.gridWidth + x] === 1 +} + +/** + * The nearest walkable sub-tile to a point. + * + * Searches outwards in square rings, so the first hit is the closest by + * Chebyshev distance and ties break in a fixed order rather than by chance. + * + * @param grid - the collision map. + * @param x - sub-tile x to search around. + * @param y - sub-tile y to search around. + * @param maxRadius - how far out to give up, in sub-tiles. + * @param region - when given, only sub-tiles inside it count. + * @returns the sub-tile, or null if everything within `maxRadius` is blocked. + */ +export function nearestWalkable( + grid: LinkGrid, + x: number, + y: number, + maxRadius = 24, + region?: WalkableRegion, +): { x: number; y: number } | null { + if (inRegion(grid, region, x, y)) return { x, y } + for (let radius = 1; radius <= maxRadius; radius += 1) { + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + // Only the ring, not its interior: the interior was covered by the + // smaller radii already. + if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue + if (inRegion(grid, region, x + dx, y + dy)) return { x: x + dx, y: y + dy } + } + } + } + return null +} + +/** + * Whether a link here could be set off by a player standing in a region. + * + * The runtime fires a link when the player's sub-tile is within the link's + * trigger radius of it. That test says nothing about whether the player can + * *get* to such a sub-tile: a staircase surrounded by open ground that is + * walled off from the rest of the map passes it and is still unreachable. So + * the question worth asking at bake time is the trigger test restricted to the + * region the player actually inhabits. + * + * With no region this degenerates to "is there open ground in the box", which + * is the weaker check the bake used before regions existed. + * + * @param grid - the collision map. + * @param region - the ground the player can reach, or undefined for anywhere. + * @param x - the link's sub-tile x. + * @param y - the link's sub-tile y. + * @param radius - the link's trigger radius, in sub-tiles. + * @returns true when some sub-tile of the region lies inside the trigger box. + */ +export function triggerableFrom( + grid: LinkGrid, + region: WalkableRegion | undefined, + x: number, + y: number, + radius: number, +): boolean { + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + if (inRegion(grid, region, x + dx, y + dy)) return true + } + } + return false +} + +/** + * The inward direction for a side, in sub-tiles. + * + * @param side - the edge. + * @returns a unit step pointing into the level. + */ +function inwardStep(side: Side): { x: number; y: number } { + switch (side) { + case 'north': + return { x: 0, y: 1 } + case 'south': + return { x: 0, y: -1 } + case 'west': + return { x: 1, y: 0 } + case 'east': + return { x: -1, y: 0 } + } +} + +/** + * Choose an arrival sub-tile inward from a border seam so arriving cannot + * immediately re-trigger the seam. + * + * Searches outward in square rings around a point `SEAM_TRIGGER_SUBTILES + 3` + * steps inside the level from `(x, y)`, accepting only sub-tiles in `region` + * that lie strictly outside the trigger box around `(x, y)`. + * + * @param grid - the collision map. + * @param x - the seam trigger's sub-tile x. + * @param y - the seam trigger's sub-tile y. + * @param side - which edge of the level the seam sits on. + * @param region - when given, only sub-tiles inside it qualify. + * @returns an arrival sub-tile safe from immediate re-triggering. + */ +export function seamArrivalSpot( + grid: LinkGrid, + x: number, + y: number, + side: Side, + region?: WalkableRegion, +): { x: number; y: number } { + const step = inwardStep(side) + const targetX = x + step.x * (SEAM_TRIGGER_SUBTILES + 3) + const targetY = y + step.y * (SEAM_TRIGGER_SUBTILES + 3) + for (let radius = 0; radius <= 32; radius += 1) { + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue + const px = targetX + dx + const py = targetY + dy + if (!inRegion(grid, region, px, py)) continue + if (Math.abs(px - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(py - y) <= SEAM_TRIGGER_SUBTILES) continue + return { x: px, y: py } + } + } + } + return nearestWalkable(grid, targetX, targetY, 32, region) ?? { x, y } +} + +/** Where an edge of a level opens, and how convincing the opening is. */ +export interface BorderOpening { + /** The gap itself, in sub-tiles. */ + readonly x: number + readonly y: number + /** One step inside, where an arriving player is put. */ + readonly arriveX: number + readonly arriveY: number + /** + * How far in from the border the gap was found, in sub-tiles. + * + * Zero means the walkable area runs off the edge of the map, which is what a + * real gate looks like. Anything larger means the scan gave up on the border + * and settled for the walkable area's closest approach to it. + */ + readonly inset: number + /** How wide the gap is along the edge, in sub-tiles. */ + readonly width: number +} + +/** + * Find the gap in one edge of a level's border. + * + * For generated levels the generator already knows where it cut the border, and + * this is not needed. Preset levels are the reason it exists: the Rogue + * Encampment's gate is painted into fixed artwork, and the only way to find it + * without hand-measuring every town is to look at what the artwork left + * walkable. + * + * The scan works inwards from the edge because a border is a band, not a line: + * the outermost sub-tiles of a town are solid cliff, and the gate first becomes + * walkable a little way in. The first band row with any walkable run wins, and + * within it the longest run, whose middle is the gap. + * + * How far in is worth looking is not a small fixed number. Measured on the + * baked collision maps, Kurast 4's west margin is 55 sub-tiles of solid jungle + * and its east margin 48, and the Act 3 docks are walled 45 sub-tiles deep on + * the east; an earlier flat limit of 40 declared all three edges solid. The + * default is therefore half the level's depth: past the midpoint a gap is no + * longer on the side we were asked about, so that is the natural place to stop + * rather than an arbitrary one. + * + * Passing `region` is strongly recommended. Without it the Rogue Encampment's + * four variants all report a gate on whichever side they were asked about, + * because every one of them has walkable sand in the moat *outside* the camp + * wall — a gate the player can see and never reach. + * + * @param grid - the collision map. + * @param side - which edge to search. + * @param region - when given, only gaps inside it count as gaps. + * @param bandDepth - how many sub-tiles inwards to look before giving up. + * Defaults to half the level's depth on the axis being searched. + * @returns the opening, or null if the edge is solid all the way in. + */ +export function findBorderOpening( + grid: LinkGrid, + side: Side, + region?: WalkableRegion, + bandDepth?: number, +): BorderOpening | null { + const horizontal = side === 'north' || side === 'south' + const span = horizontal ? grid.gridWidth : grid.gridHeight + const depth = horizontal ? grid.gridHeight : grid.gridWidth + const limit = Math.min(bandDepth ?? Math.floor(depth / 2), depth) + + for (let inset = 0; inset < limit; inset += 1) { + const fixed = side === 'north' || side === 'west' ? inset : depth - 1 - inset + + let bestStart = -1 + let bestLength = 0 + let runStart = -1 + for (let along = 0; along <= span; along += 1) { + const open = along < span + && (horizontal ? inRegion(grid, region, along, fixed) : inRegion(grid, region, fixed, along)) + if (open) { + if (runStart < 0) runStart = along + continue + } + if (runStart >= 0) { + const length = along - runStart + if (length > bestLength) { + bestLength = length + bestStart = runStart + } + runStart = -1 + } + } + if (bestLength === 0) continue + + const middle = bestStart + Math.floor(bestLength / 2) + const x = horizontal ? middle : fixed + const y = horizontal ? fixed : middle + const arrive = seamArrivalSpot(grid, x, y, side, region) + return { x, y, arriveX: arrive.x, arriveY: arrive.y, inset, width: bestLength } + } + return null +} + +/** + * Choose somewhere to stand a waypoint. + * + * A stand-in for `DRLGOUTDOORS_SpawnAct12Waypoint`, which decides this properly + * from the level's room list and is not implemented. Until it is, the middle of + * the level is the least surprising answer: it is roughly where the roads meet, + * it is reachable from every seam, and it does not collide with the border + * presets the way a corner would. + * + * @param grid - the collision map. + * @param region - when given, only sub-tiles inside it count. + * @returns the sub-tile, or null when the level has no open ground at all. + */ +export function findWaypointSpot(grid: LinkGrid, region?: WalkableRegion): { x: number; y: number } | null { + const centreX = Math.floor(grid.gridWidth / 2) + const centreY = Math.floor(grid.gridHeight / 2) + return nearestWalkable(grid, centreX, centreY, Math.max(grid.gridWidth, grid.gridHeight), region) +} diff --git a/src/game/portal.ts b/src/game/portal.ts new file mode 100644 index 0000000..da4ded1 --- /dev/null +++ b/src/game/portal.ts @@ -0,0 +1,239 @@ +/** + * The two ways to travel without walking: waypoints and the town portal. + * + * Both are pure state machines over level ids and sub-tile positions. Nothing + * here touches the DOM, the renderer or the clock — the scene asks what is + * possible and where it leads, and does the moving itself. That keeps the rules + * testable without a browser, and keeps them out of the render loop. + * + * ## Waypoints + * + * A waypoint is not a link between two places. It is a member of a network: + * step on one and you may afterwards travel to any other you have already + * stepped on. `Levels.txt` `Waypoint` gives each one a number, 0 to 38, and the + * number — not the level — is the identity, which is why the network is keyed + * on it. + * + * Activation is per character, and there is no un-activating: in the original + * game the blue ring stays lit for the rest of the game once touched. + * + * ## Town portals + * + * At most one open at a time. Casting a second closes the first, which is the + * original behaviour and also the only rule that makes the return trip + * unambiguous. A portal has two mouths — one where it was cast and one in the + * act's town — and stepping into either sends you to the other. + * + * Browser safety: no `node:` builtins, no `Math.random`, no wall-clock. + */ + +/** Where a traveller comes out. */ +export interface TravelTarget { + /** `Levels.txt` `Id` of the destination. */ + readonly levelId: number + /** Where to stand on arrival, in sub-tiles. */ + readonly x: number + readonly y: number +} + +/** One waypoint the network knows about. */ +export interface WaypointSite { + /** `Levels.txt` `Waypoint`, 0..38. */ + readonly waypointId: number + /** The level hosting it. */ + readonly levelId: number + /** The act it belongs to, 1..5, for grouping in the UI. */ + readonly act: number + /** Its display name. */ + readonly name: string + /** Where the pedestal stands, in sub-tiles. */ + readonly x: number + readonly y: number +} + +/** + * The set of waypoints this character has touched. + * + * The catalogue of every waypoint in the world is separate from the set that + * has been activated: the first is a property of the world and is the same for + * everyone, the second is save data. + */ +export class WaypointNetwork { + private readonly sites = new Map() + private readonly activated = new Set() + + /** + * Tell the network a waypoint exists. + * + * Idempotent, and later registrations win, so re-registering after a level + * variant swap corrects the position rather than duplicating the entry. + * + * @param site - the waypoint. + */ + register(site: WaypointSite): void { + this.sites.set(site.waypointId, site) + } + + /** + * Light a waypoint up. + * + * @param waypointId - the waypoint touched. + * @returns true when this was the first time. + */ + activate(waypointId: number): boolean { + if (this.activated.has(waypointId)) return false + this.activated.add(waypointId) + return true + } + + /** + * Whether a waypoint has been touched. + * + * @param waypointId - the waypoint. + * @returns true when it is lit. + */ + isActive(waypointId: number): boolean { + return this.activated.has(waypointId) + } + + /** + * Every destination currently reachable, in act then waypoint order. + * + * @returns the lit waypoints whose positions are known. + */ + destinations(): WaypointSite[] { + const out: WaypointSite[] = [] + for (const id of this.activated) { + const site = this.sites.get(id) + if (site !== undefined) out.push(site) + } + out.sort((a, b) => a.act - b.act || a.waypointId - b.waypointId) + return out + } + + /** + * Where a waypoint leads. + * + * @param waypointId - the wanted waypoint. + * @returns the target, or null when it is unknown or not yet lit. + */ + travelTo(waypointId: number): TravelTarget | null { + if (!this.activated.has(waypointId)) return null + const site = this.sites.get(waypointId) + if (site === undefined) return null + return { levelId: site.levelId, x: site.x, y: site.y } + } + + /** + * The lit waypoint ids, for saving. + * + * @returns the ids, ascending. + */ + save(): number[] { + return [...this.activated].sort((a, b) => a - b) + } + + /** + * Restore the lit set from a save. + * + * @param ids - the ids to light. + */ + load(ids: readonly number[]): void { + this.activated.clear() + for (const id of ids) this.activated.add(id) + } +} + +/** An open town portal, with a mouth at each end. */ +export interface OpenPortal { + /** The level it was cast in. */ + readonly fromLevelId: number + /** The mouth in that level, in sub-tiles. */ + readonly fromX: number + readonly fromY: number + /** The act's town. */ + readonly townLevelId: number + /** The mouth in town, in sub-tiles. */ + readonly townX: number + readonly townY: number +} + +/** + * The one town portal a character may have open. + * + * Deliberately a single slot rather than a list. Two open portals would make + * "step into the portal in town" ambiguous, and the original game does not + * allow it either: casting again closes the old one. + */ +export class TownPortalSlot { + private open: OpenPortal | null = null + + /** + * Cast a portal, replacing any previous one. + * + * @param portal - the new portal. + * @returns the portal that was closed to make room, if any. + */ + cast(portal: OpenPortal): OpenPortal | null { + const previous = this.open + this.open = portal + return previous + } + + /** Close the portal, if one is open. */ + close(): void { + this.open = null + } + + /** + * The open portal. + * + * @returns it, or null. + */ + current(): OpenPortal | null { + return this.open + } + + /** + * Where stepping into a mouth in the given level leads. + * + * @param levelId - the level the player is standing in. + * @returns the other mouth, or null when this level has no mouth. + */ + otherEnd(levelId: number): TravelTarget | null { + const portal = this.open + if (portal === null) return null + if (levelId === portal.fromLevelId) { + return { levelId: portal.townLevelId, x: portal.townX, y: portal.townY } + } + if (levelId === portal.townLevelId) { + return { levelId: portal.fromLevelId, x: portal.fromX, y: portal.fromY } + } + return null + } +} + +/** + * The town for an act. + * + * Hard-coded because `Levels.txt` does not mark towns: the Rogue Encampment's + * row looks like any other preset. The ids are stable across every version of + * the game. + * + * @param act - the act, 1..5. + * @returns the town's level id. + */ +export function townLevelForAct(act: number): number { + switch (act) { + case 1: + return 1 + case 2: + return 40 + case 3: + return 75 + case 4: + return 103 + default: + return 109 + } +} diff --git a/src/game/warp-tiles.ts b/src/game/warp-tiles.ts new file mode 100644 index 0000000..b327791 --- /dev/null +++ b/src/game/warp-tiles.ts @@ -0,0 +1,176 @@ +/** + * Find the staircases a map's artwork declares. + * + * Diablo II does not store level exits in a table of coordinates. It stores + * them in the map itself: a DS1 cell may carry a wall of **type 10 or 11**, + * which the engine calls a *special* tile. A special tile draws nothing — it is + * a marker — and its `style` field says what it marks. + * + * For styles 0..7 the meaning is a warp, and the style **is the `Warp0..7` slot + * index** from `Levels.txt`. So a tile with `style` 3 in the Act 2 town is the + * staircase for whatever `Vis3` points at, which is the second sewer entrance. + * That is the whole join between the table and the artwork, and it was verified + * against the shipped data before this module was written: + * + * | level | special tile styles | `Warp0..7` populated | + * |-------|---------------------|----------------------| + * | 20 Forgotten Tower entrance | 0, 1 | `Warp0=11`, `Warp1=12` | + * | 40 Lut Gholein | 2, 3, 4 | `Warp2=19, Warp3=20, Warp4=24` | + * | 50 Harem | 0, 2, 3 | `Warp0=25, Warp2=28, Warp3=29` | + * | 33 Cathedral | 1 | `Warp1=15` | + * + * Ten levels were checked and ten matched, including every case where a level + * skips a slot. + * + * Styles of 8 and above are other kinds of marker — town entry points, player + * start positions, act-specific arrival spots — and this module ignores them, + * because `Warp0..7` has only eight slots and anything outside that range + * cannot be a warp. + * + * A single staircase covers more than one cell: `LvlWarp.txt` `Tiles` is 2 for + * almost every warp in the game and 4 for the two Act 5 barricade gates, and + * the DS1 marks each covered cell separately with a rising `sequence`. Those + * cells are grouped back together here, so the caller gets one position per + * staircase rather than one per tile. + * + * Generated levels get this for free: the maze and wilderness generators stamp + * whole DS1 pieces, and the special tiles come along with the walls. That is + * why this module takes a `Ds1` and not a file name — it works the same on a + * shipped preset and on a level that was assembled a millisecond ago. + * + * Browser-safe: no `node:` imports, no `Math.random`, no `process`. + */ +import type { Ds1 } from '../formats/ds1.ts' + +/** + * DS1 wall types that mark something instead of drawing something. + * + * Both values mean "special"; the game uses two so that a cell can hold two + * markers at once, which happens in towns where a warp and an entry point sit + * on the same tile. + */ +const SPECIAL_WALL_TYPES = [10, 11] as const + +/** `Levels.txt` has `Warp0` through `Warp7`, so a style above this is not a warp. */ +const MAX_WARP_SLOT = 7 + +/** + * Cells this far apart still count as the same staircase. + * + * A warp is 2 or 4 cells in a row, so anything touching or diagonally adjacent + * belongs together. Two genuinely different staircases sharing a slot are + * always further apart than this — they are in different rooms. + */ +const GROUP_RADIUS = 1 + +/** One staircase, as the artwork placed it. */ +export interface WarpTile { + /** The `Warp0..7` slot, which is the DS1 special tile's `style`. */ + readonly slot: number + /** Centre of the marked cells, in cells. */ + readonly cellX: number + readonly cellY: number + /** How many cells the marker covered; 2 for most warps, 4 for a few. */ + readonly tiles: number + /** Top-left of the marked cells, for callers that want the corner. */ + readonly minCellX: number + readonly minCellY: number +} + +/** A cell carrying a marker, before grouping. */ +interface MarkedCell { + readonly slot: number + readonly x: number + readonly y: number +} + +/** + * Collect every special tile in the range that can be a warp. + * + * @param ds1 - the map. + * @returns the marked cells, in scan order. + */ +function markedCells(ds1: Ds1): MarkedCell[] { + const found: MarkedCell[] = [] + for (let y = 0; y < ds1.height; y += 1) { + const row = ds1.cells[y] + if (row === undefined) continue + for (let x = 0; x < ds1.width; x += 1) { + const cell = row[x] + if (cell === undefined) continue + for (const wall of cell.walls) { + if (!SPECIAL_WALL_TYPES.includes(wall.type as 10 | 11)) continue + if (wall.style > MAX_WARP_SLOT) continue + found.push({ slot: wall.style, x, y }) + } + } + } + return found +} + +/** + * Find the staircases in a map. + * + * Cells are grouped per slot, so a two-tile staircase produces one result. When + * a slot is marked in two places — which the shipped maps never do but a + * generated level can, because a generator may stamp the same stair piece + * twice — every group is returned, in scan order, and the caller decides. The + * first one is the natural choice and the order is deterministic. + * + * @param ds1 - the map to scan. + * @returns one entry per staircase, sorted by slot and then by position. + */ +export function findWarpTiles(ds1: Ds1): WarpTile[] { + const bySlot = new Map() + for (const cell of markedCells(ds1)) { + const list = bySlot.get(cell.slot) + if (list === undefined) bySlot.set(cell.slot, [cell]) + else list.push(cell) + } + + const out: WarpTile[] = [] + for (const [slot, cells] of [...bySlot].sort((left, right) => left[0] - right[0])) { + // Flood the adjacency so a 2-tile or 4-tile marker collapses to one warp. + const unvisited = new Set(cells.map((_, index) => index)) + while (unvisited.size > 0) { + const first = unvisited.values().next().value as number + unvisited.delete(first) + const group = [cells[first]!] + const queue = [cells[first]!] + while (queue.length > 0) { + const here = queue.pop()! + for (const index of [...unvisited]) { + const other = cells[index]! + if (Math.abs(other.x - here.x) > GROUP_RADIUS) continue + if (Math.abs(other.y - here.y) > GROUP_RADIUS) continue + unvisited.delete(index) + group.push(other) + queue.push(other) + } + } + + let minX = group[0]!.x + let minY = group[0]!.y + let maxX = minX + let maxY = minY + for (const cell of group) { + if (cell.x < minX) minX = cell.x + if (cell.y < minY) minY = cell.y + if (cell.x > maxX) maxX = cell.x + if (cell.y > maxY) maxY = cell.y + } + out.push({ + slot, + cellX: Math.floor((minX + maxX) / 2), + cellY: Math.floor((minY + maxY) / 2), + tiles: group.length, + minCellX: minX, + minCellY: minY, + }) + } + } + + out.sort((left, right) => + left.slot - right.slot || left.cellY - right.cellY || left.cellX - right.cellX) + return out +} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index dd75b67..59d6cdc 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -77,6 +77,7 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' +import type { Side } from './world-graph.ts' /** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */ export const TILES_PER_BLOCK = 8 @@ -223,6 +224,44 @@ export interface WildernessRequest { * piece instead; pass this to override that. */ readonly sizeOverride?: { readonly x: number; readonly y: number } + /** + * The cross-level openings this level must have, and which edge each is on. + * + * Supplied by the world graph rather than decided here, for two reasons. + * + * The first is that the sides are not a property of the level: Diablo II's + * outdoor placer re-picks them for every seed, subject to the constraint that + * two openings of one level may not share an edge. Hard-coding "the Cold + * Plains exit is on the north edge" states one legal roll as if it were a + * law. + * + * The second is that a seam has two ends. If this level puts its exit on the + * east edge, its neighbour must put the matching entrance on its west edge, + * and neither level can work that out alone. `assignGateSides` in + * `world-graph.ts` solves the whole act at once and hands each level the + * answer. + * + * When omitted the generator falls back to its own Act 1 table, which is what + * the tests and `verify-generators` use. + */ + readonly gates?: readonly PlannedGate[] +} + +/** One cross-level opening, as decided by the act layout. */ +export interface PlannedGate { + /** Which edge of this level the opening sits on. */ + readonly side: Side + /** The level on the other side. */ + readonly toLevelId: number + /** A human-readable name, used for the road anchor. */ + readonly label: string + /** + * Which border-piece variant to open, if the caller has a preference. + * + * Three is the ordinary open gate; four is the variant the Burial Grounds and + * the Cold Plains use for the graveyard seam. + */ + readonly variant?: number } /** The generated level. */ @@ -564,8 +603,50 @@ interface WildernessStats { groundCells: number groundTile: { style: number; sequence: number } | null sizeSource: string + roadCells: number + roadSegments: number + anchors: number + specialPresets: string[] + /** Every way into or out of the level, in cells. See `WildernessEntrance`. */ + entrances: WildernessEntrance[] } +/** + * Exact 256-entry 8-neighbor bitmask lookup table for Act 1 dirt path tiles + * (`byte_6FDCF958` in `DrlgOutdoors.cpp` / `DRLG_OUTDOORS_GenerateDirtPath`). + * + * Given a cell `(x, y)` marked as part of a dirt path, sample its 8 neighbors: + * - bit 7 (`0x80`): `(x + 1, y - 1)` + * - bit 6 (`0x40`): `(x + 1, y )` + * - bit 5 (`0x20`): `(x + 1, y + 1)` + * - bit 4 (`0x10`): `(x , y - 1)` + * - bit 3 (`0x08`): `(x , y + 1)` + * - bit 2 (`0x04`): `(x - 1, y - 1)` + * - bit 1 (`0x02`): `(x - 1, y )` + * - bit 0 (`0x01`): `(x - 1, y + 1)` + * + * Non-zero entries give the DT1 `sequence` (with `style = 0`, `prop1 = 2`) for + * the resulting straight, corner, or transition dirt road tile. + */ +export const DIRT_PATH_TILE_LUT: Readonly = new Uint8Array([ + 0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13, + 0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14, + 0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13, + 0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14, + 0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x2B, + 0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x09, 0x09, 0x02, 0x2B, 0x09, 0x09, 0x2C, 0x1A, + 0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11, + 0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x2A, 0x2A, 0x2E, 0x2A, 0x2A, 0x2A, 0x21, 0x1F, + 0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13, + 0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x23, 0x14, + 0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13, + 0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14, + 0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x25, + 0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x24, 0x24, 0x2D, 0x22, 0x24, 0x24, 0x1C, 0x1D, + 0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11, + 0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01, +]) + /** * The hardcoded passes D2MOO runs for an outdoor level that this port does not * reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so @@ -574,14 +655,11 @@ interface WildernessStats { const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', - 'DRLGOUTWILD_InitAct1OutdoorLevel', 'DRLGOUTDESR_InitAct2OutdoorLevel', 'DRLGOUTPLACE_InitAct3OutdoorLevel', 'DRLGOUTDOORS_InitAct4OutdoorLevel', 'DRLGOUTSIEGE_InitAct5OutdoorLevel', 'DRLG_GenerateJungles', - 'DRLGOUTDOORS_SpawnAct1DirtPaths', - 'DRLG_OUTDOORS_GenerateDirtPath', 'DRLGOUTDOORS_SpawnAct12Waypoint', 'DRLGOUTDOORS_SpawnAct12Shrines', 'DRLGOUTDOORS_SpawnAct3Mephisto', @@ -591,15 +669,67 @@ const UNIMPLEMENTED_PASSES: readonly string[] = [ * Border * ------------------------------------------------------------------------- */ +type BorderRole = 'tl' | 'tr' | 'br' | 'bl' | 'top' | 'right' | 'bottom' | 'left' + +function borderRoleAt(x: number, y: number, gridWidth: number, gridHeight: number): BorderRole { + if (x === 0 && y === 0) return 'tl' + if (x === gridWidth - 1 && y === 0) return 'tr' + if (x === gridWidth - 1 && y === gridHeight - 1) return 'br' + if (x === 0 && y === gridHeight - 1) return 'bl' + if (y === 0) return 'top' + if (x === gridWidth - 1) return 'right' + if (y === gridHeight - 1) return 'bottom' + return 'left' +} + +/** + * Find the directional border piece matching `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`. + * + * In Diablo II: + * - Numeric `Border 1..8` map to: 1=Bottom, 2=Left, 3=Top, 4=Right, + * 5=Bottom-Left, 6=Top-Left, 7=Top-Right, 8=Bottom-Right. + * - Compass `Border N/S/E/W/NE/NW/SE/SW` (Act 3 Kurast) map directly by cardinal direction. + */ +function findDirectionalBorderPiece(pieces: readonly WildernessPiece[], role: BorderRole): WildernessPiece | null { + const numericPatterns: Record = { + bottom: /Border\s+1\b/i, + left: /Border\s+2\b/i, + top: /Border\s+3\b/i, + right: /Border\s+4\b/i, + bl: /Border\s+5\b/i, + tl: /Border\s+6\b/i, + tr: /Border\s+7\b/i, + br: /Border\s+8\b/i, + } + const compassPatterns: Record = { + top: /Border\s+N\b/i, + bottom: /Border\s+S\b/i, + right: /Border\s+E\b/i, + left: /Border\s+W\b/i, + tr: /Border\s+NE\b/i, + tl: /Border\s+NW\b/i, + br: /Border\s+SE\b/i, + bl: /Border\s+SW\b/i, + } + const hasExtendedNumericBorders = pieces.some(p => /Border\s+(?:9|1\d)\b/i.test(p.name)) + if (!hasExtendedNumericBorders) { + const numPat = numericPatterns[role] + const matchingNum = pieces.find(p => numPat.test(p.name) && !/Cliff Border/i.test(p.name)) + ?? pieces.find(p => numPat.test(p.name)) + if (matchingNum !== undefined) return matchingNum + } + const compPat = compassPatterns[role] + const matchingComp = pieces.find(p => compPat.test(p.name)) + if (matchingComp !== undefined) return matchingComp + return null +} + /** * Walk the block grid's outer ring, clockwise from the top-left. * * This is the path `DRLGOUTPLACE_PlaceAct1245OutdoorBorders` walks: it follows * the level's outline vertex ring and stamps one preset per grid step along each - * edge with the edge's own preset id. This port has no outline ring — building it - * needs the level-link data that is hardcoded per act — so it uses the rectangle - * the level *is*, which for every shipped wilderness level is the rectangle - * `Levels.txt` declares. + * edge with the edge's own preset id. * * @param gridWidth - blocks across. * @param gridHeight - blocks down. @@ -614,9 +744,6 @@ function borderRing(gridWidth: number, gridHeight: number): { x: number; y: numb return ring } -/** - * Lay the level's border pieces around the block grid's outer ring. - * /** Orientation of a border piece along the outer edge of a level. */ export type BorderOrientation = | 'north' @@ -830,6 +957,7 @@ export function getPerimeterOpenings( * * Perimeter openings (exits/entrances) select open variants (e.g. `Bord1o`/`Bord4o`), * while all other perimeter pieces use solid/closed wall variants (`isClosedBorder`). + * Supports forced gate variants (`openGates`) and claimed border blocks (`claimedBorderBlocks`). * * @param canvas - the canvas. * @param pieces - the level's border pieces, already filtered to `border`. @@ -837,7 +965,9 @@ export function getPerimeterOpenings( * @param gridHeight - blocks down. * @param rng - the level's random stream, for the variant rotation. * @param stats - report collector. - * @param levelId - level id for perimeter opening rules. + * @param levelId - optional level id for perimeter opening rules. + * @param openGates - optional map from `${x},${y}` block key to forced variant index. + * @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions). * @returns the number of pieces stamped. */ function layBorder( @@ -847,7 +977,9 @@ function layBorder( gridHeight: number, rng: Rng, stats: WildernessStats, - levelId: number, + levelId?: number, + openGates?: ReadonlyMap, + claimedBorderBlocks?: ReadonlySet, ): number { if (pieces.length === 0) { stats.unresolved.push('no border piece for this level type') @@ -890,12 +1022,17 @@ function layBorder( { x: 0, y: gridHeight - 1 }, // SW ] - const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight) + const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set() let cursor = rng.int(0, ordered.length - 1) let stamped = 0 const stampCell = (cell: { x: number; y: number }, isCorner: boolean) => { + const key = `${String(cell.x)},${String(cell.y)}` + if (claimedBorderBlocks?.has(key) === true) { + return + } + const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight) // North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries) const preferCliff = @@ -912,8 +1049,14 @@ function layBorder( } if (!piece || piece.levels.length === 0) return - const isOpen = !isCorner && openings.has(`${String(cell.x)},${String(cell.y)}`) - const level = selectBorderVariant(piece, orientation, isOpen, rng) + let level: Ds1 | undefined + const forcedVariant = openGates?.get(key) + if (forcedVariant !== undefined) { + level = piece.levels[Math.min(forcedVariant, piece.levels.length - 1)] + } else { + const isOpen = !isCorner && openings.has(key) + level = selectBorderVariant(piece, orientation, isOpen, rng) + } if (level === undefined) return const maxOriginX = Math.max(canvas.width - level.width, 0) @@ -937,27 +1080,752 @@ function layBorder( return stamped } +/* ------------------------------------------------------------------------- * + * Act 1 Outdoor Anchors, Presets & Dirt Road Generation + * (`DRLGOUTWILD_InitAct1OutdoorLevel`, `DRLGOUTDOORS_SpawnAct1DirtPaths`, + * `DRLG_OUTDOORS_GenerateDirtPath`) + * ------------------------------------------------------------------------- */ + +/** + * Where a road ends. + * + * Until now these were purely internal: the road network needs endpoints to + * draw between and nothing more. They are also, though, the exact world + * coordinates of every way into and out of the level, which is what the world + * graph needs and what was previously being computed and thrown away — only + * `stats.anchors`, a count, escaped the generator. + * + * Coordinates are in cells, with `(0, 0)` at the final map's origin. Unlike the + * maze generator there is no `minX` shift to undo. + */ +interface RoadAnchor { + readonly label: string + /** The opening itself: the cell the player crosses. */ + readonly anchorCell: { readonly x: number; readonly y: number } + /** One step inside the level from `anchorCell`, where the road starts. */ + readonly interiorCell: { readonly x: number; readonly y: number } + /** The level on the other side, when this anchor is a way out. */ + readonly toLevelId?: number | undefined + /** Which edge of the map this anchor sits on, for border gates. */ + readonly side?: Side | undefined + /** Whether this is a border seam or the mouth of an interior preset. */ + readonly kind?: 'gate' | 'preset' | undefined +} + +/** A level entrance or exit, as reported in `stats.entrances`. */ +export interface WildernessEntrance { + /** The anchor's name, e.g. `Cold Plains Exit`. */ + readonly label: string + /** The crossing point, in cells. */ + readonly x: number + readonly y: number + /** Where the player stands after crossing, in cells. */ + readonly interiorX: number + readonly interiorY: number + /** The level on the other side, or -1 when the generator does not know. */ + readonly toLevelId: number + /** The map edge, for border seams. */ + readonly side: Side | null + /** Border seam or interior preset mouth. */ + readonly kind: 'gate' | 'preset' +} + +interface SpecialPresetPlacement { + readonly name: string + readonly ds1: Ds1 + readonly bx: number + readonly by: number + readonly blocksX: number + readonly blocksY: number +} + +interface Act1OutdoorPlan { + readonly openGates: Map + readonly claimedBorderBlocks: Set + readonly claimedBlocks: boolean[][] + readonly roadObstacleBlocks: boolean[][] + readonly specialPresets: SpecialPresetPlacement[] + readonly anchors: RoadAnchor[] +} + +/** Map a compass side onto the generator's own edge names. */ +const SIDE_TO_EDGE: Readonly> = { + north: 'top', + south: 'bottom', + east: 'right', + west: 'left', +} + +/** + * Turn the plan's anchors into the entrance list the packer bakes. + * + * @param anchors - the plan's anchors. + * @returns one entrance per anchor. + */ +function toEntrances(anchors: readonly RoadAnchor[]): WildernessEntrance[] { + return anchors.map(anchor => ({ + label: anchor.label, + x: anchor.anchorCell.x, + y: anchor.anchorCell.y, + interiorX: anchor.interiorCell.x, + interiorY: anchor.interiorCell.y, + toLevelId: anchor.toLevelId ?? -1, + side: anchor.side ?? null, + kind: anchor.kind ?? 'preset', + })) +} + +/** + * Plan Act 1 wilderness special presets, border road gates, and road anchors. + */ +function planAct1OutdoorLevel( + request: WildernessRequest, + gridWidth: number, + gridHeight: number, + rng: Rng, +): Act1OutdoorPlan { + const openGates = new Map() + const claimedBorderBlocks = new Set() + const claimedBlocks: boolean[][] = Array.from({ length: gridHeight }, () => + Array.from({ length: gridWidth }, () => false), + ) + const roadObstacleBlocks: boolean[][] = Array.from({ length: gridHeight }, () => + Array.from({ length: gridWidth }, () => false), + ) + const specialPresets: SpecialPresetPlacement[] = [] + const anchors: RoadAnchor[] = [] + + const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => { + for (let dy = 0; dy < h; dy += 1) { + for (let dx = 0; dx < w; dx += 1) { + const y = by + dy + const x = bx + dx + if (y >= 0 && y < gridHeight && x >= 0 && x < gridWidth) { + claimedBlocks[y]![x] = true + if (isObstacle) roadObstacleBlocks[y]![x] = true + } + } + } + } + + const findPiece = (prefix: string): WildernessPiece | undefined => + request.pieces.find(p => p.name.toLowerCase().startsWith(prefix.toLowerCase())) + + const addBorderGate = ( + edge: 'top' | 'bottom' | 'left' | 'right', + variant: number, + label: string, + toLevelId?: number, + ): void => { + if (edge === 'left') { + let by = rng.int(2, Math.max(2, gridHeight - 3)) + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break + by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + } + openGates.set(`0,${String(by)}`, variant) + claimedBlocks[by]![0] = true + anchors.push({ + label, + anchorCell: { x: 3, y: 8 * by + 3 }, + interiorCell: { x: 11, y: 8 * by + 3 }, + toLevelId, + side: 'west', + kind: 'gate', + }) + } else if (edge === 'right') { + let by = rng.int(2, Math.max(2, gridHeight - 3)) + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break + by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + } + openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant) + claimedBlocks[by]![gridWidth - 1] = true + anchors.push({ + label, + anchorCell: { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 }, + interiorCell: { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 }, + toLevelId, + side: 'east', + kind: 'gate', + }) + } else if (edge === 'top') { + let bx = rng.int(2, Math.max(2, gridWidth - 3)) + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break + bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + } + openGates.set(`${String(bx)},0`, variant) + claimedBlocks[0]![bx] = true + anchors.push({ + label, + anchorCell: { x: 8 * bx + 3, y: 3 }, + interiorCell: { x: 8 * bx + 3, y: 11 }, + toLevelId, + side: 'north', + kind: 'gate', + }) + } else { + let bx = rng.int(2, Math.max(2, gridWidth - 3)) + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break + bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + } + openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant) + claimedBlocks[gridHeight - 1]![bx] = true + anchors.push({ + label, + anchorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 }, + interiorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 }, + toLevelId, + side: 'south', + kind: 'gate', + }) + } + } + + const pickFreeInteriorBox = (w: number, h: number, minMargin = 2): { bx: number; by: number } | null => { + const minX = Math.min(minMargin, Math.max(1, gridWidth - w - 1)) + const minY = Math.min(minMargin, Math.max(1, gridHeight - h - 1)) + const maxX = Math.max(minX, gridWidth - minMargin - w) + const maxY = Math.max(minY, gridHeight - minMargin - h) + const candidates: { bx: number; by: number }[] = [] + for (let by = minY; by <= maxY; by += 1) { + for (let bx = minX; bx <= maxX; bx += 1) { + let free = true + for (let dy = 0; dy < h && free; dy += 1) { + for (let dx = 0; dx < w && free; dx += 1) { + if (claimedBlocks[by + dy]?.[bx + dx] === true) free = false + } + } + if (free) candidates.push({ bx, by }) + } + } + if (candidates.length === 0) return null + return candidates[rng.int(0, candidates.length - 1)]! + } + + const addInteriorPresetWithRoad = ( + prefix: string, + label: string, + forceExitSide?: 'east' | 'south', + toLevelId?: number, + ): boolean => { + const piece = findPiece(prefix) + if (piece === undefined || piece.levels.length === 0) return false + const variant = rng.int(0, piece.levels.length - 1) + const ds1 = piece.levels[variant]! + const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK)) + const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK)) + const pos = pickFreeInteriorBox(blocksX, blocksY, 2) + if (pos === null) return false + const { bx, by } = pos + claimBox(bx, by, blocksX, blocksY, true) + specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY }) + + const exitSide = forceExitSide ?? (variant === 0 ? 'east' : 'south') + if (exitSide === 'east' && bx + blocksX < gridWidth - 1) { + roadObstacleBlocks[by]![bx + blocksX - 1] = false + anchors.push({ + label, + anchorCell: { x: 8 * (bx + blocksX - 1) + 7, y: 8 * by + 3 }, + interiorCell: { x: 8 * (bx + blocksX) + 3, y: 8 * by + 3 }, + toLevelId, + kind: 'preset', + }) + } else if (by + blocksY < gridHeight - 1) { + roadObstacleBlocks[by + blocksY - 1]![bx] = false + anchors.push({ + label, + anchorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY - 1) + 7 }, + interiorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY) + 3 }, + toLevelId, + kind: 'preset', + }) + } + return true + } + + /** + * Stamp the Rogue Encampment's gate preset onto one edge. + * + * There are two authored pieces, one for the camp lying west of the Blood + * Moor and one for it lying north; the rest of the compass has no art, so a + * side we have no piece for falls back to an ordinary border gate. + * + * @param side - which edge of this level the camp is on. + * @param toLevelId - the town's level id. + * @returns whether a preset was placed. + */ + const placeTownTransition = (side: Side, toLevelId?: number): boolean => { + if (side === 'west') { + const townE = findPiece('Act 1 - Town 1 Transition E') + if (townE?.levels[0] === undefined) return false + const ds1 = townE.levels[0] + const by = Math.max(1, Math.floor((gridHeight - 5) / 2)) + for (let dy = 0; dy < 5; dy += 1) { + claimedBorderBlocks.add(`0,${String(by + dy)}`) + claimedBlocks[by + dy]![0] = true + } + specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 }) + anchors.push({ + label: 'Town E Transition', + anchorCell: { x: 7, y: 8 * by + 19 }, + interiorCell: { x: 11, y: 8 * by + 19 }, + toLevelId, + side: 'west', + kind: 'gate', + }) + return true + } + if (side === 'north') { + const townS = findPiece('Act 1 - Town 1 Transition S') + if (townS?.levels[0] === undefined) return false + const ds1 = townS.levels[0] + const bx = Math.max(1, Math.floor((gridWidth - 7) / 2)) + for (let dx = 0; dx < 7; dx += 1) { + claimedBorderBlocks.add(`${String(bx + dx)},0`) + claimedBlocks[0]![bx + dx] = true + } + specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 }) + anchors.push({ + label: 'Town S Transition', + anchorCell: { x: 8 * bx + 29, y: 7 }, + interiorCell: { x: 8 * bx + 29, y: 11 }, + toLevelId, + side: 'north', + kind: 'gate', + }) + return true + } + return false + } + + /** + * Stamp the walled graveyard onto the Burial Grounds. + * + * @param withGate - whether to cut the seam back to the Cold Plains here too. + * False when the caller is placing that seam itself from the world graph. + * @returns whether the preset fitted. + */ + const placeGraveyardPreset = (withGate: boolean): boolean => { + const gyPiece = findPiece('Act 1 - Graveyard') + if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false + const ds1 = gyPiece.levels[0] + claimBox(1, 1, 3, 4, true) + specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 }) + roadObstacleBlocks[4]![1] = false + roadObstacleBlocks[4]![2] = false + if (withGate) { + openGates.set(`2,${String(gridHeight - 1)}`, 4) + claimedBlocks[gridHeight - 1]![2] = true + anchors.push({ + label: 'Burial Grounds Gate', + anchorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 }, + interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 }, + toLevelId: 3, + side: 'south', + kind: 'gate', + }) + } + anchors.push({ + label: 'Graveyard Entrance', + anchorCell: { x: 1 * 8 + 6, y: 1 * 8 + 31 }, + interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 }, + toLevelId: 19, + kind: 'preset', + }) + return true + } + + /** + * Scatter the level's landmarks and the caves that open off it. + * + * These are separate from the seams because they do not care which edge + * anything is on: the Forgotten Tower, the Cairn Stones and the Tree of + * Inifuss all stand beside one of the level's roads, wherever the roads end + * up running, and `addInteriorPresetWithRoad` is what joins them to the + * network. + * + * @param levelId - the level being planned. + */ + const placeInteriorPresets = (levelId: number): void => { + if (levelId === 2) { + addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8) + const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages') + if (bivouac?.levels[0] !== undefined) { + const ds1 = bivouac.levels[0] + const w = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK)) + const h = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK)) + const pos = pickFreeInteriorBox(w, h, 2) + if (pos !== null) { + claimBox(pos.bx, pos.by, w, h, true) + specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h }) + } + } + } else if (levelId === 3) { + addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9) + addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south') + } else if (levelId === 4) { + addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38) + addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10) + } else if (levelId === 5) { + addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south') + addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10) + } else if (levelId === 6) { + // The Forgotten Tower is always beside one of the Black Marsh's roads, + // never dropped on open ground, which is why it goes through the + // road-joining path rather than the plain scatter. + addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20) + addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11) + } else if (levelId === 7) { + addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12) + } else if (levelId === 39) { + addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south') + addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east') + } + } + + const id = request.levelId + const plannedGates = request.gates + + if (plannedGates !== undefined) { + // Seams come from the act layout solve. The table below is not consulted: + // it records one legal roll of the outdoor placer as if it were fixed, and + // it only ever covered Act 1. + const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined + const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId) + for (const gate of plannedGates) { + if (placedTown && gate === townSeam) continue + addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId) + } + if (id === 17) placeGraveyardPreset(false) + placeInteriorPresets(id) + } else if (id === 2) { + // Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance + const townE = findPiece('Act 1 - Town 1 Transition E') + const useWest = rng.int(0, 1) === 0 && townE !== undefined + if (useWest && placeTownTransition('west', 1)) { + addBorderGate(rng.int(0, 1) === 0 ? 'right' : 'bottom', 3, 'Cold Plains Exit', 3) + } else if (placeTownTransition('north', 1)) { + addBorderGate(rng.int(0, 1) === 0 ? 'bottom' : 'right', 3, 'Cold Plains Exit', 3) + } else { + addBorderGate('left', 3, 'Town Entry', 1) + addBorderGate('right', 3, 'Cold Plains Exit', 3) + } + placeInteriorPresets(2) + } else if (id === 3) { + // Cold Plains: Blood Moor Entry + Stony Field Exit + Burial Grounds Exit (variant 4) + Cave Level 1 Entrance + addBorderGate('left', 3, 'Blood Moor Entry', 2) + addBorderGate('top', 3, 'Stony Field Exit', 4) + addBorderGate('right', 4, 'Burial Grounds Exit', 17) + placeInteriorPresets(3) + } else if (id === 4) { + // Stony Field: Cold Plains Entry + Exit Gate + Cairn Stones + Underground Passage Entrance + addBorderGate('bottom', 3, 'Cold Plains Entry', 3) + addBorderGate('top', 3, 'Wilderness Exit', 5) + placeInteriorPresets(4) + } else if (id === 5) { + // Dark Wood: Entry Gate + Black Marsh Exit + Tree of Inifuss + Underground Passage Exit + addBorderGate('left', 3, 'Wilderness Entry', 4) + addBorderGate('right', 3, 'Black Marsh Exit', 6) + placeInteriorPresets(5) + } else if (id === 6) { + // Black Marsh: Dark Wood Entry + Tamoe Highland Exit + Forgotten Tower + Hole Level 1 Entrance + addBorderGate('bottom', 3, 'Dark Wood Entry', 5) + addBorderGate('top', 3, 'Tamoe Highland Exit', 7) + placeInteriorPresets(6) + } else if (id === 7) { + // Tamoe Highland: Black Marsh Entry + Monastery Gate Exit + Pit Level 1 Entrance + addBorderGate('left', 3, 'Black Marsh Entry', 6) + addBorderGate('right', 3, 'Monastery Exit', 26) + placeInteriorPresets(7) + } else if (id === 17) { + // Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate + if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3) + } else if (id === 39) { + // Moo Moo Farm: Closed border, internal Corral & Bivouac camps connected by paths + placeInteriorPresets(39) + } else { + addBorderGate('left', 3, 'Entry Gate') + addBorderGate('right', 3, 'Exit Gate') + } + + return { + openGates, + claimedBorderBlocks, + claimedBlocks, + roadObstacleBlocks, + specialPresets, + anchors, + } +} + +/** + * Build stepped block-grid road polylines connecting all anchors to a central hub + * (`DRLGOUTDOORS_SpawnAct1DirtPaths` / `sub_6FD7F5B0`). + */ +function buildRoadNetworkPolylines( + plan: Act1OutdoorPlan, + gridWidth: number, + gridHeight: number, + rng: Rng, +): { x: number; y: number }[][] { + if (plan.anchors.length < 2) return [] + + let sumBx = 0 + let sumBy = 0 + for (const anchor of plan.anchors) { + sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK) + sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK) + } + let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1))) + let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1))) + + if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) { + let found = false + for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) { + for (let dy = -r; dy <= r && !found; dy += 1) { + for (let dx = -r; dx <= r && !found; dx += 1) { + const nx = hubBx + dx + const ny = hubBy + dy + if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) { + if (plan.roadObstacleBlocks[ny]?.[nx] !== true) { + hubBx = nx + hubBy = ny + found = true + } + } + } + } + } + } + + const hubCell = { x: hubBx * TILES_PER_BLOCK + 3, y: hubBy * TILES_PER_BLOCK + 3 } + const polylines: { x: number; y: number }[][] = [] + + for (const anchor of plan.anchors) { + const line: { x: number; y: number }[] = [ + anchor.anchorCell, + anchor.interiorCell, + ] + let currBx = Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK) + let currBy = Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK) + const maxSteps = gridWidth + gridHeight + 8 + + for (let step = 0; step < maxSteps && (currBx !== hubBx || currBy !== hubBy); step += 1) { + const dx = hubBx - currBx + const dy = hubBy - currBy + const stepX = Math.sign(dx) + const stepY = Math.sign(dy) + + const candidates: { bx: number; by: number }[] = [] + if (Math.abs(dx) >= 2 && Math.abs(dy) >= 2) { + const mode = rng.int(0, 2) + if (mode === 0) { + candidates.push({ bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy + stepY }) + } else if (mode === 1) { + candidates.push({ bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx + stepX, by: currBy + stepY }) + } else { + candidates.push({ bx: currBx + stepX, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }) + } + } else if (Math.abs(dx) > Math.abs(dy)) { + candidates.push({ bx: currBx + stepX, by: currBy }) + if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY }) + } else { + if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY }) + if (stepX !== 0) candidates.push({ bx: currBx + stepX, by: currBy }) + } + + // Detour candidates if primary directions hit an obstacle preset + if (stepX !== 0) { + candidates.push({ bx: currBx + stepX, by: currBy - 1 }, { bx: currBx + stepX, by: currBy + 1 }) + } + if (stepY !== 0) { + candidates.push({ bx: currBx - 1, by: currBy + stepY }, { bx: currBx + 1, by: currBy + stepY }) + } + + let chosen = candidates[0]! + for (const cand of candidates) { + if (cand.bx >= 1 && cand.bx < gridWidth - 1 && cand.by >= 1 && cand.by < gridHeight - 1) { + if (plan.roadObstacleBlocks[cand.by]?.[cand.bx] !== true) { + chosen = cand + break + } + } + } + + currBx = chosen.bx + currBy = chosen.by + line.push({ x: currBx * TILES_PER_BLOCK + 3, y: currBy * TILES_PER_BLOCK + 3 }) + } + line.push(hubCell) + polylines.push(line) + } + + return polylines +} + +/** + * Rasterize a 2-cell-wide line segment onto the dirt path grid (`sub_6FD75F60`). + */ +function rasterizeDirtPathSegment( + grid: Uint8Array, + width: number, + height: number, + x1: number, + y1: number, + x2: number, + y2: number, +): void { + const mark2x2 = (x: number, y: number): void => { + for (let dy = 0; dy < 2; dy += 1) { + for (let dx = 0; dx < 2; dx += 1) { + const px = x + dx + const py = y + dy + if (px >= 0 && px < width && py >= 0 && py < height) { + grid[py * width + px] = 1 + } + } + } + } + + let x = x1 + let y = y1 + const dx = Math.abs(x2 - x1) + const dy = Math.abs(y2 - y1) + const sx = x2 >= x1 ? 1 : -1 + const sy = y2 >= y1 ? 1 : -1 + + mark2x2(x, y) + if (dx >= dy) { + let err = 0 + for (let i = 0; i < dx; i += 1) { + err += dy + if (err > dx) { + y += sy + mark2x2(x, y) + err -= dx + } + x += sx + mark2x2(x, y) + } + } else { + let err = 0 + for (let i = 0; i < dy; i += 1) { + err += dx + if (err > dy) { + x += sx + mark2x2(x, y) + err -= dy + } + y += sy + mark2x2(x, y) + } + } +} + +/** + * Rasterize all road polylines into a 2D bitmask grid and apply + * `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`). + */ +function generateDirtPaths( + canvas: Canvas, + polylines: readonly (readonly { x: number; y: number }[])[], +): { dirtPathGrid: Uint8Array; roadCells: number; roadSegments: number } { + const width = canvas.width + const height = canvas.height + const dirtPathGrid = new Uint8Array(width * height) + let roadSegments = 0 + + for (const line of polylines) { + for (let i = 0; i + 1 < line.length; i += 1) { + const p0 = line[i]! + const p1 = line[i + 1]! + rasterizeDirtPathSegment(dirtPathGrid, width, height, p0.x, p0.y, p1.x, p1.y) + roadSegments += 1 + } + } + + const isRoad = (nx: number, ny: number): boolean => { + if (nx < 0 || nx >= width || ny < 0 || ny >= height) return true + return dirtPathGrid[ny * width + nx] === 1 + } + + let roadCells = 0 + for (let y = 0; y < height; y += 1) { + const row = canvas.cells[y] + if (row === undefined) continue + for (let x = 0; x < width; x += 1) { + if (dirtPathGrid[y * width + x] === 0) continue + let mask = 0 + if (isRoad(x + 1, y - 1)) mask |= 0x80 + if (isRoad(x + 1, y )) mask |= 0x40 + if (isRoad(x + 1, y + 1)) mask |= 0x20 + if (isRoad(x , y - 1)) mask |= 0x10 + if (isRoad(x , y + 1)) mask |= 0x08 + if (isRoad(x - 1, y - 1)) mask |= 0x04 + if (isRoad(x - 1, y )) mask |= 0x02 + if (isRoad(x - 1, y + 1)) mask |= 0x01 + + const seq = DIRT_PATH_TILE_LUT[mask] ?? 0 + if (seq !== 0) { + const cell = row[x] + const floor = cell?.floors[0] + if (floor !== undefined) { + Object.assign(floor, { prop1: 2, style: 0, sequence: seq, hidden: false }) + roadCells += 1 + } + } + } + } + + return { dirtPathGrid, roadCells, roadSegments } +} + /* ------------------------------------------------------------------------- * * Substitutions * ------------------------------------------------------------------------- */ +/** + * Check whether placing a substitution cluster at `(bx, by)` of size + * `(blocksX, blocksY)` would collide with claimed special presets or dirt roads. + */ +function canPlaceSubstitutionCluster( + bx: number, + by: number, + blocksX: number, + blocksY: number, + claimedBlocks: boolean[][] | undefined, + dirtPathGrid: Uint8Array | undefined, + canvasWidth: number, +): boolean { + if (claimedBlocks !== undefined) { + for (let dy = 0; dy < blocksY; dy += 1) { + for (let dx = 0; dx < blocksX; dx += 1) { + if (claimedBlocks[by + dy]?.[bx + dx] === true) return false + } + } + } + if (dirtPathGrid !== undefined) { + const startX = bx * TILES_PER_BLOCK + const startY = by * TILES_PER_BLOCK + const endX = (bx + blocksX) * TILES_PER_BLOCK + const endY = (by + blocksY) * TILES_PER_BLOCK + for (let cy = startY; cy < endY; cy += 1) { + for (let cx = startX; cx < endX; cx += 1) { + if (dirtPathGrid[cy * canvasWidth + cx] === 1) return false + } + } + } + return true +} + /** * Scatter one `LvlSub` row's pieces across the level's interior. * - * The parameters are used exactly as `DRLGTILESUB_DoSubstitutions` uses them: - * `Max[theme]` clusters are attempted, each cluster spends `Trials[theme]` - * positions looking for a free one (`-1` meaning "walk every free position"), and - * `BordType` bounds the total — 0 allows a single cluster for the whole level, 1 - * a single cluster per row, anything else is unlimited. Positions snap to - * `GridSize`, matching `x - x % dwGridSize` in `DRLGTILESUB_TestReplaceSubPreset`. - * - * The difference from D2MOO is granularity. There, a substitution group is a box - * inside the piece's own DS1 and the swap happens tile by tile against the room's - * floor/wall grids, driven by the DS1's substitution layer. `src/formats/ds1.ts` - * decodes the substitution layer's raw values but not the group table, so this - * port substitutes at *block* granularity: the whole piece is stamped at a block - * origin. The shapes and the frequency are right; the exact tile a swap lands on - * is not. + * Avoids overwriting blocks claimed by special presets (`claimedBlocks`) or + * cells traversed by generated dirt roads (`dirtPathGrid`). * * @param canvas - the canvas. * @param row - the `LvlSub` row. @@ -965,6 +1833,8 @@ function layBorder( * @param gridWidth - blocks across. * @param gridHeight - blocks down. * @param rng - the level's random stream. + * @param claimedBlocks - optional 2D block claim grid. + * @param dirtPathGrid - optional cell-level dirt road bitmask grid. * @returns how many clusters were stamped. */ function applySubstitution( @@ -974,6 +1844,8 @@ function applySubstitution( gridWidth: number, gridHeight: number, rng: Rng, + claimedBlocks?: boolean[][], + dirtPathGrid?: Uint8Array, ): number { const level = row.levels[0] if (level === undefined) return 0 @@ -997,6 +1869,17 @@ function applySubstitution( return Math.min(Math.floor(value / gridSize), limit) * gridSize } + const recordClaim = (bx: number, by: number): void => { + if (claimedBlocks === undefined) return + for (let dy = 0; dy < blocksY; dy += 1) { + for (let dx = 0; dx < blocksX; dx += 1) { + if (claimedBlocks[by + dy] !== undefined) { + claimedBlocks[by + dy]![bx + dx] = true + } + } + } + } + let stamped = 0 for (let cluster = 0; cluster < max; cluster += 1) { let placed = false @@ -1006,7 +1889,13 @@ function applySubstitution( for (let ox = 0; ox < spanX && !placed; ox += gridSize) { const snappedX = snap(ox, spanX) const snappedY = snap(oy, spanY) - stampDs1(canvas, level, (1 + snappedX) * TILES_PER_BLOCK, (1 + snappedY) * TILES_PER_BLOCK) + const bx = 1 + snappedX + const by = 1 + snappedY + if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { + continue + } + stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) + recordClaim(bx, by) placed = true } } @@ -1014,7 +1903,13 @@ function applySubstitution( for (let attempt = 0; attempt < trials && !placed; attempt += 1) { const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX) const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY) - stampDs1(canvas, level, (1 + offsetX) * TILES_PER_BLOCK, (1 + offsetY) * TILES_PER_BLOCK) + const bx = 1 + offsetX + const by = 1 + offsetY + if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { + continue + } + stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) + recordClaim(bx, by) placed = true } } @@ -1026,10 +1921,6 @@ function applySubstitution( /** * Run the `Prob` gate and then the cluster loop for every substitution row. * - * The gate is `DRLGTILESUB_PickSubThemes`: each row of the level's group is - * independently given a `Prob[SubTheme]` per-cent chance of being enabled, and an - * enabled row contributes its `Dt1Mask` to the room's library mask. - * * @param canvas - the canvas. * @param rows - the rows to run, in table order. * @param themeIndex - the clamped `SubTheme`. @@ -1037,6 +1928,8 @@ function applySubstitution( * @param gridHeight - blocks down. * @param rng - the level's random stream. * @param stats - report collector. + * @param claimedBlocks - optional 2D block claim grid. + * @param dirtPathGrid - optional cell-level dirt road bitmask grid. * @returns the number of clusters stamped. */ function applySubstitutions( @@ -1047,6 +1940,8 @@ function applySubstitutions( gridHeight: number, rng: Rng, stats: WildernessStats, + claimedBlocks?: boolean[][], + dirtPathGrid?: Uint8Array, ): number { let total = 0 let unlimitedBudget = Number.POSITIVE_INFINITY @@ -1061,7 +1956,7 @@ function applySubstitutions( const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) const wanted = Math.min(rowMax, allowance) if (wanted > 0) { - const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng) + const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid) clusters = Math.min(wanted, single) if (row.bordType !== 0 && row.bordType !== 1) clusters = single if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters) @@ -2068,6 +2963,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult borderPiecesAvailable: 0, borderBlocks: 0, borderUsage: {}, + roadCells: 0, + roadSegments: 0, + anchors: 0, + specialPresets: [], + entrances: [], substitutions: [], substitutedClusters: 0, objects: canvas.objects.length, @@ -2084,10 +2984,9 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const stats: WildernessStats = { substitutions: [], borderPieces: {}, unresolved: [], notes: [], borderStamped: 0, groundCells: 0, groundTile: null, sizeSource, + roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], } - // Layer counts must be known before the canvas exists, because a cell's layer - // arrays are fixed at creation. let wallLayers = 1 let floorLayers = 1 let substitutionType = 0 @@ -2177,6 +3076,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult borderPiecesAvailable: 0, borderBlocks: 0, borderUsage: {}, + roadCells: stats.roadCells, + roadSegments: stats.roadSegments, + anchors: stats.anchors, + entrances: stats.entrances, + specialPresets: stats.specialPresets, substitutions: stats.substitutions, substitutedClusters: placedSpecial.size, objects: canvas.objects.length, @@ -2208,7 +3112,52 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const borderPieces = levelPieces.filter(piece => piece.border) const fillPieces = levelPieces.filter(piece => !piece.border) - layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats, request.levelId) + + // Step 1-3: Act 1 Outdoor anchor & road planning + dirt road bitmask rasterization + // + // The planner was written for Act 1 and its hard-coded per-level table only + // covers Act 1, but nothing in it is Act 1 specific once the caller supplies + // the seams: `request.gates` comes from the world graph, so every act can be + // planned the same way. Without gates we keep the old Act 1 only behaviour so + // existing packs and the generator verifier reproduce byte for byte. + let act1Plan: Act1OutdoorPlan | undefined + let dirtPathGrid: Uint8Array | undefined + if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) { + act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng) + stats.anchors = act1Plan.anchors.length + stats.entrances = toEntrances(act1Plan.anchors) + const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng) + if (polylines.length > 0) { + const roadRes = generateDirtPaths(canvas, polylines) + dirtPathGrid = roadRes.dirtPathGrid + stats.roadCells = roadRes.roadCells + stats.roadSegments = roadRes.roadSegments + } + } + + // Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions) + layBorder( + canvas, + borderPieces, + gridWidth, + gridHeight, + rng, + stats, + request.levelId, + act1Plan?.openGates, + act1Plan?.claimedBorderBlocks, + ) + + // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.) + if (act1Plan !== undefined) { + for (const preset of act1Plan.specialPresets) { + const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0)) + const originY = Math.min(preset.by * TILES_PER_BLOCK, Math.max(canvas.height - preset.ds1.height, 0)) + stampDs1(canvas, preset.ds1, originX, originY) + stats.specialPresets.push(preset.name) + stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 }) + } + } let specialPresetNames = request.specialPresets if (specialPresetNames === undefined) { @@ -2248,6 +3197,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? [] } } + if (act1Plan !== undefined) { + specialPresetNames = specialPresetNames.filter(name => !stats.specialPresets.includes(name)) + } + const inX = Math.floor(gridWidth / 2) const outY = Math.floor(gridHeight / 2) const reservedRoadBlocks = new Set() @@ -2255,6 +3208,15 @@ export function generateWilderness(request: WildernessRequest): WildernessResult 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)}`) } + if (act1Plan !== undefined) { + for (let by = 0; by < gridHeight; by += 1) { + for (let bx = 0; bx < gridWidth; bx += 1) { + if (act1Plan.claimedBlocks[by]?.[bx]) { + reservedRoadBlocks.add(`${String(bx)},${String(by)}`) + } + } + } + } const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets( canvas, @@ -2267,12 +3229,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult reservedRoadBlocks, ) + // Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance 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 ?? [])] .filter(row => classifySubstitutionRole(row.name, row.type) === 'border') - // Secondary border pieces (`BordType` rows) are laid through the same cluster - // machinery, which is where their `GridSize`/`BordType` parameters belong. const substituted = applySubstitutions( canvas, [...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')], @@ -2281,6 +3242,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult gridHeight, rng, stats, + act1Plan?.claimedBlocks, + dirtPathGrid, ) // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and @@ -2342,6 +3305,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult borderPiecesAvailable: borderPieces.length, borderBlocks: stats.borderStamped, borderUsage: stats.borderPieces, + roadCells: stats.roadCells, + roadSegments: stats.roadSegments, + anchors: stats.anchors, + entrances: stats.entrances, + specialPresets: stats.specialPresets, substitutions: stats.substitutions, substitutedClusters: substituted + placedSpecialPieces.size, objects: canvas.objects.length, diff --git a/src/game/world-graph.ts b/src/game/world-graph.ts new file mode 100644 index 0000000..333bea7 --- /dev/null +++ b/src/game/world-graph.ts @@ -0,0 +1,819 @@ +/** + * The global level connectivity graph. + * + * Diablo II's world is not stored anywhere as a graph. `Levels.txt` carries + * `Vis0..7` (the destination level reachable through warp slot N) and `Warp0..7` + * (the `LvlWarp.txt` row describing that slot's tile and hitbox), and the naive + * reading is that those two columns *are* the world map. They are not, and the + * gap is the single most expensive thing to discover here: + * + * - **`Act 1 - Town` has every `Vis` at 0 and every `Warp` at -1.** So does + * `Act 5 - Town`, and so do `Act 5 - Siege 1` and both Act 5 barricades. + * `Act 1 - Wilderness 1` (the Blood Moor) points only at `Act 1 - Cave 1`. + * - Build the graph from `Vis`/`Warp` alone and you get a world where every + * cave, tomb and crypt is reachable but **no two outdoor zones connect** — + * you can enter the Den of Evil but you can never walk from the Blood Moor to + * the Cold Plains, and you can never leave town at all. + * + * The reason is that outdoor neighbours are not warps. Blizzard's outdoor DRLG + * lays sibling levels out in one per-act coordinate space and the player walks + * across the seam; the giveaway in the data is `OffsetX/OffsetY = -1`, a + * sentinel meaning "the outdoor DRLG computes my origin at runtime", which is + * set on exactly the levels that are stitched together (1, 2, 5, 6, 7, 17, the + * Act 2 desert, the Act 3 jungle, the Act 4 mesas, the Act 5 barricades) while + * self-contained dungeons get static parking slots 300 apart. That adjacency + * lives in `D2Common.dll`, not in any table, so it has to be restated here. + * + * This module therefore merges four sources: + * + * 1. `Vis0..7` / `Warp0..7` — 236 populated slots collapsing to 187 unique + * ordered pairs, because one logical warp occupies several slots (a cave + * mouth has four, one per orientation). Slots whose `Warp` is -1 but whose + * `Vis` is set are *not* warps: they are openings you walk through inside a + * preset, and they are classified as {@link LinkKind} `seamless`. + * 2. {@link SEAMLESS_ADJACENCY} — the outdoor stitching `Levels.txt` omits. + * 3. {@link PORTAL_LINKS} — quest portals and act transitions, which are not in + * any table either; their destinations are recognisable by `Position = 1`. + * 4. `Waypoint` — 39 waypoints, ids 0..38, contiguous. + * + * Everything is keyed on `Levels.txt` `Id`. **Never key on `Name`**: the + * internal names are offset from the in-game ones by one, so `Act 1 - Cave 1` + * is the Den of Evil (a single level with no descent), `Act 1 - Cave 2` is what + * the player calls Cave Level 1, and `Act 1 - Cave 2 Treasure` is Cave Level 2. + * + * No `Math.random`, no Node builtins: this runs in the browser bundle and at + * pack time, and every random choice is drawn from a caller-supplied + * {@link Rng} so the same seed replays the same world. + */ +import type { D2Table } from './acts.ts' +import { Rng } from './rng.ts' + +/** How the player gets from one level to the next. */ +export type LinkKind = + /** A clickable stair, cave mouth or door described by an `LvlWarp.txt` row. */ + | 'warp' + /** An opening the player walks through with no click and no loading screen. */ + | 'seamless' + /** A quest portal or act transition, hard-coded because no table has it. */ + | 'portal' + +/** Which edge of a level's rectangle an opening sits on. */ +export type Side = 'north' | 'east' | 'south' | 'west' + +/** The four sides, in a fixed order so iteration is deterministic. */ +export const SIDES: readonly Side[] = ['north', 'east', 'south', 'west'] + +/** + * The side facing a given side across a shared seam. + * + * Two levels abut only if the opening the player leaves through and the opening + * they arrive at are on opposite edges: walk off A's east edge and you step onto + * B's west edge. + * + * @param side - the side being left. + * @returns the side being entered. + */ +export function oppositeSide(side: Side): Side { + switch (side) { + case 'north': + return 'south' + case 'south': + return 'north' + case 'east': + return 'west' + case 'west': + return 'east' + } +} + +/** One row of `Levels.txt`, reduced to the columns connectivity needs. */ +export interface LevelRow { + /** `Levels.txt` `Id`. */ + readonly id: number + /** `Act`, 0-based as stored. */ + readonly act: number + /** `Name`, the internal name — see the module note about the off-by-one. */ + readonly name: string + /** `DrlgType`: 1 preset, 2 outdoor, 3 maze (as this codebase reads it). */ + readonly drlgType: number + /** `SizeX`/`SizeY` in cells; -1 means the generator decides at runtime. */ + readonly sizeX: number + readonly sizeY: number + /** `Waypoint`, 0..38, or 255 when the level has none. */ + readonly waypoint: number + /** `Position`; 1 marks a level that is the destination of a portal. */ + readonly position: number + /** `Portal`. */ + readonly portal: number + /** `OffsetX`/`OffsetY`; -1 is the "outdoor DRLG places me" sentinel. */ + readonly offsetX: number + readonly offsetY: number + /** `Depend`; non-zero on exactly `27` (on 26) and `33` (on 32). */ + readonly depend: number + /** `Vis0..7`, 0 meaning the slot is unused. */ + readonly vis: readonly number[] + /** `Warp0..7`, -1 meaning the slot has no warp tile. */ + readonly warp: readonly number[] +} + +/** A directed connection between two levels. */ +export interface WorldEdge { + /** Source level id. */ + readonly from: number + /** Destination level id. */ + readonly to: number + /** How the crossing works. */ + readonly kind: LinkKind + /** + * The `LvlWarp.txt` row ids that describe this crossing. + * + * Several, not one, because a cave mouth occupies four `Vis`/`Warp` slots — + * one per orientation — and which one is used depends on how the generator + * ends up facing the entrance. + */ + readonly warps: readonly number[] + /** + * The `Warp0..7` slot indices this crossing occupies, in the same order as + * {@link warps}. + * + * This is the join between the table and the artwork. A DS1 records a + * staircase as a "special" tile — wall type 10 or 11 — whose `style` field is + * the slot number, so a tile with `style` 3 is the staircase for whatever + * `Vis3` points at. Without the slot there is no way to tell which of a + * level's staircases leads where, and the packer has to guess from geometry. + */ + readonly warpSlots: readonly number[] + /** Which edge of `from` the opening sits on; only set for `seamless`. */ + readonly sideFrom: Side | null + /** Which edge of `to` the opening sits on; always opposite `sideFrom`. */ + readonly sideTo: Side | null + /** Where this edge came from, for diagnostics. */ + readonly source: 'vis' | 'adjacency' | 'portal' +} + +/** The assembled world. */ +export interface WorldGraph { + /** Every level with `Id > 0`, keyed by id. */ + readonly levels: ReadonlyMap + /** Every directed edge. */ + readonly edges: readonly WorldEdge[] + /** Waypoint id to the level that hosts it. */ + readonly waypoints: ReadonlyMap +} + +/** + * Outdoor levels that walk into each other with no warp and no loading screen. + * + * Absent from `Levels.txt` in its entirety — see the module note. Listed as + * unordered pairs; {@link buildWorldGraph} emits both directions. + * + * Deliberately **not** in this list, because each is a real warp that the data + * does describe and mistaking it for adjacency would produce a door onto + * nothing: + * + * - `6 -> 20` the Forgotten Tower (`Vis2 = 20`, `Warp2 = 10`): an 8x8 preset + * building standing beside one of the Black Marsh's roads, with a door. + * - `106 -> 107` City of the Damned to River of Flame (`Vis1 = 107`, + * `Warp1 = 69`). + * - `112 -> 113` Arreat Plateau to Crystalline Passage (`Warp2 = 71`). + */ +export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [ + // Act 1. 1 Rogue Encampment, 2 Blood Moor, 3 Cold Plains, 4 Stony Field, + // 5 Dark Wood, 6 Black Marsh, 7 Tamoe Highland, 17 Burial Grounds, + // 26 Monastery Gate. + [1, 2], + [2, 3], + [3, 4], + [3, 17], + [4, 5], + [5, 6], + [6, 7], + [7, 26], + + // Act 2. 40 Lut Gholein, 41 Rocky Waste, 42 Dry Hills, 43 Far Oasis, + // 44 Lost City, 45 Valley of Snakes. + // + // 46 (the Canyon of the Magi) is adjacent to nothing: it is reached only by + // waypoint 17 or by the Summoner's portal from the Arcane Sanctuary. + [40, 41], + [41, 42], + [42, 43], + [43, 44], + [44, 45], + + // Act 3. 75 Kurast Docks, 76 Spider Forest, 77 Great Marsh, + // 78 Flayer Jungle, 79 Lower Kurast, 80 Kurast Bazaar, 81 Upper Kurast, + // 82 Kurast Causeway, 83 Travincal. + // + // 76 <-> 78 is the Great Marsh skip: the Marsh can be bypassed, so the + // Spider Forest also touches the Flayer Jungle directly. + [75, 76], + [76, 77], + [76, 78], + [77, 78], + [78, 79], + [79, 80], + [80, 81], + [81, 82], + [82, 83], + + // Act 4. 103 Pandemonium Fortress, 104 Outer Steppes, 105 Plains of Despair, + // 106 City of the Damned. + [103, 104], + [104, 105], + [105, 106], + + // Act 5. 109 Harrogath, 110 Bloody Foothills, 111 Frigid Highlands, + // 112 Arreat Plateau. + // + // 117 (the Frozen Tundra) is an outdoor island: it is entered by warp from + // inside 115 and leaves by warp to 118, touching no outdoor level. + [109, 110], + [110, 111], + [111, 112], +] + +/** + * Seams whose side is fixed by a preset rather than chosen per seed. + * + * Everywhere else the outdoor placer re-picks which edge an opening sits on for + * every seed, so hard-coding a side would be stating a coincidence as a law. + * These are the exceptions: the opening is part of a hand-authored preset whose + * geometry cannot move. + * + * The two `Depend` values in the whole of `Levels.txt` corroborate two of them: + * `27` depends on `26` at offset `(0, -40)`, and `33` depends on `32` at + * `(-4, -34)` — in both cases the dependent level sits directly north. + * + * Keyed `"lowId:highId"`; the value is the side belonging to the *lower* id. + */ +export const PINNED_SIDES: ReadonlyMap = new Map([ + // The Rogue Encampment's gate faces south onto the Blood Moor. + ['1:2', 'south'], + // Tamoe Highland runs east into the Monastery Gate. + ['7:26', 'east'], + // Courtyard 1 sits 40 cells north of the Monastery Gate (`Depend = 26`). + ['26:27', 'north'], + // The Barracks gateway continues north out of the Outer Cloister. + ['27:28', 'north'], + // The Cathedral sits 34 cells north of the Inner Cloister (`Depend = 32`). + ['32:33', 'north'], + // The Kurast Causeway is a 48x16 bridge: its openings are the short ends. + ['81:82', 'east'], + ['82:83', 'east'], +]) + +/** A hard-coded portal, stair or act transition. */ +export interface PortalLink { + readonly from: number + readonly to: number + /** Why this link exists, for the generated graph's own documentation. */ + readonly note: string + /** Whether the player can come back the same way. */ + readonly bidirectional: boolean +} + +/** + * Connections that exist in the game but in none of its tables. + * + * Quest portals, act transitions and the uber levels. Their destinations are + * almost all flagged `Position = 1`, which is the closest thing the data has to + * a "something teleports here" marker, and several of them (`121`, `125`, + * `126`, `127`, `134`, `135`, `136`) are pointed at by nothing at all — without + * this list they are unreachable islands. + */ +export const PORTAL_LINKS: readonly PortalLink[] = [ + { from: 4, to: 38, note: 'Cairn Stones open the red portal to Tristram', bidirectional: true }, + { from: 1, to: 39, note: 'Cow level, opened with the Horadric Cube', bidirectional: true }, + { from: 1, to: 40, note: 'Act 1 to Act 2, by caravan', bidirectional: true }, + { from: 40, to: 75, note: 'Act 2 to Act 3, by ship', bidirectional: true }, + { from: 54, to: 74, note: 'Palace Cellar 3 to the Arcane Sanctuary', bidirectional: true }, + { from: 74, to: 46, note: "The Summoner's portal to the Canyon of the Magi", bidirectional: true }, + { from: 66, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 67, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 68, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 69, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 70, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 71, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 72, to: 73, note: "Tal Rasha's true tomb to Duriel's Lair", bidirectional: true }, + { from: 102, to: 103, note: 'Act 3 to Act 4, through the Infernal Gate', bidirectional: true }, + { from: 103, to: 109, note: 'Act 4 to Act 5, by Tyrael', bidirectional: true }, + { from: 109, to: 121, note: "Harrogath to Nihlathak's Temple", bidirectional: true }, + { from: 111, to: 125, note: 'Frigid Highlands to Abaddon', bidirectional: true }, + { from: 112, to: 126, note: 'Arreat Plateau to the Pit of Acheron', bidirectional: true }, + { from: 117, to: 127, note: 'Frozen Tundra to the Infernal Pit', bidirectional: true }, + { from: 109, to: 133, note: 'Pandemonium Run 1', bidirectional: true }, + { from: 109, to: 134, note: 'Pandemonium Run 2', bidirectional: true }, + { from: 109, to: 135, note: 'Pandemonium Run 3', bidirectional: true }, + { from: 109, to: 136, note: 'Uber Tristram', bidirectional: true }, +] + +/** + * `Vis` edges to discard. + * + * `133 Act 5 - Pandemonium 1` claims `Vis0 = 17 Act 1 - Graveyard` with + * `Warp0 = 8`, and level 17 does not point back. Row 133 is `LevelType 4`, the + * Act 1 crypt type, and its whole `Vis`/`Warp` block is byte-identical to row + * `18 Act 1 - Crypt 1 A` — it is a copy-paste artifact from whoever authored + * the expansion rows, not a wormhole from Hell to the Burial Grounds. It is the + * only asymmetric `Vis` edge in the entire table. + */ +export const DROPPED_VIS_EDGES: readonly (readonly [number, number])[] = [[133, 17]] + +/** + * Levels that are deliberately unreachable on foot. + * + * Both are real: the Canyon of the Magi is entered only by waypoint or by the + * Summoner's portal, and the Frozen Tundra hangs off two warps in the middle of + * the Act 5 ice caves. Connectivity checks must not treat them as islands. + */ +export const OUTDOOR_ISLANDS: readonly number[] = [46, 117] + +/** The value `Levels.txt` uses for "this level has no waypoint". */ +const NO_WAYPOINT = 255 + +/** + * Read a column by name, returning a number. + * + * Local rather than imported so this module keeps no runtime dependency on the + * DS1/DT1/PL2 decoders that `acts.ts` pulls in. + * + * @param table - the table. + * @param row - the row. + * @param column - the column name. + * @param fallback - value for a missing or unparsable cell. + * @returns the number. + */ +function num(table: D2Table, row: readonly string[], column: string, fallback = 0): number { + const index = table.header.indexOf(column) + if (index === -1) return fallback + const raw = row[index] + if (raw === undefined || raw === '') return fallback + const value = Number(raw) + return Number.isFinite(value) ? value : fallback +} + +/** + * Read a column by name, returning a string. + * + * @param table - the table. + * @param row - the row. + * @param column - the column name. + * @returns the cell, or an empty string. + */ +function str(table: D2Table, row: readonly string[], column: string): string { + const index = table.header.indexOf(column) + return index === -1 ? '' : (row[index] ?? '') +} + +/** + * Reduce `Levels.txt` to the rows and columns connectivity needs. + * + * Row `Id = 0` (`Null`) is dropped: it is a placeholder, and leaving it in makes + * every unused `Vis` slot look like an edge to it. + * + * @param levels - the parsed `Levels.txt`. + * @returns one entry per real level, in table order. + */ +export function parseLevelRows(levels: D2Table): LevelRow[] { + const out: LevelRow[] = [] + for (const row of levels.rows) { + const id = num(levels, row, 'Id', -1) + if (id <= 0) continue + const vis: number[] = [] + const warp: number[] = [] + for (let slot = 0; slot < 8; slot += 1) { + vis.push(num(levels, row, `Vis${String(slot)}`, 0)) + warp.push(num(levels, row, `Warp${String(slot)}`, -1)) + } + out.push({ + id, + act: num(levels, row, 'Act', 0), + name: str(levels, row, 'Name'), + drlgType: num(levels, row, 'DrlgType', 0), + sizeX: num(levels, row, 'SizeX', -1), + sizeY: num(levels, row, 'SizeY', -1), + waypoint: num(levels, row, 'Waypoint', NO_WAYPOINT), + position: num(levels, row, 'Position', 0), + portal: num(levels, row, 'Portal', 0), + offsetX: num(levels, row, 'OffsetX', -1), + offsetY: num(levels, row, 'OffsetY', -1), + depend: num(levels, row, 'Depend', 0), + vis, + warp, + }) + } + return out +} + +/** Key for an unordered level pair. */ +function pairKey(a: number, b: number): string { + return a < b ? `${String(a)}:${String(b)}` : `${String(b)}:${String(a)}` +} + +/** Key for an ordered level pair. */ +function edgeKey(from: number, to: number): string { + return `${String(from)}->${String(to)}` +} + +/** + * Assemble the world graph. + * + * The three sources are merged in priority order — adjacency and portals win + * over `Vis`, because where both describe the same pair the table's version is + * the coarser one — and every edge is emitted in both directions. + * + * @param rows - the output of {@link parseLevelRows}. + * @returns the graph, with `sideFrom`/`sideTo` still null; call + * {@link assignGateSides} to fill them. + */ +export function buildWorldGraph(rows: readonly LevelRow[]): WorldGraph { + const levels = new Map() + for (const row of rows) levels.set(row.id, row) + + const dropped = new Set() + for (const [from, to] of DROPPED_VIS_EDGES) dropped.add(edgeKey(from, to)) + + /** Ordered pair -> the warp ids seen for it, in slot order. */ + const visWarps = new Map() + /** Ordered pair -> the `Warp0..7` slots seen for it, parallel to `visWarps`. */ + const visSlots = new Map() + /** Ordered pair -> true when at least one slot had no warp tile. */ + const visWalkThrough = new Map() + + for (const row of rows) { + for (let slot = 0; slot < 8; slot += 1) { + const destination = row.vis[slot] ?? 0 + if (destination === 0) continue + if (!levels.has(destination)) continue + const key = edgeKey(row.id, destination) + if (dropped.has(key)) continue + const warpId = row.warp[slot] ?? -1 + const seen = visWarps.get(key) + if (seen === undefined) visWarps.set(key, warpId === -1 ? [] : [warpId]) + else if (warpId !== -1 && !seen.includes(warpId)) seen.push(warpId) + if (warpId !== -1) { + const slots = visSlots.get(key) + if (slots === undefined) visSlots.set(key, [slot]) + else if (!slots.includes(slot)) slots.push(slot) + } + // A slot with a destination but no warp tile is an opening the player + // walks through: the monastery gate, the barracks gateway, the cathedral + // steps, the Chaos Sanctuary entrance. No click, no loading screen. + if (warpId === -1) visWalkThrough.set(key, true) + } + } + + const adjacency = new Set() + for (const [a, b] of SEAMLESS_ADJACENCY) adjacency.add(pairKey(a, b)) + + const edges: WorldEdge[] = [] + const emitted = new Set() + + /** Record one direction, first writer wins. */ + const emit = ( + from: number, + to: number, + kind: LinkKind, + warps: readonly number[], + source: WorldEdge['source'], + warpSlots: readonly number[] = [], + ): void => { + const key = edgeKey(from, to) + if (emitted.has(key)) return + emitted.add(key) + edges.push({ from, to, kind, warps, warpSlots, sideFrom: null, sideTo: null, source }) + } + + // 1. Hard-coded outdoor stitching. Highest priority: where a pair is both + // adjacent and listed in `Vis` the adjacency is the truth. + for (const [a, b] of SEAMLESS_ADJACENCY) { + if (!levels.has(a) || !levels.has(b)) continue + emit(a, b, 'seamless', [], 'adjacency') + emit(b, a, 'seamless', [], 'adjacency') + } + + // 2. Hard-coded portals. + for (const link of PORTAL_LINKS) { + if (!levels.has(link.from) || !levels.has(link.to)) continue + if (!adjacency.has(pairKey(link.from, link.to))) { + emit(link.from, link.to, 'portal', [], 'portal') + if (link.bidirectional) emit(link.to, link.from, 'portal', [], 'portal') + } + } + + // 3. Whatever `Vis` describes that the first two did not. + for (const [key, warps] of visWarps) { + const [fromText, toText] = key.split('->') + const from = Number(fromText) + const to = Number(toText) + const kind: LinkKind = visWalkThrough.get(key) === true && warps.length === 0 ? 'seamless' : 'warp' + emit(from, to, kind, warps, 'vis', visSlots.get(key) ?? []) + } + + const waypoints = new Map() + for (const row of rows) { + if (row.waypoint === NO_WAYPOINT) continue + waypoints.set(row.waypoint, row.id) + } + + return { levels, edges, waypoints } +} + +/** + * Every undirected seam that needs an edge of the map assigned to it. + * + * @param graph - the graph. + * @returns the pairs, low id first, in ascending order so the result does not + * depend on `Map` iteration order. + */ +export function seamlessPairs(graph: WorldGraph): (readonly [number, number])[] { + const seen = new Set() + const pairs: (readonly [number, number])[] = [] + for (const edge of graph.edges) { + if (edge.kind !== 'seamless') continue + const key = pairKey(edge.from, edge.to) + if (seen.has(key)) continue + seen.add(key) + pairs.push(edge.from < edge.to ? [edge.from, edge.to] : [edge.to, edge.from]) + } + pairs.sort((left, right) => left[0] - right[0] || left[1] - right[1]) + return pairs +} + +/** + * Choose which edge of each level every seam sits on. + * + * Diablo II re-picks these per seed — the documented constraint is that in the + * Cold Plains the Blood Moor entrance and the two exits may not share an edge — + * so this is a constraint solve, not a table. Two rules: + * + * 1. **Opposite sides.** If the seam leaves A heading east it must arrive on + * B's west edge, or the two rectangles do not abut. + * 2. **No sharing.** Two seams of the same level may not use the same edge, or + * the two neighbours would occupy the same strip of ground. + * + * Preset-anchored seams ({@link PINNED_SIDES}) are placed first and never moved. + * The rest are assigned greedily in a shuffled order, retrying with a fresh + * shuffle when the greedy pass paints itself into a corner; with a maximum + * degree of three this converges immediately, and the retry loop is there so a + * future adjacency addition fails loudly rather than silently sharing an edge. + * + * The choice is deliberately **not** made per pack variant. A level's three + * baked variants all share one set of gate sides, so any variant of A docks + * against any variant of B; only the interior differs. + * + * @param graph - the graph to annotate. + * @param seed - the act layout seed. + * @returns a copy of the graph with `sideFrom`/`sideTo` filled on every + * seamless edge. + * @throws when no assignment satisfies the constraints. + */ +export function assignGateSides(graph: WorldGraph, seed: number): WorldGraph { + const pairs = seamlessPairs(graph) + const maxAttempts = 64 + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const rng = new Rng(seed).fork(`gate-sides:${String(attempt)}`) + /** level id -> the sides already spoken for. */ + const used = new Map>() + const taken = (level: number): Set => { + let set = used.get(level) + if (set === undefined) { + set = new Set() + used.set(level, set) + } + return set + } + + const chosen = new Map() + let failed = false + + // Pinned seams first: they cannot move, so everything else works around + // them rather than the other way round. + for (const [low, high] of pairs) { + const key = pairKey(low, high) + const pinned = PINNED_SIDES.get(key) + if (pinned === undefined) continue + const lowUsed = taken(low) + const highUsed = taken(high) + if (lowUsed.has(pinned) || highUsed.has(oppositeSide(pinned))) { + // Two pinned seams contradict each other; no shuffle can fix that. + throw new Error(`pinned sides conflict at ${key}`) + } + lowUsed.add(pinned) + highUsed.add(oppositeSide(pinned)) + chosen.set(key, pinned) + } + + // The rest, in a shuffled order so no level systematically gets first pick. + const free = pairs.filter(([low, high]) => !chosen.has(pairKey(low, high))) + for (let index = free.length - 1; index > 0; index -= 1) { + const swap = rng.int(0, index) + const hold = free[index]! + free[index] = free[swap]! + free[swap] = hold + } + + for (const [low, high] of free) { + const lowUsed = taken(low) + const highUsed = taken(high) + const candidates = SIDES.filter(side => !lowUsed.has(side) && !highUsed.has(oppositeSide(side))) + const pick = rng.pick(candidates) + if (pick === undefined) { + failed = true + break + } + lowUsed.add(pick) + highUsed.add(oppositeSide(pick)) + chosen.set(pairKey(low, high), pick) + } + + if (failed) continue + + const edges = graph.edges.map((edge): WorldEdge => { + if (edge.kind !== 'seamless') return edge + const low = Math.min(edge.from, edge.to) + const side = chosen.get(pairKey(edge.from, edge.to)) + if (side === undefined) return edge + const sideFrom = edge.from === low ? side : oppositeSide(side) + return { ...edge, sideFrom, sideTo: oppositeSide(sideFrom) } + }) + return { levels: graph.levels, edges, waypoints: graph.waypoints } + } + + throw new Error(`could not assign gate sides after ${String(maxAttempts)} attempts`) +} + +/** + * The edges leaving one level. + * + * @param graph - the graph. + * @param levelId - the level. + * @returns its outgoing edges, in graph order. + */ +export function edgesFrom(graph: WorldGraph, levelId: number): WorldEdge[] { + return graph.edges.filter(edge => edge.from === levelId) +} + +/** + * Every level reachable from a starting point. + * + * @param graph - the graph. + * @param start - the level to start from. + * @returns the reachable set, including `start`. + */ +export function reachableFrom(graph: WorldGraph, start: number): Set { + const outgoing = new Map() + for (const edge of graph.edges) { + const list = outgoing.get(edge.from) + if (list === undefined) outgoing.set(edge.from, [edge.to]) + else list.push(edge.to) + } + const seen = new Set([start]) + const queue = [start] + while (queue.length > 0) { + const at = queue.shift() + if (at === undefined) break + for (const next of outgoing.get(at) ?? []) { + if (seen.has(next)) continue + seen.add(next) + queue.push(next) + } + } + return seen +} + +/* ------------------------------------------------------------------------- * + * Warp geometry + * ------------------------------------------------------------------------- */ + +/** + * One row of `LvlWarp.txt`: where a warp's hitbox is and where it puts you. + * + * All the pixel values are relative to the bottom corner of the anchor + * sub-tile, and all of them are negative or zero, because the hitbox is drawn + * up and to the left of the tile the warp is anchored on. + */ +export interface WarpGeometry { + /** `Id`, as referenced by `Levels.txt` `Warp0..7`. */ + readonly id: number + /** `Direction`: `b` for every classic row, `l`/`r` for the Act 5 pairs. */ + readonly direction: string + /** `Name`, for diagnostics. */ + readonly name: string + /** Top-left of the mouse hitbox, in pixels; always <= 0. */ + readonly selectX: number + readonly selectY: number + /** Hitbox size in pixels. Zero on both axes means there is nothing to click. */ + readonly selectDX: number + readonly selectDY: number + /** + * Where the player materialises, in sub-tiles relative to the anchor tile. + * + * Frequently negative, which is deliberate rather than a sign error: landing + * *on* the warp tile would immediately re-trigger it, so the arrival point is + * pushed into the tile before the anchor. + */ + readonly offsetX: number + readonly offsetY: number + /** + * How far the player is walked automatically after arriving, in sub-tiles. + * + * Only ever -5, -1, 0, 2, 3 or 5; plus or minus five is one whole tile. This + * is what carries you clear of a doorway so the level behind you is not still + * under your feet. + */ + readonly exitWalkX: number + readonly exitWalkY: number + /** Whether the tile has a highlight-on-hover variant. */ + readonly litVersion: number + /** + * Value added to the DT1 tile sub-index to reach the lit variant. + * + * Two everywhere except ids 71 and 72 — the Act 5 barricades — where it is + * four. + */ + readonly tiles: number +} + +/** Key for a warp row. */ +function warpKey(id: number, direction: string): string { + return `${String(id)}:${direction}` +} + +/** + * Index `LvlWarp.txt` by `(Id, Direction)`. + * + * Keying on `Id` alone silently drops half the Act 5 barricade warps: ids 71, + * 73, 74, 81 and 82 each appear twice, once facing left and once facing right. + * The `Expansion` separator row has no numeric id and is skipped. + * + * @param lvlwarp - the parsed `LvlWarp.txt`. + * @returns the rows, keyed `"id:direction"`. + */ +export function parseWarpGeometry(lvlwarp: D2Table): Map { + const out = new Map() + for (const row of lvlwarp.rows) { + const idText = str(lvlwarp, row, 'Id') + if (idText === '') continue + const id = Number(idText) + if (!Number.isFinite(id)) continue + const direction = str(lvlwarp, row, 'Direction') || 'b' + out.set(warpKey(id, direction), { + id, + direction, + name: str(lvlwarp, row, 'Name'), + selectX: num(lvlwarp, row, 'SelectX'), + selectY: num(lvlwarp, row, 'SelectY'), + selectDX: num(lvlwarp, row, 'SelectDX'), + selectDY: num(lvlwarp, row, 'SelectDY'), + offsetX: num(lvlwarp, row, 'OffsetX'), + offsetY: num(lvlwarp, row, 'OffsetY'), + exitWalkX: num(lvlwarp, row, 'ExitWalkX'), + exitWalkY: num(lvlwarp, row, 'ExitWalkY'), + litVersion: num(lvlwarp, row, 'LitVersion'), + tiles: num(lvlwarp, row, 'Tiles', 2), + }) + } + return out +} + +/** + * Look up a warp, preferring an exact direction and falling back to any. + * + * @param geometry - the output of {@link parseWarpGeometry}. + * @param id - the `LvlWarp.txt` id. + * @param direction - the wanted direction, if the caller has one. + * @returns the row, or undefined. + */ +export function findWarpGeometry( + geometry: ReadonlyMap, + id: number, + direction?: string, +): WarpGeometry | undefined { + if (direction !== undefined) { + const exact = geometry.get(warpKey(id, direction)) + if (exact !== undefined) return exact + } + return ( + geometry.get(warpKey(id, 'b')) ?? + geometry.get(warpKey(id, 'l')) ?? + geometry.get(warpKey(id, 'r')) + ) +} + +/** + * Whether a warp has a hitbox the player can click. + * + * Ids 19, 50, 60, 61, 64, 79 and 80 have a zero-area hitbox. That is not + * missing data: those crossings are walked into, or are driven by an object + * rather than by a tile, so there is nothing for the cursor to find. + * + * @param warp - the geometry row. + * @returns true when the warp is clickable. + */ +export function isClickableWarp(warp: WarpGeometry): boolean { + return warp.selectDX > 0 && warp.selectDY > 0 +} + diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 7492354..2bac04d 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -28,12 +28,13 @@ import { decodePl2 } from '../formats/pl2.ts' import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' import { - ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, buildIsoMapScene, findIsoSpawn, levelSeed, + ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, + buildIsoMapScene, findIsoSpawn, isBlockedAt, levelSeed, } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' import type { MonsterPack, MonsterStats } from '../game/combat.ts' -import { depthInsertIndex } from '../game/map.ts' +import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" @@ -50,6 +51,12 @@ import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' import { GameLoop } from '../sim/loop.ts' import { KeyboardInput } from '../sim/input.ts' +import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts' +import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts' +import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts' +import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts' +import { Minimap } from '../ui/minimap.ts' +import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts' /** Archives the live path mounts, in load order. */ const DATA_ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const @@ -61,6 +68,22 @@ const DEFAULT_BASES = ['samples/d2', '/diablo2/data'] const DEFAULT_PACKS = ['samples/d2-packs', '/diablo2/packs'] /** Act count the page offers. */ const ACT_COUNT = 5 +/** + * Picks which baked variant of a generated level this world instance uses. + * + * Each generated level is baked three times and the world graph names none of + * them, so one has to be chosen at load time. It must be chosen from the level + * alone: keying it on the pair of levels, as an earlier version did, gave Cold + * Plains a different layout depending on whether you walked in from the Blood + * Moor or back from Stony Field, and left waypoints and town portals pointing + * at coordinates from a map that was no longer loaded. One variant per level + * per world is what the design calls for. + * + * A real game would derive this from the game seed; until there is one, a + * constant keeps every session identical, which is also what the verification + * scripts assume. + */ +const WORLD_VARIANT_SEED = 0x5eed_3000 /** Walk speed in scene pixels per second (cells are 80×40). */ const WALK_SPEED = 170 /** Character collision box at the feet, in scene pixels. */ @@ -218,6 +241,21 @@ interface MapRuntime { readonly base: string readonly level: string readonly act: number + /** + * `Levels.txt` `Id`, or -1 when the source cannot say. + * + * The world graph speaks in level ids; the pack speaks in labels. This is + * where the two meet. + */ + readonly levelId: number + /** The pack directory this map came from, for loading its neighbours. */ + readonly entryPath: string + /** Walk-through seams out of this level, in sub-tiles. */ + readonly entrances: readonly SceneEntrance[] + /** Clickable stairs and cave mouths, in sub-tiles. */ + readonly warps: readonly SceneWarp[] + /** The waypoint pedestal, when this level has one. */ + readonly waypoints: readonly SceneWaypoint[] readonly quadrants: readonly string[] /** * Which query parameter names the currently shown map block. @@ -313,14 +351,27 @@ async function fetchJson(url: string): Promise { /** A pack's index file. */ interface PackIndex { readonly palettes?: Record - readonly levels: readonly { - readonly act: number - readonly slug: string - readonly label: string - readonly levelName: string - readonly path: string - readonly pages: number - }[] + readonly levels: readonly PackIndexEntry[] +} + +/** + * One baked map in the index. + * + * `levelId` and `kind` have been written by the packer since the first bake; + * they were simply not declared here, because until now nothing needed to find + * a map by anything other than the label in the URL. Level transitions do: an + * edge of the world graph names a destination level, not a pack label, and a + * generated level has three labels for the one id. + */ +interface PackIndexEntry { + readonly act: number + readonly levelId?: number + readonly kind?: 'preset' | 'maze' | 'wilderness' + readonly slug: string + readonly label: string + readonly levelName: string + readonly path: string + readonly pages: number } /** Object frame inside an object atlas page. */ @@ -354,6 +405,8 @@ interface PackObject { /** A pack's per-map scene file. */ interface PackSceneJson { readonly act: number + /** `Levels.txt` `Id`. Written by every bake; only now read. */ + readonly levelId?: number readonly levelName: string readonly ds1: string readonly cellsX: number @@ -389,9 +442,41 @@ interface PackSceneJson { } readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly spawn: readonly number[] | null + /** Ways out, in sub-tiles. Absent in packs baked before world connectivity. */ + readonly entrances?: readonly SceneEntrance[] + readonly warps?: readonly SceneWarp[] + readonly waypoints?: readonly SceneWaypoint[] readonly stats: { readonly missingTiles: number; readonly walkable: number } } +/** + * The pack index, fetched once. + * + * A level transition needs the index to find its destination, and refetching it + * mid-walk would put a network round trip inside the fade. + */ +const packIndexCache = new Map>() + +/** + * Read a pack's index, reusing the fetch. + * + * @param packBase - directory holding `index.json`. + * @returns the index, or null when there is no pack there. + */ +async function getPackIndex(packBase: string): Promise { + let pending = packIndexCache.get(packBase) + if (pending === undefined) { + pending = fetchJson(`${packBase}/index.json`) + packIndexCache.set(packBase, pending) + } + try { + return await pending + } catch { + packIndexCache.delete(packBase) + return null + } +} + /** * Build the runtime for a prebaked pack. * @@ -407,12 +492,8 @@ async function loadPackRuntime( viewport: { width: number; height: number }, charBases: readonly string[], ): Promise { - let index: PackIndex - try { - index = await fetchJson(`${packBase}/index.json`) - } catch { - return null - } + const index = await getPackIndex(packBase) + if (index === null) return null const forAct = index.levels.filter(entry => entry.act === act) if (forAct.length === 0) return null const entry = (wantedLevel === '' @@ -421,6 +502,55 @@ async function loadPackRuntime( ?? forAct.find(candidate => candidate.slug === wantedLevel)) ?? forAct.find(candidate => candidate.slug === 'town') ?? forAct[0]! + return await buildPackRuntime(packBase, index, entry, viewport, charBases) +} + +/** + * Build the runtime for one level, named by id rather than by label. + * + * Generated levels have three baked variants and the world graph names none of + * them, so one is picked here. Which one does not matter for connectivity: the + * act layout solve gives all three the same seams on the same edges, so any + * variant docks with any neighbour. What does matter is that the choice depends + * only on the level, so that the same level is the same map every time it is + * entered — see {@link WORLD_VARIANT_SEED}. + * + * @param packBase - directory holding `index.json`. + * @param levelId - `Levels.txt` `Id` of the wanted level. + * @param variantSeed - picks between the variants deterministically. + * @returns the runtime, or null when the pack has no such level. + */ +async function loadRuntimeForLevel( + packBase: string, + levelId: number, + variantSeed: number, + viewport: { width: number; height: number }, + charBases: readonly string[], +): Promise { + const index = await getPackIndex(packBase) + if (index === null) return null + const candidates = index.levels.filter(entry => entry.levelId === levelId) + const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)] + if (entry === undefined) return null + return await buildPackRuntime(packBase, index, entry, viewport, charBases) +} + +/** + * Turn one index entry into a runtime. + * + * @param packBase - directory holding `index.json`. + * @param index - the already-fetched index. + * @param entry - the map to load. + * @returns the runtime. + */ +async function buildPackRuntime( + packBase: string, + index: PackIndex, + entry: PackIndexEntry, + viewport: { width: number; height: number }, + charBases: readonly string[], +): Promise { + const forAct = index.levels.filter(candidate => candidate.act === entry.act) const scene = await fetchJson(`${packBase}/${entry.path}/scene.json`) const frames: AtlasFrame[] = scene.framePlacement.map(place => ({ @@ -501,6 +631,11 @@ async function loadPackRuntime( palette: packPalette, level: scene.levelName, act: scene.act, + levelId: scene.levelId ?? entry.levelId ?? -1, + entryPath: entry.path, + entrances: scene.entrances ?? [], + warps: scene.warps ?? [], + waypoints: scene.waypoints ?? [], quadrants: forAct.map(candidate => candidate.label), quadrantParam: 'level', quadrant: entry.label, @@ -683,6 +818,14 @@ async function loadLiveRuntime( palette, level: town.levelName, act, + // The live path decodes one DS1 straight out of the archives. Nothing has + // solved the world layout for it, so it offers no way out; level + // transitions are a pack-path feature. + levelId: -1, + entryPath: '', + entrances: [], + warps: [], + waypoints: [], quadrants, quadrantParam: 'quadrant', quadrant: quadrants[index] ?? '', @@ -1085,11 +1228,15 @@ async function loadCharacterArt( /** * Run the loop for a runtime. * - * @param runtime - the map. + * @param initialRuntime - the map to start in. The binding inside is mutable: + * a level transition swaps the map under the running loop rather than + * rebuilding the scene, because rebuilding would take the character's bag, + * quests and experience with it. * @param renderer - the renderer (already holding the atlas or the first pages). * @param started - `performance.now()` when boot began. */ -function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number): void { +function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): void { + let runtime = initialRuntime const canvas = document.querySelector('#view')! const status = document.querySelector('#status')! const hud = document.querySelector('#hud')! @@ -1186,6 +1333,254 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) }) } + /* ----------------------------------------------------------------------- * + * World connectivity + * ----------------------------------------------------------------------- */ + + const fade = new FadeOverlay(document.body) + const minimap = new Minimap() + const waypointNetwork = new WaypointNetwork() + const portalSlot = new TownPortalSlot() + /** True while a transition is in flight, so nothing triggers a second one. */ + let travelling = false + + /** + * Tell the waypoint network about whatever level is loaded. + * + * @param map - the runtime just loaded. + */ + const registerWaypoints = (map: MapRuntime): void => { + for (const waypoint of map.waypoints) { + waypointNetwork.register({ + waypointId: waypoint.waypointId, + levelId: map.levelId, + act: map.act, + name: map.level, + x: waypoint.arriveX, + y: waypoint.arriveY, + }) + } + } + registerWaypoints(runtime) + + /** + * Where the player is, in sub-tiles. + * + * The inverse of the isometric projection: scene pixels back to the grid the + * links are expressed in. + * + * @returns the player's sub-tile. + */ + const playerSubTile = (): { x: number; y: number } => { + const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } + } + + /** + * Replace the level under the running loop. + * + * Everything about the character survives: `engine.world.player` is the same + * object before and after, so the bag, the quest flags and the experience go + * with it. Only the terrain, the collision closure and the crowd change. + * + * @param next - the level to move into. + * @param arrive - where to put the player, in sub-tiles. + */ + const swapLevel = (next: MapRuntime, arrive: { x: number; y: number }): void => { + // Give back the outgoing level's atlases before the new ones go up, or a + // long walk leaks a page set per level crossed. + for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page) + for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page) + + runtime = next + camera.mapWidthPx = next.widthPx + camera.mapHeightPx = next.heightPx + engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, { + width: FEET_WIDTH, + height: FEET_HEIGHT, + }) + + const landing = subTileToScene(arrive.x, arrive.y, next.grid) + engine.world.player.x = landing.x + engine.world.player.y = landing.y + + const defs = next.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) + engine.npcEntities.length = 0 + for (const n of next.npcs) { + const def = defs.find(d => d.id === `npc-${String(n.id)}`) + if (def === undefined) continue + engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) }) + } + + registerWaypoints(next) + state.level = next.level + state.act = next.act + state.cellsX = next.cellsX + state.cellsY = next.cellsY + state.pagesTotal = next.pages.length + state.pagesLoaded = 0 + } + + /** + * Walk the player through a link. + * + * The destination decides where the player lands, not the source: level B + * knows where its own opening back to A is, and using it means the two ends + * of a seam can never disagree. + * + * @param toLevelId - the level to move to. + * @param fadeMs - how long to black out for. + * @param override - an explicit landing sub-tile, for waypoints and portals. + */ + const travel = async ( + toLevelId: number, + fadeMs: number, + override?: { x: number; y: number }, + ): Promise => { + if (travelling || runtime.source !== 'pack') return + travelling = true + const fromLevelId = runtime.levelId + try { + await fade.out(fadeMs) + const next = await loadRuntimeForLevel( + runtime.base, + toLevelId, + // Per level, not per pair: see WORLD_VARIANT_SEED. + WORLD_VARIANT_SEED + toLevelId, + { width: canvas.width, height: canvas.height }, + runtime.charBases, + ) + if (next === null) { + status.textContent = `资源包里没有关卡 ${String(toLevelId)}。` + return + } + await next.loadPages(renderer, next.priorityPages, () => { + state.pagesLoaded = next.pages.filter(page => page !== null).length + }) + // The destination's own opening back to where we came from is the landing + // spot; an override wins, because a waypoint or a portal lands somewhere + // that no edge describes. + const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId) + ?? next.warps.find(warp => warp.toLevelId === fromLevelId) + const landing = override + ?? (back === undefined + ? { + x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2), + y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2), + } + : { x: back.arriveX, y: back.arriveY }) + swapLevel(next, landing) + preloadRemaining(next, renderer) + } catch (err) { + status.textContent = `切换关卡失败:${(err as Error).message}` + } finally { + await fade.in(fadeMs) + travelling = false + } + } + + /** + * Act on whatever the player is standing on. + * + * Seams fire on contact — the player walked off the edge of the map, and + * asking them to press a key for that would be strange. Warps, waypoints and + * portals need the use key, because they sit in the middle of walkable ground + * and stepping on one by accident is normal. + */ + const checkLinks = (): void => { + if (travelling || runtime.source !== 'pack') return + const here = playerSubTile() + const near = (x: number, y: number, radius: number): boolean => + Math.abs(here.x - x) <= radius && Math.abs(here.y - y) <= radius + + for (const entrance of runtime.entrances) { + if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue + void travel(entrance.toLevelId, SEAM_FADE_MS) + return + } + + const wantsUse = input.takeUse() + if (wantsUse) { + const portalTarget = portalSlot.otherEnd(runtime.levelId) + for (const warp of runtime.warps) { + if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue + void travel(warp.toLevelId, WARP_FADE_MS) + return + } + if (portalTarget !== null) { + void travel(portalTarget.levelId, WARP_FADE_MS, { x: portalTarget.x, y: portalTarget.y }) + return + } + } + + for (const waypoint of runtime.waypoints) { + if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue + if (waypointNetwork.activate(waypoint.waypointId)) { + status.textContent = `传送点已激活:${runtime.level}` + } + if (input.takeWaypoint()) { + // No destination picker yet, so the waypoint does the one thing a + // player always wants from it: go back to town. + const town = waypointNetwork.destinations() + .find(site => site.levelId === townLevelForAct(runtime.act)) + if (town !== undefined && town.levelId !== runtime.levelId) { + void travel(town.levelId, WARP_FADE_MS, { x: town.x, y: town.y }) + } + } + return + } + + if (input.takePortal()) { + const townLevelId = townLevelForAct(runtime.act) + if (runtime.levelId !== townLevelId) { + const townSite = waypointNetwork.destinations().find(site => site.levelId === townLevelId) + portalSlot.cast({ + fromLevelId: runtime.levelId, + fromX: here.x, + fromY: here.y, + townLevelId, + townX: townSite?.x ?? here.x, + townY: townSite?.y ?? here.y, + }) + status.textContent = `已打开回城门(${runtime.level})。走到原地按 G 返回。` + } + } + } + + /** + * The automap's view of the level under the player. + * + * Rebuilt only when the level changes: the marker list is a copy of three + * arrays, and the render loop asks for this every frame. + */ + let minimapCache: { key: string; level: MinimapLevel } | null = null + const minimapLevel = (): MinimapLevel => { + // Live runtimes have no entry path, so fall back to the level name; either + // way the key only has to change when the level does. + const key = runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath + if (minimapCache !== null && minimapCache.key === key) return minimapCache.level + const marker = (x: number, y: number, kind: MinimapMarker['kind']): MinimapMarker => ({ + cellX: Math.floor(x / SUB_TILES_PER_TILE), + cellY: Math.floor(y / SUB_TILES_PER_TILE), + kind, + }) + const level: MinimapLevel = { + key, + cellsX: runtime.grid.cellsX, + cellsY: runtime.grid.cellsY, + blocked: runtime.grid.blocked, + gridWidth: runtime.grid.gridWidth, + markers: [ + ...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit')), + ...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit')), + ...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')), + ], + } + minimapCache = { key, level } + return level + } + const loop = new GameLoop({ tickRate: 25, onTick: () => { @@ -1218,6 +1613,12 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number syncEngineState(engine, state) + // The automap is a view, but revealing is state: it has to advance with + // the simulation, not with however many frames the machine can draw. + if (input.takeMapToggle()) minimap.visible = !minimap.visible + checkLinks() + const walked = cellOf(runtime.grid, player.x, player.y) + minimap.reveal(minimapLevel(), walked.x, walked.y) const dialogPanel = document.querySelector('#dialog') if (dialogPanel !== null) { dialogPanel.hidden = state.dialog.length === 0 @@ -1389,6 +1790,15 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number // Roofs last, in their own pass: the engine paints them after every other // layer so they cover the floor, the walls and anything walking under them. drawTiles(runtime.roofs, roofBounds) + // Last in the batch, so the panel sits over every world layer. + minimap.draw( + renderer, + minimapLevel(), + cell.x, + cell.y, + { x: engine.world.player.x, y: engine.world.player.y - 16, zoom: camera.zoom }, + { width: canvas.width, height: canvas.height }, + ) renderer.flush() // The labels are recycled rather than rebuilt: `innerHTML = ''` plus one diff --git a/src/scene/transition.ts b/src/scene/transition.ts new file mode 100644 index 0000000..16fd308 --- /dev/null +++ b/src/scene/transition.ts @@ -0,0 +1,122 @@ +/** + * Level transitions: the fade, and the arithmetic of arriving. + * + * Two kinds of crossing, one pipeline. Walking through a gap in the border + * between the Cold Plains and the Stony Field is, in the original game, not a + * transition at all — the two levels share one coordinate space and the engine + * streams the neighbour in as you approach. This engine does not have that + * shared space yet, so both kinds load a fresh level; the seam simply gets a + * much shorter fade, short enough to read as continuous motion rather than as + * a loading screen. + * + * The fade is presentation, not simulation. It runs on wall-clock time in a DOM + * overlay and never touches the fixed-rate tick, because the tick has to stay a + * pure function of the inputs for lockstep networking to work. + */ + +import { ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../game/d2map.ts' + +/** How long the blackout lasts for a stair or a cave mouth, in milliseconds. */ +export const WARP_FADE_MS = 220 + +/** + * How long the blackout lasts for a walk-through seam. + * + * Short on purpose. The player did not ask for a loading screen; they walked + * off the edge of the map, and in the game they would not have noticed. + */ +export const SEAM_FADE_MS = 90 + +/** The scene-pixel position of a sub-tile. */ +export interface ScenePoint { + readonly x: number + readonly y: number +} + +/** + * Project a sub-tile onto the isometric scene. + * + * The same projection the packer used when it placed objects, so a warp baked + * at sub-tile `(257, 317)` lands exactly where its artwork was drawn. + * + * @param subTileX - sub-tile x. + * @param subTileY - sub-tile y. + * @param origin - the scene's pixel origin. + * @returns the point in scene pixels. + */ +export function subTileToScene( + subTileX: number, + subTileY: number, + origin: { readonly originX: number; readonly originY: number }, +): ScenePoint { + return { + x: (subTileX - subTileY) * ORTHO_SUB_TILE_WIDTH + origin.originX, + y: (subTileX + subTileY) * ORTHO_SUB_TILE_HEIGHT + origin.originY, + } +} + +/** + * The fade overlay. + * + * A plain absolutely-positioned div rather than a renderer pass: it has to + * cover the HUD and the NPC name labels, which are DOM, and a WebGL quad would + * sit underneath them. + */ +export class FadeOverlay { + private readonly element: HTMLElement + + /** + * @param host - the element to cover; the overlay is appended to it. + */ + constructor(host: HTMLElement) { + const element = document.createElement('div') + element.style.position = 'fixed' + element.style.inset = '0' + element.style.background = '#000' + element.style.opacity = '0' + element.style.pointerEvents = 'none' + element.style.zIndex = '50' + element.style.transition = 'opacity 0ms linear' + host.appendChild(element) + this.element = element + } + + /** + * Fade to black. + * + * @param durationMs - how long to take. + */ + async out(durationMs: number): Promise { + await this.ramp('1', durationMs) + } + + /** + * Fade back in. + * + * @param durationMs - how long to take. + */ + async in(durationMs: number): Promise { + await this.ramp('0', durationMs) + } + + /** Remove the overlay from the page. */ + dispose(): void { + this.element.remove() + } + + /** + * Run one leg of the fade and resolve when it is over. + * + * @param opacity - the target opacity. + * @param durationMs - how long to take. + */ + private async ramp(opacity: string, durationMs: number): Promise { + this.element.style.transition = `opacity ${String(durationMs)}ms linear` + // Read back a layout property so the browser commits the starting opacity + // before the new one is set; without this the two assignments coalesce and + // the transition never runs. + void this.element.offsetHeight + this.element.style.opacity = opacity + await new Promise(resolve => { setTimeout(resolve, durationMs) }) + } +} diff --git a/src/sim/input.ts b/src/sim/input.ts index daef3a3..820a80f 100644 --- a/src/sim/input.ts +++ b/src/sim/input.ts @@ -44,6 +44,14 @@ const TALK_KEYS = new Set(['KeyT']) /** Keys that save and load the game. */ const SAVE_KEYS = new Set(['KeyK']) const LOAD_KEYS = new Set(['KeyL']) +/** Keys that toggle the automap. `Tab` is where Diablo II puts it. */ +const MAP_KEYS = new Set(['Tab', 'KeyM']) +/** Keys that take the stairs, cave mouth or portal the player is standing on. */ +const USE_KEYS = new Set(['KeyG']) +/** Keys that cast a town portal. */ +const PORTAL_KEYS = new Set(['KeyP']) +/** Keys that open the waypoint destination list. */ +const WAYPOINT_KEYS = new Set(['KeyB']) /** Number keys that select a skill (1-4 → slots 1-4). */ const DIGIT_KEYS: Readonly> = { Digit1: 1, Digit2: 2, Digit3: 3, Digit4: 4, @@ -78,7 +86,21 @@ export function directionOf(movement: Movement): Direction | null { */ export class KeyboardInput { private readonly held = new Set() + /** + * Keys pressed since the last time anyone looked. + * + * Movement and attack are held states: the question is "is the key down this + * tick". Taking a staircase is not — holding `G` on a cave mouth must not + * re-trigger the transition sixty times a second — so those keys are edge + * triggered, recorded here on the way down and drained by the reader. + */ + private readonly pressed = new Set() + private readonly onKeyDown = (event: KeyboardEvent): void => { + const oneShot = MAP_KEYS.has(event.code) + || USE_KEYS.has(event.code) + || PORTAL_KEYS.has(event.code) + || WAYPOINT_KEYS.has(event.code) const recognised = KEYS[event.code] !== undefined || ATTACK_KEYS.has(event.code) || PICKUP_KEYS.has(event.code) @@ -86,9 +108,13 @@ export class KeyboardInput { || TALK_KEYS.has(event.code) || SAVE_KEYS.has(event.code) || LOAD_KEYS.has(event.code) + || oneShot if (!recognised) return // Arrows scroll the page otherwise, and held keys would repeat. event.preventDefault() + // Auto-repeat re-fires keydown without an intervening keyup; ignoring it + // is what makes these presses rather than a stream. + if (oneShot && !this.held.has(event.code)) this.pressed.add(event.code) this.held.add(event.code) } @@ -98,6 +124,7 @@ export class KeyboardInput { private readonly onBlur = (): void => { this.held.clear() + this.pressed.clear() } private readonly target: Window @@ -188,4 +215,53 @@ export class KeyboardInput { } return out } + + /** + * Whether the automap key was pressed since this was last called. + * + * @returns true once per press. + */ + takeMapToggle(): boolean { + return this.takeOnce(MAP_KEYS) + } + + /** + * Whether the use key was pressed since this was last called. + * + * @returns true once per press. + */ + takeUse(): boolean { + return this.takeOnce(USE_KEYS) + } + + /** + * Whether the town portal key was pressed since this was last called. + * + * @returns true once per press. + */ + takePortal(): boolean { + return this.takeOnce(PORTAL_KEYS) + } + + /** + * Whether the waypoint key was pressed since this was last called. + * + * @returns true once per press. + */ + takeWaypoint(): boolean { + return this.takeOnce(WAYPOINT_KEYS) + } + + /** + * Consume a pending press. + * + * @param keys - the codes that count. + * @returns true when one of them was pressed, clearing it. + */ + private takeOnce(keys: ReadonlySet): boolean { + for (const code of keys) { + if (this.pressed.delete(code)) return true + } + return false + } } diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts new file mode 100644 index 0000000..cac26cc --- /dev/null +++ b/src/ui/minimap.ts @@ -0,0 +1,222 @@ +/** + * The automap. + * + * Drawn inside the canvas with the renderer's solid-colour quad, the same way + * the orbs and the inventory grid in `map-scene.ts` are: the map has to sit on + * top of the world and scale with the viewport, and a DOM overlay would need + * its own coordinate system and its own resize handling. + * + * ## What "explored" means + * + * Diablo II reveals the automap by room, not by line of sight — walk into a + * room and the whole room appears, including the parts behind you. This is a + * cell-radius approximation of that: every cell within {@link REVEAL_RADIUS} of + * the player is marked, permanently, per level. It is generous in corridors and + * stingy in large halls, but it has the property that matters: what you have + * walked past stays on the map, and what you have not is not there. + * + * Exploration is per level and survives leaving and coming back, so it is held + * here rather than in the scene, which is rebuilt on every transition. + */ + +import type { SpriteRenderer } from '../render/renderer.ts' + +/** How far around the player the map reveals, in cells. */ +export const REVEAL_RADIUS = 9 + +/** Cell colours, as premultiplied RGBA in 0..1. */ +const COLOUR = { + /** Open ground the player has seen. */ + walkable: [0.55, 0.50, 0.42, 0.55] as const, + /** Walls and cliffs, drawn brighter so the shape of the level reads. */ + blocked: [0.22, 0.20, 0.18, 0.75] as const, + /** The player. */ + player: [1.0, 0.95, 0.7, 1.0] as const, + /** A way out of the level. */ + exit: [0.35, 0.75, 1.0, 1.0] as const, + /** A waypoint pedestal. */ + waypoint: [0.45, 0.55, 1.0, 1.0] as const, + /** The frame around the map. */ + border: [0.05, 0.05, 0.06, 0.8] as const, +} + +/** Something worth showing on the map even before the player reaches it. */ +export interface MinimapMarker { + /** Position in cells. */ + readonly cellX: number + readonly cellY: number + /** Which colour to use. */ + readonly kind: 'exit' | 'waypoint' +} + +/** What the minimap needs to know about the level under it. */ +export interface MinimapLevel { + /** A key that changes when the level does; exploration is kept per key. */ + readonly key: string + readonly cellsX: number + readonly cellsY: number + /** One byte per sub-tile, non-zero meaning impassable. */ + readonly blocked: Uint8Array + /** Sub-tiles across. */ + readonly gridWidth: number + /** Landmarks to draw once their cell is explored. */ + readonly markers: readonly MinimapMarker[] +} + +/** + * The automap overlay. + * + * One instance for the whole session; it keeps the explored set for every level + * visited, so walking back into the Cold Plains shows the roads you already + * found. + */ +export class Minimap { + /** Whether the map is on screen. */ + visible = false + + /** level key -> one byte per cell, non-zero meaning explored. */ + private readonly explored = new Map() + + /** + * Mark the ground around a point as seen. + * + * Cheap enough to call every tick: the loop is a square of side + * `2 * REVEAL_RADIUS + 1` over a byte array. + * + * @param level - the level being walked. + * @param cellX - the player's cell. + * @param cellY - the player's cell. + */ + reveal(level: MinimapLevel, cellX: number, cellY: number): void { + const seen = this.seenFor(level) + for (let dy = -REVEAL_RADIUS; dy <= REVEAL_RADIUS; dy += 1) { + const y = cellY + dy + if (y < 0 || y >= level.cellsY) continue + for (let dx = -REVEAL_RADIUS; dx <= REVEAL_RADIUS; dx += 1) { + const x = cellX + dx + if (x < 0 || x >= level.cellsX) continue + if (dx * dx + dy * dy > REVEAL_RADIUS * REVEAL_RADIUS) continue + seen[y * level.cellsX + x] = 1 + } + } + } + + /** + * How much of a level has been seen. + * + * @param level - the level. + * @returns the explored fraction, 0..1. + */ + exploredFraction(level: MinimapLevel): number { + const seen = this.seenFor(level) + let count = 0 + for (const value of seen) count += value + return seen.length === 0 ? 0 : count / seen.length + } + + /** + * Draw the map. + * + * Call between `renderer.begin` and `renderer.flush`. Everything is divided + * by the camera zoom so the map stays the same size on screen however far the + * world is zoomed, matching how the HUD in `map-scene.ts` behaves. + * + * @param renderer - the renderer, already begun. + * @param level - the level under the map. + * @param playerCellX - the player's cell. + * @param playerCellY - the player's cell. + * @param view - the camera's centre and zoom, for placing the panel. + * @param viewport - the canvas size in device pixels. + */ + draw( + renderer: SpriteRenderer, + level: MinimapLevel, + playerCellX: number, + playerCellY: number, + view: { readonly x: number; readonly y: number; readonly zoom: number }, + viewport: { readonly width: number; readonly height: number }, + ): void { + if (!this.visible) return + const seen = this.seenFor(level) + + // The panel occupies a quarter of the shorter side, top right. + const panel = Math.min(viewport.width, viewport.height) * 0.38 / view.zoom + const margin = 12 / view.zoom + const halfW = viewport.width / 2 / view.zoom + const halfH = viewport.height / 2 / view.zoom + const left = view.x + halfW - panel - margin + const top = view.y - halfH + margin + + const cellSize = Math.max( + 0.5 / view.zoom, + Math.min(panel / Math.max(1, level.cellsX), panel / Math.max(1, level.cellsY)), + ) + const width = cellSize * level.cellsX + const height = cellSize * level.cellsY + + renderer.drawSolid(left - 2 / view.zoom, top - 2 / view.zoom, width + 4 / view.zoom, height + 4 / view.zoom, COLOUR.border) + + for (let y = 0; y < level.cellsY; y += 1) { + for (let x = 0; x < level.cellsX; x += 1) { + if (seen[y * level.cellsX + x] === 0) continue + const colour = this.cellBlocked(level, x, y) ? COLOUR.blocked : COLOUR.walkable + renderer.drawSolid(left + x * cellSize, top + y * cellSize, cellSize, cellSize, colour) + } + } + + const markerSize = Math.max(cellSize * 2, 3 / view.zoom) + for (const marker of level.markers) { + if (marker.cellX < 0 || marker.cellY < 0) continue + if (marker.cellX >= level.cellsX || marker.cellY >= level.cellsY) continue + if (seen[marker.cellY * level.cellsX + marker.cellX] === 0) continue + renderer.drawSolid( + left + marker.cellX * cellSize - markerSize / 2, + top + marker.cellY * cellSize - markerSize / 2, + markerSize, + markerSize, + marker.kind === 'waypoint' ? COLOUR.waypoint : COLOUR.exit, + ) + } + + const dot = Math.max(cellSize * 2.5, 4 / view.zoom) + renderer.drawSolid( + left + playerCellX * cellSize - dot / 2, + top + playerCellY * cellSize - dot / 2, + dot, + dot, + COLOUR.player, + ) + } + + /** + * Whether a cell is wall. + * + * A cell is five sub-tiles square; it counts as wall when its middle sub-tile + * is blocked, which is what the eye reads as "you cannot go there". + * + * @param level - the level. + * @param cellX - the cell. + * @param cellY - the cell. + * @returns true when blocked. + */ + private cellBlocked(level: MinimapLevel, cellX: number, cellY: number): boolean { + const sx = cellX * 5 + 2 + const sy = cellY * 5 + 2 + return level.blocked[sy * level.gridWidth + sx] !== 0 + } + + /** + * The explored bytes for a level, created on first use. + * + * @param level - the level. + * @returns its explored map. + */ + private seenFor(level: MinimapLevel): Uint8Array { + let seen = this.explored.get(level.key) + if (seen === undefined || seen.length !== level.cellsX * level.cellsY) { + seen = new Uint8Array(level.cellsX * level.cellsY) + this.explored.set(level.key, seen) + } + return seen + } +} diff --git a/tests/warp-tiles.test.ts b/tests/warp-tiles.test.ts new file mode 100644 index 0000000..24a8add --- /dev/null +++ b/tests/warp-tiles.test.ts @@ -0,0 +1,130 @@ +/** + * The warp-tile scanner. + * + * The important behaviour is not "finds a tile" but "groups a multi-tile + * staircase into one warp and keeps slots apart", because getting either wrong + * puts a level's exit in the wrong place without failing anything else. + */ +import { describe, expect, it } from 'vitest' +import { findWarpTiles } from '../src/game/warp-tiles.ts' +import type { Ds1, Ds1Cell, Ds1Wall } from '../src/formats/ds1.ts' + +/** + * A wall entry. + * + * @param type - DS1 wall type; 10 and 11 are the special markers. + * @param style - the marker's style, which for a warp is the `Warp0..7` slot. + * @param sequence - the tile index within a multi-tile marker. + * @returns the wall. + */ +function wall(type: number, style: number, sequence = 0): Ds1Wall { + return { prop1: 0, sequence, style, type, unknown1: 0, unknown2: 0, hidden: false } +} + +/** + * An empty map with walls placed at the given cells. + * + * @param width - map width in cells. + * @param height - map height in cells. + * @param placements - what to put where. + * @returns the map. + */ +function mapWith( + width: number, + height: number, + placements: readonly { x: number; y: number; wall: Ds1Wall }[], +): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + row.push({ walls: [], floors: [], shadows: [], substitutions: [] }) + } + cells.push(row) + } + for (const placement of placements) { + const cell = cells[placement.y]![placement.x]! + ;(cell.walls as Ds1Wall[]).push(placement.wall) + } + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + groups: [], + files: [], + } as unknown as Ds1 +} + +describe('findWarpTiles', () => { + it('finds nothing in a map with no markers', () => { + expect(findWarpTiles(mapWith(4, 4, [{ x: 1, y: 1, wall: wall(1, 0) }]))).toEqual([]) + }) + + it('reads the slot from the style, for both special wall types', () => { + const found = findWarpTiles(mapWith(8, 8, [ + { x: 1, y: 1, wall: wall(10, 3) }, + { x: 5, y: 5, wall: wall(11, 6) }, + ])) + expect(found.map(tile => tile.slot)).toEqual([3, 6]) + }) + + it('collapses a two-tile staircase into one warp at its centre', () => { + // This is the shape almost every warp in the game has: `LvlWarp.Tiles` is 2 + // and the DS1 marks both cells with a rising sequence. + const found = findWarpTiles(mapWith(8, 8, [ + { x: 3, y: 4, wall: wall(11, 1, 0) }, + { x: 4, y: 4, wall: wall(11, 1, 1) }, + ])) + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ slot: 1, cellX: 3, cellY: 4, tiles: 2, minCellX: 3, minCellY: 4 }) + }) + + it('collapses a four-tile staircase too', () => { + const found = findWarpTiles(mapWith(8, 8, [ + { x: 2, y: 2, wall: wall(10, 0, 0) }, + { x: 3, y: 2, wall: wall(10, 0, 1) }, + { x: 2, y: 3, wall: wall(10, 0, 2) }, + { x: 3, y: 3, wall: wall(10, 0, 3) }, + ])) + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ slot: 0, tiles: 4, cellX: 2, cellY: 2 }) + }) + + it('keeps two staircases of the same slot apart when they are far apart', () => { + // A generator can stamp the same stair piece twice; the caller needs both + // so it can choose, rather than silently averaging them into a wall. + const found = findWarpTiles(mapWith(20, 20, [ + { x: 1, y: 1, wall: wall(10, 2) }, + { x: 15, y: 15, wall: wall(10, 2) }, + ])) + expect(found).toHaveLength(2) + expect(found.map(tile => [tile.cellX, tile.cellY])).toEqual([[1, 1], [15, 15]]) + }) + + it('ignores markers outside the eight warp slots', () => { + // Styles 30..34 are town entry points and player start positions; there is + // no `Warp30`, so treating them as warps would invent destinations. + const found = findWarpTiles(mapWith(8, 8, [ + { x: 1, y: 1, wall: wall(10, 30) }, + { x: 2, y: 2, wall: wall(10, 8) }, + { x: 3, y: 3, wall: wall(10, 7) }, + ])) + expect(found.map(tile => tile.slot)).toEqual([7]) + }) + + it('sorts by slot then position, so a bake is reproducible', () => { + const found = findWarpTiles(mapWith(20, 20, [ + { x: 10, y: 10, wall: wall(10, 4) }, + { x: 1, y: 1, wall: wall(10, 4) }, + { x: 5, y: 5, wall: wall(11, 0) }, + ])) + expect(found.map(tile => [tile.slot, tile.cellX, tile.cellY])) + .toEqual([[0, 5, 5], [4, 1, 1], [4, 10, 10]]) + }) +}) diff --git a/tests/wilderness-roads.test.ts b/tests/wilderness-roads.test.ts new file mode 100644 index 0000000..dc08b97 --- /dev/null +++ b/tests/wilderness-roads.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts' +import type { ActTables, D2Table } from '../src/game/acts.ts' +import { decodeDs1 } from '../src/formats/ds1.ts' +import { decodeDt1 } from '../src/formats/dt1.ts' +import type { Dt1 } from '../src/formats/dt1.ts' +import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts' +import { + generateWilderness, + DIRT_PATH_TILE_LUT, + TILES_PER_BLOCK, +} from '../src/game/wilderness.ts' +import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1-4)', () => { + let archives: MountedArchives + let tables: ActTables + let lvlsubTable: D2Table + let act1Pieces: WildernessPiece[] + let act1Subs: WildernessSubstitution[] + const dt1Cache = new Map() + + beforeAll(async () => { + archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + tables = await loadActTables(archives) + lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) + + const families = [ + 'Act 1 - Wild', + 'Act 1 - Town 1 Transition', + 'Act 1 - Cave Entrance', + 'Act 1 - DOE Entrance', + 'Act 1 - Cairn Stones', + 'Act 1 - Inifus', + 'Act 1 - Tower', + 'Act 1 - Graveyard', + 'Act 1 - Bivouac', + 'Act 1 - Fallen Camp', + 'Act 1 - Cottages', + 'Act 1 - Pond', + 'Act 1 - Camp', + 'Act 1 - Ruin', + 'Act 1 - Stone Fill', + 'Act 1 - Corral Fill', + 'Act 1 - Fence Fill', + 'Act 1 - Swamp Fill', + 'Act 1 - Tree Fill', + ] + act1Pieces = [] + for (const row of tables.lvlprest.rows) { + const name = cell(tables.lvlprest, row, 'Name') + if (!families.some(f => name.startsWith(f))) continue + const levels = [] + for (let i = 1; i <= 6; i += 1) { + const f = cell(tables.lvlprest, row, `File${String(i)}`) + if (f && f !== '0') { + levels.push(decodeDs1(await archives.read(tileMemberPath(f)))) + } + } + if (levels.length > 0) { + act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) }) + } + } + + act1Subs = [] + for (const row of lvlsubTable.rows) { + if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue + const file = cell(lvlsubTable, row, 'File') + if (!file || file === '0') continue + const ds1 = decodeDs1(await archives.read(tileMemberPath(file))) + const vals = (prefix: string) => + [0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0) + act1Subs.push({ + name: cell(lvlsubTable, row, 'Name'), + type: 0, + gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1, + bordType: Number(cell(lvlsubTable, row, 'BordType')), + dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0, + prob: vals('Prob'), + trials: vals('Trials'), + max: vals('Max'), + levels: [ds1], + }) + } + + // Pre-cache DT1 libraries for Act 1 wilderness levels + for (const id of [2, 3, 4, 5, 6, 7, 17]) { + const libraries = resolveLevelLibraries(tables, id) + for (const n of libraries.dt1Names) { + if (!dt1Cache.has(n)) { + dt1Cache.set(n, decodeDt1(await archives.read(n))) + } + } + } + }, 30000) + + it('DIRT_PATH_TILE_LUT matches exact D2MOO 8-neighbor bitmask tile sequences', () => { + expect(DIRT_PATH_TILE_LUT.length).toBe(256) + // Straight horizontal road top edge (West + East + South neighbors = 0x6B -> 0x11 = 17) + expect(DIRT_PATH_TILE_LUT[0x6b]).toBe(0x11) + // Straight horizontal road bottom edge (West + East + North neighbors = 0xD6 -> 0x16 = 22) + expect(DIRT_PATH_TILE_LUT[0xd6]).toBe(0x16) + // Straight vertical road left edge (North + South + East neighbors = 0xF8 -> 0x18 = 24) + expect(DIRT_PATH_TILE_LUT[0xf8]).toBe(0x18) + // Straight vertical road right edge (North + South + West neighbors = 0x1F -> 0x14 = 20) + expect(DIRT_PATH_TILE_LUT[0x1f]).toBe(0x14) + }) + + it('places directional borders (Border 1..8) around the level perimeter', () => { + const res = generateWilderness({ + levelId: 2, + levelName: 'Act 1 - Wilderness 1', + levelTypeName: 'Act 1 - Wilderness', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 0x123456, + pieces: act1Pieces, + substitutions: [], + }) + const usage = res.stats.borderUsage as Record + // Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR) + expect(usage['Act 1 - Wild Border 5']).toBe(1) + expect(usage['Act 1 - Wild Border 6']).toBe(1) + expect(usage['Act 1 - Wild Border 7']).toBe(1) + expect(usage['Act 1 - Wild Border 8']).toBe(1) + // Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right) + expect(usage['Act 1 - Wild Border 1']).toBeGreaterThan(0) + expect(usage['Act 1 - Wild Border 2']).toBeGreaterThan(0) + expect(usage['Act 1 - Wild Border 3']).toBeGreaterThan(0) + expect(usage['Act 1 - Wild Border 4']).toBeGreaterThan(0) + }) + + it.each([ + { id: 2, name: 'Act 1 - Wilderness 1', expectedPreset: 'Act 1 - DOE Entrance' }, + { id: 3, name: 'Act 1 - Wilderness 2', expectedPreset: 'Act 1 - Cave Entrance' }, + { id: 4, name: 'Act 1 - Wilderness 3', expectedPreset: 'Act 1 - Cairn Stones' }, + { id: 5, name: 'Act 1 - Wilderness 4', expectedPreset: 'Act 1 - Inifus' }, + { id: 6, name: 'Act 1 - Wilderness 5', expectedPreset: 'Act 1 - Tower 1' }, + { id: 7, name: 'Act 1 - Wilderness 6', expectedPreset: 'Act 1 - Cave Entrance' }, + { id: 17, name: 'Act 1 - Graveyard', expectedPreset: 'Act 1 - Graveyard' }, + ])('generates connected dirt roads and special presets for $name (id=$id)', async ({ id, name, expectedPreset }) => { + const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === id)! + const subType = Number(cell(tables.levels, levelRow, 'SubType')) + const subTheme = Math.max(0, Number(cell(tables.levels, levelRow, 'SubTheme'))) + const subs = subType === 0 ? act1Subs : [] + + const res = generateWilderness({ + levelId: id, + levelName: name, + levelTypeName: 'Act 1 - Wilderness', + sizeX: Number(cell(tables.levels, levelRow, 'SizeX')), + sizeY: Number(cell(tables.levels, levelRow, 'SizeY')), + subType, + subTheme, + seed: 0x5eed_1000 + id, + pieces: act1Pieces, + substitutions: subs, + }) + + // Verify road stats + expect(Number(res.stats.roadCells)).toBeGreaterThan(0) + expect(Number(res.stats.roadSegments)).toBeGreaterThan(0) + expect(Number(res.stats.anchors)).toBeGreaterThanOrEqual(2) + + // Verify special preset placement + const presets = res.stats.specialPresets as string[] + expect(presets).toContain(expectedPreset) + + // Count actual dirt road tiles (style 0, sequence 1..46) on canvas floor[0] + let dirtTileCount = 0 + for (let y = 0; y < res.level.height; y += 1) { + for (let x = 0; x < res.level.width; x += 1) { + const floor = res.level.cells[y]?.[x]?.floors[0] + if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) { + dirtTileCount += 1 + } + } + } + expect(dirtTileCount).toBeGreaterThan(30) + + // Verify DT1 tile resolvability (0 missing tiles) + const libraries = resolveLevelLibraries(tables, id) + const dt1s: Dt1[] = libraries.dt1Names.map(n => dt1Cache.get(n)!) + const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test')) + expect(scene.missingTiles).toBe(0) + }, 15000) +}) diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts new file mode 100644 index 0000000..55ce4f9 --- /dev/null +++ b/tests/world-graph.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect } from 'vitest' +import type { D2Table } from '../src/game/acts.ts' +import { + parseLevelRows, + buildWorldGraph, + assignGateSides, + seamlessPairs, + reachableFrom, + edgesFrom, + oppositeSide, + parseWarpGeometry, + findWarpGeometry, + isClickableWarp, + SIDES, + SEAMLESS_ADJACENCY, + DROPPED_VIS_EDGES, +} from '../src/game/world-graph.ts' +import type { Side } from '../src/game/world-graph.ts' + +/** + * Build a `Levels.txt`-shaped table from sparse overrides. + * + * Hand-written rather than read out of the MPQs so these tests exercise the + * graph logic on cases the shipped data does not contain — an asymmetric edge, + * a slot that repeats, a level with no connections at all. + */ +function levelsTable( + rows: readonly Partial>[], +): D2Table { + const header = [ + 'Name', + 'Id', + 'Act', + 'LevelType', + 'DrlgType', + 'SizeX', + 'SizeY', + 'OffsetX', + 'OffsetY', + 'Depend', + 'Waypoint', + 'Portal', + 'Position', + ...Array.from({ length: 8 }, (_unused, slot) => `Vis${String(slot)}`), + ...Array.from({ length: 8 }, (_unused, slot) => `Warp${String(slot)}`), + ] + const defaults: Record = { + Name: '', + Id: '0', + Act: '0', + LevelType: '0', + DrlgType: '3', + SizeX: '80', + SizeY: '80', + OffsetX: '-1', + OffsetY: '-1', + Depend: '0', + Waypoint: '255', + Portal: '0', + Position: '0', + } + for (let slot = 0; slot < 8; slot += 1) { + defaults[`Vis${String(slot)}`] = '0' + defaults[`Warp${String(slot)}`] = '-1' + } + return { + header, + rows: rows.map(row => + header.map(column => { + const value = row[column] + return value === undefined ? (defaults[column] ?? '') : String(value) + }), + ), + } +} + +describe('parseLevelRows', () => { + it('drops the Null placeholder row', () => { + const rows = parseLevelRows(levelsTable([{ Id: 0, Name: 'Null' }, { Id: 1, Name: 'Town' }])) + expect(rows.map(row => row.id)).toEqual([1]) + }) + + it('reads all eight Vis and Warp slots', () => { + const rows = parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0, Vis6: 8, Warp6: 3 }])) + expect(rows[0]?.vis).toEqual([0, 0, 0, 8, 0, 0, 8, 0]) + expect(rows[0]?.warp).toEqual([-1, -1, -1, 0, -1, -1, 3, -1]) + }) + + it('defaults a missing waypoint to 255 rather than 0', () => { + // Zero is a real waypoint id (the Rogue Encampment), so a sloppy default + // would invent a second waypoint 0 on every level in the game. + const rows = parseLevelRows(levelsTable([{ Id: 5 }])) + expect(rows[0]?.waypoint).toBe(255) + }) +}) + +describe('buildWorldGraph', () => { + it('collapses repeated slots into one edge that keeps every warp id', () => { + // A cave mouth occupies four slots, one per orientation. + const graph = buildWorldGraph( + parseLevelRows( + levelsTable([ + { Id: 2, Vis3: 8, Warp3: 0, Vis4: 8, Warp4: 1, Vis5: 8, Warp5: 2, Vis6: 8, Warp6: 3 }, + { Id: 8, Vis0: 2, Warp0: 4 }, + ]), + ), + ) + const out = edgesFrom(graph, 2) + expect(out).toHaveLength(1) + expect(out[0]?.to).toBe(8) + expect(out[0]?.warps).toEqual([0, 1, 2, 3]) + }) + + it('classifies a Vis with no Warp tile as a seamless walk-through', () => { + // The monastery gate, the barracks gateway, the cathedral steps and the + // Chaos Sanctuary entrance are all openings inside a preset: no click, no + // loading screen. + const graph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 26, Vis1: 27 }, { Id: 27, Vis0: 26 }])), + ) + expect(edgesFrom(graph, 26)[0]?.kind).toBe('seamless') + expect(edgesFrom(graph, 27)[0]?.kind).toBe('seamless') + }) + + it('classifies a Vis with a Warp tile as a warp', () => { + const graph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])), + ) + expect(edgesFrom(graph, 2)[0]?.kind).toBe('warp') + }) + + it('ignores a Vis pointing at a level that does not exist', () => { + const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 2, Vis0: 999, Warp0: 4 }]))) + expect(graph.edges).toHaveLength(0) + }) + + it('never emits an edge to or from the Null row', () => { + const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 0 }, { Id: 2, Vis0: 0 }]))) + expect(graph.edges).toHaveLength(0) + }) + + it('collects waypoints and skips the 255 sentinel', () => { + const graph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 1, Waypoint: 0 }, { Id: 2 }, { Id: 3, Waypoint: 1 }])), + ) + expect(graph.waypoints.get(0)).toBe(1) + expect(graph.waypoints.get(1)).toBe(3) + expect(graph.waypoints.size).toBe(2) + }) +}) + +describe('the hard-coded adjacency table', () => { + it('supplies the connections Levels.txt omits', () => { + // Act 1 town really does have every Vis at 0 and every Warp at -1: built + // from the table alone, the player could never leave the Rogue Encampment. + const table = levelsTable([{ Id: 1, Name: 'Act 1 - Town' }, { Id: 2, Name: 'Act 1 - Wilderness 1' }]) + const rows = parseLevelRows(table) + expect(rows.every(row => row.vis.every(value => value === 0))).toBe(true) + + const graph = buildWorldGraph(rows) + expect(edgesFrom(graph, 1).map(edge => edge.to)).toEqual([2]) + expect(edgesFrom(graph, 2).map(edge => edge.to)).toEqual([1]) + expect(edgesFrom(graph, 1)[0]?.kind).toBe('seamless') + }) + + it('lists every pair only once and never as a self-loop', () => { + const seen = new Set() + for (const [a, b] of SEAMLESS_ADJACENCY) { + const key = a < b ? `${String(a)}:${String(b)}` : `${String(b)}:${String(a)}` + expect(seen.has(key)).toBe(false) + expect(a).not.toBe(b) + seen.add(key) + } + }) + + it('outranks a Vis edge describing the same pair', () => { + const graph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 1, Vis0: 2, Warp0: 7 }, { Id: 2, Vis0: 1, Warp0: 7 }])), + ) + expect(edgesFrom(graph, 1)[0]?.kind).toBe('seamless') + expect(edgesFrom(graph, 1)[0]?.source).toBe('adjacency') + }) +}) + +describe('dropped Vis edges', () => { + it('discards the Pandemonium-to-Graveyard copy-paste artifact', () => { + // Row 133 is LevelType 4 — the Act 1 crypt type — and its whole Vis/Warp + // block is a duplicate of row 18. Level 17 does not point back. + expect(DROPPED_VIS_EDGES).toContainEqual([133, 17]) + const graph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 133, Vis0: 17, Warp0: 8 }, { Id: 17 }])), + ) + expect(graph.edges).toHaveLength(0) + }) +}) + +describe('oppositeSide', () => { + it('pairs the four sides', () => { + expect(oppositeSide('north')).toBe('south') + expect(oppositeSide('south')).toBe('north') + expect(oppositeSide('east')).toBe('west') + expect(oppositeSide('west')).toBe('east') + }) + + it('is an involution on every side', () => { + for (const side of SIDES) expect(oppositeSide(oppositeSide(side))).toBe(side) + }) +}) + +describe('assignGateSides', () => { + /** A chain of five outdoor levels plus a branch, as Act 1 is shaped. */ + const chain = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }, { Id: 5 }, { Id: 17 }])), + ) + + it('finds a side for every seam', () => { + const graph = assignGateSides(chain, 7) + for (const edge of graph.edges) { + if (edge.kind !== 'seamless') continue + expect(edge.sideFrom).not.toBeNull() + expect(edge.sideTo).not.toBeNull() + } + }) + + it('always puts the two ends of a seam on opposite sides', () => { + for (let seed = 0; seed < 50; seed += 1) { + const graph = assignGateSides(chain, seed) + for (const edge of graph.edges) { + if (edge.kind !== 'seamless' || edge.sideFrom === null) continue + expect(edge.sideTo).toBe(oppositeSide(edge.sideFrom)) + } + } + }) + + it('never puts two of a level\u2019s seams on the same side', () => { + // The documented constraint: in the Cold Plains the Blood Moor entrance + // and the two exits may not share an edge. + for (let seed = 0; seed < 50; seed += 1) { + const graph = assignGateSides(chain, seed) + const used = new Map>() + for (const edge of graph.edges) { + if (edge.kind !== 'seamless' || edge.sideFrom === null) continue + let sides = used.get(edge.from) + if (sides === undefined) { + sides = new Set() + used.set(edge.from, sides) + } + expect(sides.has(edge.sideFrom)).toBe(false) + sides.add(edge.sideFrom) + } + } + }) + + it('replays the same layout for the same seed', () => { + const render = (seed: number): string => + assignGateSides(chain, seed) + .edges.filter(edge => edge.kind === 'seamless') + .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) + .sort() + .join('|') + expect(render(4242)).toBe(render(4242)) + }) + + it('produces different layouts for different seeds', () => { + // Side choice is per seed in the real game; pinning it would be stating a + // coincidence as a law. + const render = (seed: number): string => + assignGateSides(chain, seed) + .edges.filter(edge => edge.kind === 'seamless') + .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) + .sort() + .join('|') + const layouts = new Set(Array.from({ length: 20 }, (_unused, seed) => render(seed))) + expect(layouts.size).toBeGreaterThan(1) + }) + + it('honours the preset-anchored pin between town and the Blood Moor', () => { + // The Rogue Encampment's gate is part of a hand-authored preset: it faces + // south and cannot be re-rolled. + for (let seed = 0; seed < 20; seed += 1) { + const graph = assignGateSides(chain, seed) + const gate = graph.edges.find(edge => edge.from === 1 && edge.to === 2) + expect(gate?.sideFrom).toBe('south') + expect(gate?.sideTo).toBe('north') + } + }) + + it('leaves warp edges without a side', () => { + const graph = assignGateSides( + buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])), + ), + 1, + ) + const warp = graph.edges.find(edge => edge.kind === 'warp') + expect(warp?.sideFrom).toBeNull() + }) +}) + +describe('seamlessPairs', () => { + it('returns each seam once, low id first, in a stable order', () => { + const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }]))) + const pairs = seamlessPairs(graph) + expect(pairs).toEqual([ + [1, 2], + [2, 3], + ]) + }) +}) + +describe('reachableFrom', () => { + it('walks the chain', () => { + const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }]))) + expect([...reachableFrom(graph, 1)].sort((a, b) => a - b)).toEqual([1, 2, 3, 4]) + }) + + it('reports an island as unreachable', () => { + const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 46 }]))) + expect(reachableFrom(graph, 1).has(46)).toBe(false) + }) +}) + +describe('parseWarpGeometry', () => { + const lvlwarp: D2Table = { + header: [ + 'Name', + 'Id', + 'SelectX', + 'SelectY', + 'SelectDX', + 'SelectDY', + 'ExitWalkX', + 'ExitWalkY', + 'OffsetX', + 'OffsetY', + 'LitVersion', + 'Tiles', + 'Direction', + 'Beta', + ], + rows: [ + ['Act 1 Wilderness', '0', '-90', '-100', '90', '110', '5', '0', '1', '-1', '1', '2', 'b', '1'], + ['Act 5 Barricade L', '71', '-50', '-110', '110', '150', '0', '5', '5', '1', '1', '4', 'l', '0'], + ['Act 5 Barricade R', '71', '-40', '-80', '210', '110', '0', '5', '-1', '3', '1', '4', 'r', '0'], + ['Walk in', '19', '0', '0', '0', '0', '0', '0', '0', '0', '0', '2', 'b', '1'], + ['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', ''], + ], + } + + it('keys duplicate ids on their direction', () => { + // Ids 71, 73, 74, 81 and 82 each appear twice for the Act 5 barricades; + // keying on the id alone silently loses one of each pair. + const geometry = parseWarpGeometry(lvlwarp) + expect(findWarpGeometry(geometry, 71, 'l')?.selectDX).toBe(110) + expect(findWarpGeometry(geometry, 71, 'r')?.selectDX).toBe(210) + }) + + it('skips the Expansion separator row', () => { + expect(parseWarpGeometry(lvlwarp).size).toBe(4) + }) + + it('keeps a negative arrival offset', () => { + // Negative is deliberate: landing on the warp tile would re-trigger it. + expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 0)?.offsetY).toBe(-1) + }) + + it('treats a zero-area hitbox as not clickable', () => { + const geometry = parseWarpGeometry(lvlwarp) + const walkIn = findWarpGeometry(geometry, 19) + const clickable = findWarpGeometry(geometry, 0) + expect(walkIn === undefined ? null : isClickableWarp(walkIn)).toBe(false) + expect(clickable === undefined ? null : isClickableWarp(clickable)).toBe(true) + }) + + it('falls back to any direction when none is asked for', () => { + expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 71)?.id).toBe(71) + }) +})