diablo2-web/src/scene/act-scene.ts

3929 lines
148 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Act map scene: real Diablo II levels, walked by the player.
*
* Two data paths feed the same loop, and both stay because they answer different
* questions:
*
* - **pack** (default): a prebaked `scene.json` plus indexed PNG pages produced
* by `scripts/pack-act-assets.ts`. Nothing is decoded — the browser turns a PNG
* into a texture, and the JSON already carries painter's order and the
* collision grid. Pages load by need: the ones covering the spawn viewport
* first, the rest in the background while the player is already walking.
* - **live**: the archives themselves over HTTP ranges, decoded in the browser
* (DS1 + DT1 + PL2). Slower, needs no bake step, and works for any level the
* pack does not cover. `?live=1` forces it.
*
* No monsters are loaded on either path: this page exists to walk real maps.
*/
import { ViewportCamera } from "../sim/camera.ts"
import type { Palette } from '../formats/pal.ts'
import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import {
updateAnimatableTiles,
frameDurationMsFromAnimSpeed,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
} from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks, damageMonster } from '../game/combat.ts'
import type { Monster, MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
import { allocatePacksToRooms } from '../game/monster-rooms.ts'
import type { MapRoom } from '../game/monster-rooms.ts'
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import type { DropTables } from '../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost } from '../game/skills.ts'
import type { Projectile } from '../game/skills.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { RoofFadeController } from '../render/roofs.ts'
import { facingToDirection } from '../game/character.ts'
import type { LoadedMonsterArt } from '../game/monster-art.ts'
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts'
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
import { HudManager } from '../ui/hud-manager.ts'
/** Base locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
/** Pack locations tried in order when `?pack=` is absent. */
const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs']
/** Act count the page offers. */
const ACT_COUNT = 5
/**
* Picks which baked variant of a generated level this world instance uses.
*
* Each generated level is baked three times and the world graph names none of
* them, so one has to be chosen at load time. It must be chosen from the level
* alone: keying it on the pair of levels, as an earlier version did, gave Cold
* Plains a different layout depending on whether you walked in from the Blood
* Moor or back from Stony Field, and left waypoints and town portals pointing
* at coordinates from a map that was no longer loaded. One variant per level
* per world is what the design calls for.
*
* A real game would derive this from the game seed; until there is one, a
* constant keeps every session identical, which is also what the verification
* scripts assume.
*/
const WORLD_VARIANT_SEED = 0x5eed_3000
/** Walk speed in scene pixels per second (cells are 80×40). */
const WALK_SPEED = 170
/** Character collision box at the feet, in scene pixels. */
const FEET_WIDTH = 20
const FEET_HEIGHT = 10
/** Atlas pages fetched at once while filling in the background. */
const PAGE_CONCURRENCY = 3
/** Placeholder marker size, in scene pixels. */
const MARKER_WIDTH = 16
const MARKER_HEIGHT = 26
export interface NpcLabelProjectionParams {
npc: {
x: number
y: number
frame?: {
readonly width?: number
readonly height?: number
readonly offsetX?: number
readonly offsetY?: number
} | null | undefined
}
camX: number
camY: number
cameraZoom: number
canvas: { width: number; height: number }
rect: { left: number; top: number; width: number; height: number }
markerHeight?: number
}
export interface NpcLabelProjectionResult {
domX: number
domY: number
fontSize: number
visible: boolean
}
export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult {
const markerHeight = params.markerHeight ?? MARKER_HEIGHT
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
const frame = params.npc.frame
const topX = frame != null
? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2
: params.npc.x
const topY = frame != null
? params.npc.y + (frame.offsetY ?? 0)
: params.npc.y - markerHeight
const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX
const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY
const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100
const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1)
const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom))))
return { domX, domY: domY - 2, fontSize, visible }
}
/**
* Side of a streaming room, in cells.
*
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
* each; a generated level here is 8 cells per generator block, so 24 cells is
* three blocks — about a screen and a half, which is the granularity at which
* populating a room is cheap enough to do inside one frame.
*/
const ROOM_CELL_SIZE = 24
/**
* Distance at which a room is populated and woken, in scene pixels.
*
* The viewport is ~1280×720, and the isometric projection makes a screen
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
* screen": monsters exist before they can be seen, never in front of the
* player's eyes.
*/
const ROOM_ACTIVATION_RADIUS = 1600
/**
* Distance at which a room goes back to sleep, in scene pixels.
*
* Deliberately larger than the activation radius. The gap is hysteresis: a
* player pacing across a room boundary would otherwise toggle a room's AI on
* and off every few frames.
*/
const ROOM_DEACTIVATION_RADIUS = 2400
/**
* `localStorage` key holding the single save slot.
*
* Deliberately the same key the retired `map.html` sandbox used, so a save made
* there still loads here — the snapshot format never differed, only the scene
* that drove it.
*/
const SAVE_KEY = 'd2web.save'
/**
* How often the HUD text is rewritten, in milliseconds.
*
* The overlay reports averages, so refreshing it at frame rate only buys string
* churn: four `toFixed` calls and ten concatenations that the reader cannot
* follow anyway.
*/
const HUD_INTERVAL_MS = 250
/** Live scene state, also published for automated checks. */
export interface ActSceneState {
ready: boolean
/** Where the map came from: a prebaked pack or the archives. */
source: 'pack' | 'live' | ''
act: number
level: string
variant: string
variants: string[]
/** @deprecated Use `variant` instead. */
quadrant: string
/** @deprecated Use `variants` instead. */
quadrants: string[]
/** The archive/pack location actually used. */
base: string
cellsX: number
cellsY: number
widthPx: number
heightPx: number
floors: number
walls: number
frames: number
/** Atlas pages loaded and total. */
pagesLoaded: number
pagesTotal: number
/** Pages that had arrived when the first frame was drawn. */
pagesAtFirstFrame: number
/** Milliseconds from boot to the first drawn frame. */
firstFrameMs: number
/** Page indices the spawn viewport needed, for diagnostics. */
priorityPages: number[]
/** Draws skipped because their page had not arrived yet. */
skippedDraws: number
/** Objects the pack places (their art still needs the DCC/COF decoder). */
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
characterMembers: number
/** Frames the character path has actually drawn; 0 while the marker is in use. */
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
heroToken?: string
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
heroName?: string
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */
weaponClass?: string | null | undefined
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
playerClip?: string | null | undefined
/** Milliseconds from boot to the first rendered frame. */
loadMs: number
missing: number
walkable: number
x: number
y: number
facing: number
tick: number
tickRate: number
simTps: number
renderFps: number
fps: number
/**
* GPU draw calls the last frame actually issued.
*
* This used to be hardcoded to 1, which hid the fact that switching atlas
* pages mid-list was costing up to 3 077 calls per frame. It now reads the
* renderer's own counter.
*/
drawCalls: number
/** Quads the last frame submitted, i.e. what survived culling. */
quadsDrawn: number
/** Draws rejected last frame because their bounds fell outside the viewport. */
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
canvasHeight: number
error: string | null
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
frameDurationMs?: number | undefined
/**
* Monster ids the art loader could not dress, lower case.
*
* Each one renders as a red placeholder box. The scene stays `ready` — it can
* still be walked — but it is not showing the level it claims to.
*/
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
/**
* Monsters the level's own plan produced.
*
* Zero is a real answer — a town has none — and it is now the *only* answer
* for a level nothing planned for. The scene used to drop 12 hand-written
* `DEMO_MONSTERS` into that case, which also rendered as red boxes because
* their ids are in no art table.
*/
monstersPlanned: number
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
__d2webActScene?: {
switchHero: (token: string) => Promise<boolean>
getHero: () => string
}
}
}
export interface HeroClassInfo {
readonly token: string
readonly code: string
readonly nameZh: string
readonly nameEn: string
readonly defaultLeftSkill: number
readonly defaultRightSkill: number
readonly charIdentity: {
readonly name: string
readonly classNameZh: string
readonly classNameEn: string
readonly str: number
readonly dex: number
readonly vit: number
readonly ene: number
readonly baseMaxHp: number
readonly baseMaxMana: number
}
}
export const HERO_CLASSES: Record<string, HeroClassInfo> = {
so: {
token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress',
defaultLeftSkill: 47, defaultRightSkill: 64,
charIdentity: {
name: 'Isendra', classNameZh: '女法师', classNameEn: 'Sorceress',
str: 156, dex: 75, vit: 315, ene: 95, baseMaxHp: 850, baseMaxMana: 620,
},
},
am: {
token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon',
defaultLeftSkill: 6, defaultRightSkill: 7,
charIdentity: {
name: 'Cassia', classNameZh: '亚马逊', classNameEn: 'Amazon',
str: 135, dex: 210, vit: 250, ene: 50, baseMaxHp: 920, baseMaxMana: 320,
},
},
ne: {
token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer',
defaultLeftSkill: 67, defaultRightSkill: 70,
charIdentity: {
name: 'Xul', classNameZh: '死灵法师', classNameEn: 'Necromancer',
str: 120, dex: 80, vit: 290, ene: 120, baseMaxHp: 780, baseMaxMana: 580,
},
},
pa: {
token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin',
defaultLeftSkill: 96, defaultRightSkill: 112,
charIdentity: {
name: 'Cartographus', classNameZh: '圣骑士', classNameEn: 'Paladin',
str: 175, dex: 120, vit: 340, ene: 45, baseMaxHp: 1150, baseMaxMana: 380,
},
},
ba: {
token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian',
defaultLeftSkill: 126, defaultRightSkill: 133,
charIdentity: {
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
str: 220, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
},
},
dz: {
token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid',
defaultLeftSkill: 225, defaultRightSkill: 229,
charIdentity: {
name: 'Loreov', classNameZh: '德鲁伊', classNameEn: 'Druid',
str: 165, dex: 90, vit: 330, ene: 70, baseMaxHp: 1050, baseMaxMana: 420,
},
},
ai: {
token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin',
defaultLeftSkill: 254, defaultRightSkill: 251,
charIdentity: {
name: 'Natalya', classNameZh: '刺客', classNameEn: 'Assassin',
str: 150, dex: 180, vit: 280, ene: 60, baseMaxHp: 960, baseMaxMana: 360,
},
},
}
export function normalizeHeroToken(input: string | null | undefined): string {
if (!input) return 'so'
const lower = input.toLowerCase().trim()
if (HERO_CLASSES[lower]) return lower
const codeMap: Record<string, string> = {
sor: 'so', sorceress: 'so',
ama: 'am', amazon: 'am',
nec: 'ne', necromancer: 'ne',
pal: 'pa', paladin: 'pa',
bar: 'ba', barbarian: 'ba',
dru: 'dz', druid: 'dz', dr: 'dz',
ass: 'ai', assassin: 'ai', as: 'ai',
}
return codeMap[lower] ?? 'so'
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
heroToken: 'so', heroName: 'Sorceress',
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
}
if (typeof window !== 'undefined') window.__d2webAct = state
/** One tile to draw. */
interface Drawable extends AnimatableTile {
frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
readonly roofHeight?: number
}
/** One object sprite to draw. */
interface ObjectDrawable {
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly depth: number
/** Object atlas page index this frame lives on. */
readonly page: number
}
/** Everything the render loop needs, from either data path. */
/** Everything the render loop needs, from either data path. */
export interface MapRuntime {
readonly source: 'pack' | 'live'
readonly base: string
readonly level: string
readonly act: number
/**
* `Levels.txt` `Id`, or -1 when the source cannot say.
*
* The world graph speaks in level ids; the pack speaks in labels. This is
* where the two meet.
*/
readonly levelId: number
/** The pack directory this map came from, for loading its neighbours. */
readonly entryPath: string
/** Walk-through seams out of this level, in sub-tiles. */
readonly entrances: readonly SceneEntrance[]
/** Clickable stairs and cave mouths, in sub-tiles. */
readonly warps: readonly SceneWarp[]
/** The waypoint pedestal, when this level has one. */
readonly waypoints: readonly SceneWaypoint[]
readonly variants: readonly string[]
readonly variantParam: 'level' | 'variant'
readonly variant: string
/** @deprecated Kept for backward compatibility. Use `variants` instead. */
readonly quadrants: readonly string[]
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
readonly quadrantParam: 'level' | 'quadrant'
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
readonly quadrant: string
readonly cellsX: number
readonly cellsY: number
readonly widthPx: number
readonly heightPx: number
readonly grid: CollisionGrid
readonly floors: readonly Drawable[]
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
/** Animated tile drawables whose frames cycle during rendering. */
readonly animatedDrawables?: readonly Drawable[] | undefined
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | undefined
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
readonly npcs: readonly PackObject[]
/**
* The monsters this level actually holds, planned from `Levels.txt` and
* `MonStats.txt`.
*
* Empty when the level has none — a town — and empty for a source that has
* not planned one. There is no fallback: a level nothing planned for stays
* empty and says so, rather than being filled with invented monsters.
*/
readonly monsterPacks: readonly MonsterPack[]
/**
* The level's monster types, by `MonStats.txt` id, for the status line.
*/
readonly monsterTypes: readonly string[]
readonly objects: number
readonly frames: number
/** Whether the real character art is in use. */
readonly notes: readonly string[]
/** Handles by page index; a hole means "not loaded yet". */
readonly pages: (AtlasHandle | null)[]
/** Object atlas page handles. */
readonly objectPages: (AtlasHandle | null)[]
/** Archive directories to try for the character archive, in order. */
readonly charBases: readonly string[]
/** Act palette for the character atlas, when the pack carries one. */
readonly palette: Palette | null
/** Pages the first frame needs (spawn viewport). */
readonly priorityPages: readonly number[]
/** Priority pages first, then everything else. */
readonly loadOrder: readonly number[]
/** Upload specific pages, a few in flight at a time. */
loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise<void>
}
/**
* The facing index for a screen-space direction.
*
* @param x - screen x direction, -1..1.
* @param y - screen y direction, -1..1 (down positive).
* @returns the facing index, 0 = south, turning clockwise (west).
*/
export function facingOf(x: number, y: number): number {
const angle = Math.atan2(-x, y)
return Math.round((angle / (Math.PI / 4)) + 8) % 8
}
export interface ScreenToWorldParams {
readonly clientX: number
readonly clientY: number
readonly rect: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }
readonly canvas: { readonly width: number; readonly height: number }
readonly camX: number
readonly camY: number
readonly cameraZoom: number
}
/**
* Maps screen client coordinates (pixels) to world coordinates using the exact
* inverse camera transform.
*/
export function screenToWorld(params: ScreenToWorldParams): { x: number; y: number } {
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
const worldX = params.camX + (params.clientX - params.rect.left - params.rect.width * 0.5) / (params.cameraZoom * scaleX)
const worldY = params.camY + (params.clientY - params.rect.top - params.rect.height * 0.5) / (params.cameraZoom * scaleY)
return { x: worldX, y: worldY }
}
export const PASSIVE_SKILL_IDS = new Set<number>([
37, // Warmth
61, // Fire Mastery
63, // Lightning Mastery
65, // Cold Mastery
69, // Skeleton Mastery
79, // Golem Mastery
127, 128, 129, 134, 135, 136, 141, 145, 148, 153, // Barbarian masteries & passives
224, // Lycanthropy
252, // Claw Mastery
])
export function isPassiveSkill(skillId: number): boolean {
if (skillId === 0) return false
if (PASSIVE_SKILL_IDS.has(skillId)) return true
const def = getBatch1SkillDef(skillId)
return def?.type === 'passive'
}
export function getSkillRange(skillId: number): number {
if (skillId === 0) return 52 // playerReach melee
const def = getBatch1SkillDef(skillId)
if (def !== undefined) {
if (def.type === 'melee') return 52
if (def.range !== undefined) return def.range
}
switch (skillId) {
case 40: return 0
case 42: return 160
case 44: return 240
case 47: return 450
case 54: return 450
case 56: return 450
case 59: return 450
case 64: return 480
default: return 450
}
}
export function getSkillMana(skillId: number, hudManager?: HudManager | null): number {
if (skillId === 0) return 0
if (hudManager !== null && hudManager !== undefined) {
const entry = hudManager.hotkeys.availableSkills.find(s => s.skillId === skillId)
if (entry !== undefined) return entry.manaCost
}
if (isBatch1Skill(skillId)) {
return getSkillManaCost(skillId, 1)
}
switch (skillId) {
case 40: return 7
case 42: return 9
case 59: return 27
case 64: return 30
case 56: return 22
case 49: return 11
case 38: return 4
default: return 10
}
}
export function castSkill(
skillId: number,
targetX: number,
targetY: number,
context: {
engine: GameEngine
runtime: MapRuntime
hudManager: HudManager | null
status: HTMLElement
playerAnimator?: ActorAnimator | undefined
character?: LoadedCharacter | null | undefined
},
): boolean {
const { engine, runtime, hudManager, status, playerAnimator, character } = context
const player = engine.world.player
// 1. Cooldown check
if (player.cooldown > 0) {
return false
}
// 2. Mana check
const manaCost = getSkillMana(skillId, hudManager)
const currentMana = hudManager !== null ? hudManager.mana : player.mana
if (currentMana < manaCost) {
status.textContent = '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
// 3. Deduct mana
if (manaCost > 0) {
player.mana = Math.max(0, player.mana - manaCost)
if (hudManager !== null) {
hudManager.mana = Math.max(0, hudManager.mana - manaCost)
hudManager.syncPublishedState()
}
}
// 4. Face cursor direction
player.facing = facingOf(targetX - player.x, targetY - player.y)
// 4b. Trigger character animation
if (playerAnimator !== undefined && character?.clips !== undefined) {
if (skillId === 0) {
const clip = character.clips['a1'] ?? character.clips['a2']
if (clip !== undefined) playerAnimator.play('a1', clip, { restart: true })
} else {
const clip = character.clips['sc'] ?? character.clips['a1']
if (clip !== undefined) playerAnimator.play('sc', clip, { restart: true })
}
}
// 5. Dispatch skill
if (skillId === 0) {
// Normal Melee Attack
player.cooldown = 12
player.swingTicks = 6
let targetMonster: Monster | null = null
// Prioritize monster near target cursor within melee reach of player
let bestTargetDist = 36
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
if (distToPlayer <= 52) {
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestTargetDist) {
bestTargetDist = minD
targetMonster = monster
}
}
}
// Fallback: closest monster to player within melee reach
if (targetMonster === null) {
let bestDist = 52
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= bestDist) {
bestDist = dist
targetMonster = monster
}
}
}
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, 7)
engine.metrics.playerHits += 1
status.textContent = `普通攻击命中:${targetMonster.stats.name || targetMonster.stats.id}`
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
}
return true
}
if (skillId === 54) {
// Teleport
const blocked = isBlockedAt(runtime.grid, targetX, targetY)
const overlap = engine.terrain.overlap(targetX, targetY)
player.cooldown = 10
if (!blocked && overlap === 0) {
player.x = targetX
player.y = targetY
status.textContent = '已施展传送'
} else {
status.textContent = '无法传送到该位置'
}
return true
}
if (skillId === 44) {
// Frost Nova: AoE radius 240 around player
let hitCount = 0
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 240) {
damageMonster(engine.world, monster.index, 20)
engine.metrics.castHits += 1
hitCount++
}
}
player.cooldown = 10
engine.metrics.casts += 1
status.textContent = `施展霜之新星,命中 ${hitCount} 个目标`
return true
}
if (skillId === 42) {
// Static Field: AoE radius 160 around player, 25% current HP
let hitCount = 0
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 160) {
const dmg = Math.max(1, Math.floor(monster.hp * 0.25))
damageMonster(engine.world, monster.index, dmg)
engine.metrics.castHits += 1
hitCount++
}
}
player.cooldown = 10
engine.metrics.casts += 1
status.textContent = `施展静态力场,削弱 ${hitCount} 个目标`
return true
}
if (skillId === 59) {
// Blizzard: AoE radius 120 around target, delay 45 ticks
let hitCount = 0
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
if (Math.hypot(monster.x - targetX, monster.y - targetY) <= 120) {
damageMonster(engine.world, monster.index, 35)
engine.metrics.castHits += 1
hitCount++
}
}
player.cooldown = 45
engine.metrics.casts += 1
status.textContent = `施展暴风雪,命中 ${hitCount} 个目标`
return true
}
if (skillId === 40) {
// Frozen Armor
player.cooldown = 10
status.textContent = '冰封装甲已激活'
engine.metrics.casts += 1
return true
}
// Projectile Skills
let speed = 450
let range = 450
let damage = 28
let delay = 0
let skillName = '技能'
if (skillId === 47) {
speed = 450
range = 450
damage = 28
delay = 0
skillName = '火球'
} else if (skillId === 64) {
speed = 400
range = 480
damage = 35
delay = 25
skillName = '冰封球'
} else if (skillId === 36) {
speed = 450
range = 400
damage = 16
delay = 0
skillName = '火弹'
} else if (skillId === 39) {
speed = 450
range = 400
damage = 14
delay = 0
skillName = '冰弹'
} else if (skillId === 38) {
speed = 350
range = 350
damage = 12
delay = 0
skillName = '充能弹'
} else if (skillId === 49) {
speed = 600
range = 500
damage = 30
delay = 0
skillName = '闪电'
} else if (skillId === 56) {
speed = 300
range = 450
damage = 50
delay = 30
skillName = '陨石'
} else if (isBatch1Skill(skillId)) {
const def = getBatch1SkillDef(skillId)!
speed = def.speed ?? 450
range = def.range ?? 400
damage = 25
delay = def.delay
skillName = def.name
}
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const projectile: Projectile = {
skillId: String(skillId),
x: player.x,
y: player.y,
vx: len > 0 ? (dx / len) * perTick : perTick,
vy: len > 0 ? (dy / len) * perTick : 0,
damage,
ttl,
fromPlayer: true,
}
engine.projectiles.push(projectile)
player.cooldown = delay > 0 ? delay : 10
engine.metrics.casts += 1
status.textContent = `施展技能:${skillName}`
return true
}
export type PendingInteraction =
| { readonly kind: 'npc'; readonly npc: NpcEntity }
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
| { readonly kind: 'monster'; readonly monster: Monster }
declare global {
interface HTMLCanvasElement {
clientWidth: number
clientHeight: number
}
}
export interface SceneMouseControllerOptions {
readonly canvas: HTMLCanvasElement
readonly engine: GameEngine
readonly camera: ViewportCamera
readonly input: KeyboardInput
readonly getRuntime: () => MapRuntime
readonly hudManager: HudManager | null
readonly waypointNetwork: WaypointNetwork
readonly status: HTMLElement
readonly playerAnimator?: ActorAnimator | undefined
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
}
export class SceneMouseController {
readonly canvas: HTMLCanvasElement
readonly engine: GameEngine
readonly camera: ViewportCamera
readonly input: KeyboardInput
readonly getRuntime: () => MapRuntime
readonly hudManager: HudManager | null
readonly waypointNetwork: WaypointNetwork
readonly status: HTMLElement
readonly playerAnimator?: ActorAnimator | undefined
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
navTarget: { x: number; y: number } | null = null
pendingInteraction: PendingInteraction | null = null
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'monster' = 'none'
isLeftMouseDown = false
isRightMouseDown = false
lastClientPos: { x: number; y: number } | null = null
private stuckTicks = 0
private lastDist = -1
constructor(options: SceneMouseControllerOptions) {
this.canvas = options.canvas
this.engine = options.engine
this.camera = options.camera
this.input = options.input
this.getRuntime = options.getRuntime
this.hudManager = options.hudManager
this.waypointNetwork = options.waypointNetwork
this.status = options.status
this.playerAnimator = options.playerAnimator
this.getCharacter = options.getCharacter
;(this.engine as any).dialog = null
}
readonly onContextMenu = (e: MouseEvent): void => {
e.preventDefault()
}
readonly onPointerDown = (e: PointerEvent): void => {
if (e.button !== 0 && e.button !== 2) return
// Check HUD interception
if (this.hudManager !== null) {
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
return
}
}
this.lastClientPos = { x: e.clientX, y: e.clientY }
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
if (e.button === 0) {
this.isLeftMouseDown = true
if (this.input.shiftHeld) {
// R4: Shift-hold force stand still attack
this.activeClickType = 'none'
this.navTarget = null
this.pendingInteraction = null
const player = this.engine.world.player
player.facing = facingOf(worldX - player.x, worldY - player.y)
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
this.castSkill(leftSkillId, worldX, worldY)
} else {
// R3: Left-click click-to-move & entity interaction
this.handleLeftClickWorld(worldX, worldY)
}
} else if (e.button === 2) {
this.isRightMouseDown = true
this.handleRightClickWorld(worldX, worldY)
}
}
readonly onPointerMove = (e: PointerEvent): void => {
this.lastClientPos = { x: e.clientX, y: e.clientY }
if (this.hudManager !== null) {
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
return
}
}
if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
this.navTarget = { x: worldX, y: worldY }
} else if (this.isRightMouseDown) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
this.navTarget = { x: worldX, y: worldY }
}
}
}
readonly onPointerUp = (e: PointerEvent): void => {
if (e.button === 0) {
this.isLeftMouseDown = false
this.activeClickType = 'none'
} else if (e.button === 2) {
this.isRightMouseDown = false
} else {
this.isLeftMouseDown = false
this.isRightMouseDown = false
this.activeClickType = 'none'
}
}
handleRightClickWorld(worldX: number, worldY: number): void {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
// Walk/Run fallback
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y)
this.stuckTicks = 0
} else {
// Active skill execution: halt movement and cancel pending interactions
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
}
}
handleLeftClickWorld(worldX: number, worldY: number): void {
const player = this.engine.world.player
// 1. Check NPC: within ~32 px
let clickedNpc: NpcEntity | null = null
let bestNpcDist = 36
for (const npc of this.engine.npcEntities) {
const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY)
const distBody = Math.hypot(npc.x - worldX, (npc.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestNpcDist) {
bestNpcDist = minD
clickedNpc = npc
}
}
if (clickedNpc !== null) {
this.activeClickType = 'npc'
const distToPlayer = Math.hypot(clickedNpc.x - player.x, clickedNpc.y - player.y)
if (distToPlayer <= 80) { // talkRadius = 80
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(clickedNpc.x - player.x, clickedNpc.y - player.y)
if (clickedNpc.def.questId !== null) this.engine.questLog.accept(clickedNpc.def.questId)
this.engine.dialog = npcDialog(clickedNpc.def, this.engine.questLog)
this.status.textContent = `${clickedNpc.def.name}: ${this.engine.dialog[0] ?? ''}`
} else {
;(this.engine as any).dialog = null
this.pendingInteraction = { kind: 'npc', npc: clickedNpc }
this.navTarget = { x: clickedNpc.x, y: clickedNpc.y }
}
return
}
// 2. Check Waypoint: within ~48 px
const runtime = this.getRuntime()
let clickedWp: { wp: SceneWaypoint; pos: { x: number; y: number } } | null = null
let bestWpDist = 48
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
const dist = Math.hypot(pos.x - worldX, pos.y - worldY)
if (dist <= bestWpDist) {
bestWpDist = dist
clickedWp = { wp, pos }
}
}
if (clickedWp !== null) {
this.activeClickType = 'waypoint'
const distToPlayer = Math.hypot(clickedWp.pos.x - player.x, clickedWp.pos.y - player.y)
if (distToPlayer <= 80) { // interactionRange = 80
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(clickedWp.pos.x - player.x, clickedWp.pos.y - player.y)
if (this.waypointNetwork.activate(clickedWp.wp.waypointId)) {
this.status.textContent = `传送点已激活:${runtime.level}`
}
if (this.hudManager && this.hudManager.leftPanel !== 'waypoint') {
this.hudManager.toggleLeftPanel('waypoint')
}
} else {
this.pendingInteraction = { kind: 'waypoint', waypoint: clickedWp.wp, worldPos: clickedWp.pos }
this.navTarget = { x: clickedWp.pos.x, y: clickedWp.pos.y }
}
return
}
// 3. Check Monster: living monster within ~32 px
let clickedMonster: Monster | null = null
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
}
}
if (clickedMonster !== null) {
this.activeClickType = 'monster'
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
}
// 4. Ground clicked:
this.activeClickType = 'ground'
;(this.engine as any).dialog = null
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - player.x, worldY - player.y)
this.stuckTicks = 0
}
getPointerWorldCoords(clientX: number, clientY: number): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect()
const camX = this.engine.world.player.x
const camY = this.engine.world.player.y - 16
return screenToWorld({
clientX,
clientY,
rect,
canvas: this.canvas,
camX,
camY,
cameraZoom: this.camera.zoom,
})
}
castSkill(skillId: number, targetX: number, targetY: number): boolean {
return castSkill(skillId, targetX, targetY, {
engine: this.engine,
runtime: this.getRuntime(),
hudManager: this.hudManager,
status: this.status,
playerAnimator: this.playerAnimator,
character: this.getCharacter ? this.getCharacter() : undefined,
})
}
clearTarget(): void {
this.navTarget = null
this.pendingInteraction = null
this.activeClickType = 'none'
this.isLeftMouseDown = false
this.isRightMouseDown = false
this.lastClientPos = null
this.stuckTicks = 0
this.lastDist = -1
;(this.engine as any).dialog = null
}
tick(): { movement: { x: number; y: number }; attacking: boolean } {
const keyMovement = this.input.movement()
const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0
if (isKeyMoving) {
this.navTarget = null
this.pendingInteraction = null
return { movement: keyMovement, attacking: false }
}
const player = this.engine.world.player
// Shift-Hold Stand Still Priority in tick()
if (this.input.shiftHeld) {
this.navTarget = null
this.pendingInteraction = null
if (this.isLeftMouseDown && this.lastClientPos !== null && player.cooldown === 0) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
this.castSkill(leftSkillId, worldX, worldY)
}
return { movement: { x: 0, y: 0 }, attacking: false }
}
// Check continuous holding
if (this.isLeftMouseDown && this.lastClientPos !== null) {
if (this.activeClickType === 'ground') {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (this.activeClickType === 'monster') {
if (this.pendingInteraction === null && player.cooldown === 0) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
this.castSkill(leftSkillId, worldX, worldY)
}
}
} else if (this.isRightMouseDown && this.lastClientPos !== null) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (player.cooldown === 0) {
this.navTarget = null
this.pendingInteraction = null
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.castSkill(rightSkillId, worldX, worldY)
}
}
// Process pendingInteraction arrival
if (this.pendingInteraction !== null) {
if (this.pendingInteraction.kind === 'npc') {
const npc = this.pendingInteraction.npc
const dist = Math.hypot(npc.x - player.x, npc.y - player.y)
if (dist <= 80) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(npc.x - player.x, npc.y - player.y)
if (npc.def.questId !== null) this.engine.questLog.accept(npc.def.questId)
this.engine.dialog = npcDialog(npc.def, this.engine.questLog)
this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}`
} else {
this.navTarget = { x: npc.x, y: npc.y }
}
} else if (this.pendingInteraction.kind === 'waypoint') {
const { waypoint, worldPos } = this.pendingInteraction
const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y)
if (dist <= 80) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y)
if (this.waypointNetwork.activate(waypoint.waypointId)) {
this.status.textContent = `传送点已激活:${this.getRuntime().level}`
}
if (this.hudManager && this.hudManager.leftPanel !== 'waypoint') {
this.hudManager.toggleLeftPanel('waypoint')
}
} else {
this.navTarget = { x: worldPos.x, y: worldPos.y }
}
} else if (this.pendingInteraction.kind === 'monster') {
const monster = this.pendingInteraction.monster
if (monster.state === 'dead' || monster.hp <= 0) {
this.navTarget = null
this.pendingInteraction = null
} else {
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(monster.x - player.x, monster.y - player.y)
this.castSkill(leftSkillId, monster.x, monster.y)
} else {
this.navTarget = { x: monster.x, y: monster.y }
}
}
}
}
// Move toward navTarget if active
if (this.navTarget !== null) {
const dx = this.navTarget.x - player.x
const dy = this.navTarget.y - player.y
const dist = Math.hypot(dx, dy)
if (dist <= 8) {
this.navTarget = null
this.stuckTicks = 0
this.lastDist = -1
return { movement: { x: 0, y: 0 }, attacking: false }
}
if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5) {
this.stuckTicks++
if (this.stuckTicks >= 12) {
this.navTarget = null
this.pendingInteraction = null
this.stuckTicks = 0
this.lastDist = -1
return { movement: { x: 0, y: 0 }, attacking: false }
}
} else {
this.stuckTicks = 0
this.lastDist = dist
}
const runFactor = (this.hudManager && this.hudManager.isRunning) ? 1.5 : 1.0
return {
movement: {
x: (dx / dist) * runFactor,
y: (dy / dist) * runFactor,
},
attacking: false,
}
}
return { movement: { x: 0, y: 0 }, attacking: false }
}
attach(): void {
this.canvas.addEventListener('contextmenu', this.onContextMenu)
this.canvas.addEventListener('pointerdown', this.onPointerDown)
this.canvas.addEventListener('pointermove', this.onPointerMove)
this.canvas.addEventListener('pointerup', this.onPointerUp)
this.canvas.addEventListener('pointercancel', this.onPointerUp)
if (typeof window !== 'undefined') {
window.addEventListener('pointerup', this.onPointerUp)
window.addEventListener('pointercancel', this.onPointerUp)
}
}
detach(): void {
this.canvas.removeEventListener('contextmenu', this.onContextMenu)
this.canvas.removeEventListener('pointerdown', this.onPointerDown)
this.canvas.removeEventListener('pointermove', this.onPointerMove)
this.canvas.removeEventListener('pointerup', this.onPointerUp)
this.canvas.removeEventListener('pointercancel', this.onPointerUp)
if (typeof window !== 'undefined') {
window.removeEventListener('pointerup', this.onPointerUp)
window.removeEventListener('pointercancel', this.onPointerUp)
}
}
}
/**
* Find a query parameter.
*
* @param key - parameter name.
* @param fallback - value when absent.
* @returns the value.
*/
function param(key: string, fallback: string): string {
return new URLSearchParams(location.search).get(key) ?? fallback
}
/**
* Fetch JSON, with the HTTP status in the error when it fails.
*
* @param url - location.
* @returns the parsed JSON.
*/
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) throw new Error(`${url}: HTTP ${String(response.status)}`)
return await response.json() as T
}
/** A pack's index file. */
interface PackIndex {
readonly palettes?: Record<string, number[]>
readonly levels: readonly PackIndexEntry[]
}
/**
* One baked map in the index.
*
* `levelId` and `kind` have been written by the packer since the first bake;
* they were simply not declared here, because until now nothing needed to find
* a map by anything other than the label in the URL. Level transitions do: an
* edge of the world graph names a destination level, not a pack label, and a
* generated level has three labels for the one id.
*/
interface PackIndexEntry {
readonly act: number
readonly levelId?: number
readonly kind?: 'preset' | 'maze' | 'wilderness'
readonly slug: string
readonly label: string
readonly levelName: string
readonly path: string
readonly pages: number
}
/** Object frame inside an object atlas page. */
interface PackObjectFrame {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly offsetX: number
readonly offsetY: number
}
/** Object instance serialized in scene.json. */
interface PackObject {
readonly id: number
readonly type: number
readonly name?: string | null
readonly token?: string
readonly mode?: string
readonly objectsTxtId?: number
readonly hp?: number
readonly member?: string | null
readonly alternatives?: number
readonly x: number
readonly y: number
readonly depth?: number
readonly frame?: PackObjectFrame | null
}
/** A pack's per-map scene file. */
interface PackSceneJson {
readonly act: number
/** `Levels.txt` `Id`. Written by every bake; only now read. */
readonly levelId?: number
readonly levelName: string
readonly ds1: string
readonly cellsX: number
readonly cellsY: number
readonly originX: number
readonly originY: number
readonly widthPx: number
readonly heightPx: number
readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly frames: readonly (readonly number[])[]
readonly framePlacement: readonly (readonly number[])[]
readonly floors: readonly (readonly number[])[]
readonly walls: readonly (readonly number[])[]
/** Roof draws, painted after everything else (absent in older packs). */
readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly PackObject[]
readonly npcs?: readonly PackObject[]
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | undefined
/**
* The level's monsters, planned at bake time (absent in packs baked before
* the monster columns were read).
*
* Planned at bake time rather than in the browser because the browser has no
* `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack
* is that it carries what the level needs and nothing else. The same
* `planLevelMonsters` produces both this and the live path's population, so
* the two agree by construction.
*/
readonly monsters?: {
readonly types: readonly string[]
readonly budget: number
readonly packs: readonly { readonly members: readonly MonsterStats[] }[]
}
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
/** Ways out, in sub-tiles. Absent in packs baked before world connectivity. */
readonly entrances?: readonly SceneEntrance[]
readonly warps?: readonly SceneWarp[]
readonly waypoints?: readonly SceneWaypoint[]
readonly stats: { readonly missingTiles: number; readonly walkable: number }
}
/**
* The pack index, fetched once.
*
* A level transition needs the index to find its destination, and refetching it
* mid-walk would put a network round trip inside the fade.
*/
const packIndexCache = new Map<string, Promise<PackIndex>>()
/**
* Read a pack's index, reusing the fetch.
*
* @param packBase - directory holding `index.json`.
* @returns the index, or null when there is no pack there.
*/
async function getPackIndex(packBase: string): Promise<PackIndex | null> {
let pending = packIndexCache.get(packBase)
if (pending === undefined) {
pending = fetchJson<PackIndex>(`${packBase}/index.json`)
packIndexCache.set(packBase, pending)
}
try {
return await pending
} catch {
packIndexCache.delete(packBase)
return null
}
}
/**
* Build the runtime for a prebaked pack.
*
* @param packBase - directory holding `index.json`.
* @param act - act to open.
* @param wantedLevel - `?level=` label, when the caller picked one.
* @returns the runtime, or null when no pack covers this act.
*/
async function loadPackRuntime(
packBase: string,
act: number,
wantedLevel: string,
viewport: { width: number; height: number },
charBases: readonly string[],
): Promise<MapRuntime | null> {
const index = await getPackIndex(packBase)
if (index === null) return null
const forAct = index.levels.filter(entry => entry.act === act)
if (forAct.length === 0) return null
const entry = (wantedLevel === ''
? undefined
: forAct.find(candidate => candidate.label === wantedLevel)
?? forAct.find(candidate => candidate.slug === wantedLevel))
?? getDefaultTownVariant(forAct, act)
?? forAct.find(candidate => candidate.slug.includes('town'))
?? forAct[0]!
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
/**
* Build the runtime for one level, named by id rather than by label.
*
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
* on the connecting border orientation (`fromSide`) so that the gate and road
* seamlessly align across the level boundary. If no directional requirement exists,
* it picks deterministically via `variantSeed`.
*
* @param packBase - directory holding `index.json`.
* @param levelId - `Levels.txt` `Id` of the wanted level.
* @param variantSeed - picks between the variants deterministically.
* @param viewport - current viewport dimensions.
* @param charBases - character assets bases.
* @param fromSide - the compass edge of the source level the player walked off of.
* @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[],
fromSide?: Side,
): Promise<MapRuntime | null> {
const index = await getPackIndex(packBase)
if (index === null) return null
const candidates = index.levels.filter(entry => entry.levelId === levelId)
if (candidates.length === 0) return null
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
if (entry === undefined) return null
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
/**
* Turn one index entry into a runtime.
*
* @param packBase - directory holding `index.json`.
* @param index - the already-fetched index.
* @param entry - the map to load.
* @returns the runtime.
*/
async function buildPackRuntime(
packBase: string,
index: PackIndex,
entry: PackIndexEntry,
viewport: { width: number; height: number },
charBases: readonly string[],
): Promise<MapRuntime> {
const forAct = index.levels.filter(candidate => candidate.act === entry.act)
const scene = await fetchJson<PackSceneJson>(`${packBase}/${entry.path}/scene.json`)
const frames: AtlasFrame[] = scene.framePlacement.map(place => ({
x: place[1] ?? 0, y: place[2] ?? 0, width: place[3] ?? 1, height: place[4] ?? 1,
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
const animFrames = row.length > 5 ? row.slice(5) : undefined
const anim = animFrames && animFrames.length > 1
? animFrames.map(fIdx => ({
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
page: scene.framePlacement[fIdx]?.[0] ?? 0,
}))
: undefined
const cellX = row[3] ?? 0
const cellY = row[4] ?? 0
const y = row[2] ?? 0
const orthoY = (cellX + cellY) * 40
const roofHeight = orthoY > y ? orthoY - y : undefined
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
y,
cellX,
cellY,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
...(roofHeight !== undefined ? { roofHeight } : {}),
...(anim ? { animatedFrames: anim } : {}),
}
}
const floors = scene.floors.map(toDrawable)
const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134])
if (entry.levelId !== undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
const covered = new Uint8Array(scene.cellsX * scene.cellsY)
const freq = new Map<number, number>()
for (let i = 0; i < scene.floors.length; i += 1) {
const row = scene.floors[i]!
const cx = row[3] ?? 0
const cy = row[4] ?? 0
if (cx >= 0 && cx < scene.cellsX && cy >= 0 && cy < scene.cellsY) {
covered[cy * scene.cellsX + cx] = 1
}
const fIdx = row[0] ?? 0
freq.set(fIdx, (freq.get(fIdx) ?? 0) + 1)
}
let defaultFrameIndex = scene.floors[0]?.[0] ?? 0
let maxCount = -1
for (const [fIdx, count] of freq) {
if (count > maxCount) {
maxCount = count
defaultFrameIndex = fIdx
}
}
const defaultFrame = frames[defaultFrameIndex] ?? { x: 0, y: 0, width: 1, height: 1 }
const defaultPage = scene.framePlacement[defaultFrameIndex]?.[0] ?? 0
for (let cy = 0; cy < scene.cellsY; cy += 1) {
for (let cx = 0; cx < scene.cellsX; cx += 1) {
if (covered[cy * scene.cellsX + cx] === 0) {
floors.push({
frame: defaultFrame,
x: scene.originX + (cx - cy) * ORTHO_CELL_WIDTH - 80,
y: scene.originY + (cx + cy) * ORTHO_CELL_HEIGHT,
cellX: cx,
cellY: cy,
page: defaultPage,
})
}
}
}
floors.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY)
}
const walls = scene.walls.map(toDrawable)
const roofs = (scene.roofs ?? []).map(toDrawable)
const objectDrawables: ObjectDrawable[] = []
const packObjects = [...scene.objects, ...(scene.npcs ?? [])]
for (const obj of packObjects) {
if (hasPackedSprite(obj)) {
objectDrawables.push({
frame: {
x: obj.frame.x,
y: obj.frame.y,
width: obj.frame.width,
height: obj.frame.height,
},
x: obj.x + obj.frame.offsetX,
y: obj.y + obj.frame.offsetY,
depth: obj.depth ?? ((obj.x + obj.y) / 5),
page: obj.frame.page,
})
}
}
objectDrawables.sort((a, b) => a.depth - b.depth)
const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null)
// Collision arrives as runs; expanding it here keeps the JSON small and yields
// exactly the grid the live path would have produced.
const blocked = new Uint8Array(scene.collision.width * scene.collision.height)
let at = 0
for (const run of scene.collision.runs) {
const value = run[0] ?? 0
const length = run[1] ?? 0
if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length))
at += length
}
const grid: CollisionGrid = {
originX: scene.originX,
originY: scene.originY,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
blocked,
gridWidth: scene.collision.width,
}
const spawn = scene.spawn === null || scene.spawn === undefined
? (findIsoSpawn(grid) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 })
: { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 }
// The pack carries the act palette, which the character atlas needs; without it
// the character simply stays a marker rather than rendering in wrong colours.
const paletteRgb = index.palettes?.[`act${String(scene.act)}`]
const packPalette: Palette | null = paletteRgb === undefined
? null
: { rgb: Uint8Array.from(paletteRgb), size: 256 }
const pages: (AtlasHandle | null)[] = scene.pages.map(() => null)
const priority = pagesNeededForViewport(floors, walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport))
const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))]
return {
source: 'pack',
base: packBase,
charBases,
palette: packPalette,
level: scene.levelName,
act: scene.act,
levelId: scene.levelId ?? entry.levelId ?? -1,
entryPath: entry.path,
entrances: scene.entrances ?? [],
warps: scene.warps ?? [],
waypoints: scene.waypoints ?? [],
variants: forAct.map(candidate => candidate.label),
variantParam: 'level',
variant: entry.label,
quadrants: forAct.map(candidate => candidate.label),
quadrantParam: 'level',
quadrant: entry.label,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid,
floors,
walls,
roofs,
animatedDrawables: [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
),
animSpeed: scene.animSpeed,
frameDurationMs: scene.frameDurationMs ?? (scene.animSpeed !== undefined ? frameDurationMsFromAnimSpeed(scene.animSpeed) : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS),
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
monsterTypes: Array.from(new Set([
...(scene.monsters?.types ?? []),
...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)),
])),
objects: scene.objects.length,
frames: scene.frames.length,
notes: [
`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
...(scene.monsters?.packs && scene.monsters.packs.length > 0
? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`]
: []),
],
pages,
objectPages,
priorityPages: priority,
loadOrder,
async loadPages(renderer, indices, onProgress) {
if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) {
await Promise.all(scene.objectPages.map(async (page, index) => {
if (objectPages[index] !== null) return
try {
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) return
const bitmap = await createImageBitmap(await response.blob())
try {
objectPages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
} catch (err) {
console.warn(`object page ${page.file}: ${(err as Error).message}`)
}
}))
}
const queue = indices.filter(index => pages[index] === null)
let cursor = 0
const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => {
for (;;) {
const index = queue[cursor]
cursor += 1
if (index === undefined) return
const page = scene.pages[index]!
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) throw new Error(`${page.file}: HTTP ${String(response.status)}`)
const bitmap = await createImageBitmap(await response.blob())
try {
pages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
onProgress()
}
})
await Promise.all(workers)
},
}
}
/**
* Which pages the first frame needs.
*
* Only the pages whose tiles intersect a box around the spawn point: later pages
* can be megabytes, and none of their tiles are on screen yet.
*
* @param floors - floor draws.
* @param walls - wall draws.
* @param spawn - spawn point in scene pixels.
* @returns page indices, ascending.
*/
function pagesNeededForViewport(
floors: readonly Drawable[],
walls: readonly Drawable[],
spawn: { x: number; y: number },
viewport: { width: number; height: number },
zoom: number,
): number[] {
// The first frame must cover what is *actually* on screen: a full-screen
// canvas shows far more world than the fixed 960×560 it used to assume, and a
// page that is not loaded draws nothing at all.
const halfWidth = viewport.width / zoom / 2 + 96
const halfHeight = viewport.height / zoom / 2 + 96
const left = spawn.x - halfWidth
const right = spawn.x + halfWidth
const top = spawn.y - halfHeight
const bottom = spawn.y + halfHeight
const needed = new Set<number>()
for (const draw of [...floors, ...walls]) {
if (draw.x + draw.frame.width < left || draw.x > right) continue
if (draw.y + draw.frame.height < top || draw.y > bottom) continue
needed.add(draw.page)
}
if (needed.size === 0) needed.add(0)
return [...needed].sort((a, b) => a - b)
}
/**
* Count non-null loaded atlas pages without allocating throwaway arrays.
*
* @param pages - array of page handles.
* @returns number of loaded pages.
*/
export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number {
let loaded = 0
for (let i = 0; i < pages.length; i += 1) {
const page = pages[i]
if (page !== null && page !== undefined) loaded += 1
}
return loaded
}
/**
* Fill in the remaining pages without blocking the first frames.
*
* @param runtime - the runtime.
* @param renderer - renderer to upload into.
*/
function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
const rest = runtime.loadOrder.slice(1)
if (rest.length === 0) return
void runtime.loadPages(renderer, rest, () => {
state.pagesLoaded = countLoadedPages(runtime.pages)
}).catch((err: unknown) => {
console.warn(`page preload: ${(err as Error).message}`)
})
}
/** A composited character, uploaded as its own atlas page. */
interface LoadedCharacter {
readonly handle: AtlasHandle
/** `atlas.groups[direction][frame]`, ready to draw. */
readonly groups: readonly (readonly AtlasFrame[])[]
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
}
/** Global singleton cache for DropTables using pre-baked embedded canonical drop data (zero network/MPQ). */
let sharedDropTablesPromise: Promise<DropTables> | null = null
export function getSharedDropTables(_bases: readonly string[] = DEFAULT_BASES): Promise<DropTables> {
if (!sharedDropTablesPromise) {
sharedDropTablesPromise = Promise.resolve(getEmbeddedDropTables())
}
return sharedDropTablesPromise
}
interface PackedMonsterJson {
readonly schema?: number
readonly token: string
readonly weapon: string
readonly file: string
readonly width: number
readonly height: number
readonly directions: number
readonly walkOffset: number
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
}
interface PackedCharJson {
readonly schema?: number
readonly file: string
readonly width: number
readonly height: number
readonly directions: number
readonly walk: number
readonly stand: number
readonly members: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
}
/**
* What a monster-art load produced, including what it failed to produce.
*
* The three failure channels used to be a `console.warn` and an empty `Map`,
* which the render loop turns into a red box per monster while the scene still
* reports `ready`. They are counted here so {@link ActSceneState} — and through
* it the HUD — can say so.
*/
export interface MonsterArtLoad {
/** Monster id (lower case) to its atlas. */
readonly map: Map<string, LoadedMonsterArt>
/** Monster ids left with no atlas: every one of these draws as a red box. */
readonly missing: string[]
/** Loads that threw — a per-spec failure, or the whole function. */
readonly errors: number
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
readonly layerFailures: number
}
/**
* The monster ids that got no atlas.
*
* @param monsterIds - the ids the level asked for.
* @param loaded - the ids that loaded, lower case.
* @returns the missing ids, lower case and deduplicated, in request order.
*/
export function missingMonsterArtIds(
monsterIds: readonly string[],
loaded: ReadonlySet<string>,
): string[] {
const missing: string[] = []
for (const id of monsterIds) {
const key = id.toLowerCase()
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
}
return missing
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(load: {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}): string {
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join('、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`)
}
if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* The HUD fragment describing a level's planned population.
*
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterPlanStatusText(planned: number): string {
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
/**
* Publish a level's planned population into {@link ActSceneState}.
*
* @param runtime - the level being entered.
*/
function reportMonsterPlan(runtime: MapRuntime): void {
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
state.monstersPlanned = planned
if (planned === 0) {
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
}
}
/**
* Publish a load's failures into {@link ActSceneState}.
*
* Both the boot path and the level-change path call the loader, and both used to
* drop everything but the `Map` on the floor.
*
* @param load - what the loader produced.
*/
function recordMonsterArtLoad(load: MonsterArtLoad): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* Load monster atlases on-demand for all monster types present in the scene.
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
* falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing.
*/
async function loadMonsterArtMap(
bases: readonly string[],
monsterIds: readonly string[],
palette: Palette,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<MonsterArtLoad> {
const map = new Map<string, LoadedMonsterArt>()
let errors = 0
let layerFailures = 0
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
try {
// Resolve unique (token, weapon) specs needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
if (!tokenSpecs.has(key)) {
tokenSpecs.set(key, spec)
}
}
}
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
if (packBase !== undefined) {
const globalEntitiesUrl = `${packBase}/entities`
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
await Promise.all(
[...tokenSpecs.entries()].map(async ([key, spec]) => {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
try {
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${globalEntitiesUrl}/${slug}.json`),
fetch(`${globalEntitiesUrl}/${slug}.r8`),
])
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
// Pre-baked art is already composited: there are no COF layers
// left to drop at load time.
layerFailures: [],
})
return
}
} catch (err) {
// Fall through to the per-act PNG, but do not pretend it did not happen.
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
}
// 2. Fallback to per-act PNG if available
if (actEntitiesUrl !== undefined) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${actEntitiesUrl}/${slug}.json`),
fetch(`${actEntitiesUrl}/${slug}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedMonsterJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
directions: meta.directions,
walkOffset: meta.walkOffset,
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
layerFailures: [],
})
return
}
} catch (err) {
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
}
}
fallbackSpecs.push([key, spec])
})
)
} else {
fallbackSpecs.push(...tokenSpecs.entries())
}
// Record any specs unavailable in pre-baked packs
if (fallbackSpecs.length > 0) {
for (const [key, spec] of fallbackSpecs) {
errors += 1
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable in pre-baked packs`)
}
}
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec !== null) {
const key = `${spec.token}-${spec.weapon}`
const loaded = loadedBySpecKey.get(key)
if (loaded !== undefined) {
map.set(id.toLowerCase(), loaded)
}
}
}
} catch (err) {
// This `catch` wraps the whole function, so landing here means *no* monster
// in the level has art. That is a broken scene, not a warning.
errors += 1
console.error(`monster art loading error: ${(err as Error).message}`)
}
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
}
async function loadCharacterArt(
_bases: readonly string[],
_palette: Palette | null,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
token = 'so',
): Promise<LoadedCharacter | null> {
const heroToken = normalizeHeroToken(token)
if (packBase !== undefined) {
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
try {
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${packBase}/entities/char-${heroToken}.json`),
fetch(`${packBase}/entities/char-${heroToken}.r8`),
])
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const indices = new Uint8Array(await r8Resp.arrayBuffer())
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
return {
handle,
groups,
directions: meta.directions,
walk: meta.walk,
stand: meta.stand,
members: meta.members,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
}
}
} catch {
// fall through to per-act PNG fallback
}
// 2. Fallback to per-act PNG if available
if (act !== undefined) {
const entitiesUrl = `${packBase}/act${String(act)}/entities`
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/char-${heroToken}.json`),
fetch(`${entitiesUrl}/char-${heroToken}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
const bitmap = await createImageBitmap(await pngResp.blob())
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const groups: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
return {
handle,
groups,
directions: meta.directions,
walk: meta.walk,
stand: meta.stand,
members: meta.members,
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
}
}
} catch {
// failed to load pack character
}
}
}
// Fallback to 'so' if requested hero failed
if (heroToken !== 'so') {
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
}
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
return null
}
/**
* Whether any walkable sub-tile lies inside a scene-pixel rectangle.
*
* A level's pixel extent is the bounding box of an isometric diamond, so
* roughly half of it is off-map. Rooms carved out of that half can never hold a
* monster — `spawnPacksInRoom` would search their whole area, find nothing and
* silently drop the pack — so they are discarded before packs are handed out.
*
* @param grid - the level's collision grid.
* @param minX - rectangle left edge, scene pixels.
* @param minY - rectangle top edge, scene pixels.
* @param maxX - rectangle right edge, scene pixels.
* @param maxY - rectangle bottom edge, scene pixels.
* @returns true when at least one sampled point is walkable.
*/
function roomHasWalkableGround(
grid: CollisionGrid,
minX: number,
minY: number,
maxX: number,
maxY: number,
): boolean {
const samples = 9
for (let iy = 1; iy < samples; iy += 1) {
const y = minY + (maxY - minY) * (iy / samples)
for (let ix = 1; ix < samples; ix += 1) {
const x = minX + (maxX - minX) * (ix / samples)
if (!isBlockedAt(grid, x, y)) return true
}
}
return false
}
/**
* Cuts a level into streaming rooms and allocates its monster packs to them.
*
* This is the level-generation half of `D2Game.dll`'s lazy population: the DRLG
* hands the game a room list, each room remembers *what* should live in it, and
* nothing is instantiated until the player walks close enough. `GameEngine`
* owns the other half (see `MonsterStreamingManager`).
*
* Rooms are laid out over **scene-pixel space**, not cell space. A rectangle of
* cells projects to a diamond, and the axis-aligned boxes of neighbouring cell
* rectangles overlap almost completely — which would make every room in the
* level "near" the player at once and defeat the streaming entirely.
*
* @param runtime - the level being entered.
* @param packs - the level's packs, with landmark tiles already resolved to
* scene positions.
* @param safeZones - waypoints, entrances and the landing spot; rooms touching
* one are left empty so a player never arrives inside a camp.
* @returns the rooms that hold at least one pack, in load order.
*/
function buildStreamingRooms(
runtime: MapRuntime,
packs: readonly MonsterPack[],
safeZones: readonly SafeZone[],
): StreamingRoomDef[] {
if (packs.length === 0) return []
const grid = runtime.grid
const roomWidth = ROOM_CELL_SIZE * ORTHO_CELL_WIDTH / 2
const roomHeight = ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT / 2
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
const rooms: MapRoom[] = []
let id = 0
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
const maxX = Math.min(x + roomWidth, runtime.widthPx)
const maxY = Math.min(y + roomHeight, runtime.heightPx)
if (maxX - x < 32 || maxY - y < 32) continue
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
// A safe zone is a circle; the cheap box-vs-box test is deliberate. An
// over-eager match only costs one empty room, while a missed one puts a
// pack on top of a waypoint.
const isSafeZone = safeZones.some(zone =>
zone.x + zone.radius > x && zone.x - zone.radius < maxX
&& zone.y + zone.radius > y && zone.y - zone.radius < maxY)
// Cell coordinates are bookkeeping for `allocatePacksToRooms`' area
// weighting; every room is the same size, so the value only has to be
// consistent, not exact.
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
rooms.push({
id: id,
x,
y,
width: maxX - x,
height: maxY - y,
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
cellsX: cellsPerRoomX,
cellsY: cellsPerRoomY,
isSafeZone,
})
id += 1
}
}
if (rooms.length === 0) return []
const allocation = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
const defs: StreamingRoomDef[] = []
for (const room of rooms) {
const roomPacks = allocation.get(room)
if (roomPacks === undefined || roomPacks.length === 0) continue
defs.push({
id: room.id,
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
packs: roomPacks,
room,
})
}
return defs
}
/**
* Run the loop for a runtime.
*
* @param initialRuntime - the map to start in. The binding inside is mutable:
* a level transition swaps the map under the running loop rather than
* rebuilding the scene, because rebuilding would take the character's bag,
* quests and experience with it.
* @param renderer - the renderer (already holding the atlas or the first pages).
* @param started - `performance.now()` when boot began.
*/
async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): Promise<void> {
let runtime = initialRuntime
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#hud')!
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const input = new KeyboardInput()
input.attach()
let skippedDraws = 0
let currentHeroToken = normalizeHeroToken(param('hero', param('class', 'so')))
state.heroToken = currentHeroToken
state.heroName = HERO_CLASSES[currentHeroToken]?.nameEn ?? 'Sorceress'
let activePalette: Palette | null = runtime.palette
let packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
let packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
let character: LoadedCharacter | null = null
let monsterArtMap = new Map<string, LoadedMonsterArt>()
let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator()
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === currentHeroToken && character !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
status.textContent = `正在切换角色至 ${nextInfo.nameZh} …`
const nextChar = await loadCharacterArt(
runtime.charBases,
activePalette,
renderer,
packEntityBase,
packEntityAct,
nextToken,
)
if (nextChar !== null) {
if (character?.handle) {
renderer.deleteAtlas(character.handle)
}
character = nextChar
currentHeroToken = nextToken
state.heroToken = nextToken
state.heroName = nextInfo.nameEn
state.character = true
state.characterMembers = nextChar.members
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = nextInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = nextInfo.charIdentity.str
hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = nextInfo.charIdentity.baseMaxHp
hudManager.hp = nextInfo.charIdentity.baseMaxHp
hudManager.maxMana = nextInfo.charIdentity.baseMaxMana
hudManager.mana = nextInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill
hudManager.syncPublishedState()
}
if (heroSelect !== null && heroSelect.value !== nextToken) {
heroSelect.value = nextToken
}
try {
const url = new URL(location.href)
url.searchParams.set('hero', nextToken)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
if (nextChar.clips?.nu) {
playerAnimator.play('nu', nextChar.clips.nu, { restart: true })
}
status.textContent = `已切换角色为 ${nextInfo.nameZh}(${String(nextChar.members)} 层素材)。`
setTimeout(() => {
if (status.textContent.startsWith('已切换角色为')) {
status.textContent = ''
}
}, 3000)
return true
}
status.textContent = `切换角色 ${nextInfo.nameZh} 失败。`
return false
}
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = currentHeroToken
heroSelect.onchange = async () => {
await switchHero(heroSelect.value)
}
}
if (typeof window !== 'undefined') {
window.__d2webActScene = {
switchHero,
getHero: () => currentHeroToken,
}
;(state as any).switchHero = switchHero
}
const monsterAnimators = new Map<number, ActorAnimator>()
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
camera.attach()
// Culling bounds, rebuilt on level swap: the draw lists are immutable for the
// life of a single runtime, so the per-frame cost is just the rejection test.
let floorBounds = buildBounds(runtime.floors)
let wallBounds = buildBounds(runtime.walls)
let roofBounds = buildBounds(runtime.roofs)
let objectBounds = buildBounds(runtime.objectDrawables)
let roofController = new RoofFadeController(runtime.roofs)
// `runtime.pages` is filled in by background loaders, so the count has to be
// re-read every frame. A loop is used rather than `filter().length` because
// the latter allocates a throwaway array 60 times a second.
const countPages = (): number => countLoadedPages(runtime.pages)
// NPC name plates. The container and the elements outlive a frame so the
// render loop only has to move them.
const labelsContainer = document.querySelector<HTMLElement>('#labels')
const labelPool: HTMLDivElement[] = []
/** `performance.now()` at which the HUD text may be rewritten again. */
let hudNextMs = 0
let playerMoving = false
let playerWalkFrame = 0
const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? ''))
const safeZones: SafeZone[] = [
{ x: runtime.spawn.x, y: runtime.spawn.y, radius: 180 },
]
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of runtime.entrances) {
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
return pack
})
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
// Drop tables are pre-baked into memory via getEmbeddedDropTables() (zero network, zero MPQ).
const dropTables = getEmbeddedDropTables()
const engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
width: FEET_WIDTH,
height: FEET_HEIGHT,
}),
{
spawn: runtime.spawn,
// The scene plans its own population; there is no synthetic roster to fall
// back on, so the engine's frame-0 spawner has nothing to draw from.
stats: [],
xpTable: DEMO_EXPERIENCE,
difficulty: 'normal',
dropTables,
monsterKinds: dropTables.monsterKinds,
skills: DEMO_SKILLS,
npcDefs,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: WALK_SPEED,
playerReach: 52,
playerCooldownTicks: 12,
playerDamage: 7,
playerManaPerAttack: 2,
respawnTicks: 75,
// Aggro is safe again now that rooms stream: a monster the player has
// not walked up to is either unspawned or asleep, so "chase the player"
// can no longer drag the whole level into a single conga line. It was
// only ever disabled because every pack on the map was awake at once.
disableMonsterAggro: false,
},
lootSeed: 0x5eed,
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
// With rooms present the engine skips frame-0 spawning entirely and lets
// the streaming manager do it room by room; with none it places the
// planned packs at once. A level that planned nothing gets nothing.
monsterCount: 0,
monsterPacks: resolvedPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
roomPacks: streamingRooms,
streamingOptions: {
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones,
},
safeZones,
// This scene knows where its NPCs stand — the DS1 carries their
// coordinates — so the engine must not build a ring we would discard.
npcRingFallback: false,
// The engine decides when to save; the browser only says where. Reads are
// guarded because private-browsing windows throw on access, not on use.
snapshotStore: {
read: () => localStorage.getItem(SAVE_KEY),
write: (data: string) => { localStorage.setItem(SAVE_KEY, data) },
},
}
)
engine.setDropTables(dropTables)
// `npcRingFallback: false` above means the engine left this empty, so we are
// filling it rather than replacing a synthetic ring.
for (const n of runtime.npcs) {
const def = npcDefs.find(d => d.id === `npc-${String(n.id)}`)!
// Same predicate the sprite layer gated on above, so the box and the art
// can never both be drawn for one NPC.
engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n), frame: n.frame })
}
if (typeof window !== 'undefined') {
;(window as any).__d2webCamera = camera
;(window as any).__d2webEngine = engine
}
/* ----------------------------------------------------------------------- *
* World connectivity
* ----------------------------------------------------------------------- */
const fade = new FadeOverlay(document.body)
const minimap = new Minimap()
const waypointNetwork = new WaypointNetwork()
const portalSlot = new TownPortalSlot()
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
const hudManager = hudCanvas !== null
? new HudManager(hudCanvas, {
onToggleAutomap: () => {
minimap.visible = !minimap.visible
},
onWaypointTeleport: (targetAct, slug, levelId) => {
void teleportViaWaypoint(targetAct, slug, levelId)
},
onCastTownPortal: () => {
status.textContent = `已通过回城之书开启返回 ${ACT_NAMES_ZH[runtime.act] ?? '营地'} 的传送门`
},
onDropItemToGround: (itemName) => {
status.textContent = `已将「${itemName}」丢弃在地面`
},
})
: null
if (hudManager !== null) {
try {
await hudManager.init('/ui')
} catch (err) {
console.error('Failed to initialize HudManager:', err)
}
if (typeof window !== 'undefined' && window.__d2webHud) {
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
}
}
const mouseController = new SceneMouseController({
canvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager,
waypointNetwork,
status,
playerAnimator,
getCharacter: () => character,
})
mouseController.attach()
if (typeof window !== 'undefined') {
(window as any).__d2webMouseController = mouseController
}
/** True while a transition is in flight, so nothing triggers a second one. */
let travelling = false
let lastContactWaypointId: number | null = null
let minimapCache: { key: string; level: MinimapLevel } | null = null
/**
* 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 = async (next: MapRuntime, arrive: { x: number; y: number }): Promise<void> => {
mouseController.clearTarget()
// 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)
for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle)
monsterArtMap = new Map()
runtime = next
floorBounds = buildBounds(next.floors)
wallBounds = buildBounds(next.walls)
roofBounds = buildBounds(next.roofs)
objectBounds = buildBounds(next.objectDrawables)
roofController = new RoofFadeController(next.roofs)
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), frame: n.frame })
}
// Clear outgoing monsters and ground items, then populate the destination level
engine.world.monsters.length = 0
engine.ground.length = 0
const nextSafeZones: SafeZone[] = [
{ x: landing.x, y: landing.y, radius: 180 },
]
for (const wp of next.waypoints) {
const pos = subTileToScene(wp.x, wp.y, next.grid)
nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of next.entrances) {
const pos = subTileToScene(ent.x, ent.y, next.grid)
nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
const nextResolvedPacks: MonsterPack[] = next.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(next.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
return pack
})
const spread = Math.max(next.widthPx, next.heightPx) / 4
// A level change means a new room list. `engine.terrain` was already
// swapped above, which matters: the manager captures it and uses it for
// every later collision test, so building it any earlier would place the
// destination's monsters against the departed level's walls.
const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: nextSafeZones,
},
)
} else {
// Nothing to stream: the planned packs go down at once. A level the baker
// gave no plan for stays empty — and the status line says so, rather than
// 12 invented monsters standing in for the data that is missing.
engine.streamingManager = undefined
if (nextResolvedPacks.length > 0) {
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
}
}
reportMonsterPlan(next)
if (next.palette !== null) {
renderer.setPalette(next.palette.rgb)
activePalette = next.palette
packEntityBase = next.source === 'pack' ? next.base : undefined
packEntityAct = next.source === 'pack' ? next.act : undefined
const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
activePalette,
renderer,
packEntityBase,
packEntityAct,
)
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
}
}
if (next.source === 'pack') {
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
}
registerWaypoints(next)
state.level = next.level
state.act = next.act
state.variant = next.variant
state.variants = [...next.variants]
state.quadrant = next.variant
state.quadrants = [...next.variants]
state.cellsX = next.cellsX
state.cellsY = next.cellsY
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.animatedTiles = next.animatedDrawables?.length ?? 0
state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs
const names = getLevelNames(next.levelId ?? next.level)
hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn)
}
/**
* 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.
* @param fromSide - the compass edge left by the player on the source level.
*/
const travel = async (
toLevelId: number,
fadeMs: number,
override?: { x: number; y: number },
fromSide?: Side,
): 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,
fromSide,
)
if (next === null) {
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
return
}
await next.loadPages(renderer, next.priorityPages, () => {
state.pagesLoaded = countLoadedPages(next.pages)
})
// 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 })
await swapLevel(next, landing)
preloadRemaining(next, renderer)
} catch (err) {
status.textContent = `切换关卡失败:${(err as Error).message}`
} finally {
await fade.in(fadeMs)
travelling = false
}
}
/**
* Teleport to a designated waypoint across acts and map variants.
*/
const teleportViaWaypoint = async (
targetAct: number,
slug: string,
targetLevelId?: number,
): Promise<void> => {
if (travelling || runtime.source !== 'pack') return
travelling = true
try {
await fade.out(WARP_FADE_MS)
const index = await getPackIndex(runtime.base)
if (index === null) {
status.textContent = '无法读取资源包索引。'
return
}
const normalizedLevelId = targetLevelId === 120 ? 123 : targetLevelId
let pool = index.levels.filter(
entry =>
entry.act === targetAct &&
(normalizedLevelId !== undefined
? entry.levelId === normalizedLevelId
: entry.slug === slug || entry.label === slug),
)
if (pool.length === 0) {
pool = index.levels.filter(
entry =>
(normalizedLevelId !== undefined && entry.levelId === normalizedLevelId) ||
entry.slug === slug ||
entry.label === slug,
)
}
if (pool.length === 0) {
status.textContent = `资源包里没有传送点关卡:${slug}`
return
}
const chosenEntry = pool[Math.floor(Math.random() * pool.length)]!
const next = await buildPackRuntime(
runtime.base,
index,
chosenEntry,
{ width: canvas.width, height: canvas.height },
runtime.charBases,
)
await next.loadPages(renderer, next.priorityPages, () => {
state.pagesLoaded = countLoadedPages(next.pages)
})
const wp = next.waypoints[0]
const landing = wp !== undefined
? { x: wp.arriveX, y: wp.arriveY }
: {
x: Math.floor((next.grid.cellsX * SUB_TILES_PER_TILE) / 2),
y: Math.floor((next.grid.cellsY * SUB_TILES_PER_TILE) / 2),
}
await swapLevel(next, landing)
preloadRemaining(next, renderer)
minimapCache = null
const names = getLevelNames(next.levelId ?? next.level)
status.textContent = `已传送到:${names.nameZh}(Act ${String(next.act)})`
} catch (err) {
status.textContent = `传送点传送失败:${(err as Error).message}`
} finally {
await fade.in(WARP_FADE_MS)
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, undefined, entrance.side)
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
}
}
let nearWaypoint = false
for (const waypoint of runtime.waypoints) {
if (!near(waypoint.x, waypoint.y, WARP_TRIGGER_SUBTILES)) continue
nearWaypoint = true
if (lastContactWaypointId !== waypoint.waypointId) {
lastContactWaypointId = waypoint.waypointId
if (hudManager && hudManager.leftPanel !== 'waypoint') {
hudManager.toggleLeftPanel('waypoint')
}
}
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 (!nearWaypoint) {
lastContactWaypointId = null
}
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.
*/
const minimapLevel = (): MinimapLevel => {
// Live runtimes have no entry path, so fall back to the level name; either
// way the key only has to change when the level does.
const key = runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath
if (minimapCache !== null && minimapCache.key === key) return minimapCache.level
const marker = (x: number, y: number, kind: MinimapMarker['kind']): MinimapMarker => ({
cellX: Math.floor(x / SUB_TILES_PER_TILE),
cellY: Math.floor(y / SUB_TILES_PER_TILE),
kind,
})
const level: MinimapLevel = {
key,
cellsX: runtime.grid.cellsX,
cellsY: runtime.grid.cellsY,
blocked: runtime.grid.blocked,
gridWidth: runtime.grid.gridWidth,
markers: [
...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit')),
...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit')),
...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')),
],
}
minimapCache = { key, level }
return level
}
const loop = new GameLoop({
tickRate: 25,
onTick: () => {
const mouseStep = mouseController.tick()
const effectiveMovement = mouseStep.movement
engine.tick({
movement: effectiveMovement,
attacking: input.attacking,
pickingUp: input.pickingUp,
talking: input.talking,
digits: input.digits,
saving: input.saving,
loading: input.loading,
})
const player = engine.world.player
const movement = effectiveMovement
// Assign, do not redeclare: `onRender` reads the outer `playerMoving` to
// pick the walk animation, and a shadowing `let` here left it stuck false.
playerMoving = movement.x !== 0 || movement.y !== 0
if (playerMoving) {
playerWalkFrame = (playerWalkFrame + 1) % 24
} else {
playerWalkFrame = 0
}
monsterWalkFrame = (monsterWalkFrame + 1) % 120
// Advance player animation
if (character !== null) {
if (player.hp <= 0) {
if (playerAnimator.mode !== 'dt' && playerAnimator.mode !== 'dd') {
const clip = character.clips?.['dt']
if (clip !== undefined) playerAnimator.play('dt', clip)
} else if (playerAnimator.mode === 'dt' && playerAnimator.finished) {
const clip = character.clips?.['dd']
if (clip !== undefined) playerAnimator.play('dd', clip)
}
} else {
// If attacking or casting, check if finished
const isOneShot = playerAnimator.mode === 'a1' || playerAnimator.mode === 'a2' || playerAnimator.mode === 'sc' || playerAnimator.mode === 'gh'
if (!isOneShot || playerAnimator.finished) {
if (playerMoving) {
const isRunning = hudManager ? hudManager.isRunning : false
const mode = (isRunning && character.clips?.['rn']) ? 'rn' : 'wl'
const clip = character.clips?.[mode] ?? character.clips?.['wl']
if (clip !== undefined) playerAnimator.play(mode, clip)
} else {
const clip = character.clips?.['nu']
if (clip !== undefined) playerAnimator.play('nu', clip)
}
}
}
playerAnimator.tick()
state.playerClip = playerAnimator.mode ?? (playerMoving ? 'wl' : 'nu')
const hud = (typeof window !== 'undefined'
? (window as unknown as { __d2webHudInstance?: { inventory?: { equipped?: { weapon1?: { code?: string } } } } }).__d2webHudInstance
: undefined)
const w1Code = hud?.inventory?.equipped?.weapon1?.code
state.weaponClass = w1Code === '7wa' ? '2hs' : (w1Code ? '1hs' : 'hth')
}
// Advance monster animations
const activeMonsterIndices = new Set<number>()
for (const monster of engine.world.monsters) {
activeMonsterIndices.add(monster.index)
let animator = monsterAnimators.get(monster.index)
if (animator === undefined) {
animator = new ActorAnimator()
monsterAnimators.set(monster.index, animator)
}
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
if (art !== undefined && art.clips !== undefined) {
if (monster.state === 'dead') {
if (animator.mode !== 'dt' && animator.mode !== 'dd') {
const clip = art.clips['dt']
if (clip !== undefined) animator.play('dt', clip)
} else if (animator.mode === 'dt' && animator.finished) {
const clip = art.clips['dd']
if (clip !== undefined) animator.play('dd', clip)
}
} else if (monster.state === 'attack') {
if (animator.mode !== 'a1' && animator.mode !== 'a2') {
const clip = art.clips['a1'] ?? art.clips['a2']
if (clip !== undefined) animator.play('a1', clip)
}
} else if (monster.state === 'chase') {
const clip = art.clips['wl'] ?? art.clips['nu']
if (clip !== undefined) animator.play('wl', clip)
} else {
const clip = art.clips['nu'] ?? art.clips['wl']
if (clip !== undefined) animator.play('nu', clip)
}
animator.tick()
}
}
for (const idx of monsterAnimators.keys()) {
if (!activeMonsterIndices.has(idx)) monsterAnimators.delete(idx)
}
state.tick = engine.world.tick
state.x = player.x
state.y = player.y
state.facing = player.facing
syncEngineState(engine, state)
// The automap is a view, but revealing is state: it has to advance with
// the simulation, not with however many frames the machine can draw.
if (input.takeMapToggle()) minimap.visible = !minimap.visible
checkLinks()
const walked = cellOf(runtime.grid, player.x, player.y)
minimap.reveal(minimapLevel(), walked.x, walked.y)
const dialogPanel = document.querySelector<HTMLElement>('#dialog')
if (dialogPanel !== null) {
dialogPanel.hidden = !state.dialog || state.dialog.length === 0
dialogPanel.textContent = (state.dialog ?? []).join('\n')
}
},
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs)
}
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
state.firstFrameMs = Math.round(performance.now() - started)
}
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
skippedDraws = 0
let culledDraws = 0
const view = viewportRect(camX, camY, canvas.width, canvas.height, camera.zoom)
const visible = (bounds: Float32Array, index: number): boolean => intersects(bounds, index, view)
const drawTiles = (list: readonly Drawable[], bounds: Float32Array): void => {
for (let index = 0; index < list.length; index += 1) {
if (!visible(bounds, index)) { culledDraws += 1; continue }
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => {
for (let index = 0; index < list.length; index += 1) {
if (!visible(bounds, index)) { culledDraws += 1; continue }
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
const alpha = roofController.getAlpha(index)
if (alpha < 0.999) {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] })
} else {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
}
drawTiles(runtime.floors, floorBounds)
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
roofController.update(engine.world.player, deltaMs, cell)
state.roofAlpha = roofController.overallAlpha
const playerDepth = cell.x + cell.y
let wallIdx = 0
let objIdx = 0
const walls = runtime.walls
const objs = runtime.objectDrawables
const drawUpToDepth = (maxDepth: number): void => {
while (wallIdx < walls.length || objIdx < objs.length) {
const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null
const nextObj = objIdx < objs.length ? objs[objIdx]! : null
const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity
const objDepth = nextObj !== null ? nextObj.depth : Infinity
if (wallDepth > maxDepth && objDepth > maxDepth) break
// Culling only skips the `draw`; the cursors still advance, because the
// two lists are merged by depth and a stalled cursor would reorder
// everything behind it.
if (wallDepth <= objDepth) {
if (!visible(wallBounds, wallIdx)) {
culledDraws += 1
} else {
const page = runtime.pages[nextWall!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page })
}
}
wallIdx += 1
} else {
if (!visible(objectBounds, objIdx)) {
culledDraws += 1
} else {
const page = runtime.objectPages[nextObj!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page })
}
}
objIdx += 1
}
}
}
interface EntityDraw {
x: number
y: number
depth: number
draw: () => void
}
const entities: EntityDraw[] = []
const pushEntity = (x: number, y: number, draw: () => void) => {
const c = cellOf(runtime.grid, x, y)
entities.push({ x, y, depth: c.x + c.y, draw })
}
pushEntity(engine.world.player.x, engine.world.player.y, () => {
if (character === null) {
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1])
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
} else {
let groupIndex: number
let frameIndex: number
const clip = playerAnimator.clip
if (clip !== null && clip !== undefined) {
const clipDirections = clip.directions ?? character.directions
const dir = facingToDirection(engine.world.player.facing, clipDirections)
groupIndex = clip.group + dir
frameIndex = playerAnimator.frameIndex
} else {
const direction = facingToDirection(engine.world.player.facing, character.directions)
groupIndex = (playerMoving ? character.walk : character.stand) + direction
const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
frameIndex = playerMoving
? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1)
: 0
}
state.characterGroup = groupIndex
const group = character.groups[groupIndex]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined
? engine.world.player.x + frame.anchorX
: engine.world.player.x - frame.width / 2
const drawY = frame.anchorY !== undefined
? engine.world.player.y + frame.anchorY
: engine.world.player.y - frame.height + FEET_HEIGHT / 2
renderer.draw(frame, drawX, drawY, { atlas: character.handle })
state.characterFrames += 1
state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height }
}
}
})
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' && monster.corpseTicks <= 0) continue
pushEntity(monster.x, monster.y, () => {
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
if (art !== undefined) {
const animator = monsterAnimators.get(monster.index)
const clip = animator?.clip
let groupIndex = 0
let frameIndex = 0
if (clip !== null && clip !== undefined && animator !== undefined) {
const clipDirections = clip.directions ?? art.directions
const dir = facingToDirection(monster.facing, clipDirections)
groupIndex = clip.group + dir
frameIndex = animator.frameIndex
} else {
const direction = facingToDirection(monster.facing, art.directions)
const isMoving = monster.state === 'chase'
groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction
const group = art.groups[groupIndex]
const frameCount = Math.max(1, group?.length ?? 1)
frameIndex = isMoving
? Math.floor(monsterWalkFrame / 3) % frameCount
: Math.floor(monsterWalkFrame / 5) % frameCount
}
const group = art.groups[groupIndex]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined
? monster.x + frame.anchorX
: monster.x - frame.width / 2
const drawY = frame.anchorY !== undefined
? monster.y + frame.anchorY
: monster.y - frame.height + FEET_HEIGHT / 2
renderer.draw(frame, drawX, drawY, { atlas: art.handle })
return
}
}
if (monster.state !== 'dead') {
const missingId = monster.stats.id.toLowerCase()
if (!state.missingMonsterArt.includes(missingId)) {
state.missingMonsterArt.push(missingId)
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
}
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
}
})
}
for (const npc of engine.npcEntities) {
// Sprite-backed NPCs are already painted by the object layer; the box
// is only here so an art-less NPC still shows up and stays clickable.
// The name label below is drawn for both kinds.
if (npc.hasSprite) continue
pushEntity(npc.x, npc.y, () => {
renderer.drawSolid(npc.x - MARKER_WIDTH / 2, npc.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.2, 0.9, 0.2, 1])
})
}
for (const shot of engine.projectiles) {
pushEntity(shot.x, shot.y, () => {
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
})
}
for (const entry of engine.ground) {
pushEntity(entry.x, entry.y, () => {
const size = 6
renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1])
})
}
entities.sort((a, b) => a.depth - b.depth)
for (const entity of entities) {
drawUpToDepth(entity.depth)
entity.draw()
}
drawUpToDepth(Infinity)
// 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.
drawRoofs(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()
if (hudManager !== null) {
let nearestMonster: typeof engine.world.monsters[number] | null = null
let bestDistSq = 380 * 380
for (const m of engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const dx = m.x - engine.world.player.x
const dy = m.y - engine.world.player.y
const dSq = dx * dx + dy * dy
if (dSq < bestDistSq) {
bestDistSq = dSq
nearestMonster = m
}
}
if (nearestMonster !== null) {
const r = nearestMonster.stats.rank
hudManager.targetMonster = {
name: nearestMonster.stats.name || nearestMonster.stats.id,
hp: nearestMonster.hp,
maxHp: nearestMonster.stats.hp,
rank: r === 'champion' ? 'champion' : r === 'unique' ? 'unique' : 'normal',
affixes: nearestMonster.stats.modifiers ?? [],
subType: 'Demon · 恶魔',
}
}
hudManager.render(renderStarted, playerMoving)
}
// The labels are recycled rather than rebuilt: `innerHTML = ''` plus one
// `createElement` per NPC per frame forced a full style recalculation and
// made the DOM, not the GPU, the slowest part of some town frames.
if (labelsContainer !== null) {
let labelCount = 0
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(camera.zoom))))}px`
for (const npc of engine.npcEntities) {
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
const domX = rect.left + rect.width * 0.5 + (topX - camX) * camera.zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (topY - camY) * camera.zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100) continue
if (domY <= -100 || domY >= rect.height + 100) continue
let label = labelPool[labelCount]
if (label === undefined) {
label = document.createElement('div')
label.style.position = 'absolute'
label.style.transform = 'translate(-50%, -100%)'
label.style.color = '#c8a15a'
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
label.style.whiteSpace = 'nowrap'
label.style.pointerEvents = 'none'
labelPool.push(label)
labelsContainer.appendChild(label)
}
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
}
state.drawCalls = renderer.drawCalls
state.quadsDrawn = renderer.quadsSubmitted
state.culledDraws = culledDraws
state.tickRate = loop.tickRate
state.simTps = loop.simTps
state.renderFps = loop.fps
state.fps = loop.fps
state.zoom = Number(camera.zoom.toFixed(2))
state.canvasWidth = canvas.width
state.canvasHeight = canvas.height
state.pagesLoaded = countPages()
state.skippedDraws = skippedDraws
state.renderMs = performance.now() - renderStarted
// The HUD is a single text node, but rebuilding it means ten string
// concatenations and four `toFixed` calls; at 4 Hz that is invisible to the
// reader and stops the overlay from competing with the frame it describes.
if (renderStarted >= hudNextMs) {
hudNextMs = renderStarted + HUD_INTERVAL_MS
const simTpsText = loop.simTps.toFixed(1)
const renderFpsText = loop.fps.toFixed(0)
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
// A level whose monsters are all red boxes used to look exactly like a
// healthy one here.
+ monsterPlanStatusText(state.monstersPlanned)
+ monsterArtStatusText({
missing: state.missingMonsterArt,
errors: state.monsterArtErrors,
layerFailures: state.monsterArtLayerFailures,
})
}
},
})
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
renderer.setPalette(runtime.palette.rgb)
activePalette = runtime.palette
packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
// Collect monster types to load
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
activePalette,
renderer,
packEntityBase,
packEntityAct,
)
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
}
const loadedChar = await loadCharacterArt(
runtime.charBases,
activePalette,
renderer,
packEntityBase,
packEntityAct,
currentHeroToken,
)
character = loadedChar
state.character = loadedChar !== null
if (loadedChar !== null) state.characterMembers = loadedChar.members
const heroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = heroInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = heroInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = heroInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = heroInfo.charIdentity.str
hudManager.charSheet.attrs.dex = heroInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = heroInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = heroInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = heroInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = heroInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = heroInfo.charIdentity.baseMaxHp
hudManager.hp = heroInfo.charIdentity.baseMaxHp
hudManager.maxMana = heroInfo.charIdentity.baseMaxMana
hudManager.mana = heroInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = heroInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
hudManager.syncPublishedState()
}
}
loop.start()
reportMonsterPlan(runtime)
state.ready = true
state.loadMs = Math.round(performance.now() - started)
status.textContent = ''
window.addEventListener('beforeunload', () => {
loop.stop()
input.detach()
mouseController.detach()
if (character?.handle) renderer.deleteAtlas(character.handle)
for (const art of monsterArtMap.values()) {
renderer.deleteAtlas(art.handle)
}
for (const page of runtime.pages) {
if (page !== null) renderer.deleteAtlas(page)
}
for (const page of runtime.objectPages) {
if (page !== null) renderer.deleteAtlas(page)
}
renderer.dispose()
})
}
/**
* The cell containing a scene point.
*
* @param grid - the collision grid.
* @param x - scene x.
* @param y - scene y.
* @returns rounded-down cell coordinates.
*/
function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
const px = x - grid.originX
const py = y - grid.originY
return {
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
}
}
/**
* Copy a runtime's description into the published state.
*
* @param runtime - the runtime.
* @param base - location the data came from.
*/
function publish(runtime: MapRuntime, base: string): void {
state.source = runtime.source
state.base = base
state.act = runtime.act
state.level = runtime.level
state.variant = runtime.variant
state.variants = [...runtime.variants]
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
state.cellsY = runtime.cellsY
state.widthPx = runtime.widthPx
state.heightPx = runtime.heightPx
state.floors = runtime.floors.length
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = countLoadedPages(runtime.pages)
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.animSpeed = runtime.animSpeed
state.frameDurationMs = runtime.frameDurationMs
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
async function populatePackSelectors(packBase: string, current: { act: number; label: string }): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
if (actSelect === null || sceneSelect === null || variantSelect === null) return
let index: PackIndex
try {
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
} catch {
return
}
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<number, Scene[]>()
for (const entry of index.levels) {
let scenes = scenesByAct.get(entry.act)
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
scene = { slug: entry.slug, name: sceneNameZh(entry.slug, entry.levelName), variants: [] }
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
`${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1 ? variantLabelZh(suffix) : `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}
/**
* Boot the page from pre-packed assets.
*/
async function boot(): Promise<void> {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
heroSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('hero', heroSelect.value)
location.search = params.toString()
}
}
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
const started = performance.now()
// Pre-warm DropTables synchronously from canonical embedded data (zero network, zero MPQ)
getEmbeddedDropTables()
// Pre-warm static HUD assets in parallel with map pack loading
if (typeof Image !== 'undefined') {
const criticalImages = [
'/ui/ctrlpnl-800.png',
'/ui/globe-life.png',
'/ui/globe-mana.png',
'/ui/globe-poison.png',
'/ui/overlap-left.png',
'/ui/overlap-right.png',
'/ui/minipanel.png',
'/ui/minipanel-btns.png',
'/ui/runbutton.png',
'/ui/menubutton.png',
'/ui/border-left.png',
'/ui/border-right.png',
]
for (const src of criticalImages) {
const img = new Image()
img.src = src
}
}
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
// The canvas fills the window, so its size decides how much world the first
// frame needs and therefore which atlas pages must load before it.
const viewport = ViewportCamera.syncCanvasSize(canvas)
try {
const packParam = param('pack', '')
const packBases = packParam === '' ? DEFAULT_PACKS : [packParam]
for (const packBase of packBases) {
status.textContent = `正在读取资源包 ${packBase}/index.json …`
const baseParamForChar = param('base', '')
const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar]
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
if (runtime === null) continue
publish(runtime, packBase)
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant })
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
// The first frame waits only for the spawn-area pages.
await runtime.loadPages(renderer, runtime.priorityPages, () => {
state.pagesLoaded = countLoadedPages(runtime.pages)
})
await runScene(runtime, renderer, started)
return
}
throw new Error('no pack location worked')
} catch (err) {
state.error = (err as Error).message
status.textContent = `启动失败:${state.error}`
if (heroSelect !== null) heroSelect.disabled = false
actSelect.disabled = false
sceneSelect.disabled = false
variantSelect.disabled = false
}
}
if (typeof document !== 'undefined') void boot()