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:acts": "tsx scripts/verify-acts.ts",
|
||||||
"verify:monsters": "tsx scripts/verify-monsters.ts",
|
"verify:monsters": "tsx scripts/verify-monsters.ts",
|
||||||
"verify:generators": "tsx scripts/verify-generators.ts samples/d2",
|
"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",
|
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
||||||
"pack:data": "tsx scripts/pack-act-assets.ts",
|
"pack:data": "tsx scripts/pack-act-assets.ts",
|
||||||
"verify:packs": "tsx scripts/verify-packs.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 { 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 { planLevelMonsters } from '../src/game/monsters.ts'
|
||||||
import type { IsoMapScene } from '../src/game/d2map.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 { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
|
||||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||||
import { decodeDc6 } from '../src/formats/dc6.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 { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
|
||||||
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||||
import { generateWilderness } from '../src/game/wilderness.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'
|
import { encodeIndexedPng } from './png.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -326,6 +348,457 @@ const monstersTable = {
|
||||||
statsById,
|
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()
|
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[]>> = {
|
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||||
'Act 1 - Wilderness': [
|
'Act 1 - Wilderness': [
|
||||||
'Act 1 - Wild',
|
'Act 1 - Wild',
|
||||||
|
'Act 1 - Town 1 Transition',
|
||||||
'Act 1 - DOE Entrance',
|
'Act 1 - DOE Entrance',
|
||||||
'Act 1 - Cave Entrance',
|
'Act 1 - Cave Entrance',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
|
|
@ -593,7 +1067,8 @@ function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]>
|
||||||
if (!families.some(family => name.startsWith(family))) continue
|
if (!families.some(family => name.startsWith(family))) continue
|
||||||
const levels = await rowDs1s(tables.lvlprest, row)
|
const levels = await rowDs1s(tables.lvlprest, row)
|
||||||
if (levels.length === 0) continue
|
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
|
return pieces
|
||||||
})()
|
})()
|
||||||
|
|
@ -670,6 +1145,16 @@ async function bakeDs1Variant(
|
||||||
ds1Name: string,
|
ds1Name: string,
|
||||||
label: string,
|
label: string,
|
||||||
seed: number,
|
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> {
|
): Promise<void> {
|
||||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||||
const spawn = findIsoSpawn(scene)
|
const spawn = findIsoSpawn(scene)
|
||||||
|
|
@ -727,6 +1212,13 @@ async function bakeDs1Variant(
|
||||||
const objectPages = new PageBuilder(palette)
|
const objectPages = new PageBuilder(palette)
|
||||||
const objects: unknown[] = []
|
const objects: unknown[] = []
|
||||||
const npcs: 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[] = []
|
const missingObjects: string[] = []
|
||||||
let objectsWithArt = 0
|
let objectsWithArt = 0
|
||||||
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
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 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({
|
list.push({
|
||||||
id: object.id,
|
id: object.id,
|
||||||
type: object.type,
|
type: object.type,
|
||||||
name: resolved.name ?? row?.name ?? resolved.token,
|
name: resolvedName,
|
||||||
token: resolved.token,
|
token: resolved.token,
|
||||||
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
||||||
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
|
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
|
||||||
|
|
@ -874,6 +1372,16 @@ async function bakeDs1Variant(
|
||||||
PLAYER_WALK_SPEED_PX,
|
PLAYER_WALK_SPEED_PX,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sceneLinks = buildSceneLinks(
|
||||||
|
entry.levelId,
|
||||||
|
entry.kind,
|
||||||
|
level,
|
||||||
|
scene,
|
||||||
|
generatorLinks.entrances,
|
||||||
|
generatorLinks.mazeWarps,
|
||||||
|
waypointCells,
|
||||||
|
)
|
||||||
|
|
||||||
const sceneJson = {
|
const sceneJson = {
|
||||||
version: 1,
|
version: 1,
|
||||||
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
|
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
|
||||||
|
|
@ -908,6 +1416,10 @@ async function bakeDs1Variant(
|
||||||
},
|
},
|
||||||
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
||||||
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
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: {
|
stats: {
|
||||||
floors: scene.floors.length,
|
floors: scene.floors.length,
|
||||||
walls: scene.walls.length,
|
walls: scene.walls.length,
|
||||||
|
|
@ -924,6 +1436,9 @@ async function bakeDs1Variant(
|
||||||
objectsUnresolved: missingObjects,
|
objectsUnresolved: missingObjects,
|
||||||
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
|
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
|
||||||
dt1Libraries: dt1Names.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))
|
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
|
||||||
|
|
@ -960,6 +1475,10 @@ async function bakeDs1Variant(
|
||||||
pages: pageFiles.length,
|
pages: pageFiles.length,
|
||||||
objectPages: objectFiles.length,
|
objectPages: objectFiles.length,
|
||||||
missingTiles: scene.missingTiles,
|
missingTiles: scene.missingTiles,
|
||||||
|
entrances: sceneLinks.entrances.length,
|
||||||
|
warps: sceneLinks.warps.length,
|
||||||
|
waypoints: sceneLinks.waypoints.length,
|
||||||
|
unplacedEdges: sceneLinks.unplacedEdges.length,
|
||||||
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
||||||
})
|
})
|
||||||
totalLevels += 1
|
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 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) {
|
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 levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
|
||||||
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
|
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
|
||||||
|
|
@ -1031,7 +1559,11 @@ for (const entry of LEVELS) {
|
||||||
seed,
|
seed,
|
||||||
pieces,
|
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) {
|
} catch (err) {
|
||||||
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
|
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
@ -1054,6 +1586,10 @@ for (const entry of LEVELS) {
|
||||||
const libraries: Dt1[] = []
|
const libraries: Dt1[] = []
|
||||||
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
|
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) {
|
for (let v = 1; v <= 3; v += 1) {
|
||||||
const seed = 0x5eed_1000 + entry.levelId * 10 + v
|
const seed = 0x5eed_1000 + entry.levelId * 10 + v
|
||||||
const label = `${entry.slug}-var${v}`
|
const label = `${entry.slug}-var${v}`
|
||||||
|
|
@ -1070,8 +1606,13 @@ for (const entry of LEVELS) {
|
||||||
pieces,
|
pieces,
|
||||||
substitutions: rows,
|
substitutions: rows,
|
||||||
shrineSubstitutions: shrineRows,
|
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) {
|
} catch (err) {
|
||||||
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
|
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))
|
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) {
|
if (!process.env.SKIP_ENTITIES) {
|
||||||
const { bakeEntities } = await import('./pack-entity-assets.ts')
|
const { bakeEntities } = await import('./pack-entity-assets.ts')
|
||||||
await bakeEntities(archiveDir, outDir)
|
await bakeEntities(archiveDir, outDir)
|
||||||
}
|
}
|
||||||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${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(`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[]>> = {
|
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||||
'Act 1 - Wilderness': [
|
'Act 1 - Wilderness': [
|
||||||
'Act 1 - Wild',
|
'Act 1 - Wild',
|
||||||
|
'Act 1 - Town 1 Transition',
|
||||||
|
'Act 1 - Cave Entrance',
|
||||||
|
'Act 1 - DOE Entrance',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
'Act 1 - Fence Fill',
|
'Act 1 - Fence Fill',
|
||||||
'Act 1 - River',
|
'Act 1 - River',
|
||||||
|
|
@ -478,7 +481,8 @@ async function wildernessPieces(
|
||||||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||||
const levels = await rowDs1s(archives, lvlprest, row)
|
const levels = await rowDs1s(archives, lvlprest, row)
|
||||||
if (levels.length === 0) continue
|
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
|
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('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')
|
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
|
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)}%`)
|
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 的索引数据)`)
|
console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`)
|
||||||
if (failures > 0) process.exit(1)
|
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
|
readonly monumod: D2Table
|
||||||
/** `SuperUniques.txt` — the named bosses, e.g. Bishibosh, Rakanishu. */
|
/** `SuperUniques.txt` — the named bosses, e.g. Bishibosh, Rakanishu. */
|
||||||
readonly superuniques: D2Table
|
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. */
|
/** Everything needed to place and render one level. */
|
||||||
|
|
@ -139,6 +152,28 @@ export interface LevelInfo {
|
||||||
readonly sizeY: number
|
readonly sizeY: number
|
||||||
/** Palette member path. */
|
/** Palette member path. */
|
||||||
readonly paletteName: string
|
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'),
|
montype: await read('MonType.txt'),
|
||||||
monumod: await read('MonUMod.txt'),
|
monumod: await read('MonUMod.txt'),
|
||||||
superuniques: await read('SuperUniques.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 {
|
return {
|
||||||
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
||||||
levelName: cell(tables.levels, row, 'Name'),
|
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')),
|
sizeX: Number(cell(tables.levels, row, 'SizeX')),
|
||||||
sizeY: Number(cell(tables.levels, row, 'SizeY')),
|
sizeY: Number(cell(tables.levels, row, 'SizeY')),
|
||||||
paletteName: `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`,
|
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 { Palette } from '../formats/pal.ts'
|
||||||
import type { SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteSheet } from '../formats/sprite.ts'
|
||||||
import {
|
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'
|
} from '../game/d2map.ts'
|
||||||
import type { CollisionGrid } from '../game/d2map.ts'
|
import type { CollisionGrid } from '../game/d2map.ts'
|
||||||
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
||||||
import type { MonsterPack, MonsterStats } from '../game/combat.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 { GameEngine, syncEngineState } from "../game/engine.ts"
|
||||||
import type { NpcEntity } 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"
|
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 { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
|
||||||
import { GameLoop } from '../sim/loop.ts'
|
import { GameLoop } from '../sim/loop.ts'
|
||||||
import { KeyboardInput } from '../sim/input.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. */
|
/** Archives the live path mounts, in load order. */
|
||||||
const DATA_ARCHIVES = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
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']
|
const DEFAULT_PACKS = ['samples/d2-packs', '/diablo2/packs']
|
||||||
/** Act count the page offers. */
|
/** Act count the page offers. */
|
||||||
const ACT_COUNT = 5
|
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). */
|
/** Walk speed in scene pixels per second (cells are 80×40). */
|
||||||
const WALK_SPEED = 170
|
const WALK_SPEED = 170
|
||||||
/** Character collision box at the feet, in scene pixels. */
|
/** Character collision box at the feet, in scene pixels. */
|
||||||
|
|
@ -218,6 +241,21 @@ interface MapRuntime {
|
||||||
readonly base: string
|
readonly base: string
|
||||||
readonly level: string
|
readonly level: string
|
||||||
readonly act: number
|
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[]
|
readonly quadrants: readonly string[]
|
||||||
/**
|
/**
|
||||||
* Which query parameter names the currently shown map block.
|
* 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. */
|
/** A pack's index file. */
|
||||||
interface PackIndex {
|
interface PackIndex {
|
||||||
readonly palettes?: Record<string, number[]>
|
readonly palettes?: Record<string, number[]>
|
||||||
readonly levels: readonly {
|
readonly levels: readonly PackIndexEntry[]
|
||||||
readonly act: number
|
}
|
||||||
readonly slug: string
|
|
||||||
readonly label: string
|
/**
|
||||||
readonly levelName: string
|
* One baked map in the index.
|
||||||
readonly path: string
|
*
|
||||||
readonly pages: number
|
* `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. */
|
/** Object frame inside an object atlas page. */
|
||||||
|
|
@ -354,6 +405,8 @@ interface PackObject {
|
||||||
/** A pack's per-map scene file. */
|
/** A pack's per-map scene file. */
|
||||||
interface PackSceneJson {
|
interface PackSceneJson {
|
||||||
readonly act: number
|
readonly act: number
|
||||||
|
/** `Levels.txt` `Id`. Written by every bake; only now read. */
|
||||||
|
readonly levelId?: number
|
||||||
readonly levelName: string
|
readonly levelName: string
|
||||||
readonly ds1: string
|
readonly ds1: string
|
||||||
readonly cellsX: number
|
readonly cellsX: number
|
||||||
|
|
@ -389,9 +442,41 @@ interface PackSceneJson {
|
||||||
}
|
}
|
||||||
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
||||||
readonly spawn: readonly number[] | null
|
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 }
|
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.
|
* Build the runtime for a prebaked pack.
|
||||||
*
|
*
|
||||||
|
|
@ -407,12 +492,8 @@ async function loadPackRuntime(
|
||||||
viewport: { width: number; height: number },
|
viewport: { width: number; height: number },
|
||||||
charBases: readonly string[],
|
charBases: readonly string[],
|
||||||
): Promise<MapRuntime | null> {
|
): Promise<MapRuntime | null> {
|
||||||
let index: PackIndex
|
const index = await getPackIndex(packBase)
|
||||||
try {
|
if (index === null) return null
|
||||||
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const forAct = index.levels.filter(entry => entry.act === act)
|
const forAct = index.levels.filter(entry => entry.act === act)
|
||||||
if (forAct.length === 0) return null
|
if (forAct.length === 0) return null
|
||||||
const entry = (wantedLevel === ''
|
const entry = (wantedLevel === ''
|
||||||
|
|
@ -421,6 +502,55 @@ async function loadPackRuntime(
|
||||||
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
||||||
?? forAct.find(candidate => candidate.slug === 'town')
|
?? forAct.find(candidate => candidate.slug === 'town')
|
||||||
?? forAct[0]!
|
?? 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 scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
|
||||||
|
|
||||||
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
|
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
|
||||||
|
|
@ -501,6 +631,11 @@ async function loadPackRuntime(
|
||||||
palette: packPalette,
|
palette: packPalette,
|
||||||
level: scene.levelName,
|
level: scene.levelName,
|
||||||
act: scene.act,
|
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),
|
quadrants: forAct.map(candidate => candidate.label),
|
||||||
quadrantParam: 'level',
|
quadrantParam: 'level',
|
||||||
quadrant: entry.label,
|
quadrant: entry.label,
|
||||||
|
|
@ -683,6 +818,14 @@ async function loadLiveRuntime(
|
||||||
palette,
|
palette,
|
||||||
level: town.levelName,
|
level: town.levelName,
|
||||||
act,
|
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,
|
quadrants,
|
||||||
quadrantParam: 'quadrant',
|
quadrantParam: 'quadrant',
|
||||||
quadrant: quadrants[index] ?? '',
|
quadrant: quadrants[index] ?? '',
|
||||||
|
|
@ -1085,11 +1228,15 @@ async function loadCharacterArt(
|
||||||
/**
|
/**
|
||||||
* Run the loop for a runtime.
|
* 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 renderer - the renderer (already holding the atlas or the first pages).
|
||||||
* @param started - `performance.now()` when boot began.
|
* @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 canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||||||
const status = document.querySelector<HTMLElement>('#status')!
|
const status = document.querySelector<HTMLElement>('#status')!
|
||||||
const hud = document.querySelector<HTMLElement>('#hud')!
|
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) })
|
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({
|
const loop = new GameLoop({
|
||||||
tickRate: 25,
|
tickRate: 25,
|
||||||
onTick: () => {
|
onTick: () => {
|
||||||
|
|
@ -1218,6 +1613,12 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
||||||
|
|
||||||
syncEngineState(engine, state)
|
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')
|
const dialogPanel = document.querySelector<HTMLElement>('#dialog')
|
||||||
if (dialogPanel !== null) {
|
if (dialogPanel !== null) {
|
||||||
dialogPanel.hidden = state.dialog.length === 0
|
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
|
// 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.
|
// layer so they cover the floor, the walls and anything walking under them.
|
||||||
drawTiles(runtime.roofs, roofBounds)
|
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()
|
renderer.flush()
|
||||||
|
|
||||||
// The labels are recycled rather than rebuilt: `innerHTML = ''` plus one
|
// 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. */
|
/** Keys that save and load the game. */
|
||||||
const SAVE_KEYS = new Set(['KeyK'])
|
const SAVE_KEYS = new Set(['KeyK'])
|
||||||
const LOAD_KEYS = new Set(['KeyL'])
|
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). */
|
/** Number keys that select a skill (1-4 → slots 1-4). */
|
||||||
const DIGIT_KEYS: Readonly<Record<string, number>> = {
|
const DIGIT_KEYS: Readonly<Record<string, number>> = {
|
||||||
Digit1: 1, Digit2: 2, Digit3: 3, Digit4: 4,
|
Digit1: 1, Digit2: 2, Digit3: 3, Digit4: 4,
|
||||||
|
|
@ -78,7 +86,21 @@ export function directionOf(movement: Movement): Direction | null {
|
||||||
*/
|
*/
|
||||||
export class KeyboardInput {
|
export class KeyboardInput {
|
||||||
private readonly held = new Set<string>()
|
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 => {
|
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
|
const recognised = KEYS[event.code] !== undefined
|
||||||
|| ATTACK_KEYS.has(event.code)
|
|| ATTACK_KEYS.has(event.code)
|
||||||
|| PICKUP_KEYS.has(event.code)
|
|| PICKUP_KEYS.has(event.code)
|
||||||
|
|
@ -86,9 +108,13 @@ export class KeyboardInput {
|
||||||
|| TALK_KEYS.has(event.code)
|
|| TALK_KEYS.has(event.code)
|
||||||
|| SAVE_KEYS.has(event.code)
|
|| SAVE_KEYS.has(event.code)
|
||||||
|| LOAD_KEYS.has(event.code)
|
|| LOAD_KEYS.has(event.code)
|
||||||
|
|| oneShot
|
||||||
if (!recognised) return
|
if (!recognised) return
|
||||||
// Arrows scroll the page otherwise, and held keys would repeat.
|
// Arrows scroll the page otherwise, and held keys would repeat.
|
||||||
event.preventDefault()
|
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)
|
this.held.add(event.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +124,7 @@ export class KeyboardInput {
|
||||||
|
|
||||||
private readonly onBlur = (): void => {
|
private readonly onBlur = (): void => {
|
||||||
this.held.clear()
|
this.held.clear()
|
||||||
|
this.pressed.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly target: Window
|
private readonly target: Window
|
||||||
|
|
@ -188,4 +215,53 @@ export class KeyboardInput {
|
||||||
}
|
}
|
||||||
return out
|
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