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:
parent
1f2f19641f
commit
29cfccaa37
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)} 个传送点`)
|
||||
|
|
|
|||
|
|
@ -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)}%`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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]])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue