feat(world): 全局世界连通、关卡无缝切换、传送点、传送门与小地图 (fixes #23)

实现《暗黑破坏神 II》全五幕(136 个关卡)的完整世界连通拓扑、烘焙期链接落位、
运行时无缝边界与楼梯渐变过渡、传送点网络、城镇传送门以及实时小地图。

1. 全局世界连通拓扑 (src/game/world-graph.ts)
   - 融合 Levels.txt (Vis0..7, Warp0..7)、28 对野外无缝相邻接缝 (SEAMLESS_ADJACENCY)、
     23 对双向传送门链接 (PORTAL_LINKS) 以及 LvlWarp.txt (88 行几何偏移)
     构建 136 关卡、288 条有向边连通图。
   - 实现确定性边界方向分配器 (assignGateSides),保证同一关卡的多条边不会冲突。

2. 楼梯瓦片识别与几何计算 (src/game/warp-tiles.ts, src/game/level-links.ts)
   - findWarpTiles: 扫描 DS1 墙体层 (type 10/11) 提取 style 0..7 对应 Warp0..7 瓦片。
   - level-links: 显式栈 4-连通分量洪水填充提取最大可行走主区域 (largestWalkableRegion),
     向内扫描边界缺口 (findBorderOpening),以及计算安全向内落脚点 (seamArrivalSpot)。

3. 野外道路与锚点导出 (src/game/wilderness.ts, src/game/acts.ts)
   - planAct1OutdoorLevel 导出城镇过渡口、野外边界门、洞穴入口与路边地标。
   - 实现泥土路网与 256 项 8 邻域位掩码地砖自动铺设。

4. 烘焙期流水线增强 (scripts/pack-act-assets.ts)
   - buildSceneLinks 5 级流水线:DS1 真实瓦片 -> 野外生成器锚点 -> 迷宫楼梯间 ->
     预设边界扫描与营地四朝向自适应 -> 兜底安全落脚点。
   - 全量重新烘焙 samples/d2-packs (365 张地图),输出 entrances, warps, waypoints
     与 world-graph.json。

5. 运行时交互与表现 (src/scene/act-scene.ts, src/scene/transition.ts, src/game/portal.ts, src/ui/minimap.ts)
   - 支持接触边界无缝切换 (90ms 淡入淡出) 与按 G 键 / 交互楼梯传送 (220ms 淡入淡出)。
   - swapLevel 保留玩家状态(位置、属性、背包、随从)并在对应配对出入口安全落地。
   - WaypointNetwork: 小站发现与跨图传送;TownPortalSlot: T 键回城与往返传送。
   - Minimap: Tab 键切换右上角小地图 / 全屏自动地图,绘制迷雾、玩家、出口、小站、回城门。

6. 验证护栏与测试
   - verify:world-graph: 40,003 项断言确认全 136 关卡全连通、边双向对称。
   - verify:world-walk: 模拟玩家徒步全图 338 张有出口地图,1046/1046 出入口 100% 互达。
   - 补齐 tests/world-graph.test.ts, tests/warp-tiles.test.ts, tests/wilderness-roads.test.ts。
This commit is contained in:
troytt 2026-09-16 23:26:10 +00:00
parent 1f2f19641f
commit 29cfccaa37
19 changed files with 5909 additions and 67 deletions

View File

@ -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",

View File

@ -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<number, number> => {
const outgoing = new Map<number, number[]>()
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<number, number>()
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<number>()
const placedWarps = new Set<number>()
// 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<number, WorldEdge>()
/** `Warp0..7` slot -> the edge that slot crosses. */
const warpEdgeBySlot = new Map<number, WorldEdge>()
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<Side, BorderOpening>()
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<Record<string, readonly string[]>> = {
'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<WildernessPiece[]>
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<void> {
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<string, { placement: Placement; offsetX: number; offsetY: number }>()
@ -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)} 个传送点`)

View File

@ -399,6 +399,9 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'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)}%`)

View File

@ -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<number>()
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<number>()
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)

View File

@ -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<number> | 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<string>()
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<string>()
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<number, Map<Side, number>>()
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<Side, number>()
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)
}

View File

@ -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<string, PackedScene>()
/**
* Read one variant's `scene.json`, cached.
*
* @param entry - the index entry.
* @returns the decoded scene.
*/
async function sceneOf(entry: IndexEntry): Promise<PackedScene> {
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<number, { levelId: number; x: number; y: number }>()
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<number>((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

View File

@ -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<ActTable
montype: await read('MonType.txt'),
monumod: await read('MonUMod.txt'),
superuniques: await read('SuperUniques.txt'),
lvlwarp: await read('LvlWarp.txt'),
}
}
@ -238,6 +274,13 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
}
}
const vis: number[] = []
const warp: number[] = []
for (let slot = 0; slot < 8; slot += 1) {
vis.push(Number(cell(tables.levels, row, `Vis${String(slot)}`) || '0'))
warp.push(Number(cell(tables.levels, row, `Warp${String(slot)}`) || '-1'))
}
return {
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
levelName: cell(tables.levels, row, 'Name'),
@ -250,6 +293,11 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
sizeX: Number(cell(tables.levels, row, 'SizeX')),
sizeY: Number(cell(tables.levels, row, 'SizeY')),
paletteName: `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`,
vis,
warp,
waypoint: Number(cell(tables.levels, row, 'Waypoint') || '255'),
position: Number(cell(tables.levels, row, 'Position') || '0'),
portal: Number(cell(tables.levels, row, 'Portal') || '0'),
}
}

527
src/game/level-links.ts Normal file
View File

@ -0,0 +1,527 @@
/**
* Level links: the baked description of every way out of a level.
*
* The world graph in `world-graph.ts` says *that* the Cold Plains connects to
* the Stony Field. It cannot say *where*, because "where" depends on the roll
* of the generator that built this particular copy of the Cold Plains. This
* module is the other half: the shapes that carry the coordinates, and the
* geometry helpers that recover them when the generator did not hand them over.
*
* Three kinds of link, because the player crosses them in three different ways:
*
* - {@link SceneEntrance} — a gap in the border that the player simply walks
* through. No click, no click target, and in the original game no loading
* screen either; the engine streams the neighbouring level in behind a very
* short fade.
* - {@link SceneWarp} — a stair, cave mouth or door. Clickable, described by a
* row of `LvlWarp.txt`, and always a full transition.
* - {@link SceneWaypoint} — the blue portal ring. Not a link to one place but
* to every activated waypoint at once, so it carries a network id rather than
* a destination.
*
* ## Coordinates
*
* Everything here is in **sub-tiles**, the five-to-a-cell grid the collision
* map and the player's position both use, with `(0, 0)` at the level's origin.
* The generators work in cells and `LvlWarp.txt` works in pixels; both are
* converted on the way in, so that nothing downstream has to remember which
* unit a given number is in.
*
* ## Browser safety
*
* Imported by the runtime, so the same rules as `maze.ts` and `wilderness.ts`
* apply: no `node:` builtins, no `Buffer`, no `Math.random`, no `process.env`.
*/
import type { Side } from './world-graph.ts'
/** Sub-tiles per cell, matching `SUB_TILES_PER_TILE` in `map.ts`. */
const SUB_TILES_PER_TILE = 5
/**
* How close the player must be to a border seam for it to fire, in sub-tiles.
*
* Generous: a seam is a gap several tiles wide and the player should cross it
* by walking at it, not by finding one exact sub-tile. Three sub-tiles is a
* little over half a cell either side of the recorded midpoint.
*
* Lives here rather than in the scene because `verify-world-walk` has to agree
* with the scene about what counts as standing on a link; two copies of the
* number would let the guardrail pass while the game does something else.
*/
export const SEAM_TRIGGER_SUBTILES = 3
/**
* How close the player must be to a warp for the use key to take it.
*
* Tighter than a seam, because warps sit in open ground and two of them can be
* in the same room.
*/
export const WARP_TRIGGER_SUBTILES = 5
/**
* A collision map, reduced to what the geometry helpers need.
*
* Structurally satisfied by both `IsoMapScene` and `CollisionGrid`, so callers
* can pass either without adapting.
*/
export interface LinkGrid {
/** Cells across and down. */
readonly cellsX: number
readonly cellsY: number
/** Sub-tiles across; `cellsX * 5`. */
readonly gridWidth: number
/** Sub-tiles down; `cellsY * 5`. */
readonly gridHeight: number
/** One byte per sub-tile, non-zero meaning impassable. */
readonly blocked: Uint8Array
}
/** A gap in the border the player walks through. */
export interface SceneEntrance {
/** The level on the other side. */
readonly toLevelId: number
/** Which edge of this level the gap is in. */
readonly side: Side
/** The generator's name for it, e.g. `Cold Plains Exit`. */
readonly label: string
/** The gap itself, in sub-tiles: cross this and you have left. */
readonly x: number
readonly y: number
/**
* Where to put the player when they arrive *here* through this gap.
*
* One step inside the level, never on the gap itself: landing on the trigger
* would bounce the player straight back out.
*/
readonly arriveX: number
readonly arriveY: number
}
/** A stair, cave mouth or door. */
export interface SceneWarp {
/** The level on the other side, or -1 when the bake could not resolve one. */
readonly toLevelId: number
/** The `LvlWarp.txt` row describing it, or -1 when there is no row. */
readonly warpId: number
/**
* Which way it goes. `down`/`up` are the two ends of a dungeon staircase;
* `in`/`out` are a preset's mouth, where there is no depth to speak of.
*/
readonly direction: 'up' | 'down' | 'in' | 'out'
/** The generator's name for it. */
readonly label: string
/** The anchor sub-tile: what the click target is measured from. */
readonly x: number
readonly y: number
/** Where the player lands when arriving here, in sub-tiles. */
readonly arriveX: number
readonly arriveY: number
/**
* The click target, in pixels relative to the anchor sub-tile's bottom
* corner, straight from `LvlWarp.txt`. A zero-sized box means the warp has no
* clickable tile and is triggered by walking into it.
*/
readonly selectX: number
readonly selectY: number
readonly selectDX: number
readonly selectDY: number
/** The short auto-walk away from the warp on arrival, in sub-tiles. */
readonly exitWalkX: number
readonly exitWalkY: number
/**
* How the bake found this warp.
*
* - `tile` — the map's own special tile said so. This is the real position.
* - `room` — the maze generator stamped a staircase room and the warp was
* matched to it by direction. Right room, approximate spot within it.
* - `fallback` — neither existed, and the warp was put on open ground so the
* level is not a trap. The position is invented and says nothing about
* where the original game put the stairs.
*
* The last case only happens for level types whose DRLG staircase pass is not
* transcribed yet, and `verify-packs` counts them so the number cannot creep
* up unnoticed.
*/
readonly source: 'tile' | 'room' | 'fallback'
}
/** A waypoint pedestal. */
export interface SceneWaypoint {
/** `Levels.txt` `Waypoint`, 0..38. The network is keyed on this. */
readonly waypointId: number
/** The pedestal, in sub-tiles. */
readonly x: number
readonly y: number
/** Where the player lands when arriving by waypoint. */
readonly arriveX: number
readonly arriveY: number
/**
* How the position was decided.
*
* - `object` — read off a waypoint object baked into the level's artwork.
* This is the real position.
* - `placed` — chosen by {@link findWaypointSpot} because the generator does
* not yet run `DRLGOUTDOORS_SpawnAct12Waypoint`. An approximation.
*/
readonly source: 'object' | 'placed'
}
/** Everything the runtime needs to leave a level. */
export interface SceneLinks {
readonly entrances: readonly SceneEntrance[]
readonly warps: readonly SceneWarp[]
readonly waypoints: readonly SceneWaypoint[]
/**
* Graph edges this level could not place an opening for.
*
* Recorded rather than silently dropped: an edge with nowhere to stand is a
* hole in the world, and the verifier reports on this list.
*/
readonly unplacedEdges: readonly { readonly toLevelId: number; readonly reason: string }[]
}
/**
* Convert a cell coordinate to the sub-tile at its centre.
*
* @param cell - the cell coordinate.
* @returns the centre sub-tile.
*/
export function cellToSubTile(cell: number): number {
return cell * SUB_TILES_PER_TILE + 2
}
/**
* Whether a sub-tile can be stood on.
*
* @param grid - the collision map.
* @param x - sub-tile x.
* @param y - sub-tile y.
* @returns true when in bounds and not blocked.
*/
export function isWalkable(grid: LinkGrid, x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= 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)
}

239
src/game/portal.ts Normal file
View File

@ -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<number, WaypointSite>()
private readonly activated = new Set<number>()
/**
* 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
}
}

176
src/game/warp-tiles.ts Normal file
View File

@ -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<number, MarkedCell[]>()
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
}

File diff suppressed because it is too large Load Diff

819
src/game/world-graph.ts Normal file
View File

@ -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<number, LevelRow>
/** Every directed edge. */
readonly edges: readonly WorldEdge[]
/** Waypoint id to the level that hosts it. */
readonly waypoints: ReadonlyMap<number, number>
}
/**
* 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<string, Side> = 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<number, LevelRow>()
for (const row of rows) levels.set(row.id, row)
const dropped = new Set<string>()
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<string, number[]>()
/** Ordered pair -> the `Warp0..7` slots seen for it, parallel to `visWarps`. */
const visSlots = new Map<string, number[]>()
/** Ordered pair -> true when at least one slot had no warp tile. */
const visWalkThrough = new Map<string, boolean>()
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<string>()
for (const [a, b] of SEAMLESS_ADJACENCY) adjacency.add(pairKey(a, b))
const edges: WorldEdge[] = []
const emitted = new Set<string>()
/** 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<number, number>()
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<string>()
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<number, Set<Side>>()
const taken = (level: number): Set<Side> => {
let set = used.get(level)
if (set === undefined) {
set = new Set<Side>()
used.set(level, set)
}
return set
}
const chosen = new Map<string, Side>()
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<number> {
const outgoing = new Map<number, number[]>()
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<number>([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<string, WarpGeometry> {
const out = new Map<string, WarpGeometry>()
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<string, WarpGeometry>,
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
}

View File

@ -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<T>(url: string): Promise<T> {
/** A pack's index file. */
interface PackIndex {
readonly palettes?: Record<string, number[]>
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<string, Promise<PackIndex>>()
/**
* 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<PackIndex | null> {
let pending = packIndexCache.get(packBase)
if (pending === undefined) {
pending = fetchJson<PackIndex>(`${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<MapRuntime | null> {
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${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<MapRuntime | null> {
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<MapRuntime> {
const forAct = index.levels.filter(candidate => candidate.act === entry.act)
const scene = await fetchJson<PackSceneJson>(`${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<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#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<void> => {
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<HTMLElement>('#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

122
src/scene/transition.ts Normal file
View File

@ -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<void> {
await this.ramp('1', durationMs)
}
/**
* Fade back in.
*
* @param durationMs - how long to take.
*/
async in(durationMs: number): Promise<void> {
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<void> {
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<void>(resolve => { setTimeout(resolve, durationMs) })
}
}

View File

@ -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<Record<string, number>> = {
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<string>()
/**
* 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<string>()
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<string>): boolean {
for (const code of keys) {
if (this.pressed.delete(code)) return true
}
return false
}
}

222
src/ui/minimap.ts Normal file
View File

@ -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<string, Uint8Array>()
/**
* 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
}
}

130
tests/warp-tiles.test.ts Normal file
View File

@ -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]])
})
})

View File

@ -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<string, Dt1>()
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<string, number>
// 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)
})

378
tests/world-graph.test.ts Normal file
View File

@ -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<Record<string, string | number>>[],
): 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<string, string> = {
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<string>()
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<number, Set<Side>>()
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<Side>()
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)
})
})