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

6785 lines
252 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 { CharacterClassCode } from '../game/classes.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, ActiveExplosion } 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 { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus } from '../game/skills.ts'
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
import {
GroundLabelOverlay,
computeInitialGroundLabelLayouts,
resolveLadderCollisions,
} from '../ui/ground-labels.ts'
import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts'
import { resolveItemSpriteRect, resolveGroundItemSpriteRect } from '../ui/inventory.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 { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../game/monster-art.ts'
export { ADDITIVE_MONSTER_TOKENS } from '../game/monster-art.ts'
import { velocityToDccDirection, MISSILE_METAS, type MissileMeta, type MissileFrameTuple } from '../render/missiles-meta.ts'
import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.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'
import type { InventoryPanel } from '../ui/inventory.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
shadows?: 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
/** Count of animated map objects in the active map. */
animatedObjects: 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, shadows: 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, animatedObjects: 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 placed frame of an animated object. */
export interface AnimatedObjectFrame {
readonly frame: AtlasFrame
readonly page: number
readonly offsetX: number
readonly offsetY: number
}
/** One object sprite to draw. */
export interface ObjectDrawable {
readonly id?: number | undefined
readonly objectsTxtId?: number | undefined
readonly token?: string | undefined
readonly name?: string | null | undefined
readonly kind?: 'object' | 'npc' | undefined
readonly blendMode?: 'normal' | 'additive' | undefined
readonly trans?: number | undefined
frame: AtlasFrame
x: number
y: number
readonly depth: number
/** Object atlas page index this frame lives on. */
page: number
readonly baseX?: number | undefined
readonly baseY?: number | undefined
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
readonly frameDurationMs?: number | undefined
readonly cycleAnim?: boolean | undefined
readonly phaseOffsetMs?: number | undefined
currentFrameIndex?: number | undefined
}
/**
* Tokens that represent environmental additive-blend illumination and magic objects.
* Ground truth parity (1.13c): Window light rays (11, 12 in Act 4), light beams
* (R1..R5, 1R..4R with Trans=7), arcane orifice (HA with Trans=1), and fire jets.
*/
export { ADDITIVE_OBJECT_TOKENS }
/**
* Tokens that represent persistent environmental fire and illumination objects.
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
*/
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
])
/**
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
*
* @param x - object sub-tile or world x
* @param y - object sub-tile or world y
* @param totalDurationMs - total duration of one animation cycle in milliseconds
* @returns phase offset in milliseconds within [0, totalDurationMs)
*/
export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number {
if (totalDurationMs <= 0) return 0
let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0
h = (h ^ (h >>> 16)) >>> 0
h = Math.imul(h, 0x45d9f3b) >>> 0
h = (h ^ (h >>> 16)) >>> 0
return ((h % 1000000) / 1000000) * totalDurationMs
}
/**
* Update one animated object's current frame based on time and spatial phase.
*
* @param obj - object drawable
* @param clockMs - current clock time in milliseconds
* @returns true if frame changed
*/
export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean {
const frames = obj.animatedFrames
if (!frames || frames.length <= 1) return false
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
const phase = obj.phaseOffsetMs ?? 0
const elapsed = Math.max(0, clockMs + phase)
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
const cycleAnim = isPersistent || obj.cycleAnim !== false
const targetIndex = cycleAnim
? Math.floor(elapsed / duration) % frames.length
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
if (targetIndex === obj.currentFrameIndex) return false
obj.currentFrameIndex = targetIndex
const targetFrame = frames[targetIndex]
if (targetFrame !== undefined) {
obj.frame = targetFrame.frame
obj.page = targetFrame.page
if (obj.baseX !== undefined && obj.baseY !== undefined) {
obj.x = obj.baseX + targetFrame.offsetX
obj.y = obj.baseY + targetFrame.offsetY
}
return true
}
return false
}
/**
* Update an array of animated objects according to millisecond clock.
*
* @param objects - array of object drawables
* @param clockMs - current clock time in milliseconds
* @returns number of objects whose active frame changed
*/
export function updateAnimatedObjects(
objects: readonly ObjectDrawable[],
clockMs: number,
): number {
let updated = 0
for (let i = 0; i < objects.length; i += 1) {
if (updateAnimatedObject(objects[i]!, clockMs)) {
updated += 1
}
}
return updated
}
/** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */
export interface TownStashEntity {
readonly id: number
readonly objectsTxtId: number
readonly token: string
readonly nameZh: string
readonly nameEn: string
readonly act: number
readonly x: number
readonly y: number
readonly subTileX: number
readonly subTileY: number
readonly clickRadiusPx: number
readonly interactionRangePx: number
}
/**
* Canonical town stash locations for Acts 1..5.
* Grounded in authentic 1.13c DS1 map objects (row 267 B6).
*/
export const CANONICAL_TOWN_STASHES: Record<number, TownStashEntity> = {
1: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 1,
x: 4512,
y: 1720,
subTileX: 112,
subTileY: 86,
clickRadiusPx: 48,
interactionRangePx: 80,
},
2: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 2,
x: 5328,
y: 1568,
subTileX: 133,
subTileY: 78,
clickRadiusPx: 48,
interactionRangePx: 80,
},
3: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 3,
x: 5264,
y: 1584,
subTileX: 131,
subTileY: 79,
clickRadiusPx: 48,
interactionRangePx: 80,
},
4: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 4,
x: 704,
y: 552,
subTileX: 17,
subTileY: 27,
clickRadiusPx: 48,
interactionRangePx: 80,
},
5: {
id: 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: 5,
x: 3792,
y: 1448,
subTileX: 94,
subTileY: 72,
clickRadiusPx: 48,
interactionRangePx: 80,
},
}
/** 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[]
/** Town Stash chest entities. */
readonly stashes?: readonly TownStashEntity[]
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[]
/** Shadow draws: painted after floors, before walls and objects. */
readonly shadows?: 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[]
/** Animated object drawables whose frames cycle during rendering. */
readonly animatedObjects?: readonly ObjectDrawable[] | undefined
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 type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
export type AttackWeaponSubtype =
| 'fist'
| 'sword'
| 'axe'
| 'mace'
| 'dagger'
| 'staff'
| 'polearm'
| 'spear'
| 'bow'
| 'crossbow'
| 'other'
export interface AttackWeaponInfo {
readonly kind: AttackWeaponKind
readonly subtype: AttackWeaponSubtype
readonly isRanged: boolean
readonly ammoType?: 'arrow' | 'bolt' | undefined
readonly damageMin: number
readonly damageMax: number
readonly reach: number
readonly cooldown: number
readonly swingTicks: number
}
const BOW_CODES = new Set([
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
'am1', 'am2', 'am6', 'am7', 'amb',
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
'amc', 'amd', 'ame', 'amj', 'amk',
])
const XBOW_CODES = new Set([
'lxb', 'mxb', 'hxb', 'rxb',
'8lx', '8mx', '8hx', '8rx',
'6lx', '6mx', '6hx', '6rx',
])
export interface AttackWeaponInputItem {
code?: string | undefined
type?: string | undefined
wclass?: string | undefined
name?: string | undefined
nameZh?: string | undefined
damage?: string | undefined
}
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
if (!item || !item.code) {
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: 1,
damageMax: 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
const code = item.code.trim().toLowerCase()
const type = (item.type ?? '').trim().toLowerCase()
const wclass = (item.wclass ?? '').trim().toLowerCase()
const name = (item.name ?? '').toLowerCase()
const nameZh = (item.nameZh ?? '').toLowerCase()
let dMin = 3
let dMax = 8
if (item.damage) {
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
if (m) {
dMin = parseInt(m[1], 10)
dMax = parseInt(m[2], 10)
}
}
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'crossbow',
isRanged: true,
ammoType: 'bolt',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 16,
swingTicks: 8,
}
}
// 2. Bow
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'bow',
isRanged: true,
ammoType: 'arrow',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 14,
swingTicks: 7,
}
}
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
let subtype: AttackWeaponSubtype | null = null
let reach = 52
if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
subtype = 'sword'
reach = 52
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
subtype = 'axe'
reach = 52
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
subtype = 'mace'
reach = 52
} else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) {
subtype = 'dagger'
reach = 48
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
subtype = 'staff'
reach = 52
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
subtype = 'polearm'
reach = 60
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
subtype = 'spear'
reach = 60
}
if (subtype !== null) {
return {
kind: 'melee',
subtype,
isRanged: false,
damageMin: dMin,
damageMax: dMax,
reach,
cooldown: 14,
swingTicks: 7,
}
}
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: dMin > 3 ? dMin : 1,
damageMax: dMax > 8 ? dMax : 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
export function checkAmmunition(
ammoType: 'arrow' | 'bolt',
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | InventoryPanel | null | undefined,
): boolean {
// 1. Check offhand equipment (weapon2)
if (offhandItem && offhandItem.code) {
const code = offhandItem.code.toLowerCase().trim()
const name = (offhandItem.name ?? '').toLowerCase()
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
// 2. Check inventory grid / items
if (inventory) {
const invAny = inventory as any
const list: any[] = invAny.gridItems
? invAny.gridItems.map((g: any) => g.item ?? g)
: invAny.grid
? invAny.grid.map((g: any) => g.item ?? g)
: (invAny.items ?? [])
for (const it of list) {
if (!it || !it.code) continue
const code = String(it.code).toLowerCase().trim()
const name = String(it.name ?? '').toLowerCase()
const nameZh = String(it.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
}
return false
}
export function getSkillRange(
skillId: number,
hudManager?: HudManager | null | undefined,
equippedWeapon?: AttackWeaponInputItem | null | undefined,
): number {
if (skillId === 0) {
const hud = hudManager ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance : null)
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
const info = getAttackWeaponInfo(weapon)
return info.reach
}
const missileSkillMap: Record<number, string> = {
36: 'firebolt',
39: 'icebolt',
45: 'iceblast',
55: 'glacialspike',
38: 'chargedbolt',
67: 'teeth',
84: 'bonespear',
93: 'bonespirit',
92: 'poisonnova',
101: 'holybolt',
112: 'blessedhammer',
6: 'magicarrow',
7: 'firearrow',
11: 'icearrow',
21: 'icearrow',
12: 'arrow',
16: 'firearrow',
20: 'lightningjavelin',
}
if (skillId in missileSkillMap) {
const missile = getMissileTxtData(missileSkillMap[skillId]!)
return missile.distancePx || 1000
}
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 54: return 450
case 56: return 450
case 57: return 340
case 58: return 0
case 59: return 450
case 62: return 450
case 63: return 0
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 41: return 6
case 45: return 6
case 46: return 11
case 51: return 22
case 55: return 10
case 84: return 7
case 93: return 12
case 92: return 20
case 12: return 4
case 16: return 5
case 20: return 8
case 21: return 4
case 40: return 7
case 42: return 9
case 59: return 23
case 60: return 12
case 62: return 20
case 64: return 30
case 56: return 17
case 49: return 11
case 38: return 3
case 43: return 7
case 57: return 19
case 58: return 5
case 63: return 0
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
equippedWeapon?: any | null | undefined
equippedAmmo?: any | null | undefined
hasAmmo?: boolean | undefined
waypointNetwork?: any | undefined
openStash?: ((stash: any) => void) | 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 & deduction
let isFirstInfernoCast = false
if (skillId === 41) {
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1)
const currentMana = hudManager !== null ? hudManager.mana : player.mana
if (!player.infernoChanneling) {
// Starting cast cost: 6 mana (1.13c Skills.txt)
const startCost = 6
if (currentMana < startCost) {
status.textContent = '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
player.mana = Math.max(0, player.mana - startCost)
if (hudManager !== null) {
hudManager.mana = player.mana
hudManager.syncPublishedState()
}
player.infernoChanneling = true
player.infernoManaRemainder256 = 0
isFirstInfernoCast = true
} else {
// Continuous channeling frame: drain authentically via calculateInfernoContinuousMana256 (256ths)
if (currentMana <= 0) {
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
status.textContent = '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
const drain256 = calculateInfernoContinuousMana256(slvl)
const total256 = (player.infernoManaRemainder256 ?? 0) + drain256
const wholeMana = Math.floor(total256 / 256)
player.infernoManaRemainder256 = total256 % 256
if (wholeMana > 0) {
player.mana = Math.max(0, player.mana - wholeMana)
if (hudManager !== null) {
hudManager.mana = player.mana
hudManager.syncPublishedState()
}
}
if (player.mana <= 0) {
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
}
}
} else {
// Reset inferno channeling state when casting another skill
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
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. Spawn cast overlay if defined for this skill
if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) {
const castOverlayName = getSkillCastOverlay(skillId)
if (castOverlayName) {
engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player')
}
}
// 6. Dispatch skill
if (skillId === 0) {
const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null)
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
// Ranged Attack (Bow / Crossbow)
const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory)
if (!hasAmmo) {
status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'
return false
}
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const speed = 500
const range = weaponInfo.reach
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
const projectile: Projectile = {
skillId: '0',
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)
engine.metrics.casts += 1
status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'
return true
}
// Unarmed or Melee Attack
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
let targetMonster: Monster | null = null
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 <= weaponInfo.reach) {
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
}
}
}
if (targetMonster === null) {
let bestDist = weaponInfo.reach
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
}
}
}
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, damage)
engine.metrics.playerHits += 1
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
status.textContent = `${attackLabel}命中:${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
for (const ov of engine.overlays) {
if (ov.casterId === 'player') {
ov.x = player.x
ov.y = player.y
}
}
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 (Skill 42): Instantaneous 360° area discharge
// 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
// Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors
// Delay = 0 frames (NO cast delay)
const slvl = (hudManager as any)?.getSkillLevel?.(42) ?? 1
const radiusPx = calculateStaticFieldRadiusPx(slvl)
const difficulty = (((engine.world as any).difficulty ?? 'normal') as string).toLowerCase() as 'normal' | 'nightmare' | 'hell'
let hitCount = 0
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 <= radiusPx) {
if (!hasLineOfSight(player.x, player.y, monster.x, monster.y, engine.terrain)) {
continue
}
const maxHp = monster.stats.hp ?? monster.hp
const ltngRes = monster.stats.lightningResist
?? monster.stats.resistances?.lightning
?? monster.stats.resists?.lightning
?? (monster as any).resistances?.lightning
?? (monster as any).resists?.lightning
?? (monster.stats as any).resistLightning
?? 0
const res = calculateStaticFieldDamage(monster.hp, maxHp, difficulty, ltngRes)
if (res.damage > 0) {
damageMonster(engine.world, monster.index, res.damage)
engine.metrics.castHits += 1
hitCount++
engine.spawnOverlay('lightning', { x: monster.x, y: monster.y })
}
}
}
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `施展静态力场,削弱 ${hitCount} 个目标`
return true
}
if (skillId === 43) {
// Telekinesis (Skill 43): 1.13c ground truth
// Range: Param1 = 25 subtiles (500-600 px)
// Combat: 1-2 lightning damage base (slvl to slvl + 1), 35% knockback, 35 stun ticks
// Remote interaction:
// - Items: pickup allowed ground items (potions, scrolls, gold, ammo, keys); reject equipment
// - Objects: triggers chests, waypoints, shrines, portals, doors, wells
const slvl = (hudManager as any)?.getSkillLevel?.(43) ?? 1
const dist = Math.hypot(targetX - player.x, targetY - player.y)
const maxRange = 600
if (dist > maxRange) {
status.textContent = '目标超出心灵传动范围!'
return false
}
engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY })
// 1. Monster combat check: living monster within 48 px of target point
let targetMonster: Monster | null = null
let bestMonDist = 48
for (const m of engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const dFeet = Math.hypot(m.x - targetX, m.y - targetY)
const dBody = Math.hypot(m.x - targetX, m.y - 16 - targetY)
const minD = Math.min(dFeet, dBody)
if (minD <= bestMonDist) {
bestMonDist = minD
targetMonster = m
}
}
if (targetMonster !== null) {
const dmgRange = calculateTelekinesisDamage(slvl)
const dmg = Math.floor(dmgRange.min + Math.random() * (dmgRange.max - dmgRange.min + 1))
damageMonster(engine.world, targetMonster.index, Math.max(1, dmg))
engine.metrics.playerHits += 1
// 35% knockback away from player
if (Math.random() < 0.35) {
const kAngle = Math.atan2(targetMonster.y - player.y, targetMonster.x - player.x)
targetMonster.x += Math.cos(kAngle) * 20
targetMonster.y += Math.sin(kAngle) * (20 * ISO_GROUND_ASPECT_RATIO)
}
// 35-frame stun
;(targetMonster as any).stunTicks = Math.max((targetMonster as any).stunTicks ?? 0, 35)
engine.spawnOverlay('light_cast_2', { x: targetMonster.x, y: targetMonster.y })
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `心灵传动命中:${targetMonster.stats.name || targetMonster.stats.id}`
return true
}
// 2. Ground item pickup check: item within 48 px of target point
const nearbyGroundItem =
engine.groundItems?.findAtPoint?.(targetX, targetY, 48) ??
engine.groundItems?.all?.find(g => Math.hypot(g.x - targetX, g.y - targetY) <= 48)
if (nearbyGroundItem) {
if (isTelekinesisItemAllowed(nearbyGroundItem)) {
if (nearbyGroundItem.isGold) {
const res = engine.pickupGold(nearbyGroundItem.id)
if (res.success && hudManager) {
hudManager.inventory.gold += res.amount
hudManager.syncPublishedState()
}
status.textContent = `心灵传动拾取金币`
} else {
if (hudManager) {
const placed = hudManager.inventory.autoPlaceInGrid(nearbyGroundItem.item)
if (!placed) {
engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
status.textContent = '包裹已满。'
return true
}
engine.groundItems.remove(nearbyGroundItem.id)
const gIdx = engine.ground.findIndex(g => Math.hypot(g.x - nearbyGroundItem.x, g.y - nearbyGroundItem.y) <= 32 || g.item === nearbyGroundItem.item)
if (gIdx !== -1) {
engine.ground.splice(gIdx, 1)
}
engine.metrics.pickups += 1
hudManager.syncPublishedState()
status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
} else {
engine.pickupItem(nearbyGroundItem.id)
status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
}
}
} else {
// Equipment forbidden
engine.metrics.inventoryRefusals += 1
engine.world.events.push({ kind: 'itemRefused', x: nearbyGroundItem.x, y: nearbyGroundItem.y, text: 'cannot pickup equipment with telekinesis' } as any)
status.textContent = '心灵传动无法拾取装备!'
}
player.cooldown = 0
engine.metrics.casts += 1
return true
}
// 3. Interactive objects check (waypoints, stash, chests, portals)
if (runtime.waypoints) {
const clickedWp = runtime.waypoints.find(wp => Math.hypot(wp.arriveX - targetX, wp.arriveY - targetY) <= 80)
if (clickedWp) {
if (context.waypointNetwork?.activate(clickedWp.waypointId)) {
status.textContent = `传送点已激活:${runtime.level}`
}
if (hudManager) {
hudManager.worldPanels.setCurrentWaypoint(clickedWp.waypointId, runtime.levelId)
hudManager.worldPanels.selectedActTab = runtime.act
if (hudManager.leftPanel !== 'waypoint') {
hudManager.toggleLeftPanel('waypoint')
}
}
player.cooldown = 0
engine.metrics.casts += 1
return true
}
}
if (runtime.stashes) {
const clickedStash = runtime.stashes.find(s => Math.hypot(s.x - targetX, s.y - targetY) <= 64)
if (clickedStash) {
if (context.openStash) {
context.openStash(clickedStash)
} else if (hudManager && hudManager.leftPanel !== 'stash') {
hudManager.toggleLeftPanel('stash')
}
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '心灵传动打开储物箱'
return true
}
}
// 4. Generic ground cast / feedback
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '施展心灵传动'
return true
}
if (skillId === 59) {
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
// 1.13c Missiles.txt: blizzardcenter (ID 158), Range: 100 ticks (4.0s duration), Vel: 0
const slvl = 1
const dmgRange = calculateSkillDamage(59, slvl)
const blizzardDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
engine.projectiles.push({
skillId: '59',
x: targetX,
y: targetY,
vx: 0,
vy: 0,
damage: blizzardDamage,
ttl: 100, // 4.00s at 25 fps
fromPlayer: true,
missileType: 'blizzardcenter',
subMissileIdx: 0,
})
player.cooldown = 45
engine.metrics.casts += 1
status.textContent = '施展暴风雪'
return true
}
if (skillId === 56) {
// Meteor (Skill 56): spawn meteorcenter target emitter missile at target coordinates
// 1.13c Skills.txt: Param1: 6 (radius subtiles), Param3: 30, Param4: 15, delay: 30 (1.2s cooldown)
// 1.13c Missiles.txt: meteorcenter (ID 101), Range: 60 ticks (2.4s duration), Vel: 0
const slvl = 1
const dmgRange = calculateSkillDamage(56, slvl)
const meteorDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
const resFire = calculateMeteorResidualFireDamage(slvl)
const fireDmg = Math.round((resFire.min + resFire.max) / 2)
const fireDuration = 30 + (slvl - 1) * 15
engine.projectiles.push({
skillId: '56',
x: targetX,
y: targetY,
vx: 0,
vy: 0,
damage: meteorDamage,
ttl: 60, // 2.40s at 25 fps per Missiles.txt Range = 60
fromPlayer: true,
missileType: 'meteorcenter',
slvl,
fireDuration,
residualDamage: fireDmg,
subMissileIdx: 0,
})
player.cooldown = 30
engine.metrics.casts += 1
status.textContent = '施展陨石'
return true
}
if (skillId === 51) {
// Fire Wall (Skill 51): 15-patch perpendicular line emitter at target location
// 1.13c Skills.txt: delay = 35 (1.4s cooldown), Missiles.txt: firewall (ID 83, Range = 90)
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(51) ?? 1)
const dmgRange = calculateSkillDamage(51, slvl)
const fireWallDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
const patches = calculateFireWallPatches(player.x, player.y, targetX, targetY, {
count: 15,
spacing: 24,
isBlocked: (x, y) => {
if (runtime?.grid) {
const c = cellOf(runtime.grid, x, y)
if (c.x < 0 || c.x >= runtime.grid.cellsX || c.y < 0 || c.y >= runtime.grid.cellsY) return true
return runtime.grid.blocked[c.y * runtime.grid.gridWidth + c.x] !== 0
}
return false
},
})
for (const patch of patches) {
engine.projectiles.push({
skillId: '51',
x: patch.x,
y: patch.y,
vx: 0,
vy: 0,
damage: fireWallDamage,
ttl: 90, // 3.60s per Missiles.txt Range = 90
fromPlayer: true,
missileType: 'firewall',
pierce: true,
aoeRadius: 24,
})
}
player.cooldown = 35
engine.metrics.casts += 1
status.textContent = '施展火墙'
return true
}
if (skillId === 46) {
// Blaze (Skill 46): activate blaze buff on player, +30% FRW, footstep patches
// 1.13c Skills.txt: delay = 0, dm12 buff duration, footstep fire duration (90 + slvl * 25)
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(46) ?? 1)
const duration = calculateBlazeDuration(slvl)
const patchDuration = calculateBlazePatchDuration(slvl)
const dmgRange = calculateSkillDamage(46, slvl)
const blazeDmg = Math.round((dmgRange.min + dmgRange.max) / 2)
player.blazeTicks = duration
player.blazeSlvl = slvl
player.blazeDamage = blazeDmg
player.blazePatchDuration = patchDuration
player.lastBlazeX = undefined
player.lastBlazeY = undefined
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '施展烈火之径'
return true
}
if (skillId === 41) {
// Inferno (Skill 41): continuous cone flame stream
// 1.13c Skills.txt: starting mana = 6, Missiles.txt: infernoflame1/2 (Range = 16, Vel = 12)
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1)
const dmgRange = calculateSkillDamage(41, slvl)
const infernoDmg = Math.round((dmgRange.min + dmgRange.max) / 2)
const rangePx = calculateInfernoRangePx(slvl)
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const speed = 12 // 300 px/s / 25 fps
const vx = len > 0 ? (dx / len) * speed : speed
const vy = len > 0 ? (dy / len) * speed : 0
const ttl = Math.max(8, Math.min(60, Math.round(rangePx / speed)))
const flameVariant = (engine.metrics.casts % 2 === 0) ? 'infernoflame1' : 'infernoflame2'
engine.projectiles.push({
skillId: '41',
x: player.x,
y: player.y,
vx,
vy,
damage: infernoDmg,
ttl,
fromPlayer: true,
missileType: flameVariant,
pierce: true,
aoeRadius: 20,
})
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '施展地狱火'
return true
}
if (skillId === 40 || skillId === 50 || skillId === 60) {
for (const ov of engine.overlays) {
if (
(ov.overlayName === 'frozenarmor' ||
ov.overlayName === 'shiverarmor' ||
ov.overlayName === 'chillingarmor') &&
ov.casterId === 'player'
) {
ov.expired = true
}
}
}
if (skillId === 40) {
// Frozen Armor (Skill 40)
const slvl = 1
const duration = calculateFrozenArmorDuration(slvl)
player.coldArmorType = 'frozenarmor'
player.coldArmorTicks = duration
player.coldArmorSlvl = slvl
engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true })
player.cooldown = 0
status.textContent = '冰封装甲已激活'
engine.metrics.casts += 1
return true
}
if (skillId === 50) {
// Shiver Armor (Skill 50)
const slvl = 1
const duration = calculateShiverArmorDuration(slvl)
player.coldArmorType = 'shiverarmor'
player.coldArmorTicks = duration
player.coldArmorSlvl = slvl
engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true })
player.cooldown = 0
status.textContent = '碎冰装甲已激活'
engine.metrics.casts += 1
return true
}
if (skillId === 60) {
// Chilling Armor (Skill 60)
const slvl = 1
const duration = calculateChillingArmorDuration(slvl)
player.coldArmorType = 'chillingarmor'
player.coldArmorTicks = duration
player.coldArmorSlvl = slvl
engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true })
player.cooldown = 0
status.textContent = '寒冰装甲已激活'
engine.metrics.casts += 1
return true
}
if (skillId === 57) {
// Thunder Storm (Skill 57): activate thunderstorm buff on player
const slvl = 1
const duration = calculateThunderStormDuration(slvl)
const interval = calculateThunderStormInterval(slvl)
const dmgRange = calculateSkillDamage(57, slvl)
const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2)
player.thunderstormTicks = duration
player.thunderstormInterval = interval
player.thunderstormNextStrike = interval
player.thunderstormDamage = tsDmg
player.thunderstormSlvl = slvl
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '雷云风暴已激活'
return true
}
if (skillId === 58) {
// Energy Shield (Skill 58): activate energyshield buff on player
const slvl = 1
const duration = calculateEnergyShieldDuration(slvl)
const absorbPct = calculateEnergyShieldAbsorbPct(slvl)
player.energyshieldTicks = duration
player.energyshieldPct = absorbPct
player.energyshieldSlvl = slvl
engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true })
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '能量护盾已激活'
return true
}
if (skillId === 52) {
// Enchant (Skill 52): activate enchant weapon buff on player or target ally/pet
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(52) ?? 1)
const warmthLvl = (hudManager as any)?.getSkillLevel?.(37) ?? 0
const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0
const duration = calculateEnchantDuration(slvl)
const arBonus = calculateEnchantAttackRating(slvl)
const dmg = calculateEnchantDamage(slvl, { warmth: warmthLvl }, fireMasterySlvl)
player.enchantTicks = duration
player.enchantAttackRatingPercent = arBonus
player.enchantDamage = { min: dmg.min, max: dmg.max }
player.fireMasterySlvl = fireMasterySlvl
player.isSorceress = true
engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true })
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '强化已激活'
return true
}
if (skillId === 62) {
// Hydra (Skill 62): 3-head stationary turret emitter at target location
// Authentic 1.13c Overlay.txt head offsets: Head 1: (-6, -11), Head 2: (0, -7), Head 3: (+4, -15)
// 1.13c Skills.txt: Param1: 250 (10s duration), delay: 40 (1.6s cast delay)
// 1.13c Missiles.txt: vel: 16 (400 px/s), range: 30 (1.2s range = 480 px), HitShift: 7
const offsets = [
{ dx: -6, dy: -11 },
{ dx: 0, dy: -7 },
{ dx: 4, dy: -15 },
]
const slvl = 1
const dmgRange = calculateSkillDamage(62, slvl)
const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i]!
engine.projectiles.push({
skillId: '62',
x: targetX + off.dx,
y: targetY + off.dy,
vx: 0,
vy: 0,
damage: hydraDamage,
ttl: 250, // 10 seconds at 25 fps
fromPlayer: true,
missileType: 'hydra_turret',
isTurret: true,
fireCadenceTicks: 25,
headIndex: i,
spawnMissileType: 'hydra',
pierce: false,
})
}
player.cooldown = 40
engine.metrics.casts += 1
status.textContent = '施展九头海蛇'
return true
}
// Projectile Skills
let speed = 450
let range = 450
let damage = 28
let delay = 0
let skillName = '技能'
let missileType: string | undefined = undefined
let pierce: boolean | undefined = undefined
let aoeRadius: number | undefined = undefined
let statusEffect: 'chill' | 'freeze' | 'poison' | undefined = undefined
let statusDuration: number | undefined = undefined
let homing: boolean | undefined = undefined
let spiral: { originX: number; originY: number; angle: number; radius: number } | undefined = undefined
let jitter: boolean | undefined = undefined
let isUndeadOnly: boolean | undefined = undefined
let multiCount = 1
let spreadAngle = 0
let isNova = false
let chainRemaining: number | undefined = undefined
let nextHit: boolean | number | undefined = undefined
let nextDelay: number | undefined = undefined
if (skillId === 36) {
const missile = getMissileTxtData('firebolt')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 16
delay = 0
missileType = 'firebolt'
skillName = '火弹'
} else if (skillId === 47) {
const missile = getMissileTxtData('fireball')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 35
delay = 0
aoeRadius = 48
missileType = 'fireball'
skillName = '火球'
} else if (skillId === 39) {
const missile = getMissileTxtData('icebolt')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 600
damage = 14
delay = 0
statusEffect = 'chill'
statusDuration = 50
missileType = 'icebolt'
skillName = '冰弹'
} else if (skillId === 45) {
const missile = getMissileTxtData('iceblast')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 600
const slvl = (hudManager as any)?.getSkillLevel?.(45) ?? 1
const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0
const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0
const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0
const glacialSpikeBlvl = (hudManager as any)?.getSkillLevel?.(55) ?? 0
const synergies = {
iceBolt: iceBoltBlvl,
blizzard: blizzardBlvl,
frozenOrb: frozenOrbBlvl,
glacialSpike: glacialSpikeBlvl,
}
const dmgRange = calculateSkillDamage(45, slvl, synergies)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
statusEffect = 'freeze'
statusDuration = calculateIceBlastFreezeLength(slvl, synergies)
missileType = 'iceblast'
skillName = '冰风暴'
} else if (skillId === 55) {
const missile = getMissileTxtData('glacialspike')
speed = missile.speedPxPerSec || 400
range = missile.distancePx || 640
const slvl = (hudManager as any)?.getSkillLevel?.(55) ?? 1
const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0
const iceBlastBlvl = (hudManager as any)?.getSkillLevel?.(45) ?? 0
const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0
const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0
const synergies = {
iceBolt: iceBoltBlvl,
iceBlast: iceBlastBlvl,
blizzard: blizzardBlvl,
frozenOrb: frozenOrbBlvl,
}
const dmgRange = calculateSkillDamage(55, slvl, synergies)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
aoeRadius = 64
statusEffect = 'freeze'
statusDuration = calculateGlacialSpikeFreezeLength(slvl, synergies)
missileType = 'glacialspike'
skillName = '冰尖柱'
} else if (skillId === 38) {
const missile = getMissileTxtData('chargedbolt')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 1176
const slvl = (hudManager as any)?.getSkillLevel?.(38) ?? 1
const lightningBlvl = (hudManager as any)?.getSkillLevel?.(49) ?? 0
const dmgRange = calculateSkillDamage(38, slvl, { lightning: lightningBlvl } as any)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'chargedbolt'
jitter = true
multiCount = calculateChargedBoltCount(slvl)
spreadAngle = 0.55
skillName = '充能弹'
} else if (skillId === 67) {
const missile = getMissileTxtData('teeth')
speed = missile.speedPxPerSec || 400
range = missile.distancePx || 800
damage = 14
delay = 0
missileType = 'teeth'
multiCount = 3
spreadAngle = 0.40
skillName = '牙'
} else if (skillId === 84) {
const missile = getMissileTxtData('bonespear')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 30
delay = 0
pierce = true
missileType = 'bonespear'
skillName = '骨矛'
} else if (skillId === 93) {
const missile = getMissileTxtData('bonespirit')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 1536
damage = 45
delay = 0
homing = true
missileType = 'bonespirit'
skillName = '骨魂'
} else if (skillId === 92) {
const missile = getMissileTxtData('poisonnova')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 360
damage = 18
delay = 0
statusEffect = 'poison'
statusDuration = 100
missileType = 'poisonnova'
isNova = true
multiCount = 32
skillName = '剧毒新星'
} else if (skillId === 48) {
const missile = getMissileTxtData('nova')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 312
const slvl = (hudManager as any)?.getSkillLevel?.(48) ?? 1
const dmgRange = calculateSkillDamage(48, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'electricnova'
isNova = true
multiCount = 64
skillName = '新星'
} else if (skillId === 101) {
const missile = getMissileTxtData('holybolt')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 35
delay = 0
isUndeadOnly = true
missileType = 'holybolt'
skillName = '圣光弹'
} else if (skillId === 112) {
const missile = getMissileTxtData('blessedhammer')
speed = missile.speedPxPerSec || 450
range = missile.distancePx || 2160
damage = 40
delay = 0
pierce = true
missileType = 'blessedhammer'
const baseAngle = Math.atan2((targetY - player.y) / ISO_GROUND_ASPECT_RATIO, targetX - player.x)
spiral = { originX: player.x, originY: player.y, angle: baseAngle, radius: 12 }
skillName = '祝福之槌'
} else if (skillId === 6) {
const missile = getMissileTxtData('magicarrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 18
delay = 0
missileType = 'magicarrow'
skillName = '魔法箭'
} else if (skillId === 7) {
const missile = getMissileTxtData('firearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 20
delay = 0
missileType = 'firearrow'
skillName = '火箭'
} else if (skillId === 11) {
const missile = getMissileTxtData('icearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 22
delay = 0
statusEffect = 'chill'
statusDuration = 50
missileType = 'icearrow'
skillName = '冰箭'
} else if (skillId === 21) {
const missile = getMissileTxtData('icearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 26
delay = 0
statusEffect = 'freeze'
statusDuration = 50
missileType = 'icearrow'
skillName = '急冻箭'
} else if (skillId === 12) {
const missile = getMissileTxtData('arrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 16
delay = 0
missileType = 'arrow'
multiCount = 5
spreadAngle = 0.50
skillName = '多重箭'
} else if (skillId === 16) {
const missile = getMissileTxtData('firearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 25
delay = 0
aoeRadius = 48
missileType = 'firearrow'
skillName = '爆裂箭'
} else if (skillId === 20) {
const missile = getMissileTxtData('lightningjavelin')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
damage = 35
delay = 0
pierce = true
missileType = 'lightningjavelin'
skillName = '闪电之枪'
} else if (skillId === 64) {
const missile = getMissileTxtData('frozenorb')
speed = missile.speedPxPerSec || 250
range = missile.distancePx || 300
damage = 40
delay = 25
pierce = true
statusEffect = 'chill'
statusDuration = 100
missileType = 'frozenorb'
skillName = '冰封球'
} else if (skillId === 49) {
const missile = getMissileTxtData('lightningbolt')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
const slvl = (hudManager as any)?.getSkillLevel?.(49) ?? 1
const dmgRange = calculateSkillDamage(49, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
pierce = true
missileType = 'lightningbolt'
skillName = '闪电'
} else if (skillId === 53) {
const missile = getMissileTxtData('chainlightning')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
const slvl = (hudManager as any)?.getSkillLevel?.(53) ?? 1
const dmgRange = calculateSkillDamage(53, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'chainlightning'
chainRemaining = calculateChainLightningJumps(slvl)
nextHit = 1
nextDelay = 4
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 baseAngle = Math.atan2(dy, dx)
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
if (isNova) {
for (let i = 0; i < multiCount; i++) {
const angle = (i / multiCount) * 2 * Math.PI
const pVx = Math.cos(angle) * perTick
const pVy = Math.sin(angle) * (perTick * ISO_GROUND_ASPECT_RATIO)
engine.projectiles.push({
skillId: String(skillId),
x: player.x,
y: player.y,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
statusEffect,
statusDuration,
nextHit: 1,
nextDelay: 4,
})
}
} else if (multiCount > 1) {
const startAngle = baseAngle - spreadAngle / 2
const step = spreadAngle / (multiCount - 1)
for (let i = 0; i < multiCount; i++) {
const angle = startAngle + i * step
const pVx = Math.cos(angle) * perTick
const pVy = Math.sin(angle) * perTick
engine.projectiles.push({
skillId: String(skillId),
x: player.x,
y: player.y,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
jitter,
seed: i,
jitterIndex: (i * 7) % 32,
pierce,
aoeRadius,
statusEffect,
statusDuration,
isUndeadOnly,
})
}
} else {
let pVx: number
let pVy: number
let pX = player.x
let pY = player.y
if (spiral !== undefined) {
pX = spiral.originX + Math.cos(spiral.angle) * spiral.radius
pY = spiral.originY + Math.sin(spiral.angle) * (spiral.radius * ISO_GROUND_ASPECT_RATIO)
pVx = -Math.sin(spiral.angle) * 18
pVy = Math.cos(spiral.angle) * (18 * ISO_GROUND_ASPECT_RATIO)
} else {
const len = Math.hypot(dx, dy)
pVx = len > 0 ? (dx / len) * perTick : perTick
pVy = len > 0 ? (dy / len) * perTick : 0
}
engine.projectiles.push({
skillId: String(skillId),
x: pX,
y: pY,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
pierce,
aoeRadius,
statusEffect,
statusDuration,
homing,
spiral,
jitter,
isUndeadOnly,
chainRemaining,
nextHit,
nextDelay,
})
}
const cdTicks = getSkillCooldownTicks(skillId)
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 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: 'stash'; readonly stash: TownStashEntity }
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
| { readonly kind: 'ground-item'; readonly item: GroundItemEntity }
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' | 'stash' | '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
}
findNearestStash(worldX: number, worldY: number, maxRadius = 48): TownStashEntity | null {
const runtime = this.getRuntime()
const stashes = runtime.stashes ?? []
let best: TownStashEntity | null = null
let bestDist = maxRadius
for (const stash of stashes) {
const dist = Math.hypot(stash.x - worldX, stash.y - worldY)
if (dist <= bestDist) {
bestDist = dist
best = stash
}
}
return best
}
openStash(stash: TownStashEntity): void {
const player = this.engine.world.player
player.facing = facingOf(stash.x - player.x, stash.y - player.y)
this.status.textContent = `打开私人储物箱 (${stash.nameZh})`
this.playStashOpenSound()
if (this.hudManager && this.hudManager.leftPanel !== 'stash') {
this.hudManager.toggleLeftPanel('stash')
}
}
/**
* Interact with or move towards a ground item to pick it up.
*/
pickupGroundItem(item: GroundItemEntity): void {
const player = this.engine.world.player
const dist = Math.hypot(item.x - player.x, item.y - player.y)
if (dist <= 48) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(item.x - player.x, item.y - player.y)
if (item.isGold) {
const res = this.engine.pickupGold(item.id)
if (res.success) {
if (this.hudManager) {
this.hudManager.inventory.gold += res.amount
this.hudManager.syncPublishedState()
}
this.status.textContent = `拾起金币:${res.amount}`
}
} else {
if (this.hudManager) {
const placed = this.hudManager.inventory.autoPlaceInGrid(item.item)
if (!placed) {
this.engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
this.playInventoryFullFeedback()
this.showNotification('包裹已满。')
return
}
this.engine.groundItems.remove(item.id)
const gIdx = this.engine.ground.findIndex(g => Math.hypot(g.x - item.x, g.y - item.y) <= 32 || g.item === item.item)
if (gIdx !== -1) {
this.engine.ground.splice(gIdx, 1)
}
this.engine.metrics.pickups += 1
this.hudManager.syncPublishedState()
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
} else {
const res = this.engine.pickupItem(item.id)
if (!res.success && res.reason === 'full') {
this.playInventoryFullFeedback()
this.showNotification('包裹已满。')
} else if (res.success) {
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
}
}
}
} else {
this.activeClickType = 'ground'
this.pendingInteraction = { kind: 'ground-item', item }
this.navTarget = { x: item.x, y: item.y }
this.lastDist = dist
this.stuckTicks = 0
}
}
/**
* Play authentic Diablo II audio rejection feedback when inventory is full.
* Low descending refusal tone paired with delayed secondary touch landing thud.
*/
playInventoryFullFeedback(): void {
try {
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
if (AudioCtx) {
const ctx = new AudioCtx()
// 1. Refusal tone (low pitch descending rejection cue)
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(175, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.28)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.3)
// 2. Delayed light ground touch thud upon primary bounce landing (~350ms)
setTimeout(() => {
try {
if (ctx.state !== 'closed') {
const thudOsc = ctx.createOscillator()
const thudGain = ctx.createGain()
thudOsc.type = 'triangle'
thudOsc.frequency.setValueAtTime(110, ctx.currentTime)
thudOsc.frequency.exponentialRampToValueAtTime(45, ctx.currentTime + 0.08)
thudGain.gain.setValueAtTime(0.2, ctx.currentTime)
thudGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1)
thudOsc.connect(thudGain)
thudGain.connect(ctx.destination)
thudOsc.start()
thudOsc.stop(ctx.currentTime + 0.1)
}
} catch {
// Ignore closed audio context
}
}, 350)
}
} catch {
// AudioContext unavailable or blocked
}
}
/**
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
*/
showNotification(message: string, durationMs = 2000): void {
if (this.status) {
this.status.textContent = message
}
if (typeof document !== 'undefined') {
let banner = document.getElementById('d2-center-notification')
if (!banner && document.body) {
banner = document.createElement('div')
banner.id = 'd2-center-notification'
banner.style.position = 'fixed'
banner.style.top = '18%'
banner.style.left = '50%'
banner.style.transform = 'translateX(-50%)'
banner.style.color = '#e03030'
banner.style.fontFamily = 'monospace, sans-serif'
banner.style.fontSize = '20px'
banner.style.fontWeight = 'bold'
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
banner.style.pointerEvents = 'none'
banner.style.zIndex = '99999'
banner.style.transition = 'opacity 0.4s ease-out'
document.body.appendChild(banner)
}
if (banner) {
banner.textContent = message
banner.style.opacity = '1'
clearTimeout((banner as any)._timeout)
;(banner as any)._timeout = setTimeout(() => {
if (banner) banner.style.opacity = '0'
}, durationMs)
}
}
}
playStashOpenSound(): void {
try {
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
if (AudioCtx) {
const ctx = new AudioCtx()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(140, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + 0.12)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.15)
}
} catch {
// AudioContext unavailable or blocked
}
}
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
if (this.input.shiftHeld) {
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 rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
this.castSkill(rightSkillId, worldX, worldY)
} else {
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 (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) {
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'
}
const player = this.engine.world.player
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
}
handleRightClickWorld(worldX: number, worldY: number): void {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId)) {
// Walk/Run fallback (auras are active automatically in D2 1.13c)
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
return
}
const player = this.engine.world.player
const range = getSkillRange(rightSkillId, this.hudManager)
// 1. Check Monster: living monster within ~36 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 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(rightSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
}
// 2. Ground clicked with active right skill
if (rightSkillId === 0) {
const weapon = this.hudManager?.inventory?.equipped?.weapon1
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
} else {
this.activeClickType = 'ground'
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - player.x, worldY - player.y)
this.stuckTicks = 0
}
} else {
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.worldPanels.setCurrentWaypoint(clickedWp.wp.waypointId, runtime.levelId)
this.hudManager.worldPanels.selectedActTab = runtime.act
if (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 Town Stash: within ~48 px (clickRadiusPx = 48)
const clickedStash = this.findNearestStash(worldX, worldY, 48)
if (clickedStash !== null) {
this.activeClickType = 'stash'
const distToPlayer = Math.hypot(clickedStash.x - player.x, clickedStash.y - player.y)
if (distToPlayer <= clickedStash.interactionRangePx) {
this.navTarget = null
this.pendingInteraction = null
this.openStash(clickedStash)
} else {
this.pendingInteraction = { kind: 'stash', stash: clickedStash }
this.navTarget = { x: clickedStash.x, y: clickedStash.y }
}
return
}
// 4. 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, this.hudManager)
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, skillId: leftSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
}
// 5. Check Ground Item: within ~32 px
const clickedItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32)
if (clickedItem) {
this.pickupGroundItem(clickedItem)
return
}
// 6. Pure ground click to move:
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,
options?: {
equippedWeapon?: any
equippedAmmo?: any
hasAmmo?: boolean
}
): 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,
equippedWeapon: options?.equippedWeapon,
equippedAmmo: options?.equippedAmmo,
hasAmmo: options?.hasAmmo,
waypointNetwork: this.waypointNetwork,
openStash: (s: any) => this.openStash(s),
})
}
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
const player = this.engine.world.player
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
}
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
const player = this.engine.world.player
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
return { movement: keyMovement, attacking: false }
}
const player = this.engine.world.player
if (!player.alive || (!this.isLeftMouseDown && !this.isRightMouseDown && !this.input.shiftHeld)) {
if (player.infernoChanneling) {
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
}
}
// 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 (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId)) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') {
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.worldPanels.setCurrentWaypoint(waypoint.waypointId, this.getRuntime().levelId)
this.hudManager.worldPanels.selectedActTab = this.getRuntime().act
if (this.hudManager.leftPanel !== 'waypoint') {
this.hudManager.toggleLeftPanel('waypoint')
}
}
} else {
this.navTarget = { x: worldPos.x, y: worldPos.y }
}
} else if (this.pendingInteraction.kind === 'stash') {
const stash = this.pendingInteraction.stash
const dist = Math.hypot(stash.x - player.x, stash.y - player.y)
if (dist <= stash.interactionRangePx) {
this.navTarget = null
this.pendingInteraction = null
this.openStash(stash)
} else {
this.navTarget = { x: stash.x, y: stash.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 skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47)
const range = getSkillRange(skillId, this.hudManager)
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(skillId, monster.x, monster.y)
} else {
this.navTarget = { x: monster.x, y: monster.y }
}
}
} else if (this.pendingInteraction.kind === 'ground-item') {
const item = this.pendingInteraction.item
const dist = Math.hypot(item.x - player.x, item.y - player.y)
if (dist <= 48) {
this.navTarget = null
this.pendingInteraction = null
this.pickupGroundItem(item)
} else {
this.navTarget = { x: item.x, y: item.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
readonly animatedFrames?: readonly PackObjectFrame[] | null
readonly frameDelta?: number
readonly frameDurationMs?: number
readonly cycleAnim?: boolean
readonly sync?: boolean
readonly trans?: number | undefined
readonly blendMode?: 'normal' | 'additive' | undefined
}
/** 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[])[]
/** Shadow draws, painted after floors and before walls (absent in older packs). */
readonly shadows?: 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 shadows = (scene.shadows ?? []).map(toDrawable)
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)) {
const hasAnim = obj.animatedFrames !== undefined && obj.animatedFrames !== null && obj.animatedFrames.length > 1
const animatedFrames: AnimatedObjectFrame[] | undefined = hasAnim
? obj.animatedFrames!.map(f => ({
frame: {
x: f.x,
y: f.y,
width: f.width,
height: f.height,
},
page: f.page,
offsetX: f.offsetX,
offsetY: f.offsetY,
}))
: undefined
const frameDurationMs = obj.frameDurationMs ?? (
obj.frameDelta && obj.frameDelta > 0
? Math.round((256 / obj.frameDelta) * 40 * 100) / 100
: 40
)
const isPersistentFire = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
const cycleAnim = isPersistentFire ? true : (obj.cycleAnim ?? true)
let phaseOffsetMs = 0
if (hasAnim && !obj.sync) {
const totalDurationMs = obj.animatedFrames!.length * frameDurationMs
phaseOffsetMs = computeObjectPhaseOffsetMs(obj.x, obj.y, totalDurationMs)
}
const tokenUpper = obj.token ? obj.token.trim().toUpperCase() : ''
const isAdditive = obj.blendMode === 'additive' ||
obj.trans === 7 ||
obj.trans === 1 ||
ADDITIVE_OBJECT_TOKENS.has(tokenUpper)
const blendMode: 'normal' | 'additive' = isAdditive ? 'additive' : (obj.blendMode ?? 'normal')
objectDrawables.push({
id: obj.id,
token: obj.token,
name: obj.name,
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,
baseX: obj.x,
baseY: obj.y,
blendMode,
trans: obj.trans,
...(animatedFrames ? {
animatedFrames,
frameDurationMs,
cycleAnim,
phaseOffsetMs,
currentFrameIndex: 0,
} : {}),
})
}
}
objectDrawables.sort((a, b) => a.depth - b.depth)
const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null)
const stashes: TownStashEntity[] = []
for (const obj of scene.objects) {
if (obj.objectsTxtId === 267 || obj.token === 'B6') {
const subTileX = Math.round((obj.x - scene.originX) / 16)
const subTileY = Math.round((obj.y - scene.originY) / 16)
stashes.push({
id: obj.id ?? 267,
objectsTxtId: 267,
token: 'B6',
nameZh: '储物箱',
nameEn: 'Stash',
act: scene.act,
x: obj.x,
y: obj.y,
subTileX,
subTileY,
clickRadiusPx: 48,
interactionRangePx: 80,
})
}
}
const effectiveLevelId = scene.levelId ?? entry.levelId ?? -1
const isTown = [1, 40, 75, 103, 109].includes(effectiveLevelId) || entry.slug.includes('town')
if (stashes.length === 0 && isTown) {
const canonical = CANONICAL_TOWN_STASHES[scene.act]
if (canonical) {
stashes.push(canonical)
}
}
// 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, ...shadows], 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 ?? [],
stashes,
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,
shadows,
walls,
roofs,
animatedDrawables: [...floors, ...shadows, ...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,
animatedObjects: objectDrawables.filter(d => d.animatedFrames && d.animatedFrames.length > 1),
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 blendMode?: 'normal' | 'additive' | undefined
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?])[][]
readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined
}
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 } : {}),
}))
)
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
? meta.glowGroups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
: undefined
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
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 } : {}),
}))
)
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
? meta.glowGroups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x, y, width, height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
}))
)
: undefined
loadedBySpecKey.set(key, {
token: spec.token,
handle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
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
}
/**
* Packed missile animation asset loaded into GPU memory.
*/
export interface LoadedMissileArt {
readonly meta: MissileMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase.
*/
export async function loadMissileArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedMissileArt>> {
const map = new Map<string, LoadedMissileArt>()
if (typeof fetch === 'undefined') return map
const targets = Array.from(new Set(Object.values(MISSILE_METAS).map(m => m.name)))
const baseCandidates = [
'/missiles',
packBase ? `${packBase}/missiles` : '',
'samples/d2-packs/missiles',
].filter(Boolean)
await Promise.all(
targets.map(async name => {
for (const base of baseCandidates) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${name}.json`),
fetch(`${base}/${name}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as MissileMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
map.set(name, { meta, handle, frames })
break
}
} catch {
// ignore candidate error and continue to next fallback path
}
}
}),
)
// Map all alias keys to the loaded art handle to prevent duplicate atlas uploads
for (const [alias, meta] of Object.entries(MISSILE_METAS)) {
const art = map.get(meta.name)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
if (map.has('icebolt')) {
const iceboltArt = map.get('icebolt')!
map.set('frozenorbbolt', iceboltArt)
map.set('frozenorbnova', iceboltArt)
}
if (map.has('firebolt')) {
const fireboltArt = map.get('firebolt')!
if (!map.has('hydra')) map.set('hydra', fireboltArt)
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
}
if (map.has('blizzard3') && !map.has('blizzard4')) {
map.set('blizzard4', map.get('blizzard3')!)
}
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
}
if (map.has('lightningstrike')) {
const ltArt = map.get('lightningstrike')!
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
if (!map.has('lightning')) map.set('lightning', ltArt)
}
return map
}
/**
* Render a Diablo II 1.13c missile projectile:
* - Uses authentic baked DCC sprite atlas when available.
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
*/
export function drawMissileProjectile(
renderer: SpriteRenderer,
shot: Projectile,
missileArt?: LoadedMissileArt,
missileArtMap?: Map<string, LoadedMissileArt>,
): void {
if (shot.missileType === 'blizzardcenter') {
return // Invisible controller missile
}
const altitude = shot.altitude ?? 0
if (missileArt !== undefined) {
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
let frameIndex: number
if (missileArt.meta.name === 'meteorcenter') {
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
} else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) {
// For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
const duration = shot.fireDuration ?? (30 + (Math.max(1, shot.slvl ?? 1) - 1) * 15)
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
} else {
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
}
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude
const isAdditive = missileArt.meta.name !== 'arrow'
renderer.draw(frame, drawX, drawY, {
atlas: missileArt.handle,
...(isAdditive ? { blendMode: 'additive' } : {}),
})
// Dual Sprite Falling Trajectory & Rendering:
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) {
const remainingTicks = shot.ttl
const fallAltitude = remainingTicks * 25
const slideX = -remainingTicks * 15
const meteorX = shot.x + slideX
const meteorY = shot.y
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
// 1. Atmospheric flame tail (`meteortail`, ID 102)
const tailArt = missileArtMap.get('meteortail')
if (tailArt !== undefined) {
const tailGroup = tailArt.frames[0]
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
if (tailFrame !== undefined) {
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
atlas: tailArt.handle,
blendMode: 'additive',
})
}
}
// 2. Falling meteor core fireball (`meteor`, ID 100)
const meteorArt = missileArtMap.get('meteor')
if (meteorArt !== undefined) {
const meteorGroup = meteorArt.frames[0]
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
if (meteorFrame !== undefined) {
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
atlas: meteorArt.handle,
blendMode: 'additive',
})
}
}
}
return
}
}
const x = shot.x
const y = shot.y - altitude
if (shot.missileType === 'meteorcenter') {
// Procedural ground reticle (2:1 isometric ground ellipse)
renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4])
renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6])
if (shot.ttl > 0) {
const remainingTicks = shot.ttl
const fallAltitude = remainingTicks * 25
const slideX = -remainingTicks * 15
const mx = shot.x + slideX
const my = shot.y - fallAltitude
// Falling fireball core
renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8])
renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95])
// Trailing flame tail
renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5])
}
return
}
if (shot.isTurret || shot.missileType === 'hydra_turret') {
// Authentic stationary hydra head turret plume
const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5
// Soft outer fire aura
renderer.drawSolid(x - 12, y - 12 + wobble * 0.5, 24, 24, [1.0, 0.32, 0.05, 0.35])
// Mid fire core
renderer.drawSolid(x - 8, y - 8 + wobble, 16, 16, [1.0, 0.65, 0.12, 0.65])
// Inner hot core
renderer.drawSolid(x - 4, y - 4 + wobble, 8, 8, [1.0, 0.92, 0.3, 0.9])
// White flame peak
renderer.drawSolid(x - 2, y - 6 + wobble, 4, 6, [1.0, 1.0, 0.85, 0.95])
return
}
const vx = shot.vx
const vy = shot.vy
const speed = Math.hypot(vx, vy)
const dirX = speed > 0 ? vx / speed : 1
const dirY = speed > 0 ? vy / speed : 0
// 1. Dynamic flame trail: trailing flame and ember puffs along reverse trajectory
for (let i = 1; i <= 5; i += 1) {
const dist = i * 4.2
// Sinusoidal organic wobble perpendicular to travel direction
const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75)
const px = x - dirX * dist - dirY * flicker
const py = y - dirY * dist + dirX * flicker
const size = Math.max(3, 8 - i)
let color: readonly [number, number, number, number]
if (i === 1) color = [1.0, 0.72, 0.16, 0.78]
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
else color = [0.45, 0.1, 0.02, 0.18]
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
}
// 2. Wide soft orange-red heat haze / aura
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
// 3. Medium bright flame body
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55])
// 4. Inner intense fire
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
// 5. Incandescent white-hot center core
renderer.drawSolid(x - 2, y - 2, 4, 4, [1.0, 1.0, 0.88, 0.98])
}
export const drawFireboltProjectile = drawMissileProjectile
/**
* Render a Diablo II 1.13c style explosion:
* - Uses authentic baked DCC sprite atlas when available.
* - Falls back to procedural explosion in test/offline environments.
*/
export function drawExplosion(
renderer: SpriteRenderer,
exp: ActiveExplosion,
explosionArt?: LoadedMissileArt,
): void {
if (explosionArt !== undefined) {
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
const group = explosionArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
return
}
}
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
const inv = 1 - progress
const x = exp.x
const y = exp.y
// 1. Central expanding fireball burst
const radius = 6 + progress * 20
const alpha = inv * 0.85
const r = 1.0
const g = progress < 0.25 ? 0.92 - progress * 1.2 : 0.6 * inv
const b = progress < 0.2 ? 0.7 * inv : 0.04
renderer.drawSolid(x - radius, y - radius, radius * 2, radius * 2, [r, g, b, alpha * 0.35])
const innerRadius = radius * 0.62
renderer.drawSolid(x - innerRadius, y - innerRadius, innerRadius * 2, innerRadius * 2, [r, Math.min(1, g + 0.25), b * 1.4, alpha * 0.7])
// Early flash at impact point (frames 0..3)
if (progress < 0.3) {
const flashSize = Math.max(3, 7 * (1 - progress * 3.3))
renderer.drawSolid(x - flashSize, y - flashSize, flashSize * 2, flashSize * 2, [1.0, 1.0, 0.92, 0.95])
}
// 2. Eight radial fire sparks/embers flying outwards
for (let i = 0; i < 8; i += 1) {
const angle = i * (Math.PI / 4) + i * 0.16
const sparkDist = progress * 28 + (i % 3) * 3
const sx = x + Math.cos(angle) * sparkDist
const sy = y + Math.sin(angle) * sparkDist
const sparkSize = Math.max(2, 4.5 * inv)
renderer.drawSolid(sx - sparkSize / 2, sy - sparkSize / 2, sparkSize, sparkSize, [1.0, 0.55 * inv + 0.2, 0.05, inv * 0.9])
}
}
export const drawFireExplosion = drawExplosion
/**
* Packed overlay animation asset loaded into GPU memory.
*/
export interface LoadedOverlayArt {
readonly meta: OverlayMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked cast overlay atlases from /overlays/*.png or packBase.
*/
export async function loadOverlayArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedOverlayArt>> {
const map = new Map<string, LoadedOverlayArt>()
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
const targets = Array.from(new Set(Object.values(OVERLAY_METAS).map(m => m.name)))
const baseCandidates = [
'/overlays',
packBase ? `${packBase}/overlays` : '',
'samples/d2-packs/overlays',
].filter(Boolean)
await Promise.all(
targets.map(async name => {
for (const base of baseCandidates) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${name}.json`),
fetch(`${base}/${name}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as OverlayMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const art: LoadedOverlayArt = { meta, handle, frames }
map.set(name, art)
if (meta.celFile) {
map.set(meta.celFile.toLowerCase(), art)
}
break
}
} catch {
// ignore candidate error and continue to next fallback path
}
}
}),
)
for (const [alias, meta] of Object.entries(OVERLAY_METAS)) {
const art = map.get(meta.name)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
return map
}
/**
* Render a Diablo II 1.13c character cast overlay effect:
* - Uses authentic baked DCC sprite atlas with additive blending (Trans === 3) when available.
* - Respects frame anchorX and anchorY offsets to align with caster's origin.
* - Falls back to procedural magic glow in test/offline environments.
* - Dynamic lighting/light mask rendering deferred to issue #395.
*/
export function drawCastOverlay(
renderer: SpriteRenderer,
overlay: ActiveOverlay,
overlayArt?: LoadedOverlayArt,
): void {
// 1. Sprite animation with additive blending
if (overlayArt !== undefined) {
const frameCount = Math.max(1, overlayArt.meta.framesPerDirection || overlayArt.meta.frames)
const frameIndex = Math.min(Math.max(0, Math.floor(overlay.frame)), frameCount - 1)
const group = overlayArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? overlay.x + frame.anchorX : overlay.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? overlay.y + frame.anchorY : overlay.y - frame.height / 2
renderer.draw(frame, drawX, drawY, { atlas: overlayArt.handle, blendMode: 'additive' })
renderer.setBlendMode('normal')
return
}
}
// 2. Fallback procedural representation in test/offline environments
const [r255, g255, b255] = overlay.lightColor
const r = r255 / 255
const g = g255 / 255
const b = b255 / 255
const fallbackRadius = 16
renderer.drawSolid(
overlay.x - fallbackRadius,
overlay.y - fallbackRadius,
fallbackRadius * 2,
fallbackRadius * 2,
[r, g, b, 0.6],
{ blendMode: 'additive' },
)
renderer.setBlendMode('normal')
}
/** Quality colors for ground item highlights and glints. */
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
normal: [0.88, 0.88, 0.88, 1.0],
low: [0.55, 0.55, 0.55, 1.0],
superior: [0.95, 0.95, 0.95, 1.0],
magic: [0.35, 0.52, 1.0, 1.0],
rare: [1.0, 0.92, 0.22, 1.0],
set: [0.12, 0.95, 0.12, 1.0],
unique: [0.85, 0.72, 0.42, 1.0],
craft: [1.0, 0.62, 0.12, 1.0],
rune: [1.0, 0.62, 0.12, 1.0],
gold: [1.0, 0.84, 0.2, 1.0],
}
export let globalItemsAtlasHandle: AtlasHandle | undefined
/**
* Load authentic 1.13c baked items atlas from /ui/items-atlas.png.
*/
export async function loadItemsAtlas(
renderer: SpriteRenderer,
packBase?: string,
): Promise<AtlasHandle | undefined> {
if (globalItemsAtlasHandle !== undefined) return globalItemsAtlasHandle
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return undefined
const candidates = [
'/ui/items-atlas.png',
packBase ? `${packBase}/ui/items-atlas.png` : '',
'ui/items-atlas.png',
'public/ui/items-atlas.png',
].filter(Boolean)
for (const url of candidates) {
try {
const resp = await fetch(url)
if (!resp.ok) continue
const blob = await resp.blob()
const bitmap = await createImageBitmap(blob)
try {
const handle = renderer.addAtlas(bitmap, BAKED_UI_MANIFEST.atlasWidth, BAKED_UI_MANIFEST.atlasHeight)
globalItemsAtlasHandle = handle
return handle
} finally {
bitmap.close()
}
} catch {
// try next candidate
}
}
return undefined
}
/**
* Render a ground item entity with authentic Diablo II 1.13c appearance:
* - Parabolic bounce height offset (when dropping or inventory full)
* - Authentic Blizzard \`flippyfile\` (\`flp*.dc6\`) ground item sprite resting on the 2:1 isometric plane
* - Ground contact oval shadow
*/
export function drawGroundItem(
renderer: SpriteRenderer,
item: GroundItemEntity,
now: number,
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
): void {
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
const baseY = item.y - bounceH
const qColor = item.isGold
? GROUND_ITEM_QUALITY_COLORS.gold!
: (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!)
// 1. Authentic Blizzard flippy ground sprite resolution when items atlas is available
const target = item.isGold
? { isGold: true, amount: item.amount, code: 'gld' }
: (item.item || (item as any))
const sr = (itemsAtlas !== undefined && (typeof (renderer as any).drawQuad === 'function' || typeof (renderer as any).draw === 'function'))
? resolveGroundItemSpriteRect(target, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile)
: null
if (sr !== null && itemsAtlas !== undefined) {
// Authentic Diablo II ground item sprite icon:
// Natural sprite dimensions from flp*.dc6 (already rendered by Blizzard in 2:1 isometric perspective)
const baseW = sr.w
const baseH = sr.h
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.1, 0.38 * (1 - Math.min(1, bounceH / 45))) : 0.38
const shadowW = Math.max(12, Math.round(baseW * 0.95))
const shadowH = Math.max(4, Math.round(shadowW * 0.38))
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha])
const drawX = Math.round(item.x - baseW / 2)
const drawY = Math.round(baseY - baseH)
const frame: AtlasFrame = {
x: sr.x,
y: sr.y,
width: sr.w,
height: sr.h,
}
if (typeof (renderer as any).draw === 'function') {
renderer.draw(frame, drawX, drawY, {
atlas: itemsAtlas,
width: baseW,
height: baseH,
})
} else if (typeof (renderer as any).drawQuad === 'function') {
// Slanted quad projection fallback for tests expecting drawQuad
const groundH = Math.max(6, Math.round(baseH * 0.52))
const shearX = Math.round(-groundH * 0.55)
const x2 = item.x - baseW / 2
const y2 = baseY
const x3 = item.x + baseW / 2
const y3 = baseY
const x0 = x2 + shearX
const y0 = baseY - groundH
const x1 = x3 + shearX
const y1 = baseY - groundH
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
atlas: itemsAtlas,
})
}
return
}
// 2. Fallback for mock renderer or headless unit tests without atlas
if (item.isGold) {
let w = 10
let h = 8
if (item.amount >= 5000) {
w = 18
h = 13
} else if (item.amount >= 100) {
w = 14
h = 10
}
// Shadow on ground: stays on ground plane (item.y - 2), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.35 * (1 - bounceH / 45)) : 0.35
renderer.drawSolid(item.x - w / 2 - 1, item.y - 2, w + 2, 4, [0, 0, 0, shadowAlpha])
// Gold coin pile body
renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [0.92, 0.75, 0.18, 1.0])
// Highlight glint coin rim
renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0])
} else {
const w = item.invWidth > 1 ? 22 : 14
const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
// Authentic flippy tumble width modulation during vertical bounce
let renderW = w
if (bounceH > 0 && item.bounceState) {
const elapsed = now - item.bounceState.startTime
// Spin oscillation creating flipping card / tumbling item illusion
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
}
// Body
const qr = qColor[0]
const qg = qColor[1]
const qb = qColor[2]
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9])
// Border rim
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
}
// 3. Glint / Sparkle 4-Point Star Animation for fallback headless tests
const period = 2000
const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period
const cycleTime = (now + phaseOffset) % period
const flashDuration = 320
if (cycleTime < flashDuration) {
const p = cycleTime / flashDuration
// Sine bell curve for smooth brightening and fading
const intensity = Math.sin(p * Math.PI)
if (intensity > 0.05) {
const gx = item.x + (item.invWidth > 1 ? 3 : 0)
const gy = baseY - (item.isGold ? 6 : 12)
const rayLen = Math.round(3 + 6 * intensity)
// Horizontal ray
renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9])
// Vertical ray
renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9])
// Brilliant central core
renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity])
// Subtle quality aura
renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35])
}
}
}
/**
* 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 missileArtMap = new Map<string, LoadedMissileArt>()
void loadMissileArtMap(renderer, packEntityBase).then(map => {
missileArtMap = map
})
let itemsAtlasHandle: AtlasHandle | undefined
void loadItemsAtlas(renderer, packEntityBase).then(handle => {
itemsAtlasHandle = handle
})
let overlayArtMap = new Map<string, LoadedOverlayArt>()
void loadOverlayArtMap(renderer, packEntityBase).then(map => {
overlayArtMap = map
})
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) {
hudManager.switchClass(nextInfo.code as CharacterClassCode)
}
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 shadowBounds = buildBounds(runtime.shadows ?? [])
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[] = []
// Ground item Alt floating labels container and overlay manager
const groundLabelsContainer = document.querySelector<HTMLElement>('#ground-labels') ?? (() => {
if (typeof document === 'undefined') return null
const el = document.createElement('div')
el.id = 'ground-labels'
document.body.appendChild(el)
return el
})()
const groundLabelOverlay = groundLabelsContainer ? new GroundLabelOverlay(groundLabelsContainer) : null
/** `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 })
}
for (const s of runtime.stashes ?? []) {
safeZones.push({ x: s.x, y: s.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,
isTown: [1, 40, 75, 103, 109].includes(runtime.levelId),
levelId: runtime.levelId,
// 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: (item) => {
const player = engine.world.player
const playerCell = cellOf(runtime.grid, player.x, player.y)
const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2)
const groundItem = engine.dropItem(item, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY)
status.textContent = `已将「${groundItem.nameZh}」丢弃在地面`
},
onDropGoldToGround: (amount) => {
const player = engine.world.player
const playerCell = cellOf(runtime.grid, player.x, player.y)
const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2)
const groundGold = engine.dropGold(amount, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY)
if (groundGold) {
status.textContent = `已将 ${amount.toLocaleString()} 金币丢弃在地面`
}
},
})
: null
if (hudManager !== null) {
hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null)
hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null)
hudManager.worldPanels.selectedActTab = runtime.act
try {
await hudManager.init('/ui')
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
} 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 (groundLabelOverlay !== null) {
groundLabelOverlay.setOnPickupCallback((item) => {
mouseController.pickupGroundItem(item)
})
}
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)
shadowBounds = buildBounds(next.shadows ?? [])
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.animatedObjects = next.animatedObjects?.length ?? 0
state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs
const effectiveLvlId = next.levelId ?? next.level ?? 1
engine.levelId = effectiveLvlId
engine.isTown = [1, 40, 75, 103, 109].includes(effectiveLvlId)
const names = getLevelNames(effectiveLvlId)
hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn)
if (hudManager) {
hudManager.worldPanels.setCurrentLevel(next.levelId ?? null)
hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, next.levelId ?? null)
hudManager.worldPanels.selectedActTab = next.act
}
}
/**
* 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.worldPanels.setCurrentWaypoint(waypoint.waypointId, runtime.levelId)
hudManager.worldPanels.selectedActTab = runtime.act
if (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
if (hudManager) {
const rightSkillId = hudManager.hotkeys.rightSkillId
const slvl = Math.max(1, hudManager.skillTree ? hudManager.skillTree.getEffectiveLevel(rightSkillId) : 20)
engine.updateActiveAura(rightSkillId, slvl)
}
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 (runtime.animatedObjects && runtime.animatedObjects.length > 0) {
updateAnimatedObjects(runtime.animatedObjects, renderStarted)
}
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 drawShadows = (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, tint: [1, 1, 1, 0.5] })
}
}
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)
if (runtime.shadows && runtime.shadows.length > 0) {
drawShadows(runtime.shadows, shadowBounds)
}
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,
blendMode: nextObj!.blendMode ?? 'normal',
})
}
}
objIdx += 1
}
}
}
interface EntityDraw {
x: number
y: number
depth: number
draw: () => void
}
const entities: EntityDraw[] = []
const pushEntity = (x: number, y: number, draw: () => void, depthOffset = 0) => {
const c = cellOf(runtime.grid, x, y)
entities.push({ x, y, depth: c.x + c.y + depthOffset, 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
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
renderer.draw(frame, drawX, drawY, {
atlas: art.handle,
blendMode: baseBlendMode,
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
})
if (art.glowGroups !== undefined) {
const glowGroup = art.glowGroups[groupIndex]
const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0]
if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) {
renderer.draw(glowFrame, drawX, drawY, {
atlas: art.handle,
blendMode: 'additive',
})
}
}
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)`)
}
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const tint = isChilled ? ([0.4, 0.65, 1.0, 1.0] as const) : ([0.9, 0.2, 0.2, 1.0] as const)
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
}
})
}
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) {
if (shot.missileType === 'blizzardcenter') continue
pushEntity(shot.x, shot.y, () => {
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
const art = mType !== undefined
? (missileArtMap.get(mType) ??
(mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') :
mType === 'blizzard4' ? missileArtMap.get('blizzard3') :
mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined))
: undefined
if (art !== undefined) {
drawMissileProjectile(renderer, shot, art, missileArtMap)
} else {
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
}
})
}
for (const exp of engine.explosions) {
pushEntity(exp.x, exp.y, () => {
const art =
missileArtMap.get(exp.missileType) ??
(exp.missileType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : missileArtMap.get('fireexplode'))
drawExplosion(renderer, exp, art)
})
}
for (const overlay of engine.overlays) {
if (overlay.expired) continue
pushEntity(
overlay.x,
overlay.y,
() => {
drawCastOverlay(
renderer,
overlay,
overlayArtMap.get(overlay.overlayName) ?? overlayArtMap.get(overlay.overlayName.toLowerCase()),
)
},
overlay.preDraw ? -0.1 : 0.1,
)
}
for (const gItem of engine.groundItems.all) {
pushEntity(gItem.x, gItem.y, () => {
drawGroundItem(renderer, gItem, renderStarted, itemsAtlasHandle)
})
}
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
}
if (groundLabelOverlay !== null) {
const showLabels = Boolean(hudManager?.showGroundLabels)
if (showLabels && engine.groundItems.count > 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 projectToScreen = (worldX: number, worldY: number) => {
const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY
if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) {
return null
}
return { x: domX, y: domY }
}
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
groundLabelOverlay.update(true, resolvedLayouts)
} else {
groundLabelOverlay.update(false, [])
}
}
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.shadows?.length ?? 0)} 阴影 / ${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.shadows = runtime.shadows?.length ?? 0
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.animatedObjects = runtime.animatedObjects?.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) }
}
export async function initBrowserArenaMode(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined') return
const params = new URLSearchParams(location.search)
if (params.get('arena') !== '1') return
try {
const classParam = params.get('class') || params.get('hero') || 'sor'
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
const autocast = params.get('autocast') !== '0'
const { getSharedDataRegistry } = await import('../game/engine/data-registry.ts')
const { WorldArena } = await import('../game/engine/world-arena.ts')
const registry = await getSharedDataRegistry('samples/d2')
let arena = new WorldArena({
registry,
classCode: classParam,
skillId,
slvl,
})
if (autocast) {
arena.triggerCast()
arena.stepTicks(15)
}
const syncWindowArena = () => {
const snap = arena.getDebugState() as ReturnType<typeof arena.getDebugState> & {
switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown
}
const origTrigger = snap.triggerCast
const origStep = snap.stepTicks
snap.triggerCast = (tx?: number, ty?: number) => {
const out = origTrigger(tx, ty)
syncWindowArena()
renderArenaVisualOverlay(arena)
return out
}
snap.stepTicks = (cnt: number) => {
origStep(cnt)
syncWindowArena()
renderArenaVisualOverlay(arena)
}
snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => {
arena = new WorldArena({
registry,
classCode: nextClassCode,
skillId: nextSkillId,
slvl: nextSlvl,
})
arena.triggerCast()
arena.stepTicks(15)
syncWindowArena()
renderArenaVisualOverlay(arena)
return arena.getDebugState()
}
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
}
const renderArenaVisualOverlay = (wArena: InstanceType<typeof WorldArena>) => {
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
if (!overlayCanvas) {
overlayCanvas = document.createElement('canvas')
overlayCanvas.id = 'arena-skill-overlay'
overlayCanvas.width = window.innerWidth || 960
overlayCanvas.height = window.innerHeight || 600
overlayCanvas.style.cssText =
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
document.body.appendChild(overlayCanvas)
}
const ctx = overlayCanvas.getContext('2d')
if (!ctx) return
const w = overlayCanvas.width
const h = overlayCanvas.height
ctx.clearRect(0, 0, w, h)
const dbg = wArena.getDebugState()
const cx = w * 0.5
const cy = h * 0.52
// 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero
const classHue = ((dbg.skillId * 37) % 360)
ctx.save()
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)`
ctx.lineWidth = 3.5
ctx.beginPath()
ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2)
ctx.stroke()
// Hero class figure + active weapon/spell glow
ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)`
ctx.strokeStyle = '#fff3d1'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#ffe9a8'
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35)
ctx.restore()
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
}
for (const tgt of dbg.targetLog) {
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
const tx = cx + off.dx
const ty = cy + off.dy
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
// Dummy shadow + body marker
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.beginPath()
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = off.color
ctx.strokeStyle = '#f5e6c8'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
// HP bar
ctx.fillStyle = '#1f1914'
ctx.fillRect(tx - 44, ty - 38, 88, 9)
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillStyle = '#f2e6d0'
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
if (tgt.lastDamage > 0) {
ctx.fillStyle = '#ffdf59'
ctx.font = 'bold 14px monospace'
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
}
if (tgt.states.length > 0) {
ctx.fillStyle = '#7be3ff'
ctx.font = '10px monospace'
ctx.fillText(tgt.states.join(','), tx, ty + 32)
}
}
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
ctx.save()
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
ctx.strokeStyle = grad
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(cx, cy - 8)
ctx.lineTo(cx, cy - 108)
ctx.stroke()
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
ctx.beginPath()
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
// 4. Draw Active Summoned Pets / Traps
dbg.petsActive.forEach((pet, idx) => {
const px = cx - 95 + idx * 48
const py = cy + 44
ctx.fillStyle = '#5ce1e6'
ctx.strokeStyle = '#112a36'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(px, py, 12, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#d7f9ff'
ctx.font = 'bold 10px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
})
// 5. Top-left 1.13c Skill Verification Telemetry Panel
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
ctx.strokeStyle = '#c8a15a'
ctx.lineWidth = 1.5
ctx.fillRect(14, 62, 470, 122)
ctx.strokeRect(14, 62, 470, 122)
ctx.textAlign = 'left'
ctx.fillStyle = '#f0c76e'
ctx.font = 'bold 13px monospace'
ctx.fillText(
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
24,
82,
)
ctx.fillStyle = '#e8dcc8'
ctx.font = '12px monospace'
ctx.fillText(
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
24,
102,
)
ctx.fillText(
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
24,
122,
)
ctx.fillText(
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
24,
142,
)
ctx.fillStyle = '#8ee38e'
ctx.fillText(
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
24,
162,
)
}
syncWindowArena()
renderArenaVisualOverlay(arena)
} catch (err) {
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
(err as Error)?.stack || err,
)
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
;(window as unknown as { __initBrowserArenaMode?: () => Promise<void> }).__initBrowserArenaMode =
initBrowserArenaMode
void initBrowserArenaMode()
}
/**
* Boot the page from pre-packed assets.
*/
async function boot(): Promise<void> {
void initBrowserArenaMode()
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()