/** * 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 } 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 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 = { 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 = { 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 = { 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 } /** * 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([ 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 = { 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 59: return 450 case 62: return 450 case 64: return 480 default: return 450 } } export function getSkillMana(skillId: number, hudManager?: HudManager | null): number { if (skillId === 0) return 0 if (hudManager !== null && hudManager !== undefined) { const entry = hudManager.hotkeys.availableSkills.find(s => s.skillId === skillId) if (entry !== undefined) return entry.manaCost } if (isBatch1Skill(skillId)) { return getSkillManaCost(skillId, 1) } switch (skillId) { case 45: return 6 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 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 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) { 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 === 40) { // Frozen Armor player.cooldown = 10 status.textContent = '冰封装甲已激活' engine.metrics.casts += 1 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 damage = 24 delay = 0 statusEffect = 'freeze' statusDuration = 75 missileType = 'iceblast' skillName = '冰风暴' } else if (skillId === 55) { const missile = getMissileTxtData('glacialspike') speed = missile.speedPxPerSec || 400 range = missile.distancePx || 640 damage = 32 delay = 0 aoeRadius = 64 statusEffect = 'freeze' statusDuration = 50 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' } } 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 } tick(): { movement: { x: number; y: number }; attacking: boolean } { const keyMovement = this.input.movement() const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0 if (isKeyMoving) { this.navTarget = null this.pendingInteraction = null return { movement: keyMovement, attacking: false } } const player = this.engine.world.player // Shift-Hold Stand Still Priority in tick() if (this.input.shiftHeld) { this.navTarget = null this.pendingInteraction = null if (this.isLeftMouseDown && this.lastClientPos !== null && player.cooldown === 0) { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 this.castSkill(leftSkillId, worldX, worldY) } return { movement: { x: 0, y: 0 }, attacking: false } } // Check continuous holding if (this.isLeftMouseDown && this.lastClientPos !== null) { if (this.activeClickType === 'ground') { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) this.navTarget = { x: worldX, y: worldY } } else if (this.activeClickType === 'monster') { if (this.pendingInteraction === null && player.cooldown === 0) { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 this.castSkill(leftSkillId, worldX, worldY) } } } else if (this.isRightMouseDown && this.lastClientPos !== null) { const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 if (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(url: string): Promise { 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 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>() /** * 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 { let pending = packIndexCache.get(packBase) if (pending === undefined) { pending = fetchJson(`${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 { 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 { 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 { const forAct = index.levels.filter(candidate => candidate.act === entry.act) const scene = await fetchJson(`${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() 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() 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> } /** Global singleton cache for DropTables using pre-baked embedded canonical drop data (zero network/MPQ). */ let sharedDropTablesPromise: Promise | null = null export function getSharedDropTables(_bases: readonly string[] = DEFAULT_BASES): Promise { 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> 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> 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 /** 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[] { 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 { const map = new Map() 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() 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() 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 { 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> { const map = new Map() 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, ): 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> { const map = new Map() 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 = { 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 { 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 { let runtime = initialRuntime const canvas = document.querySelector('#view')! const status = document.querySelector('#status')! const hud = document.querySelector('#hud')! const heroSelect = document.querySelector('#hero, #class') const actSelect = document.querySelector('#act')! const variantSelect = document.querySelector('#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() let missileArtMap = new Map() void loadMissileArtMap(renderer, packEntityBase).then(map => { missileArtMap = map }) let itemsAtlasHandle: AtlasHandle | undefined void loadItemsAtlas(renderer, packEntityBase).then(handle => { itemsAtlasHandle = handle }) let overlayArtMap = new Map() void loadOverlayArtMap(renderer, packEntityBase).then(map => { overlayArtMap = map }) let monsterWalkFrame = 0 const playerAnimator = new ActorAnimator() const switchHero = async (newHeroToken: string): Promise => { 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() 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('#labels') const labelPool: HTMLDivElement[] = [] // Ground item Alt floating labels container and overlay manager const groundLabelsContainer = document.querySelector('#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('#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 => { 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 => { 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 => { 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() 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('#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 { const actSelect = document.querySelector('#act') const sceneSelect = document.querySelector('#scene') const variantSelect = document.querySelector('#variant') if (actSelect === null || sceneSelect === null || variantSelect === null) return let index: PackIndex try { index = await fetchJson(`${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() 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 { 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 & { 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) => { let overlayCanvas = document.querySelector('#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 = { '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 }).__initBrowserArenaMode = initBrowserArenaMode void initBrowserArenaMode() } /** * Boot the page from pre-packed assets. */ async function boot(): Promise { void initBrowserArenaMode() const canvas = document.querySelector('#view')! const status = document.querySelector('#status')! const heroSelect = document.querySelector('#hero, #class') const actSelect = document.querySelector('#act')! const sceneSelect = document.querySelector('#scene')! const variantSelect = document.querySelector('#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()