diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f7b9a1d..963954b 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -1,7820 +1,159 @@ -/** - * 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, isMissileBlockedAt, 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 { - applyEliteModifiers, - rollEliteModifiers, - enrichMonsterStats, - enrichMonsterPacks, - ELITE_HEALTH_MULTIPLIER, - MONUMOD_CONSTANTS, - formatEliteAffixLabel, - monsterDisplayName, - type MonsterRank, -} from '../game/monsters.ts' -import { Rng } from '../game/rng.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 { getTownNpcDescriptorByName, npcDisplayName } from '../game/npc-table.ts' -import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' -import { npcDialog } from '../game/quests.ts' -import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateThunderStormDamage, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } from '../game/skills.ts' -import type { Projectile, ActiveOverlay } from '../game/skills.ts' -import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, isWithinPickupBounds, getPickupEdgeDistance, type GroundItemEntity } from '../game/ground-items.ts' -import { - GroundLabelOverlay, - computeInitialGroundLabelLayouts, - resolveLadderCollisions, - isGroundItemEtherealOrSocketed, - cleanChiItemName, -} from '../ui/ground-labels.ts' -import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts' -import { - resolveItemSpriteRect, - resolveGroundItemSpriteRect, - itemToUiInventoryItem, - PLAYER_GOLD_CAP, - getInventoryGoldLimit, -} 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 { - SorceressLayerCompositor, - resolveSorceressAppearance, - appearanceKey, - type SorceressAppearanceSpec, -} from '../game/char-appearance.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 { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts' -import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts' -import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { AudioManager } from '../audio/manager.ts' -import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' -import { - ACT_NAMES_EN, - ACT_NAMES_ZH, - LEVEL_NAMES_ZH, - getLevelNames, - levelDisplayName, - sceneDisplayName, - sceneNameZh, - variantDisplayLabel, - variantLabelZh, -} from '../game/level-names-zh.ts' -import { - buildLangSwitchHref, - getTblLang, - initLangFromBrowser, - type TblLang, -} from '../i18n/lang.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, resolveTownPortalSubTile, townLevelForAct, type OpenPortal } from '../game/portal.ts' -import type { Side } from '../game/variants.ts' -import { WorldVariants, WORLD_VARIANT_SEED, type ActLayout } from '../game/act-variants.ts' -import { Minimap } from '../ui/minimap.ts' -import type { - AutomapOverlayLabel, - AutomapViewportMetrics, - MinimapLevel, - MinimapMarker, -} from '../ui/minimap.ts' -import type { AutomapLayerData } from '../formats/automap-table.ts' -import { HudManager } from '../ui/hud-manager.ts' -import type { MonsterTargetHudInfo } from '../ui/world-panels.ts' -import { getSkillName as getSkillDisplayName } from '../ui/skill-tree-panel.ts' -import type { InventoryPanel } from '../ui/inventory.ts' -import { LightGrid } from '../game/engine/light-grid.ts' +import type { Palette } from '../formats/pal.ts' +import { WORLD_VARIANT_SEED } from '../game/act-variants.ts' +import { ActorAnimator } from '../game/actor-animator.ts' +import { damageMonster, type MonsterPack, type SafeZone } from '../game/combat.ts' +import { cellCentre } from '../game/d2map.ts' +import { DEMO_QUESTS, DEMO_SKILLS } from '../game/demo-data.ts' +import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' +import { GameEngine, syncEngineState } from '../game/engine.ts' import { Environment } from '../game/engine/environment.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 -/** 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 } -} - -/** Maximum range in scene pixels for Telekinesis (Skill 43) warp activation. */ -export const TELEKINESIS_RANGE_PX = 320 -/** Radial proximity click tolerance in scene pixels for a SceneWarp. */ -export const WARP_CLICK_RADIUS_PX = 56 -/** Radial proximity click tolerance in scene pixels for a SceneEntrance. */ -export const ENTRANCE_CLICK_RADIUS_PX = 56 - -/** - * Convert scene pixel coordinates back to isometric sub-tile coordinates. - */ -export function sceneToSubTile( - worldX: number, - worldY: number, - grid: { readonly originX: number; readonly originY: number }, -): { x: number; y: number } { - const dx = (worldX - grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (worldY - grid.originY) / ORTHO_SUB_TILE_HEIGHT - return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } -} - -/** - * Compute the scene coordinate for a `SceneWarp` overhead destination badge. - */ -export function getWarpLabelWorldPos( - warp: SceneWarp, - grid: { readonly originX: number; readonly originY: number }, -): { x: number; y: number } { - const anchor = subTileToScene(warp.x, warp.y, grid) - if (warp.selectDX > 0 && warp.selectDY > 0) { - return { - x: anchor.x + warp.selectX + warp.selectDX * 0.5, - y: anchor.y + warp.selectY - 6, - } - } - return { - x: anchor.x, - y: anchor.y - 48, - } -} - -/** - * Compute the scene coordinate for a `SceneEntrance` overhead destination badge. - */ -export function getEntranceLabelWorldPos( - entrance: SceneEntrance, - grid: { readonly originX: number; readonly originY: number }, -): { x: number; y: number } { - const anchor = subTileToScene(entrance.x, entrance.y, grid) - return { - x: anchor.x, - y: anchor.y - 48, - } -} - -/** - * Hit-test a world-space pointer position against a `SceneWarp` (`LvlWarp.txt` - * selection bounding box when non-zero, overhead badge footprint, plus radial - * proximity to anchor, walkable arrival point, and overhead label). - */ -export function hitTestWarp( - warp: SceneWarp, - worldX: number, - worldY: number, - grid: { readonly originX: number; readonly originY: number }, -): { - hit: boolean - dist: number - anchorPos: { x: number; y: number } - arrivePos: { x: number; y: number } -} { - const anchorPos = subTileToScene(warp.x, warp.y, grid) - const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) - if (!(warp.toLevelId > 0)) { - return { - hit: false, - dist: Infinity, - anchorPos, - arrivePos, - } - } - const labelPos = getWarpLabelWorldPos(warp, grid) - - let boxDist = Infinity - let inBox = false - if (warp.selectDX > 0 && warp.selectDY > 0) { - const minX = anchorPos.x + warp.selectX - const maxX = minX + warp.selectDX - const minY = anchorPos.y + warp.selectY - const maxY = minY + warp.selectDY - if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { - inBox = true - const cx = (minX + maxX) * 0.5 - const cy = (minY + maxY) * 0.5 - boxDist = Math.hypot(worldX - cx, worldY - cy) - } - } - - const badgeText = getTblLang() === 'ENG' - ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(warp.toLevelId).nameZh}` - const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) - const inBadge = - Math.abs(worldX - labelPos.x) <= badgeHalfWidth && - worldY >= labelPos.y - 28 && - worldY <= labelPos.y + 8 - const badgeDist = inBadge - ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 - : Infinity - - const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) - const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) - const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) - const distAnchorBody = Math.hypot(worldX - anchorPos.x, worldY - (anchorPos.y - 48)) - const radialDist = Math.min(distAnchor, distArrive, distLabel, distAnchorBody) - const inRadius = radialDist <= WARP_CLICK_RADIUS_PX - - return { - hit: inBox || inBadge || inRadius, - dist: Math.min(boxDist, badgeDist, radialDist), - anchorPos, - arrivePos, - } -} - -/** - * Hit-test a world-space pointer position against a `SceneEntrance` (overhead - * badge footprint plus radial proximity to entrance anchor, walkable arrival - * point, and overhead label). - */ -export function hitTestEntrance( - entrance: SceneEntrance, - worldX: number, - worldY: number, - grid: { readonly originX: number; readonly originY: number }, -): { - hit: boolean - dist: number - anchorPos: { x: number; y: number } - arrivePos: { x: number; y: number } -} { - const anchorPos = subTileToScene(entrance.x, entrance.y, grid) - const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid) - if (!(entrance.toLevelId > 0)) { - return { - hit: false, - dist: Infinity, - anchorPos, - arrivePos, - } - } - const labelPos = getEntranceLabelWorldPos(entrance, grid) - - const badgeText = getTblLang() === 'ENG' - ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(entrance.toLevelId).nameZh}` - const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) - const inBadge = - Math.abs(worldX - labelPos.x) <= badgeHalfWidth && - worldY >= labelPos.y - 28 && - worldY <= labelPos.y + 8 - const badgeDist = inBadge - ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 - : Infinity - - const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) - const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) - const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) - const distArriveBody = Math.hypot(worldX - arrivePos.x, worldY - (arrivePos.y - 48)) - const radialDist = Math.min(distAnchor, distArrive, distLabel, distArriveBody) - - return { - hit: inBadge || radialDist <= ENTRANCE_CLICK_RADIUS_PX, - dist: Math.min(badgeDist, radialDist), - anchorPos, - arrivePos, - } -} - -/** - * Check whether the player is within trigger range (`WARP_TRIGGER_SUBTILES` or - * 80px proximity radius) of a warp or entrance's anchor or walkable arrival point. - */ -export function isPlayerWithinLinkRange( - playerX: number, - playerY: number, - link: { readonly x: number; readonly y: number; readonly arriveX: number; readonly arriveY: number }, - grid: { readonly originX: number; readonly originY: number }, - triggerSubTiles = WARP_TRIGGER_SUBTILES, -): boolean { - const sub = sceneToSubTile(playerX, playerY, grid) - const nearAnchorSub = Math.abs(sub.x - link.x) <= triggerSubTiles && Math.abs(sub.y - link.y) <= triggerSubTiles - const nearArriveSub = Math.abs(sub.x - link.arriveX) <= triggerSubTiles && Math.abs(sub.y - link.arriveY) <= triggerSubTiles - if (nearAnchorSub || nearArriveSub) return true - - const anchorPos = subTileToScene(link.x, link.y, grid) - const arrivePos = subTileToScene(link.arriveX, link.arriveY, grid) - const distAnchor = Math.hypot(playerX - anchorPos.x, playerY - anchorPos.y) - const distArrive = Math.hypot(playerX - arrivePos.x, playerY - arrivePos.y) - return Math.min(distAnchor, distArrive) <= 80 -} - -/** - * Resolve an obstacle-aware immediate steering target toward `navTarget` on `grid`. - * When the direct sub-tile line between the player and `navTarget` is unobstructed, - * returns `navTarget` directly. When blocked by dungeon walls in `grid.blocked`, - * computes an 8-directional shortest walkable sub-tile path (A*) around corners and - * returns the furthest visible waypoint along that path. - */ -export function resolveObstacleAwareSteerTarget( - playerX: number, - playerY: number, - navTarget: { readonly x: number; readonly y: number }, - grid: CollisionGrid | undefined, -): { x: number; y: number } { - if (!grid || !grid.blocked || grid.blocked.length === 0 || grid.gridWidth <= 0 || grid.cellsY <= 0) { - return navTarget - } - - const gridW = grid.gridWidth - const gridH = grid.cellsY * SUB_TILES_PER_TILE - const startSub = sceneToSubTile(playerX, playerY, grid) - const rawEndSub = sceneToSubTile(navTarget.x, navTarget.y, grid) - - if (startSub.x < 0 || startSub.y < 0 || startSub.x >= gridW || startSub.y >= gridH) { - return navTarget - } - const endSub = { - x: Math.max(0, Math.min(gridW - 1, rawEndSub.x)), - y: Math.max(0, Math.min(gridH - 1, rawEndSub.y)), - } - if (startSub.x === endSub.x && startSub.y === endSub.y) { - return navTarget - } - - const isSubTileBlocked = (sx: number, sy: number): boolean => { - if (sx < 0 || sy < 0 || sx >= gridW || sy >= gridH) return true - return grid.blocked[sy * gridW + sx] !== 0 - } - - const hasWallNeighbor = (sx: number, sy: number): boolean => - isSubTileBlocked(sx - 1, sy) || - isSubTileBlocked(sx + 1, sy) || - isSubTileBlocked(sx, sy - 1) || - isSubTileBlocked(sx, sy + 1) - - const hasClearLine = (x0: number, y0: number, x1: number, y1: number, requireClearance: boolean): boolean => { - let cx = x0 - let cy = y0 - const dx = Math.abs(x1 - x0) - const dy = Math.abs(y1 - y0) - const sx = x0 < x1 ? 1 : -1 - const sy = y0 < y1 ? 1 : -1 - let err = dx - dy - let step = 0 - - while (true) { - if (step > 0) { - if (isSubTileBlocked(cx, cy)) return false - if (requireClearance && (cx !== x1 || cy !== y1) && hasWallNeighbor(cx, cy)) return false - } - if (cx === x1 && cy === y1) break - const prevX = cx - const prevY = cy - const e2 = 2 * err - if (e2 > -dy) { - err -= dy - cx += sx - } - if (e2 < dx) { - err += dx - cy += sy - } - if (cx !== prevX && cy !== prevY) { - if (isSubTileBlocked(cx, prevY) || isSubTileBlocked(prevX, cy)) return false - } - step += 1 - } - return true - } - - if (hasClearLine(startSub.x, startSub.y, endSub.x, endSub.y, false)) { - return navTarget - } - - // Bounded 8-directional A* search on sub-tile grid - const maxExpanded = 4096 - const gScore = new Map() - const parent = new Map() - const closed = new Set() - const open: Array<{ key: number; x: number; y: number; g: number; f: number }> = [] - - const startKey = startSub.y * gridW + startSub.x - const endKey = endSub.y * gridW + endSub.x - const heuristic = (x: number, y: number): number => Math.hypot(endSub.x - x, endSub.y - y) - - gScore.set(startKey, 0) - open.push({ key: startKey, x: startSub.x, y: startSub.y, g: 0, f: heuristic(startSub.x, startSub.y) }) - - const dirs: ReadonlyArray = [ - [1, 0, 1], - [-1, 0, 1], - [0, 1, 1], - [0, -1, 1], - [1, 1, Math.SQRT2], - [1, -1, Math.SQRT2], - [-1, 1, Math.SQRT2], - [-1, -1, Math.SQRT2], - ] - - let reachedKey: number | null = null - let bestFallbackKey: number | null = null - let bestFallbackDist = heuristic(startSub.x, startSub.y) - let bestFallbackG = Infinity - let expanded = 0 - - while (open.length > 0 && expanded < maxExpanded) { - let bestIdx = 0 - for (let i = 1; i < open.length; i += 1) { - if (open[i]!.f < open[bestIdx]!.f) bestIdx = i - } - const cur = open[bestIdx]! - open[bestIdx] = open[open.length - 1]! - open.pop() - - if (closed.has(cur.key)) continue - closed.add(cur.key) - expanded += 1 - - if (cur.key === endKey) { - reachedKey = cur.key - break - } - - if (cur.key !== startKey) { - const hCur = heuristic(cur.x, cur.y) - if ( - hCur < bestFallbackDist - 1e-6 || - (Math.abs(hCur - bestFallbackDist) <= 1e-6 && cur.g < bestFallbackG) - ) { - bestFallbackDist = hCur - bestFallbackG = cur.g - bestFallbackKey = cur.key - } - } - - for (const [dx, dy, baseCost] of dirs) { - const nx = cur.x + dx - const ny = cur.y + dy - if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue - const nKey = ny * gridW + nx - if (closed.has(nKey)) continue - if (nKey !== endKey && isSubTileBlocked(nx, ny)) continue - if (dx !== 0 && dy !== 0) { - if (isSubTileBlocked(cur.x + dx, cur.y) || isSubTileBlocked(cur.x, cur.y + dy)) continue - } - - const wallPenalty = (nKey !== endKey && hasWallNeighbor(nx, ny)) ? 0.6 : 0 - const tentativeG = cur.g + baseCost + wallPenalty - const prevG = gScore.get(nKey) - if (prevG === undefined || tentativeG < prevG) { - gScore.set(nKey, tentativeG) - parent.set(nKey, cur.key) - open.push({ key: nKey, x: nx, y: ny, g: tentativeG, f: tentativeG + heuristic(nx, ny) }) - } - } - } - - const finalKey = reachedKey ?? bestFallbackKey - if (finalKey === null) { - return navTarget - } - - const path: Array<{ x: number; y: number }> = [] - let curr: number | undefined = finalKey - while (curr !== undefined) { - const py = Math.floor(curr / gridW) - const px = curr - py * gridW - path.push({ x: px, y: py }) - if (curr === startKey) break - curr = parent.get(curr) - } - path.reverse() - - if (path.length <= 1) { - return navTarget - } - - let targetIdx = 1 - for (let i = path.length - 1; i >= 2; i -= 1) { - const pt = path[i]! - if (hasClearLine(startSub.x, startSub.y, pt.x, pt.y, true)) { - targetIdx = i - break - } - } - - if (targetIdx === path.length - 1 && reachedKey !== null) { - return navTarget - } - - const wp = path[targetIdx]! - const wpScene = subTileToScene(wp.x, wp.y, grid) - if (Math.hypot(wpScene.x - playerX, wpScene.y - playerY) <= 10 && targetIdx + 1 < path.length) { - const nextWp = path[targetIdx + 1]! - return subTileToScene(nextWp.x, nextWp.y, grid) - } - return wpScene -} - - -/** - * 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', 'stf', 'orb', 'bow', 'xbw'). */ - weaponClass?: string | null | undefined - /** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */ - playerClip?: string | null | undefined - /** Canonical appearance cache key for dynamic modular paperdoll compositing. */ - characterAppearanceKey?: string | undefined - /** Active Sorceress 16-component appearance and palette shift spec. */ - characterAppearance?: SorceressAppearanceSpec | 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 - /** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */ - lightingPreset?: LightingPreset | undefined - /** Active lighting mode ('Glide 1.13c' or undefined). */ - lightingMode?: string | undefined - /** Ambient light intensity [0..255]. */ - ambientIntensity?: number | undefined - /** Ambient color [R, G, B] in [0..255]. */ - ambientColor?: readonly [number, number, number] | undefined - /** 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 - /** Active monster density multiplier (0.5, 1, 2, 4, 8). */ - densityMultiplier?: DensityMultiplier | undefined - /** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */ - eliteMultiplier?: EliteMultiplier | undefined - /** Dynamic monster re-budget and re-stream method without page reload. */ - reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void - /** Active player Town Portal (if open). */ - portal?: OpenPortal | null | undefined - /** Whether the automap/minimap is visible. */ - minimapVisible?: boolean | undefined - /** Active automap mode ('minimap' | 'full'). */ - automapMode?: 'minimap' | 'full' | undefined - /** Active automap projection divisor (20 for minimap, 10 for full). */ - automapDivisor?: 10 | 20 | undefined - /** Number ofloaded automap sprite atlases (0..8). */ - automapAtlasesLoaded?: number | undefined - /** Pre-baked/active automap cell counts per layer. */ - automapCellCounts?: { - floors: number - walls: number - objects: number - extras: number - total: number - } | undefined - /** Revealed DRLG room count for the active level. */ - automapRevealedRooms?: number | undefined - /** Automap sprites drawn last frame per layer. */ - automapSpritesDrawn?: { - floors: number - walls: number - objects: number - extras: number - portals: number - total: number - } | undefined - /** Automap 12-segment crosshairs drawn last frame. */ - automapCrosshairsDrawn?: { - player: number - party: number - npcs: number - mercenaries: number - bosses: number - total: number - } | undefined - /** Revealed warp/stash Font6 labels on the automap. */ - automapWarpLabels?: AutomapOverlayLabel[] | undefined - /** Top-right gold Font16 Level Name + Difficulty HUD readout. */ - automapHudText?: { - levelName: string - difficulty: string - x: number - y1: number - y2: number - } | null | undefined - /** Active automap viewport metrics. */ - automapViewport?: AutomapViewportMetrics | undefined -} - -declare global { - interface Window { - /** Act scene state for automation and debugging. */ - __d2webAct?: ActSceneState - __d2webActs?: ActSceneState - __d2webMinimap?: Minimap - __d2webActScene?: { - switchHero: (token: string) => Promise - getHero: () => string - switchLighting?: (preset: string) => LightingPreset - getLighting?: () => LightingPreset - reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void - setPlayers?: (count: number, partyCount?: number) => void - executeCommand?: (cmd: string) => boolean - castTownPortal?: () => boolean - enterTownPortal?: () => void - getPortal?: () => OpenPortal | null - getMinimap?: () => Minimap - syncAppearance?: () => Promise - } - } -} - -/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */ -export const TOWN_LEVEL_IDS = new Set([1, 40, 75, 103, 109]) - -export function isTownLevel(levelId: number | undefined | null): boolean { - if (levelId === undefined || levelId === null || levelId <= 0) return false - return TOWN_LEVEL_IDS.has(levelId) -} - -export type DensityMultiplier = 0.5 | 1 | 2 | 4 | 8 -export type EliteMultiplier = 0.5 | 1 | 2 | 4 | 'all' - -export function parseDensityMultiplier(raw: string | number | null | undefined): DensityMultiplier { - if (raw === null || raw === undefined) return 1 - const num = typeof raw === 'number' ? raw : parseFloat(String(raw).trim()) - if (num === 0.5 || num === 1 || num === 2 || num === 4 || num === 8) { - return num as DensityMultiplier - } - return 1 -} - -export function parseEliteMultiplier(raw: string | number | null | undefined): EliteMultiplier { - if (raw === null || raw === undefined) return 1 - const str = String(raw).trim().toLowerCase() - if (str === 'all') return 'all' - const num = parseFloat(str) - if (num === 0.5 || num === 1 || num === 2 || num === 4) { - return num as EliteMultiplier - } - return 1 -} - -export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void { - if (!select) return - const isNonDefault = select.value !== defaultValue - select.classList.toggle('non-default', isNonDefault) - if (isNonDefault) { - select.style.border = '1px solid #e74c3c' - } else { - select.style.border = '' - } -} - -export function updateToolbarUrl( - currentUrlOrSearch: string | URL, - options: { density?: number | string | undefined; elite?: number | string | undefined }, -): string { - const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://') - const url = isUrl - ? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href) - : new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`) - - if (options.density !== undefined) { - const density = parseDensityMultiplier(options.density) - if (density === 1) { - url.searchParams.delete('density') - } else { - url.searchParams.set('density', String(density)) - } - } - - if (options.elite !== undefined) { - const elite = parseEliteMultiplier(options.elite) - if (elite === 1) { - url.searchParams.delete('elite') - } else { - url.searchParams.set('elite', String(elite)) - } - } - - if (isUrl) { - return url.toString() - } - return url.search -} - -/** - * Processes chat and console commands for ActScene (e.g. "/players <1-8>"). - * - * Ground truth rules (1.13c parity): - * - Accepts "/players N" or "players N" where N is an integer between 1 and 8. - * - Calling without a number queries the current setting. - * - Out-of-range numbers display an error notification. - * - * @param rawCommand - The input command string. - * @param engine - GameEngine instance. - * @param statusEl - Optional status notification element. - * @returns True if command was recognized and handled; false otherwise. - */ -export function handleActSceneChatCommand( - rawCommand: string, - engine: { setPlayers: (players: number, partyPlayers?: number) => void; gamePlayers: number }, - statusEl?: { textContent: string | null } | null, -): boolean { - const trimmed = rawCommand.trim() - const match = trimmed.match(/^\/?players(?:\s+(\d+))?$/i) - if (match) { - if (match[1] !== undefined) { - const n = parseInt(match[1], 10) - if (n >= 1 && n <= 8) { - engine.setPlayers(n) - const msg = `Players set to ${engine.gamePlayers}` - if (statusEl) statusEl.textContent = msg - return true - } else { - const msg = `Invalid players count: ${n}. Range is 1-8.` - if (statusEl) statusEl.textContent = msg - return false - } - } else { - const msg = `Players currently set to ${engine.gamePlayers}` - if (statusEl) statusEl.textContent = msg - return true - } - } - return false -} - -function promoteToChampion(pack: MonsterPack, rng: Rng): MonsterPack { - const leaderBase = pack.members[0] - if (!leaderBase) return pack - const count = rng.int(2, 4) - const mods = rollEliteModifiers('champion', rng) - const members: MonsterStats[] = [] - for (let i = 0; i < count; i += 1) { - const base = pack.members[i % pack.members.length] ?? leaderBase - const scaledBase: MonsterStats = { - ...base, - hp: Math.max(1, Math.round(base.hp * ELITE_HEALTH_MULTIPLIER.champion)), - } - members.push(applyEliteModifiers(scaledBase, 'champion', mods)) - } - return { - ...pack, - members, - } -} - -function promoteToUnique(pack: MonsterPack, rng: Rng): MonsterPack { - const leaderBase = pack.members[0] - if (!leaderBase) return pack - const minionCount = rng.int(3, 6) - const mods = rollEliteModifiers('unique', rng) - const scaledLeader: MonsterStats = { - ...leaderBase, - hp: Math.max(1, Math.round(leaderBase.hp * ELITE_HEALTH_MULTIPLIER.unique)), - } - const leader = applyEliteModifiers(scaledLeader, 'unique', mods) - const members: MonsterStats[] = [leader] - for (let i = 0; i < minionCount; i += 1) { - const minionBase = pack.members[(i + 1) % pack.members.length] ?? leaderBase - const scaledMinion: MonsterStats = { - ...minionBase, - hp: Math.max(1, Math.round(minionBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))), - } - members.push(applyEliteModifiers(scaledMinion, 'minion', [])) - } - return { - ...pack, - members, - } -} - -function demoteToNormal(pack: MonsterPack): MonsterPack { - const members: MonsterStats[] = pack.members.map(m => { - let hp = m.hp - let level = m.level - if (m.rank === 'champion') { - hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion)) - if (level !== undefined) { - level = Math.max(1, level - 2) - } - } else if (m.rank === 'unique') { - hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique)) - if (level !== undefined) { - level = Math.max(1, level - 3) - } - } else if (m.rank === 'minion') { - hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100))) - if (level !== undefined) { - level = Math.max(1, level - 3) - } - } - return { - ...m, - hp, - ...(level !== undefined ? { level } : {}), - rank: 'normal', - modifiers: [], - } - }) - return { - ...pack, - members, - } -} - -export interface RebudgetOptions { - readonly densityMultiplier?: number | string | undefined - readonly eliteMultiplier?: number | string | undefined - readonly levelId?: number | undefined - readonly seed?: number | undefined -} - -export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo { - const dropTables = getEmbeddedDropTables() - const stats = - monster.stats.isDemon === undefined && - monster.stats.isUndead === undefined && - monster.stats.nameKey === undefined - ? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques) - : monster.stats - const r = stats.rank - const statsAny = stats as MonsterStats & { - readonly descStr?: string - readonly subType?: string - readonly affixes?: readonly string[] - } - return { - name: stats.name || stats.id, - ...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}), - hp: monster.hp, - maxHp: stats.hp, - rank: - r === 'champion' - ? 'champion' - : r === 'unique' - ? 'unique' - : (r as string) === 'superunique' - ? 'superunique' - : r === 'minion' - ? 'minion' - : r === 'boss' || stats.isBoss === true - ? 'boss' - : 'normal', - ...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}), - ...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}), - ...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}), - ...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}), - ...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}), - ...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}), - ...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}), - ...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}), - ...(stats.resists !== undefined ? { resists: stats.resists } : {}), - ...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}), - } -} - -export function rebudgetMonsterPacks( - basePacks: readonly MonsterPack[], - options?: RebudgetOptions, -): MonsterPack[] { - const levelId = options?.levelId - if (levelId !== undefined && isTownLevel(levelId)) { - return [] - } - if (!basePacks || basePacks.length === 0) { - return [] - } - - const dropTables = getEmbeddedDropTables() - const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques) - - const density = parseDensityMultiplier(options?.densityMultiplier) - const elite = parseEliteMultiplier(options?.eliteMultiplier) - - if (density === 1 && elite === 1) { - return enrichedBasePacks.map(pack => ({ - ...pack, - members: pack.members.map(m => ({ ...m })), - })) - } - - const rng = new Rng(options?.seed ?? 0x9e37) - - // Separate fixed / landmark packs from regular wild packs - const fixedPacks = enrichedBasePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined) - const regularPacks = enrichedBasePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined) - - // 1. Density scaling on regular packs - let scaledRegularPacks: MonsterPack[] = [] - if (regularPacks.length > 0) { - const targetCount = Math.max(1, Math.round(regularPacks.length * density)) - if (targetCount <= regularPacks.length) { - for (let i = 0; i < targetCount; i += 1) { - const idx = Math.floor((i * regularPacks.length) / targetCount) - const src = regularPacks[idx]! - scaledRegularPacks.push({ - ...src, - members: src.members.map(m => ({ ...m })), - }) - } - } else { - for (let i = 0; i < targetCount; i += 1) { - const src = regularPacks[i % regularPacks.length]! - scaledRegularPacks.push({ - ...src, - members: src.members.map(m => ({ ...m })), - }) - } - } - } - - // 2. Elite scaling on regular packs - if (elite === 'all') { - scaledRegularPacks = scaledRegularPacks.map((pack, idx) => { - const normalPack = demoteToNormal(pack) - const packRng = rng.fork(`all-elite-${idx}`) - const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) - return isChampion ? promoteToChampion(normalPack, packRng) : promoteToUnique(normalPack, packRng) - }) - } else if (elite !== 1) { - const currentEliteIndices: number[] = [] - const currentNormalIndices: number[] = [] - for (let i = 0; i < scaledRegularPacks.length; i += 1) { - const leaderRank = scaledRegularPacks[i]?.members[0]?.rank - if (leaderRank === 'champion' || leaderRank === 'unique') { - currentEliteIndices.push(i) - } else { - currentNormalIndices.push(i) - } - } - - if (currentEliteIndices.length > 0) { - const targetEliteCount = Math.min(scaledRegularPacks.length, Math.max(0, Math.round(currentEliteIndices.length * elite))) - if (targetEliteCount < currentEliteIndices.length) { - const toDemoteCount = currentEliteIndices.length - targetEliteCount - for (let i = 0; i < toDemoteCount; i += 1) { - const idx = currentEliteIndices[currentEliteIndices.length - 1 - i]! - scaledRegularPacks[idx] = demoteToNormal(scaledRegularPacks[idx]!) - } - } else if (targetEliteCount > currentEliteIndices.length) { - const toPromoteCount = Math.min(currentNormalIndices.length, targetEliteCount - currentEliteIndices.length) - for (let i = 0; i < toPromoteCount; i += 1) { - const idx = currentNormalIndices[i]! - const packRng = rng.fork(`promote-${idx}`) - const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) - scaledRegularPacks[idx] = isChampion - ? promoteToChampion(scaledRegularPacks[idx]!, packRng) - : promoteToUnique(scaledRegularPacks[idx]!, packRng) - } - } - } else if (elite > 1 && currentNormalIndices.length > 0) { - const targetEliteCount = Math.min( - scaledRegularPacks.length, - Math.max(1, Math.round(scaledRegularPacks.length * 0.05 * elite)), - ) - const toPromote = Math.min(currentNormalIndices.length, targetEliteCount) - for (let i = 0; i < toPromote; i += 1) { - const idx = currentNormalIndices[i]! - const packRng = rng.fork(`promote-zero-${idx}`) - const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) - scaledRegularPacks[idx] = isChampion - ? promoteToChampion(scaledRegularPacks[idx]!, packRng) - : promoteToUnique(scaledRegularPacks[idx]!, packRng) - } - } - } - - return [ - ...fixedPacks.map(p => ({ ...p, members: p.members.map(m => ({ ...m })) })), - ...scaledRegularPacks, - ] -} - -export type LightingPreset = - | 'auto' - | 'noon' - | 'morning' - | 'sunset' - | 'night' - | 'cave' - | 'hell' - | 'eclipse' - | 'off' - -export interface LightingPresetConfig { - readonly id: LightingPreset - readonly labelZh: string - readonly labelEn: string - readonly enabled: boolean - readonly overrideAmbient?: { - readonly intensity: number - readonly red: number - readonly green: number - readonly blue: number - } -} - -export const LIGHTING_PRESETS: Readonly> = { - auto: { - id: 'auto', - labelZh: '自动 (原版 1.13c)', - labelEn: 'Auto (Original 1.13c)', - enabled: true, - }, - noon: { - id: 'noon', - labelZh: '正午 (明亮日光)', - labelEn: 'Noon (Bright Daylight)', - enabled: true, - overrideAmbient: { intensity: 255, red: 255, green: 255, blue: 255 }, - }, - morning: { - id: 'morning', - labelZh: '清晨 (晨曦暖金)', - labelEn: 'Morning (Warm Dawn)', - enabled: true, - overrideAmbient: { intensity: 190, red: 225, green: 198, blue: 145 }, - }, - sunset: { - id: 'sunset', - labelZh: '黄昏 (暮色紫金)', - labelEn: 'Sunset (Dusk Purple-Gold)', - enabled: true, - overrideAmbient: { intensity: 145, red: 210, green: 155, blue: 198 }, - }, - night: { - id: 'night', - labelZh: '午夜 (幽暗蓝月)', - labelEn: 'Midnight (Moonlit Blue)', - enabled: true, - overrideAmbient: { intensity: 38, red: 115, green: 140, blue: 240 }, - }, - cave: { - id: 'cave', - labelZh: '深渊地牢 (火炬暗黑)', - labelEn: 'Deep Dungeon (Torchlight)', - enabled: true, - overrideAmbient: { intensity: 5, red: 185, green: 175, blue: 165 }, - }, - hell: { - id: 'hell', - labelZh: '地狱血月 (第四幕猩红)', - labelEn: 'Hell Blood Moon (Act IV Crimson)', - enabled: true, - overrideAmbient: { intensity: 72, red: 255, green: 62, blue: 52 }, - }, - eclipse: { - id: 'eclipse', - labelZh: '蚀日之咒 (幽蓝暗蚀)', - labelEn: 'Solar Eclipse (Dark Blue)', - enabled: true, - overrideAmbient: { intensity: 28, red: 45, green: 85, blue: 245 }, - }, - off: { - id: 'off', - labelZh: '关闭光照 (全亮无光影)', - labelEn: 'Lighting Off (Full Bright)', - enabled: false, - }, -} - -export function lightingLabel( - cfg: LightingPresetConfig, - lang: TblLang = getTblLang(), -): string { - return lang === 'ENG' ? cfg.labelEn : cfg.labelZh -} - -export function normalizeLightingPreset(raw: string | null | undefined): LightingPreset { - const clean = (raw ?? '').trim().toLowerCase() - if (clean === '0' || clean === 'false' || clean === 'off' || clean === 'none') return 'off' - if (clean === '1' || clean === 'true' || clean === 'auto' || clean === '') return 'auto' - if (clean in LIGHTING_PRESETS) return clean as LightingPreset - return 'auto' -} - -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: 235, 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, - densityMultiplier: 1, eliteMultiplier: 1, -} -if (typeof window !== 'undefined') { - window.__d2webAct = state - window.__d2webActs = 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 - /** Pre-baked 1.13c Automap layers (floors, walls, objects, extras). */ - readonly automap?: AutomapLayerData | 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. - */ - monsterPacks: readonly MonsterPack[] - baseMonsterPacks?: readonly MonsterPack[] | undefined - /** - * 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', -]) - -const DAGGER_CODES = new Set([ - 'dgr', 'dir', 'kri', 'bld', - '9dg', '9di', '9kr', '9bl', - '7dg', '7di', '7kr', '7bl', -]) - -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 === 'knif' || - type === 'tkni' || - DAGGER_CODES.has(code) || - ((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor') - ) { - subtype = 'dagger' - reach = 48 - } else 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 === '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 57: return 340 - case 58: return 0 - case 59: return 450 - case 62: return 450 - case 63: return 0 - case 64: return 480 - case 219: - case 220: return 0 - default: return 450 - } -} - -export function getSkillMana(skillId: number, hudManager?: HudManager | null): number { - if (skillId === 0) return 0 - if (hudManager !== null && hudManager !== undefined) { - const entry = hudManager.hotkeys?.availableSkills?.find(s => s.skillId === skillId) - if (entry !== undefined) return entry.manaCost - } - if (isBatch1Skill(skillId)) { - return getSkillManaCost(skillId, 1) - } - switch (skillId) { - case 41: return 6 - case 45: return 6 - case 46: return 11 - case 51: return 22 - case 55: return 10 - case 73: return 3 - case 78: return 17 - case 83: return 8 - case 84: return 7 - case 88: return 27 - case 93: return 12 - case 92: return 20 - case 12: return 4 - case 16: return 5 - case 20: return 8 - case 21: return 4 - case 40: return 7 - case 42: return 9 - case 59: return 23 - case 60: return 12 - case 62: return 20 - case 64: return 30 - case 56: return 17 - case 49: return 11 - case 38: return 3 - case 43: return 7 - case 57: return 19 - case 58: return 5 - case 63: return 0 - case 219: - case 220: return 0 - default: return 10 - } -} - -let hydraGroupSeqCounter = 0 - -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 - portalSlot?: TownPortalSlot | undefined - onEnterPortal?: (() => void) | undefined - onCastTownPortal?: (() => boolean) | undefined - onEnterWarp?: ((warp: SceneWarp) => void) | undefined - onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined - }, -): boolean { - const { engine, runtime, hudManager, status, playerAnimator, character } = context - const player = engine.world.player - const isEn = (hudManager?.lang ?? getTblLang()) === 'ENG' - - // 1. Cooldown check - if (player.cooldown > 0) { - return false - } - - // 1b. Shield requirement check for Smite (97) and Holy Shield (117) - if (skillId === 97 || skillId === 117) { - const shield = - (context as any).equippedShield ?? - hudManager?.inventory?.equipped?.weapon2 ?? - (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null) - if (!shield) { - status.textContent = isEn - ? (skillId === 97 ? 'Smite requires a shield!' : 'Holy Shield requires a shield!') - : (skillId === 97 ? '重击需要装备盾牌!' : '圣盾需要装备盾牌!') - return false - } - } - - // 2. Mana check & deduction - let isFirstInfernoCast = false - if (skillId === 41) { - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1) - const currentMana = hudManager !== null ? hudManager.mana : player.mana - - if (!player.infernoChanneling) { - // Starting cast cost: 6 mana (1.13c Skills.txt) - const startCost = 6 - if (currentMana < startCost) { - status.textContent = isEn ? 'Not enough mana!' : '法力不足!' - engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) - return false - } - player.mana = Math.max(0, player.mana - startCost) - if (hudManager !== null) { - hudManager.mana = player.mana - hudManager.syncPublishedState() - } - player.infernoChanneling = true - player.infernoManaRemainder256 = 0 - isFirstInfernoCast = true - } else { - // Continuous channeling frame: drain authentically via calculateInfernoContinuousMana256 (256ths) - if (currentMana <= 0) { - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - status.textContent = isEn ? 'Not enough mana!' : '法力不足!' - engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) - return false - } - const drain256 = calculateInfernoContinuousMana256(slvl) - const total256 = (player.infernoManaRemainder256 ?? 0) + drain256 - const wholeMana = Math.floor(total256 / 256) - player.infernoManaRemainder256 = total256 % 256 - if (wholeMana > 0) { - player.mana = Math.max(0, player.mana - wholeMana) - if (hudManager !== null) { - hudManager.mana = player.mana - hudManager.syncPublishedState() - } - } - if (player.mana <= 0) { - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - } - } - } else { - // Reset inferno channeling state when casting another skill - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - - const manaCost = getSkillMana(skillId, hudManager) - const currentMana = hudManager !== null ? hudManager.mana : player.mana - if (currentMana < manaCost) { - status.textContent = isEn ? 'Not enough mana!' : '法力不足!' - engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) - return false - } - - // 3. Deduct mana - if (manaCost > 0) { - player.mana = Math.max(0, player.mana - manaCost) - if (hudManager !== null) { - hudManager.mana = Math.max(0, hudManager.mana - manaCost) - hudManager.syncPublishedState?.() - } - } - } - - // 4. Face cursor direction - player.facing = facingOf(targetX - player.x, targetY - player.y) - - // 4b. Trigger character animation - if (playerAnimator !== undefined && character?.clips !== undefined) { - if (skillId === 0) { - const clip = character.clips['a1'] ?? character.clips['a2'] - if (clip !== undefined) playerAnimator.play('a1', clip, { restart: true }) - } else { - const clip = character.clips['sc'] ?? character.clips['a1'] - if (clip !== undefined) playerAnimator.play('sc', clip, { restart: true }) - } - } - - // 5. Spawn cast overlay if defined for this skill - if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) { - const castOverlayName = getSkillCastOverlay(skillId) - if (castOverlayName) { - engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player', { isCastOverlay: true }) - } - } - - // 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 = isEn - ? (weaponInfo.ammoType === 'arrow' ? 'Out of arrows!' : 'Out of bolts!') - : (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 = isEn - ? (weaponInfo.subtype === 'bow' ? 'Attack: Shoot Arrow' : 'Attack: Shoot Bolt') - : (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 - if (isEn) { - const attackLabel = weaponInfo.kind === 'unarmed' ? 'Unarmed Attack' : 'Attack' - status.textContent = `${attackLabel} hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}` - } else { - 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 = isEn ? 'Cast Teleport' : '已施展传送' - } else { - status.textContent = isEn ? 'Cannot teleport there' : '无法传送到该位置' - } - 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 = isEn - ? `Cast Static Field (${hitCount} targets)` - : `施展静态力场,削弱 ${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, warps - const slvl = (hudManager as any)?.getSkillLevel?.(43) ?? 1 - const dist = Math.hypot(targetX - player.x, targetY - player.y) - const maxRange = 600 - const refundTelekinesisMana = (): void => { - const manaCost = getSkillMana(43, hudManager) - if (manaCost > 0) { - player.mana = Math.min(player.maxMana ?? player.mana + manaCost, player.mana + manaCost) - if (hudManager !== null) { - hudManager.mana = Math.min(hudManager.maxMana ?? hudManager.mana + manaCost, hudManager.mana + manaCost) - hudManager.syncPublishedState?.() - } - } - } - - if (dist > maxRange) { - refundTelekinesisMana() - status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!' - return false - } - - // 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) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - 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 = isEn - ? `Telekinesis hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}` - : `心灵传动命中:${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) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - if (isTelekinesisItemAllowed(nearbyGroundItem)) { - if (nearbyGroundItem.isGold) { - const res = engine.pickupGold(nearbyGroundItem.id) - if (res.success) { - if (hudManager) { - const playerLevel = engine.world.player?.level ?? 1 - const goldCap = getInventoryGoldLimit(playerLevel) - hudManager.inventory.playerLevel = playerLevel - hudManager.inventory.gold = Math.min(goldCap, hudManager.inventory.gold + res.amount) - hudManager.syncPublishedState() - } - if (res.remaining && res.remaining > 0) { - status.textContent = isEn - ? `Telekinesis picked up gold: ${res.amount} (${res.remaining} remaining)` - : `心灵传动拾取金币:${res.amount}(地面剩余:${res.remaining})` - } else { - status.textContent = isEn - ? `Telekinesis picked up gold: ${res.amount}` - : `心灵传动拾取金币:${res.amount}` - } - } else { - engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now()) - playInventoryFullFeedback() - status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。' - showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。') - } - } else { - if (hudManager) { - const autoBelted = Boolean(hudManager.belt?.autoPlacePotion?.(nearbyGroundItem.item)) - if (!autoBelted) { - const uiItem = itemToUiInventoryItem(nearbyGroundItem.item, engine.dropTables) - const placed = hudManager.inventory.autoPlaceInGrid(uiItem) - if (!placed) { - engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now()) - status.textContent = isEn ? 'I am overburdened.' : '包裹已满。' - 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 = isEn - ? `Telekinesis picked up: ${nearbyGroundItem.name}` - : `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}` - } else { - engine.pickupItem(nearbyGroundItem.id) - status.textContent = isEn - ? `Telekinesis picked up: ${nearbyGroundItem.name}` - : `心灵传动拾取物品:${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 = isEn ? 'Cannot pick up equipment with Telekinesis!' : '心灵传动无法拾取装备!' - } - player.cooldown = 0 - engine.metrics.casts += 1 - return true - } - - // 3. Interactive objects check (waypoints, stash, chests, portals, warps) - if (runtime.waypoints) { - const clickedWp = runtime.waypoints.find(wp => Math.hypot(wp.arriveX - targetX, wp.arriveY - targetY) <= 80) - if (clickedWp) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - if (context.waypointNetwork?.activate(clickedWp.waypointId)) { - status.textContent = isEn ? `Waypoint activated: ${runtime.level}` : `传送点已激活:${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) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - if (context.openStash) { - context.openStash(clickedStash) - } else if (hudManager && hudManager.leftPanel !== 'stash') { - hudManager.toggleLeftPanel('stash') - } - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Telekinesis opened Stash' : '心灵传动打开储物箱' - return true - } - } - - if (context.portalSlot && context.onEnterPortal && runtime.grid) { - const mouth = context.portalSlot.mouthInLevel(runtime.levelId) - if (mouth) { - const portalPos = subTileToScene(mouth.x, mouth.y, runtime.grid) - const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, targetY)) - if (Math.hypot(portalPos.x - targetX, clampedPortalY - targetY) <= 64) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Telekinesis entered Town Portal' : '心灵传动进入传送门' - context.onEnterPortal() - return true - } - } - } - - if (runtime.warps && runtime.grid) { - let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null - for (const warp of runtime.warps) { - const res = hitTestWarp(warp, targetX, targetY, runtime.grid) - if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { - bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } - } - } - if (bestWarp !== null) { - const warpPlayerDist = Math.min( - Math.hypot(bestWarp.anchorPos.x - player.x, bestWarp.anchorPos.y - player.y), - Math.hypot(bestWarp.arrivePos.x - player.x, bestWarp.arrivePos.y - player.y), - ) - if (warpPlayerDist <= TELEKINESIS_RANGE_PX) { - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - const destName = getLevelNames(bestWarp.warp.toLevelId).nameZh - status.textContent = isEn - ? `Telekinesis triggered warp: To ${levelDisplayName(bestWarp.warp.toLevelId, 'ENG')}` - : `心灵传动触发入口:通往 ${destName}` - context.onEnterWarp?.(bestWarp.warp) - return true - } - refundTelekinesisMana() - status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!' - return false - } - } - - // 4. Generic ground cast / feedback - engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Cast Telekinesis' : '施展心灵传动' - return true - } - - if (skillId === 219 || skillId === 220) { - // Scroll of Town Portal (219) / Book of Town Portal (220) - if (context.onCastTownPortal) { - const ok = context.onCastTownPortal() - if (!ok) return false - player.cooldown = 10 - engine.metrics.casts += 1 - return true - } - return false - } - - 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 = isEn ? 'Cast Blizzard' : '施展暴风雪' - 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 = isEn ? 'Cast Meteor' : '施展陨石' - return true - } - - if (skillId === 51) { - // Fire Wall (Skill 51): 15-patch perpendicular line emitter at target location - // 1.13c Skills.txt: delay = 35 (1.4s cooldown), Missiles.txt: firewall (ID 83, Range = 90) - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(51) ?? 1) - const dmgRange = calculateSkillDamage(51, slvl) - const fireWallDamage = Math.round((dmgRange.min + dmgRange.max) / 2) - const patches = calculateFireWallPatches(player.x, player.y, targetX, targetY, { - count: 15, - spacing: 24, - isBlocked: (x, y) => { - if (runtime?.grid) { - return isMissileBlockedAt(runtime.grid, x, y) - } - if ((runtime as any)?.walkable) { - return !(runtime as any).walkable.isWalkable(x, y) - } - if ((runtime as any)?.collision) { - return !(runtime as any).collision.isWalkable(x, y) - } - return false - }, - }) - - for (const patch of patches) { - engine.projectiles.push({ - skillId: '51', - x: patch.x, - y: patch.y, - vx: 0, - vy: 0, - damage: fireWallDamage, - ttl: 90, // 3.60s per Missiles.txt Range = 90 - fireDuration: 90, - fromPlayer: true, - missileType: 'firewall', - pierce: true, - aoeRadius: 24, - }) - } - - player.cooldown = 35 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Cast Fire Wall' : '施展火墙' - return true - } - - if (skillId === 46) { - // Blaze (Skill 46): activate blaze buff on player, +30% FRW, footstep patches - // 1.13c Skills.txt: delay = 0, dm12 buff duration, footstep fire duration (90 + slvl * 25) - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(46) ?? 1) - const duration = calculateBlazeDuration(slvl) - const patchDuration = calculateBlazePatchDuration(slvl) - const dmgRange = calculateSkillDamage(46, slvl) - const blazeDmg = Math.round((dmgRange.min + dmgRange.max) / 2) - - player.blazeTicks = duration - player.blazeSlvl = slvl - player.blazeDamage = blazeDmg - player.blazePatchDuration = patchDuration - player.lastBlazeX = undefined - player.lastBlazeY = undefined - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Cast Blaze' : '施展烈火之径' - return true - } - - if (skillId === 41) { - // Inferno (Skill 41): continuous cone flame stream - // 1.13c Skills.txt: starting mana = 6, Missiles.txt: infernoflame1/2 (Range = 16, Vel = 12) - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1) - const dmgRange = calculateSkillDamage(41, slvl) - const infernoDmg = Math.round((dmgRange.min + dmgRange.max) / 2) - const rangePx = calculateInfernoRangePx(slvl) - const dx = targetX - player.x - const dy = targetY - player.y - const len = Math.hypot(dx, dy) - const speed = 12 // 300 px/s / 25 fps - const vx = len > 0 ? (dx / len) * speed : speed - const vy = len > 0 ? (dy / len) * speed : 0 - const ttl = Math.max(8, Math.min(60, Math.round(rangePx / speed))) - - const flameVariant = (engine.metrics.casts % 2 === 0) ? 'infernoflame1' : 'infernoflame2' - engine.projectiles.push({ - skillId: '41', - x: player.x, - y: player.y, - vx, - vy, - damage: infernoDmg, - ttl, - fromPlayer: true, - missileType: flameVariant, - pierce: true, - aoeRadius: 20, - }) - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Cast Inferno' : '施展地狱火' - return true - } - - if (skillId === 40 || skillId === 50 || skillId === 60) { - for (const ov of engine.overlays) { - if ( - (ov.overlayName === 'frozenarmor' || - ov.overlayName === 'shiverarmor' || - ov.overlayName === 'chillingarmor') && - ov.casterId === 'player' - ) { - ov.expired = true - } - } - } - - if (skillId === 40) { - // Frozen Armor (Skill 40) - const slvl = 1 - const duration = calculateFrozenArmorDuration(slvl) - player.coldArmorType = 'frozenarmor' - player.coldArmorTicks = duration - player.coldArmorSlvl = slvl - engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) - player.cooldown = 0 - status.textContent = isEn ? 'Frozen Armor active' : '冰封装甲已激活' - engine.metrics.casts += 1 - return true - } - - if (skillId === 50) { - // Shiver Armor (Skill 50) - const slvl = 1 - const duration = calculateShiverArmorDuration(slvl) - player.coldArmorType = 'shiverarmor' - player.coldArmorTicks = duration - player.coldArmorSlvl = slvl - engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) - player.cooldown = 0 - status.textContent = isEn ? 'Shiver Armor active' : '碎冰装甲已激活' - engine.metrics.casts += 1 - return true - } - - if (skillId === 60) { - // Chilling Armor (Skill 60) - const slvl = 1 - const duration = calculateChillingArmorDuration(slvl) - player.coldArmorType = 'chillingarmor' - player.coldArmorTicks = duration - player.coldArmorSlvl = slvl - engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) - player.cooldown = 0 - status.textContent = isEn ? 'Chilling Armor active' : '寒冰装甲已激活' - engine.metrics.casts += 1 - return true - } - - if (skillId === 57) { - // Thunder Storm (Skill 57): activate thunderstorm buff on player - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(57) ?? 1) - const lmSlvl = (hudManager as any)?.getSkillLevel?.(63) ?? 0 - const duration = calculateThunderStormDuration(slvl) - const interval = calculateThunderStormInterval(slvl) - const dmgRange = calculateThunderStormDamage(slvl, { lightningMastery: lmSlvl }) - const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2) - - player.thunderstormTicks = duration - player.thunderstormInterval = interval - player.thunderstormNextStrike = interval - player.thunderstormDamage = tsDmg - player.thunderstormDamageRange = dmgRange - player.thunderstormSlvl = slvl - - engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Thunder Storm active' : '雷云风暴已激活' - return true - } - - if (skillId === 58) { - // Energy Shield (Skill 58): activate energyshield buff on player - const slvl = 1 - const duration = calculateEnergyShieldDuration(slvl) - const absorbPct = calculateEnergyShieldAbsorbPct(slvl) - - player.energyshieldTicks = duration - player.energyshieldPct = absorbPct - player.energyshieldSlvl = slvl - - engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Energy Shield active' : '能量护盾已激活' - return true - } - - if (skillId === 52) { - // Enchant (Skill 52): activate enchant weapon buff on player or target ally/pet - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(52) ?? 1) - const warmthLvl = (hudManager as any)?.getSkillLevel?.(37) ?? 0 - const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 - const duration = calculateEnchantDuration(slvl) - const arBonus = calculateEnchantAttackRating(slvl) - const dmg = calculateEnchantDamage(slvl, { warmth: warmthLvl }, fireMasterySlvl) - - player.enchantTicks = duration - player.enchantAttackRatingPercent = arBonus - player.enchantDamage = { min: dmg.min, max: dmg.max } - player.fireMasterySlvl = fireMasterySlvl - player.isSorceress = true - - engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: false }) - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Enchant active' : '强化已激活' - 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 = Math.max(1, (hudManager as any)?.getSkillLevel?.(62) ?? 1) - const dmgRange = calculateSkillDamage(62, slvl) - const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2) - - // Enforce 1.13c Hydra summon cap (MAX_ACTIVE_HYDRA_GROUPS = 6 groups, 18 heads max) with FIFO eviction - const MAX_ACTIVE_HYDRA_GROUPS = 6 - const activeTurrets = engine.projectiles.filter( - p => (p.isTurret || p.missileType === 'hydra_turret') && p.ttl > 0 - ) - const existingGroupIds = Array.from(new Set( - activeTurrets.map(p => (p as any).groupId ?? 0) - )).sort((a, b) => a - b) - - if (existingGroupIds.length >= MAX_ACTIVE_HYDRA_GROUPS) { - const groupsToEvictCount = (existingGroupIds.length - MAX_ACTIVE_HYDRA_GROUPS) + 1 - const evictGids = new Set(existingGroupIds.slice(0, groupsToEvictCount)) - engine.projectiles = engine.projectiles.filter( - p => !((p.isTurret || p.missileType === 'hydra_turret') && evictGids.has((p as any).groupId ?? 0)) - ) - } - - const currentGroupId = ++hydraGroupSeqCounter - 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, - groupId: currentGroupId, - spawnMissileType: 'hydra', - pierce: false, - } as any) - } - - player.cooldown = 40 - engine.metrics.casts += 1 - status.textContent = isEn ? 'Cast Hydra' : '施展九头海蛇' - return true - } - - if (skillId === 68) { - // Bone Armor: absorbs physical damage (20-210 HP base + 15/pt synergy from Bone Wall & Prison) - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(68) ?? 1) - const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 - const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 - const capacity = calculateBoneArmorCapacity(slvl, { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl }) - - ;(player as any).boneArmorHp = capacity - ;(player as any).boneArmorMaxHp = capacity - - // Spawns emerge overlay and attaches front and back orbiting bone overlays - engine.spawnOverlay('bonearmor_cast', { x: player.x, y: player.y }, 'player') - engine.spawnOverlay('bonearmor_front', { x: player.x, y: player.y }, 'player') - engine.spawnOverlay('bonearmor_back', { x: player.x, y: player.y }, 'player') - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Cast Bone Armor, absorbs ${capacity} physical damage` - : `施展白骨装甲,吸收 ${capacity} 点物理伤害` - return true - } - - if (skillId === 73) { - // Poison Dagger: requires dagger weapon equipped - 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.subtype !== 'dagger') { - status.textContent = isEn ? 'Poison Dagger requires a dagger equipped!' : '淬毒匕首需要装备匕首类武器!' - return false - } - - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(73) ?? 1) - const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 - const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 - const pDmg = calculatePoisonDaggerDamage(slvl, { - poisonExplosion: poisonExplosionBlvl, - poisonNova: poisonNovaBlvl, - }) - const arBonus = calculatePoisonDaggerAttackRatingBonus(slvl) - - player.cooldown = weaponInfo.cooldown - player.swingTicks = weaponInfo.swingTicks - - // Find melee target monster within weapon reach - let targetMonster: Monster | null = null - let bestTargetDist = 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 <= weaponInfo.reach) { - const distCursor = Math.hypot(monster.x - targetX, monster.y - targetY) - if (distCursor <= bestTargetDist) { - bestTargetDist = distCursor - targetMonster = monster - } - } - } - - if (targetMonster !== null) { - const baseDmg = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2) - damageMonster(engine.world, targetMonster.index, baseDmg) - // Apply poison effect - ;(targetMonster as any).statusEffect = 'poison' - ;(targetMonster as any).statusDuration = pDmg.durationFrames - ;(targetMonster as any).poisonDamagePerTick = pDmg.minRate / 256 - damageMonster(engine.world, targetMonster.index, pDmg.minDamage) - engine.metrics.playerHits += 1 - engine.metrics.casts += 1 - const mName = monsterDisplayName(targetMonster.stats) - status.textContent = isEn - ? `Poison Dagger (+${arBonus}% AR) hit: ${mName}, dealt ${pDmg.minDamage}-${pDmg.maxDamage} poison damage` - : `淬毒匕首(+${arBonus}%命中)命中:${targetMonster.stats.name || targetMonster.stats.id},造成 ${pDmg.minDamage}-${pDmg.maxDamage} 点毒素伤害` - } else { - engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) - engine.metrics.casts += 1 - status.textContent = isEn - ? `Poison Dagger (+${arBonus}% AR): missed target` - : `淬毒匕首(+${arBonus}%命中):未命中目标` - } - return true - } - - if (skillId === 74) { - // Corpse Explosion: requires nearby target corpse, deals 70-120% base unscaled HP (50% phys, 50% fire) - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(74) ?? 1) - const radiusPx = calculateCorpseExplosionRadiusPx(slvl) - - // Locate closest unconsumed dead monster corpse near target (within 80px) - let targetCorpse: Monster | null = null - let bestCorpseDist = 80 - for (const monster of engine.world.monsters) { - if (monster.state !== 'dead' && monster.hp > 0) continue - if ((monster as any).corpseConsumed) continue - if ((monster.stats as any).isBoss || (monster.stats as any).type === 'boss') continue // Boss corpses cannot be detonated - const d = Math.hypot(monster.x - targetX, monster.y - targetY) - if (d <= bestCorpseDist) { - bestCorpseDist = d - targetCorpse = monster - } - } - - if (!targetCorpse) { - status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!' - return false - } - - // Consume corpse - ;(targetCorpse as any).corpseConsumed = true - const cx = targetCorpse.x - const cy = targetCorpse.y - - // Spawn explosion visual/missile - engine.spawnOverlay('corpseexplosion', { x: cx, y: cy }) - - const baseHp = targetCorpse.stats.hp ?? 1000 - const ceDmg = calculateCorpseExplosionDamage(baseHp) - - // Area explosion in 2:1 isometric ground perspective - let hitCount = 0 - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) - if (dist <= radiusPx) { - const totalDmg = Math.max(1, ceDmg.phys + ceDmg.fire) - damageMonster(engine.world, monster.index, totalDmg) - engine.metrics.castHits += 1 - hitCount++ - } - } - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Corpse Explosion (Radius ${(radiusPx / 40).toFixed(1)} yds) detonated corpse, hit ${hitCount} targets` - : `尸体爆炸(范围 ${(radiusPx / 40).toFixed(1)} 码)消灭尸体,命中 ${hitCount} 个目标` - return true - } - - if (skillId === 78) { - // Bone Wall: spawns 5 destructible wall segments lasting 24s - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(78) ?? 1) - const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 - const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 - const wallStats = calculateBoneWallStats(slvl, { boneArmor: boneArmorBlvl, bonePrison: bonePrisonBlvl }) - - // Compute perpendicular direction to line from player to target - const dx = targetX - player.x - const dy = (targetY - player.y) / ISO_GROUND_ASPECT_RATIO - const len = Math.hypot(dx, dy) || 1 - const perpX = -dy / len - const perpY = dx / len - const spacing = 24 - - for (let i = -2; i <= 2; i++) { - const segX = targetX + perpX * (i * spacing) - const segY = targetY + perpY * (i * spacing * ISO_GROUND_ASPECT_RATIO) - engine.spawnOverlay('bonecast', { x: segX, y: segY }) - } - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Cast Bone Wall (Life ${wallStats.hp}, Duration ${wallStats.durationSeconds}s, ${wallStats.segmentCount} segments)` - : `施展骨墙(生命 ${wallStats.hp},持续 ${wallStats.durationSeconds} 秒,${wallStats.segmentCount} 骨节)` - return true - } - - if (skillId === 83) { - // Poison Explosion: detonates corpse into lingering poison cloud - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(83) ?? 1) - const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 - const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 - const peDmg = calculatePoisonExplosionDamage(slvl, { - poisonDagger: poisonDaggerBlvl, - poisonNova: poisonNovaBlvl, - }) - - // Locate unconsumed dead monster corpse - let targetCorpse: Monster | null = null - let bestCorpseDist = 80 - for (const monster of engine.world.monsters) { - if (monster.state !== 'dead' && monster.hp > 0) continue - if ((monster as any).corpseConsumed) continue - const d = Math.hypot(monster.x - targetX, monster.y - targetY) - if (d <= bestCorpseDist) { - bestCorpseDist = d - targetCorpse = monster - } - } - - if (!targetCorpse) { - status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!' - return false - } - - ;(targetCorpse as any).corpseConsumed = true - const cx = targetCorpse.x - const cy = targetCorpse.y - - engine.spawnOverlay('poisoncorpseexplosion', { x: cx, y: cy }) - engine.spawnOverlay('poisonexplosioncloud', { x: cx, y: cy }) - - // Apply poison to monsters within cloud radius (80 px) - let hitCount = 0 - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) - if (dist <= 80) { - ;(monster as any).statusEffect = 'poison' - ;(monster as any).statusDuration = peDmg.durationFrames - damageMonster(engine.world, monster.index, peDmg.minDamage) - engine.metrics.castHits += 1 - hitCount++ - } - } - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Poison Explosion (Duration ${peDmg.durationSeconds.toFixed(1)}s, Damage ${peDmg.minDamage}-${peDmg.maxDamage}) hit ${hitCount} targets` - : `毒爆(持续 ${peDmg.durationSeconds.toFixed(1)} 秒,伤害 ${peDmg.minDamage}-${peDmg.maxDamage})命中 ${hitCount} 个目标` - return true - } - - if (skillId === 88) { - // Bone Prison: spawns ring of destructible bone segments encircling target unit - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(88) ?? 1) - const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 - const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 - const prisonStats = calculateBonePrisonStats(slvl, { boneArmor: boneArmorBlvl, boneWall: boneWallBlvl }) - - const ringRadius = 40 - for (let i = 0; i < prisonStats.segmentCount; i++) { - const angle = (i / prisonStats.segmentCount) * 2 * Math.PI - const segX = targetX + Math.cos(angle) * ringRadius - const segY = targetY + Math.sin(angle) * (ringRadius * ISO_GROUND_ASPECT_RATIO) - engine.spawnOverlay('bonecast', { x: segX, y: segY }) - } - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Cast Bone Prison (Life ${prisonStats.hp}, Duration ${prisonStats.durationSeconds}s, ${prisonStats.segmentCount} segments)` - : `施展骨牢(生命 ${prisonStats.hp},持续 ${prisonStats.durationSeconds} 秒,${prisonStats.segmentCount} 骨柱)` - return true - } - - if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(skillId)) { - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(skillId) ?? 1) - const curseEffect = calculateCurseEffect(skillId, slvl) - - // Spawn authentic cast overlay 'cursecast' at targeted location - engine.spawnOverlay('cursecast', { x: targetX, y: targetY }) - - const getCurseOverhead = (id: number): string => { - switch (id) { - case 66: return 'curseamplifydamage' - case 71: return 'cursedimvision' - case 72: return 'curseweaken' - case 76: return 'curseironmaiden' - case 77: return 'curseterror' - case 81: return 'curseconfuse' - case 82: return 'cursereversevampire' - case 86: return 'curseattract' - case 87: return 'cursedecrepify' - case 91: return 'curselowerresist' - default: return 'curseamplifydamage' - } - } - - if (skillId === 86) { - // Attract (Skill 86): single-target curse (search radius 6.0y / 240px) - let targetMonster: Monster | null = null - let bestDist = curseEffect.radiusPx // 240 px - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) - if (dist <= bestDist) { - bestDist = dist - targetMonster = monster - } - } - - if (!targetMonster) { - status.textContent = isEn ? 'Requires a valid target!' : '需要有效的目标!' - return false - } - - const isBossOrChamp = Boolean( - (targetMonster.stats as any).isBoss || - (targetMonster.stats as any).type === 'boss' || - targetMonster.stats.rank === 'champion' || - targetMonster.stats.rank === 'unique' - ) - if (isBossOrChamp) { - status.textContent = isEn ? 'Bosses and Champions are immune to Attract!' : '首领和头目免疫吸引!' - return false - } - - if (targetMonster.curse?.curseType === 2 || targetMonster.curse?.name === 'attract') { - status.textContent = isEn ? 'Target is already Attracted!' : '目标已被吸引!' - return false - } - - targetMonster.curse = { - name: 'attract', - slvl, - curseType: 2, - ticksRemaining: curseEffect.durationFrames, - overlayName: 'curseattract', - } - engine.spawnOverlay('curseattract', { x: targetMonster.x, y: targetMonster.y }, `monster-${targetMonster.index}`, { loop: true }) - - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Attract (Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit target` - : `吸引(持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中目标` - return true - } - - // AoE Curses: 66, 71, 72, 76, 77, 81, 82, 87, 91 - let affectedCount = 0 - const overheadOverlay = getCurseOverhead(skillId) - - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) - if (dist <= curseEffect.radiusPx) { - // 1.13c Rule: Attract (cursetype = 2) CANNOT be overwritten by any curse! - if (monster.curse?.curseType === 2 || monster.curse?.name === 'attract') { - continue - } - - // 1.13c Rule: Bosses & Champions are IMMUNE to mental curses (Dim Vision 71, Terror 77, Confuse 81) - const isBossOrChamp = Boolean( - (monster.stats as any).isBoss || - (monster.stats as any).type === 'boss' || - monster.stats.rank === 'champion' || - monster.stats.rank === 'unique' - ) - if ([71, 77, 81].includes(skillId) && isBossOrChamp) { - continue - } - - monster.curse = { - name: curseEffect.stateName, - slvl, - curseType: 1, - ticksRemaining: curseEffect.durationFrames, - overlayName: overheadOverlay, - casterX: player.x, - casterY: player.y, - returnPct: skillId === 76 ? calculateIronMaidenReturnPct(slvl) : undefined, - } - - if (skillId === 77) { - const fleeDx = monster.x - player.x - const fleeDy = monster.y - player.y - const fleeLen = Math.hypot(fleeDx, fleeDy) || 1 - const fleeDistPx = calculateTerrorDistance(slvl) * (40 * (2 / 3)) - monster.curse.fleeTarget = { - x: monster.x + (fleeDx / fleeLen) * fleeDistPx, - y: monster.y + (fleeDy / fleeLen) * fleeDistPx, - } - } - - engine.spawnOverlay(overheadOverlay, { x: monster.x, y: monster.y }, `monster-${monster.index}`, { loop: true }) - affectedCount++ - } - } - - player.cooldown = 0 - engine.metrics.casts += 1 - const curseName = isEn ? getSkillDisplayName(skillId, 'ENG') : curseEffect.skillName - status.textContent = isEn - ? `${curseName} (Radius ${(curseEffect.radiusPx / 40).toFixed(1)} yds, Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit ${affectedCount} targets` - : `${curseEffect.skillName}(范围 ${(curseEffect.radiusPx / 40).toFixed(1)} 码,持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中 ${affectedCount} 个目标` - return true - } - - // Necromancer Summoning Spells Tree: 69 (Mastery), 70 (Raise Skeleton), 75 (Clay Golem), - // 79 (Golem Mastery), 80 (Raise Skeletal Mage), 85 (Blood Golem), 89 (Summon Resist), - // 90 (Iron Golem), 94 (Fire Golem), 95 (Revive) - if ([69, 70, 75, 79, 80, 85, 89, 90, 94, 95].includes(skillId)) { - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(skillId) ?? 1) - - // Passive skills (69, 79, 89) - if (skillId === 69) { - status.textContent = isEn - ? `Skeleton Mastery (Level ${slvl}): Passively increases life and damage of raised skeletons and revived monsters` - : `支配骷髅(等级 ${slvl}):被动提升骷髅与复活怪物的生命与伤害` - return true - } - if (skillId === 79) { - status.textContent = isEn - ? `Golem Mastery (Level ${slvl}): Passively increases life and velocity of Golems` - : `支配石魔(等级 ${slvl}):被动提升石魔的生命与移动速度` - return true - } - if (skillId === 89) { - status.textContent = isEn - ? `Summon Resist (Level ${slvl}): Passively increases elemental resistances of all summons` - : `召唤物抗性(等级 ${slvl}):被动提升所有召唤物的元素抗性` - return true - } - - // Corpse-consuming summons: Raise Skeleton (70), Skeletal Mage (80), Revive (95) - if (skillId === 70 || skillId === 80 || skillId === 95) { - let targetCorpse: Monster | null = null - let bestCorpseDist = 80 // 80px range - for (const monster of engine.world.monsters) { - if (monster.state !== 'dead' && monster.hp > 0) continue - if ((monster as any).corpseConsumed) continue - if ((monster.stats as any)?.isBoss || (monster.stats as any)?.type === 'boss') continue - const d = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) - if (d <= bestCorpseDist) { - bestCorpseDist = d - targetCorpse = monster - } - } - - if (!targetCorpse) { - status.textContent = isEn - ? 'Requires an unconsumed monster corpse (non-Boss)!' - : '需要未被消耗的怪物尸体(非首领)!' - return false - } - - ;(targetCorpse as any).corpseConsumed = true - targetCorpse.corpseTicks = 0 - - // Spawn authentic summon cast overlay - engine.spawnOverlay('bonecast', { x: targetCorpse.x, y: targetCorpse.y }) - - player.cooldown = 0 - engine.metrics.casts += 1 - - if (skillId === 70) { - const maxSkel = calculateSkeletonMaxCount(slvl) - status.textContent = isEn - ? `Raise Skeleton (Level ${slvl}, Max ${maxSkel}) summoned` - : `复生骷髅(等级 ${slvl},上限 ${maxSkel} 只)召唤成功` - } else if (skillId === 80) { - const maxMage = calculateSkeletalMageMaxCount(slvl) - status.textContent = isEn - ? `Raise Skeletal Mage (Level ${slvl}, Max ${maxMage}) summoned` - : `复生骷髅法师(等级 ${slvl},上限 ${maxMage} 只)召唤成功` - } else { - const maxRevive = calculateReviveMaxCount(slvl) - status.textContent = isEn - ? `Revive (Level ${slvl}, Max ${maxRevive}, Duration 180s) summoned` - : `重生(等级 ${slvl},上限 ${maxRevive} 只,持续 180 秒)召唤成功` - } - return true - } - - // Golems: Clay (75), Blood (85), Iron (90), Fire (94) - if (skillId === 90) { - // Iron Golem requires metallic item - engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Iron Golem (Level ${slvl}, inherits item properties and thorns) summoned` - : `钢铁石魔(等级 ${slvl},继承装备属性与反伤)召唤成功` - return true - } - - if (skillId === 75) { - engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Clay Golem (Level ${slvl}, slows enemies on hit) summoned` - : `粘土石魔(等级 ${slvl},附带减速攻击与受击减速)召唤成功` - return true - } - - if (skillId === 85) { - engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Blood Golem (Level ${slvl}, 70% life link) summoned` - : `鲜血石魔(等级 ${slvl},附带 70% 生命链接与伤害分担)召唤成功` - return true - } - - if (skillId === 94) { - engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) - player.cooldown = 0 - engine.metrics.casts += 1 - status.textContent = isEn - ? `Fire Golem (Level ${slvl}, Holy Fire aura & fire absorb) summoned` - : `火焰石魔(等级 ${slvl},附带圣火脉冲与火焰吸收)召唤成功` - return true - } - } - - // Projectile Skills - let speed = 450 - let range = 450 - let damage = 28 - let delay = 0 - let skillName = isEn ? 'Skill' : '技能' - 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 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(47) ?? 1) - const fireBoltBlvl = (hudManager as any)?.getSkillLevel?.(36) ?? 0 - const meteorBlvl = (hudManager as any)?.getSkillLevel?.(56) ?? 0 - const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 - const dmgRange = calculateSkillDamage(47, slvl, { fireBolt: fireBoltBlvl, meteor: meteorBlvl, fireMastery: fireMasterySlvl }) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - aoeRadius = 80 - missileType = 'fireball' - skillName = '火球' - } else if (skillId === 39) { - const missile = getMissileTxtData('icebolt') - speed = missile.speedPxPerSec || 300 - range = missile.distancePx || 600 - damage = 14 - delay = 0 - statusEffect = 'chill' - statusDuration = 50 - missileType = 'icebolt' - skillName = '冰弹' - } else if (skillId === 45) { - const missile = getMissileTxtData('iceblast') - speed = missile.speedPxPerSec || 300 - range = missile.distancePx || 600 - const slvl = (hudManager as any)?.getSkillLevel?.(45) ?? 1 - const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 - const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 - const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 - const glacialSpikeBlvl = (hudManager as any)?.getSkillLevel?.(55) ?? 0 - const synergies = { - iceBolt: iceBoltBlvl, - blizzard: blizzardBlvl, - frozenOrb: frozenOrbBlvl, - glacialSpike: glacialSpikeBlvl, - } - const dmgRange = calculateSkillDamage(45, slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - statusEffect = 'freeze' - statusDuration = calculateIceBlastFreezeLength(slvl, synergies) - missileType = 'iceblast' - skillName = '冰风暴' - } else if (skillId === 55) { - const missile = getMissileTxtData('glacialspike') - speed = missile.speedPxPerSec || 400 - range = missile.distancePx || 640 - const slvl = (hudManager as any)?.getSkillLevel?.(55) ?? 1 - const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 - const iceBlastBlvl = (hudManager as any)?.getSkillLevel?.(45) ?? 0 - const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 - const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 - const synergies = { - iceBolt: iceBoltBlvl, - iceBlast: iceBlastBlvl, - blizzard: blizzardBlvl, - frozenOrb: frozenOrbBlvl, - } - const dmgRange = calculateSkillDamage(55, slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - aoeRadius = 64 - statusEffect = 'freeze' - statusDuration = calculateGlacialSpikeFreezeLength(slvl, synergies) - missileType = 'glacialspike' - skillName = '冰尖柱' - } else if (skillId === 38) { - const missile = getMissileTxtData('chargedbolt') - speed = missile.speedPxPerSec || 300 - range = missile.distancePx || 1176 - const slvl = (hudManager as any)?.getSkillLevel?.(38) ?? 1 - const lightningBlvl = (hudManager as any)?.getSkillLevel?.(49) ?? 0 - const dmgRange = calculateSkillDamage(38, slvl, { lightning: lightningBlvl } as any) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - missileType = 'chargedbolt' - jitter = true - multiCount = calculateChargedBoltCount(slvl) - spreadAngle = 0.55 - skillName = '充能弹' - } else if (skillId === 67) { - const missile = getMissileTxtData('teeth') - speed = missile.speedPxPerSec || 400 - range = missile.distancePx || 800 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(67) ?? 1) - const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 - const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 - const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 - const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 - const synergies = { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpear: boneSpearBlvl, boneSpirit: boneSpiritBlvl } - const dmgRange = calculateTeethDamage(slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - missileType = 'teeth' - multiCount = calculateTeethCount(slvl) - spreadAngle = Math.min(1.2, 0.25 + multiCount * 0.04) - nextDelay = 4 - skillName = '牙' - } else if (skillId === 84) { - const missile = getMissileTxtData('bonespear') - speed = missile.speedPxPerSec || 600 - range = missile.distancePx || 960 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(84) ?? 1) - const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 - const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 - const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 - const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 - const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpirit: boneSpiritBlvl } - const dmgRange = calculateBoneSpearDamage(slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - pierce = true - missileType = 'bonespear' - skillName = '骨矛' - } else if (skillId === 93) { - const missile = getMissileTxtData('bonespirit') - speed = missile.speedPxPerSec || 300 - range = missile.distancePx || 1536 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(93) ?? 1) - const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 - const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 - const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 - const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 - const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, boneSpear: boneSpearBlvl, bonePrison: bonePrisonBlvl } - const dmgRange = calculateBoneSpiritDamage(slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - homing = true - missileType = 'bonespirit' - skillName = '骨魂' - } else if (skillId === 92) { - const missile = getMissileTxtData('poisonnova') - speed = missile.speedPxPerSec || 300 - range = missile.distancePx || 360 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(92) ?? 1) - const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 - const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 - const synergies = { poisonDagger: poisonDaggerBlvl, poisonExplosion: poisonExplosionBlvl } - const pnDmg = calculatePoisonNovaDamage(slvl, synergies) - damage = Math.round((pnDmg.minDamage + pnDmg.maxDamage) / 2) - delay = 0 - statusEffect = 'poison' - statusDuration = pnDmg.durationFrames - missileType = 'poisonnova' - isNova = true - multiCount = 64 - nextDelay = 4 - 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 === 44) { - const missile = getMissileTxtData('frostnova') - speed = missile.speedPxPerSec || 600 - range = missile.distancePx || 336 - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(44) ?? 1) - const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 - const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 - const synergies = { blizzard: blizzardBlvl, frozenOrb: frozenOrbBlvl } - const dmgRange = calculateFrostNovaDamage(slvl, synergies) - damage = Math.round((dmgRange.min + dmgRange.max) / 2) - delay = 0 - statusEffect = 'chill' - statusDuration = calculateFrostNovaChillLength(slvl) - missileType = 'frostnova' - isNova = true - multiCount = 64 - nextDelay = 4 - 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 (skillId === 121) { - // Fist of the Heavens (121): 25-frame cast delay (1.0s cooldown), celestial lightning strike - const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(121) ?? 1) - const holyShockBlvl = (hudManager as any)?.getSkillLevel?.(118) ?? 0 - const fohDmg = calculateFistOfTheHeavensDamage(slvl, { holyShock: holyShockBlvl }) - damage = Math.round((fohDmg.lightningMin + fohDmg.lightningMax) / 2) - speed = 0 - range = 40 - delay = 25 // 25-frame cast delay (1.0s) - skillName = '天堂之拳' - missileType = 'handofgod' - engine.spawnOverlay('handofgod', { x: targetX, y: targetY }) - // Radial holy bolts on impact - multiCount = 6 - spreadAngle = Math.PI * 2 - isUndeadOnly = true - } 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 * (skillId === 67 || skillId === 38 ? ISO_GROUND_ASPECT_RATIO : 1)) - 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, - nextDelay, - }) - } - } 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 === 44 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10)) - engine.metrics.casts += 1 - const displaySkillName = isEn ? getSkillDisplayName(skillId, 'ENG') : skillName - status.textContent = isEn ? `Cast skill: ${displaySkillName}` : `施展技能:${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: 'portal'; readonly worldPos: { readonly x: number; readonly y: number } } - | { readonly kind: 'warp'; readonly warp: SceneWarp; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } - | { readonly kind: 'entrance'; readonly entrance: SceneEntrance; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } - | { 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 - readonly portalSlot?: TownPortalSlot | undefined - readonly onEnterPortal?: (() => void) | undefined - readonly onCastTownPortal?: (() => boolean) | undefined - readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined - readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined - readonly isTravelling?: (() => boolean) | undefined - readonly labelsContainer?: HTMLElement | null | undefined - readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined -} - -/** - * Play authentic Diablo II audio rejection feedback when inventory is full. - * Low descending refusal tone paired with delayed secondary touch landing thud. - */ -export function 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. - */ -export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void { - if (status) { - 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 = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", 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) - } - } -} - -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 - readonly portalSlot?: TownPortalSlot | undefined - readonly onEnterPortal?: (() => void) | undefined - readonly onCastTownPortal?: (() => boolean) | undefined - readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined - readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined - readonly isTravelling?: (() => boolean) | undefined - readonly labelsContainer?: HTMLElement | null | undefined - readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined - - navTarget: { x: number; y: number } | null = null - pendingInteraction: PendingInteraction | null = null - activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'warp' | 'entrance' | 'monster' = 'none' - isLeftMouseDown = false - isRightMouseDown = false - lastClientPos: { x: number; y: number } | null = null - hoveredMonster: Monster | null = null - lockedMonster: Monster | null = null - hoveredGroundItem: GroundItemEntity | null = null - hoveredWarp: SceneWarp | null = null - hoveredEntrance: SceneEntrance | null = null - readonly labelPool: HTMLDivElement[] = [] - - private stuckTicks = 0 - private lastDist = -1 - private lastPlayerPos: { x: number; y: number } | null = null - - 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.portalSlot = options.portalSlot - this.onEnterPortal = options.onEnterPortal - this.onCastTownPortal = options.onCastTownPortal - this.onEnterWarp = options.onEnterWarp - this.onEnterEntrance = options.onEnterEntrance - this.isTravelling = options.isTravelling - this.labelsContainer = options.labelsContainer ?? null - this.getMonsterArtMap = options.getMonsterArtMap - ;(this.engine as any).dialog = null - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - /** - * Hit-test world coordinates against living, killable, non-town monsters using - * their rendered sprite bounding box (`m.drawBounds`, loaded sprite frame 0 from - * `monsterArtMap` before first render, or rank/boss-aware fallback footprint), - * selecting the frontmost monster in isometric depth (`m.y` descending, - * tie-breaking by center distance). - */ - findMonsterAtWorld(worldX: number, worldY: number): Monster | null { - const runtime = this.getRuntime?.() - if (runtime && isTownLevel(runtime.levelId)) return null - const monsters = this.engine?.world?.monsters ?? [] - const artMap = this.getMonsterArtMap?.() - let bestMonster: Monster | null = null - let bestDepthY = -Infinity - let bestCenterDist = Infinity - - for (const m of monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) continue - - const bounds = m.drawBounds - let minX: number - let maxX: number - let minY: number - let maxY: number - if (bounds !== undefined && bounds.w > 0 && bounds.h > 0) { - minX = bounds.x - 4 - maxX = bounds.x + bounds.w + 4 - minY = bounds.y - 4 - maxY = bounds.y + bounds.h + 4 - } else { - const art = artMap?.get(m.stats.id.toLowerCase()) - const standGroup = art !== undefined ? (art.groups[art.standOffset] ?? art.groups[0]) : undefined - const frame = standGroup?.[0] - if (frame !== undefined && frame.width > 0 && frame.height > 0) { - const drawX = frame.anchorX !== undefined ? m.x + frame.anchorX : m.x - frame.width / 2 - const drawY = frame.anchorY !== undefined ? m.y + frame.anchorY : m.y - frame.height + FEET_HEIGHT / 2 - minX = drawX - 4 - maxX = drawX + frame.width + 4 - minY = drawY - 4 - maxY = drawY + frame.height + 4 - } else if (m.stats?.isBoss === true || m.stats?.rank === 'boss') { - minX = m.x - 36 - maxX = m.x + 36 - minY = m.y - 96 - maxY = m.y + 8 - } else { - minX = m.x - 24 - maxX = m.x + 24 - minY = m.y - 64 - maxY = m.y + 8 - } - } - - if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { - const cx = (minX + maxX) * 0.5 - const cy = (minY + maxY) * 0.5 - const centerDist = Math.hypot(worldX - cx, worldY - cy) - if ( - bestMonster === null || - m.y > bestDepthY + 1 || - (Math.abs(m.y - bestDepthY) <= 1 && centerDist < bestCenterDist) - ) { - bestMonster = m - bestDepthY = m.y - bestCenterDist = centerDist - } - } - } - - return bestMonster - } - - /** - * Resolve the currently targeted monster (either target-locked by held left-click - * or hovered under the mouse cursor), or `null` if none / dead. - */ - getActiveTargetMonster(): Monster | null { - if ( - this.isLeftMouseDown && - this.activeClickType === 'monster' && - this.lockedMonster !== null - ) { - if ( - this.lockedMonster.state !== 'dead' && - this.lockedMonster.hp > 0 && - this.lockedMonster.stats?.killable !== false && - this.lockedMonster.stats?.inTown !== true - ) { - return this.lockedMonster - } - this.lockedMonster = null - } - if (this.hoveredMonster !== null) { - if ( - this.hoveredMonster.state !== 'dead' && - this.hoveredMonster.hp > 0 && - this.hoveredMonster.stats?.killable !== false && - this.hoveredMonster.stats?.inTown !== true - ) { - return this.hoveredMonster - } - this.hoveredMonster = null - } - return null - } - - /** - * Synchronize `hoveredMonster` and `hudManager.targetMonster` with the current - * pointer position and monster states. - */ - syncMonsterTargetHud( - camX = this.engine.world.player.x, - camY = this.engine.world.player.y - 16, - ): Monster | null { - let hudIntercepted = false - if (this.lastClientPos !== null) { - if (this.hudManager !== null) { - const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) - hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y) - } - if (hudIntercepted) { - this.hoveredMonster = null - } else { - const rect = this.canvas.getBoundingClientRect() - const zoom = this.camera?.zoom ?? 1 - const worldAtPointer = screenToWorld({ - clientX: this.lastClientPos.x, - clientY: this.lastClientPos.y, - rect, - canvas: this.canvas, - camX, - camY, - cameraZoom: zoom, - }) - this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) - } - } - - if (hudIntercepted && !(this.isLeftMouseDown && this.activeClickType === 'monster')) { - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - return null - } - - const activeTarget = this.getActiveTargetMonster() - if (this.hudManager !== null) { - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - return activeTarget - } - - private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean { - 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) - if (Math.min(distFeet, distBody) <= 36) return true - } - - const runtime = this.getRuntime() - if (runtime?.grid) { - for (const wp of runtime.waypoints ?? []) { - const pos = subTileToScene(wp.x, wp.y, runtime.grid) - if (Math.hypot(pos.x - worldX, pos.y - worldY) <= 48) return true - } - } - - if (this.findNearestStash(worldX, worldY, 48) !== null) return true - - if (runtime?.grid) { - const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null - if (activePortalMouth !== null) { - const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) - if (Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) <= 48) return true - } - } - - if (this.findMonsterAtWorld(worldX, worldY) !== null) return true - - 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) - if (Math.min(distFeet, distBody) <= 36) return true - } - - if (this.engine.groundItems?.findAtPoint(worldX, worldY, 32)) return true - - return false - } - - private syncLinkHoverAtWorld(worldX: number, worldY: number): void { - if (this.isHigherPriorityEntityAt(worldX, worldY)) { - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.canvas?.style) { - this.canvas.style.cursor = '' - } - return - } - - const runtime = this.getRuntime() - let bestWarp: { warp: SceneWarp; dist: number } | null = null - let bestEntrance: { entrance: SceneEntrance; dist: number } | null = null - if (runtime?.grid) { - for (const warp of runtime.warps ?? []) { - const res = hitTestWarp(warp, worldX, worldY, runtime.grid) - if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { - bestWarp = { warp, dist: res.dist } - } - } - for (const entrance of runtime.entrances ?? []) { - const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) - if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { - bestEntrance = { entrance, dist: res.dist } - } - } - } - if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { - this.hoveredWarp = bestWarp.warp - this.hoveredEntrance = null - } else if (bestEntrance !== null) { - this.hoveredWarp = null - this.hoveredEntrance = bestEntrance.entrance - } else { - this.hoveredWarp = null - this.hoveredEntrance = null - } - if (this.canvas?.style) { - this.canvas.style.cursor = (this.hoveredWarp !== null || this.hoveredEntrance !== null) ? 'pointer' : '' - } - } - - private allocateLabelElement(container: HTMLElement): HTMLDivElement { - let label: HTMLDivElement - if (typeof document !== 'undefined' && typeof document.createElement === 'function') { - label = document.createElement('div') - } else { - label = { - tagName: 'DIV', - textContent: '', - hidden: false, - className: '', - dataset: {}, - style: {}, - } as unknown as HTMLDivElement - } - label.style.position = 'absolute' - label.style.transform = 'translate(-50%, -100%)' - 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' - this.labelPool.push(label) - if (typeof container.appendChild === 'function') { - container.appendChild(label) - } else if (Array.isArray((container as any).children)) { - ;(container as any).children.push(label) - } - return label - } - - /** - * Render recycled overhead labels for on-screen NPCs, Town Portals, - * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. - */ - updateOverheadLabels( - camX = this.engine.world.player.x, - camY = this.engine.world.player.y - 16, - activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, - activePortalPosOverride?: { readonly x: number; readonly y: number } | null, - ): void { - const container = this.labelsContainer - if (!container) return - - const runtime = this.getRuntime() - let labelCount = 0 - const rect = this.canvas.getBoundingClientRect() - const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1 - const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1 - const zoom = this.camera?.zoom ?? 1 - const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px` - - if (this.lastClientPos !== null) { - let hudIntercepted = false - if (this.hudManager !== null) { - const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) - hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y) - } - if (hudIntercepted) { - this.hoveredMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.canvas?.style) { - this.canvas.style.cursor = '' - } - } else { - const worldAtPointer = screenToWorld({ - clientX: this.lastClientPos.x, - clientY: this.lastClientPos.y, - rect, - canvas: this.canvas, - camX, - camY, - cameraZoom: zoom, - }) - this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) - this.hoveredGroundItem = this.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null - this.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y) - } - } - - const activeNpcMenuName = this.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase() - const currentLang = getTblLang() - const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1) - for (const npc of this.engine.npcEntities ?? []) { - if (npc.def.name.toLowerCase() === 'dummy') continue - const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc) - const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name - if ( - activeNpcMenuName && - (npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName) - ) { - continue - } - 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) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY - - if (domX <= -100 || domX >= rect.width + 100) continue - if (domY <= -100 || domY >= rect.height + 100) continue - const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) - if (label.textContent !== npcLabelText) label.textContent = npcLabelText - label.className = 'd2-overhead-label d2-npc-label' - if (label.dataset) { - label.dataset.kind = 'npc' - delete label.dataset.toLevelId - delete label.dataset.hovered - } - label.style.color = '#c8a15a' - label.style.background = 'transparent' - label.style.border = 'none' - label.style.padding = '0' - label.style.borderRadius = '0' - label.style.fontWeight = 'normal' - label.style.boxShadow = 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - - const portalMouth = activePortalMouthOverride !== undefined - ? activePortalMouthOverride - : (this.portalSlot && runtime ? this.portalSlot.mouthInLevel(runtime.levelId) : null) - const portalPos = activePortalPosOverride !== undefined - ? activePortalPosOverride - : (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null) - - if (portalMouth !== null && portalPos !== null) { - const topX = portalPos.x - const topY = portalPos.y - 94 - const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY - if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) { - const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) - const portalLabelText = currentLang === 'ENG' - ? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}` - : `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}` - if (label.textContent !== portalLabelText) label.textContent = portalLabelText - label.className = 'd2-overhead-label d2-portal-label' - if (label.dataset) { - label.dataset.kind = 'portal' - label.dataset.toLevelId = String(portalMouth.toLevelId) - delete label.dataset.hovered - } - label.style.color = '#6cb4ff' - label.style.background = 'transparent' - label.style.border = 'none' - label.style.padding = '0' - label.style.borderRadius = '0' - label.style.fontWeight = 'normal' - label.style.boxShadow = 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - } - - if (runtime?.grid) { - for (const warp of runtime.warps ?? []) { - if (!(warp.toLevelId > 0)) continue - const pos = getWarpLabelWorldPos(warp, runtime.grid) - const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY - if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue - const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) - const badgeText = currentLang === 'ENG' - ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(warp.toLevelId).nameZh}` - if (label.textContent !== badgeText) label.textContent = badgeText - const isHovered = this.hoveredWarp === warp - label.className = isHovered - ? 'd2-overhead-label d2-exit-badge is-hovered' - : 'd2-overhead-label d2-exit-badge' - if (label.dataset) { - label.dataset.kind = 'warp' - label.dataset.toLevelId = String(warp.toLevelId) - label.dataset.hovered = isHovered ? 'true' : 'false' - } - label.style.color = isHovered ? '#fff3c4' : '#f0d080' - label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' - label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' - label.style.padding = '2px 7px' - label.style.borderRadius = '3px' - label.style.fontWeight = isHovered ? 'bold' : 'normal' - label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' - label.style.left = `${domX}px` - label.style.top = `${domY - 2}px` - label.style.fontSize = fontSizePx - label.hidden = false - labelCount += 1 - } - - for (const entrance of runtime.entrances ?? []) { - if (!(entrance.toLevelId > 0)) continue - const pos = getEntranceLabelWorldPos(entrance, runtime.grid) - const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX - const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY - if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue - const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container) - const badgeText = currentLang === 'ENG' - ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `通往 ${getLevelNames(entrance.toLevelId).nameZh}` - if (label.textContent !== badgeText) label.textContent = badgeText - const isHovered = this.hoveredEntrance === entrance - label.className = isHovered - ? 'd2-overhead-label d2-exit-badge is-hovered' - : 'd2-overhead-label d2-exit-badge' - if (label.dataset) { - label.dataset.kind = 'entrance' - label.dataset.toLevelId = String(entrance.toLevelId) - label.dataset.hovered = isHovered ? 'true' : 'false' - } - label.style.color = isHovered ? '#fff3c4' : '#f0d080' - label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' - label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' - label.style.padding = '2px 7px' - label.style.borderRadius = '3px' - label.style.fontWeight = isHovered ? 'bold' : 'normal' - label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' - 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 < this.labelPool.length; index += 1) { - const unused = this.labelPool[index]! - unused.hidden = true - if (unused.dataset && unused.dataset.hovered === 'true') { - unused.dataset.hovered = 'false' - } - if (unused.className.includes('is-hovered')) { - unused.className = unused.className.replace(/\s*is-hovered\b/g, '') - } - } - } - - 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) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Open ${stash.nameEn}` - : `打开私人储物箱 (${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) - const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) - const isEn = getTblLang() === 'ENG' - if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { - this.navTarget = null - this.pendingInteraction = null - if (this.hoveredGroundItem?.id === item.id) { - this.hoveredGroundItem = 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) { - const playerLevel = this.engine.world.player?.level ?? 1 - const goldCap = getInventoryGoldLimit(playerLevel) - this.hudManager.inventory.playerLevel = playerLevel - this.hudManager.inventory.gold = Math.min(goldCap, this.hudManager.inventory.gold + res.amount) - this.hudManager.syncPublishedState() - } - if (res.remaining && res.remaining > 0) { - this.status.textContent = isEn - ? `Picked up Gold: ${res.amount} (Remaining on ground: ${res.remaining})` - : `拾起金币:${res.amount}(地面剩余:${res.remaining})` - } else { - this.status.textContent = isEn - ? `Picked up Gold: ${res.amount}` - : `拾起金币:${res.amount}` - } - } else { - this.engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.playInventoryFullFeedback() - this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。' - this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。') - } - } else { - if (this.hudManager) { - const autoBelted = Boolean(this.hudManager.belt?.autoPlacePotion?.(item.item)) - if (!autoBelted) { - const uiItem = itemToUiInventoryItem(item.item, this.engine.dropTables) - const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem) - if (!placed) { - this.engine.metrics.inventoryRefusals += 1 - triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.playInventoryFullFeedback() - this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') - 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 = isEn - ? `Picked up item: ${item.name}` - : `拾起物品:${item.nameZh || item.name}` - } else { - const res = this.engine.pickupItem(item.id) - if (!res.success && res.reason === 'full') { - this.playInventoryFullFeedback() - this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') - } else if (res.success) { - this.status.textContent = isEn - ? `Picked up item: ${item.name}` - : `拾起物品:${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 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } - - /** - * Play authentic Diablo II audio rejection feedback when inventory is full. - * Low descending refusal tone paired with delayed secondary touch landing thud. - */ - playInventoryFullFeedback(): void { - playInventoryFullFeedback() - } - - /** - * Display prominent Diablo II style on-screen notification banner with auto fade-out. - */ - showNotification(message: string, durationMs = 2000): void { - showNotification(message, durationMs, this.status) - } - - 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 - if (this.isTravelling?.()) 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 - } - if (this.hudManager.worldPanels?.npcMenu) { - this.hudManager.worldPanels.npcMenu = null - this.hudManager.syncPublishedState?.() - } - } - - 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) - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } 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) - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } 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)) { - this.hoveredMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (!(this.isLeftMouseDown && this.activeClickType === 'monster')) { - this.hudManager.targetMonster = null - } - if (this.canvas.style) { - this.canvas.style.cursor = '' - } - if (this.labelsContainer) { - this.updateOverheadLabels() - } - return - } - } - - const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) - this.hoveredMonster = this.findMonsterAtWorld(worldX, worldY) - const activeTarget = this.getActiveTargetMonster() - if (this.hudManager !== null) { - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null - this.syncLinkHoverAtWorld(worldX, worldY) - if (this.labelsContainer) { - this.updateOverheadLabels() - } - - if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') { - 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')) { - this.navTarget = { x: worldX, y: worldY } - } - } - } - - readonly onPointerLeave = (e: PointerEvent): void => { - const target = e.relatedTarget as HTMLElement | null - if (target && target.closest?.('.d2-ground-label')) { - return - } - this.lastClientPos = null - this.hoveredMonster = null - this.lockedMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - if (this.canvas.style) { - this.canvas.style.cursor = '' - } - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - readonly onPointerUp = (e: PointerEvent): void => { - if (e.button === 0) { - this.isLeftMouseDown = false - this.activeClickType = 'none' - this.lockedMonster = null - } else if (e.button === 2) { - this.isRightMouseDown = false - if (!this.isLeftMouseDown) { - this.activeClickType = 'none' - } - } else { - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.activeClickType = 'none' - this.lockedMonster = null - } - this.syncMonsterTargetHud() - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - } - - handleRightClickWorld(worldX: number, worldY: number): void { - const player = this.engine.world.player - 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 - player.x, worldY - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - return - } - - const range = getSkillRange(rightSkillId, this.hudManager) - - // 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px - const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) - let clickedMonster: Monster | null = hoveredAtClick - if (clickedMonster === null) { - let bestMonDist = 36 - for (const m of this.engine.world.monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) 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' - this.hoveredMonster = hoveredAtClick - 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 } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - 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 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } else { - this.navTarget = null - this.pendingInteraction = null - this.castSkill(rightSkillId, worldX, worldY) - } - } - - interactWithNpc(npc: NpcEntity): void { - const player = this.engine.world.player - 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) - const runtime = this.getRuntime() - const actIndex = Math.max(0, (runtime.act ?? 1) - 1) - const descriptor = getTownNpcDescriptorByName(npc.def.name, actIndex) - this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}` - - if (descriptor) { - if (descriptor.canHeal) { - if (player.maxHp !== undefined) player.hp = player.maxHp - if (this.hudManager) { - this.hudManager.hp = this.hudManager.maxHp - this.hudManager.mana = this.hudManager.maxMana - this.hudManager.stamina = this.hudManager.maxStamina - } - } - if (this.hudManager) { - if (this.status && 'hidden' in this.status) { - this.status.hidden = true - } - let menuX = 400 - let menuY = 180 - const rect = this.canvas?.getBoundingClientRect?.() - if ( - rect && - rect.width > 0 && - rect.height > 0 && - typeof this.hudManager.clientToLogical === 'function' - ) { - const camX = this.engine.world.player.x - const camY = this.engine.world.player.y - 16 - const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1 - const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1 - const zoom = this.camera?.zoom ?? 1 - // D2Client.dll 0x6faf6880 (0x6faf68de: add edi, -150; cmp eax, 20): - // Project 150 world pixels above NPC feet so the dialog box sits cleanly above the NPC sprite - const anchorDomX = rect.left + rect.width * 0.5 + (npc.x - camX) * zoom * scaleX - const anchorDomY = rect.top + rect.height * 0.5 + (npc.y - 150 - camY) * zoom * scaleY - const logicalAnchor = this.hudManager.clientToLogical(anchorDomX, anchorDomY) - menuX = Math.round(logicalAnchor.x) - menuY = Math.max(20, Math.round(logicalAnchor.y)) - } else if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') { - const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) - menuX = logical.x - menuY = logical.y - } - if (this.hudManager.worldPanels) { - if (this.engine.dropTables) { - this.hudManager.worldPanels.vendorSession.setDropTables(this.engine.dropTables) - } - this.hudManager.worldPanels.vendorCharLevel = (player as any).level ?? this.hudManager.worldPanels.vendorCharLevel ?? 85 - this.hudManager.worldPanels.openNpcMenu(descriptor, menuX, menuY) - } - this.hudManager.syncPublishedState?.() - } - } - } - - handleLeftClickWorld(worldX: number, worldY: number): void { - const player = this.engine.world.player - this.lockedMonster = null - - // 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 - this.interactWithNpc(clickedNpc) - } else { - ;(this.engine as any).dialog = null - this.pendingInteraction = { kind: 'npc', npc: clickedNpc } - this.navTarget = { x: clickedNpc.x, y: clickedNpc.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.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)) { - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level - this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - 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 } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.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 } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - // 3b. Check Town Portal: within ~48 px of portal vertical oval [portalPos.y - 80, portalPos.y] - const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null - if (activePortalMouth !== null) { - const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) - const minPortalDist = Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) - if (minPortalDist <= 48) { - this.activeClickType = 'portal' - const distToPlayer = Math.hypot(portalPos.x - player.x, portalPos.y - player.y) - if (distToPlayer <= 80) { // OperateRange = 2 tiles (~80 px) - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(portalPos.x - player.x, portalPos.y - player.y) - this.onEnterPortal?.() - } else { - this.pendingInteraction = { kind: 'portal', worldPos: portalPos } - this.navTarget = { x: portalPos.x, y: portalPos.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - } - - // 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px - const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) - let clickedMonster: Monster | null = hoveredAtClick - if (clickedMonster === null) { - let bestMonDist = 36 - for (const m of this.engine.world.monsters) { - if (m.state === 'dead' || m.hp <= 0) continue - if (m.stats?.killable === false || m.stats?.inTown === true) 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' - this.lockedMonster = clickedMonster - this.hoveredMonster = hoveredAtClick - 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 } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - return - } - - // 5. Check Ground Item: within ~32 px - const clickedItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) - if (clickedItem) { - this.pickupGroundItem(clickedItem) - return - } - - // 5b. Check SceneWarp & SceneEntrance (LvlWarp.txt bounding box + radial proximity) - if (runtime.grid) { - let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null - for (const warp of runtime.warps ?? []) { - const res = hitTestWarp(warp, worldX, worldY, runtime.grid) - if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { - bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } - } - } - - let bestEntrance: { entrance: SceneEntrance; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null - for (const entrance of runtime.entrances ?? []) { - const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) - if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { - bestEntrance = { entrance, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } - } - } - - if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { - this.activeClickType = 'warp' - ;(this.engine as any).dialog = null - const { warp, anchorPos, arrivePos } = bestWarp - if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `进入:${getLevelNames(warp.toLevelId).nameZh}` - this.onEnterWarp?.(warp) - } else { - this.pendingInteraction = { kind: 'warp', warp, anchorPos, arrivePos } - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - return - } - - if (bestEntrance !== null) { - this.activeClickType = 'entrance' - ;(this.engine as any).dialog = null - const { entrance, anchorPos, arrivePos } = bestEntrance - if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `进入:${getLevelNames(entrance.toLevelId).nameZh}` - this.onEnterEntrance?.(entrance) - } else { - this.pendingInteraction = { kind: 'entrance', entrance, anchorPos, arrivePos } - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - 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 - this.lastPlayerPos = { x: player.x, y: player.y } - } - - 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), - portalSlot: this.portalSlot, - onEnterPortal: this.onEnterPortal, - onCastTownPortal: this.onCastTownPortal, - onEnterWarp: this.onEnterWarp, - onEnterEntrance: this.onEnterEntrance, - }) - } - - clearTarget(): void { - this.navTarget = null - this.pendingInteraction = null - this.activeClickType = 'none' - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.lastClientPos = null - this.hoveredMonster = null - this.lockedMonster = null - this.hoveredGroundItem = null - this.hoveredWarp = null - this.hoveredEntrance = null - if (this.hudManager !== null) { - this.hudManager.targetMonster = null - } - if (this.canvas?.style) { - this.canvas.style.cursor = '' - } - this.stuckTicks = 0 - this.lastDist = -1 - this.lastPlayerPos = null - ;(this.engine as any).dialog = null - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - if (this.labelsContainer) { - this.updateOverheadLabels() - } - } - - tick(): { movement: { x: number; y: number }; attacking: boolean } { - if (this.isTravelling?.()) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - - const keyMovement = this.input.movement() - const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0 - - if (isKeyMoving) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - const player = this.engine.world.player - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - return { movement: keyMovement, attacking: false } - } - - const player = this.engine.world.player - if (!player.alive || (!this.isLeftMouseDown && !this.isRightMouseDown && !this.input.shiftHeld)) { - if (player.infernoChanneling) { - player.infernoChanneling = false - player.infernoManaRemainder256 = 0 - } - } - - // Shift-Hold Stand Still Priority in tick() - if (this.input.shiftHeld) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = 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') { - const locked = this.lockedMonster - if (locked !== null) { - if (locked.state === 'dead' || locked.hp <= 0 || locked.stats?.killable === false) { - this.lockedMonster = null - this.pendingInteraction = null - this.navTarget = null - } else if (this.pendingInteraction === null) { - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - const range = getSkillRange(leftSkillId, this.hudManager) - const distToPlayer = Math.hypot(locked.x - player.x, locked.y - player.y) - if (distToPlayer <= range) { - if (player.cooldown === 0) { - player.facing = facingOf(locked.x - player.x, locked.y - player.y) - this.castSkill(leftSkillId, locked.x, locked.y) - } - } else { - this.pendingInteraction = { kind: 'monster', monster: locked, skillId: leftSkillId } - this.navTarget = { x: locked.x, y: locked.y } - this.lastDist = distToPlayer - this.stuckTicks = 0 - this.lastPlayerPos = { x: player.x, y: player.y } - } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else 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 && - rightSkillId !== 43 && - rightSkillId !== 219 && - rightSkillId !== 220 - ) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = 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 - this.lastPlayerPos = null - this.interactWithNpc(npc) - } 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 - this.lastPlayerPos = null - player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) - if (this.waypointNetwork.activate(waypoint.waypointId)) { - const rt = this.getRuntime() - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(rt.levelId, 'ENG') : rt.level - this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - 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.lastPlayerPos = null - this.openStash(stash) - } else { - this.navTarget = { x: stash.x, y: stash.y } - } - } else if (this.pendingInteraction.kind === 'portal') { - const { worldPos } = this.pendingInteraction - const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y) - if (dist <= 80) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) - this.onEnterPortal?.() - } else { - this.navTarget = { x: worldPos.x, y: worldPos.y } - } - } else if (this.pendingInteraction.kind === 'warp') { - const { warp, anchorPos, arrivePos } = this.pendingInteraction - const runtime = this.getRuntime() - if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` - : `进入:${getLevelNames(warp.toLevelId).nameZh}` - this.onEnterWarp?.(warp) - } else { - this.navTarget = { x: arrivePos.x, y: arrivePos.y } - } - } else if (this.pendingInteraction.kind === 'entrance') { - const { entrance, anchorPos, arrivePos } = this.pendingInteraction - const runtime = this.getRuntime() - if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = null - this.isLeftMouseDown = false - this.activeClickType = 'none' - player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) - const isEn = getTblLang() === 'ENG' - this.status.textContent = isEn - ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` - : `进入:${getLevelNames(entrance.toLevelId).nameZh}` - this.onEnterEntrance?.(entrance) - } else { - this.navTarget = { x: arrivePos.x, y: arrivePos.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 - this.lastPlayerPos = 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 - this.lastPlayerPos = 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 } - } - } - if (this.hudManager !== null) { - const activeTarget = this.getActiveTargetMonster() - this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null - } - } else if (this.pendingInteraction.kind === 'ground-item') { - const item = this.pendingInteraction.item - const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) - if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { - this.navTarget = null - this.pendingInteraction = null - this.lastPlayerPos = 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 - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - - const playerStep = this.lastPlayerPos !== null - ? Math.hypot(player.x - this.lastPlayerPos.x, player.y - this.lastPlayerPos.y) - : Infinity - this.lastPlayerPos = { x: player.x, y: player.y } - - if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5 && playerStep < 0.5) { - this.stuckTicks++ - if (this.stuckTicks >= 12) { - this.navTarget = null - this.pendingInteraction = null - this.stuckTicks = 0 - this.lastDist = -1 - this.lastPlayerPos = null - return { movement: { x: 0, y: 0 }, attacking: false } - } - } else { - this.stuckTicks = 0 - this.lastDist = dist - } - - const steerTarget = resolveObstacleAwareSteerTarget( - player.x, - player.y, - this.navTarget, - this.getRuntime()?.grid, - ) - const steerDx = steerTarget.x - player.x - const steerDy = steerTarget.y - player.y - const steerDist = Math.hypot(steerDx, steerDy) - const normDist = steerDist > 1e-4 ? steerDist : dist - const moveDx = steerDist > 1e-4 ? steerDx : dx - const moveDy = steerDist > 1e-4 ? steerDy : dy - - const runFactor = (this.hudManager && this.hudManager.isRunning) ? 1.5 : 1.0 - return { - movement: { - x: (moveDx / normDist) * runFactor, - y: (moveDy / normDist) * runFactor, - }, - attacking: false, - } - } - - this.lastPlayerPos = null - 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('pointerleave', this.onPointerLeave) - 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('pointerleave', this.onPointerLeave) - 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) - } - - this.isLeftMouseDown = false - this.isRightMouseDown = false - this.lastClientPos = null - this.clearTarget() - for (const label of this.labelPool) { - label.hidden = true - } - } -} - -/** - * Find a query parameter. - * - * @param key - parameter name. - * @param fallback - value when absent. - * @returns the value. - */ -function param(key: string, fallback: string): string { - if (typeof location === 'undefined') return fallback - 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[] - /** DRLG-generated copies of an act, keyed by act number (see act-variants.ts). */ - readonly actLayouts?: Readonly> -} - -/** - * 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 - /** DS1 member (presets) or generator tag the map was baked from. */ - readonly ds1?: string - /** DRLG act variant of a generated level (see act-variants.ts). */ - readonly actVariant?: number - /** DRLG parity fidelity tier of this level. */ - readonly fidelity?: 'exact' | 'layout' | 'approximation' -} - -/** 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 - /** DRLG parity fidelity tier of this scene. */ - readonly fidelity?: 'exact' | 'layout' | 'approximation' - 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 automap?: AutomapLayerData | undefined - 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 - } -} - -/** - * The per-session choice of level copies, one chooser per pack (see act-variants.ts). - * - * It lives beside the index cache because it is part of the same world: every transition in the - * session must agree on which generated copy of an act is loaded. - */ -const worldVariantsCache = new Map>() - -/** - * The pack's copy chooser, created on first use. - * - * @param packBase - directory holding `index.json`. - * @param index - the pack's already-fetched index. - * @returns the chooser shared by every load from this pack. - */ -function worldVariantsFor(packBase: string, index: PackIndex): WorldVariants { - let variants = worldVariantsCache.get(packBase) - if (variants === undefined) { - variants = new WorldVariants(index, WORLD_VARIANT_SEED) - worldVariantsCache.set(packBase, variants) - } - return variants -} - -/** - * Build the runtime for a prebaked pack. - * - * An explicitly named copy (`?level=`) decides its act's generated variant, so walking on from it - * stays inside the act it was generated with. Without one the act's town opens: in a DRLG-generated - * act that is the town file the chosen copy of the act fixed (its gate faces the generated level). - * - * @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 variants = worldVariantsFor(packBase, index) - const wanted = wantedLevel === '' - ? undefined - : forAct.find(candidate => candidate.label === wantedLevel) - ?? forAct.find(candidate => candidate.slug === wantedLevel) - if (wanted !== undefined) variants.pin(wanted) - const entry = wanted ?? variants.startEntry(act) - if (entry === null) return null - return await buildPackRuntime(packBase, index, entry, viewport, charBases) -} - -/** - * Build the runtime for one level, named by id rather than by label. - * - * The copy comes from the session's {@link WorldVariants}: a level of a DRLG-generated act uses the - * act's chosen copy (the town uses the file that copy fixed); any other level with several baked - * copies (Lut Gholein LutN/LutW, Cloister Courtyard N/E/W, ...) prefers the copy whose opening faces - * the side the player arrives from, so the gate and road line up across the border. - * - * @param packBase - directory holding `index.json`. - * @param levelId - `Levels.txt` `Id` of the wanted level. - * @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, - viewport: { width: number; height: number }, - charBases: readonly string[], - fromSide?: Side, - preferredVariantLabel?: string, -): Promise { - const index = await getPackIndex(packBase) - if (index === null) return null - const candidates = index.levels.filter(entry => entry.levelId === levelId) - const preferred = preferredVariantLabel !== undefined && fromSide === undefined - ? candidates.find( - c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel, - ) - : undefined - const entry = preferred ?? worldVariantsFor(packBase, index).entryForLevel(levelId, fromSide) - if (entry === null) 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]) - // The fill below covers floor gaps left by the older wilderness generator - // (Acts 2-5). It must not touch scenes baked from the DRLG port (the entries - // that carry an `actVariant`, i.e. the Act I outdoor levels). There, a cell - // without a floor is a cell D2 leaves blank, and D2 draws blank cells as void: - // the grid cells behind the tree borders that - // DRLGOUTPLACE_SetBlankBorderGridCells (D2Common.0x6FD80C80) marks, and the - // extra last DS1 column and row of the (width + 1) x (height + 1) canvas. The - // bake blocks those cells in the collision map, so grass painted there would - // show ground nobody can walk on. - if (entry.levelId !== undefined && entry.actVariant === 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, - } - let spawn = scene.spawn === null || scene.spawn === undefined - ? findIsoSpawn(grid) - : { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 } - if (effectiveLevelId === 1 && param('gameName', '') !== '') { - const warriv = (scene.npcs ?? []).find( - n => n.id === 155 || (n.name ?? '').toLowerCase().includes('warriv'), - ) - const anchorX = (warriv?.x ?? stashes[0]?.x ?? 4512) - 32 - const anchorY = (warriv?.y ?? stashes[0]?.y ?? 1720) + 32 - let foundTownCenter: { x: number; y: number } | null = null - for (let r = 0; r <= 12 && !foundTownCenter; r++) { - for (let dy = -r; dy <= r && !foundTownCenter; dy++) { - for (let dx = -r; dx <= r && !foundTownCenter; dx++) { - if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue - const px = anchorX + dx * 16 - const py = anchorY + dy * 16 - if (!isBlockedAt(grid, px, py)) { - foundTownCenter = { x: px, y: py } - } - } - } - } - if (foundTownCenter !== null) { - spawn = foundTownCenter - } - } - if (spawn === null) throw new Error(`${entry.label}: the map has no walkable spawn`) - - // 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), - ...(scene.automap !== undefined ? { automap: scene.automap } : {}), - spawn, - npcs: scene.npcs ?? [], - monsterPacks: scene.monsters?.packs ?? [], - baseMonsterPacks: 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: getTblLang() === 'ENG' - ? [ - `Initial pages ${priority.join(',')}, total ${String(pages.length)} pages`, - ...(scene.monsters?.packs && scene.monsters.packs.length > 0 - ? [`Monsters: ${String(scene.monsters.budget)} / ${String(scene.monsters.packs.length)} packs (${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join(', ')})`] - : []), - ] - : [ - `首屏页 ${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 - }, - lang: TblLang = getTblLang(), -): string { - const isEn = lang === 'ENG' - const parts: string[] = [] - if (load.missing.length > 0) { - const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、') - const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : '' - parts.push( - isEn - ? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})` - : `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`, - ) - } - if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`) - if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${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, lang: TblLang = getTblLang()): string { - if (lang === 'ENG') { - return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}` - } - 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('meteorexplode')) { - const meteorArt = map.get('meteorexplode')! - if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt) - } - 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' || - missileArt.meta.name === 'firewall' || - missileArt.meta.name === 'groundfirebig') && - frameCount === 37 - ) { - // For meteorfire & firewall: 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)). - // Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade. - const defaultDuration = - missileArt.meta.name === 'meteorfire' - ? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15 - : shot.ttl > 0 - ? (shot.ageTicks ?? 0) + shot.ttl - : 90 - const duration = shot.fireDuration ?? defaultDuration - const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl) - const EXTINCTION_TICKS = 12 - if (shot.ttl <= EXTINCTION_TICKS) { - frameIndex = Math.max(0, Math.min(11, shot.ttl - 1)) - } else { - 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 isGroundFlame = - missileArt.meta.name === 'meteorfire' || - missileArt.meta.name === 'firewall' || - missileArt.meta.name === 'groundfirebig' - const isExtinguishing = isGroundFlame && shot.ttl <= 12 - const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 - const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 - const opacity = isExtinguishing ? decayRatio : 1.0 - - const drawW = frame.width * scale - const drawH = frame.height * scale - const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2 - const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2 - const drawX = shot.x + baseAnchorX * scale - const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude - - const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1 - renderer.draw(frame, drawX, drawY, { - atlas: missileArt.handle, - ...(isAdditive ? { blendMode: 'additive' } : {}), - ...(isExtinguishing - ? { - width: drawW, - height: drawH, - tint: [1, 1, 1, opacity], - } - : {}), - }) - - // 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') { - const age = shot.ageTicks ?? 0 - const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3 - const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5 - - // 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base) - renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5]) - renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75]) - renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9]) - - // 2. Serpentine segmented neck rising from the lava pit - const seg1Y = y - 7 + wobble * 0.3 - const seg1X = x + swayX * 0.3 - renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85]) - renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9]) - - const seg2Y = y - 14 + wobble * 0.6 - const seg2X = x + swayX * 0.7 - renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85]) - renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9]) - - // 3. Serpent head crown with flaming mane & glowing eyes - const headY = y - 22 + wobble - const headX = x + swayX - // Flaming crest / mane - renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6]) - renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85]) - renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95]) - // Dragon/serpent snout & jaw - renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9]) - // Glowing incandescent eye - renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0]) - - // 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest - const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt') - if (art !== undefined) { - const group = art.frames[0] - const frameCount = Math.max(1, art.meta.framesPerDirection) - const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount - const frame = group?.[frameIndex] ?? group?.[0] - if (frame !== undefined) { - const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2 - const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2 - renderer.draw(frame, drawX, drawY, { - atlas: art.handle, - blendMode: 'additive', - }) - } - } - return - } - - if ( - shot.missileType === 'meteorfire' || - shot.missileType === 'firewall' || - shot.missileType === 'groundfirebig' - ) { - const isExtinguishing = shot.ttl <= 12 - const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 - const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 - const alpha = isExtinguishing ? decayRatio : 1.0 - renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha]) - renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha]) - renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha]) - 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 - - const isLightningMissile = - shot.missileType === 'lightningbolt' || - shot.missileType === 'chainlightning' || - shot.missileType === 'lightning' || - shot.missileType === 'lightninghit' - - if (isLightningMissile) { - drawElectricBoltFallback(renderer, x, y) - return - } - - // 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) - if (explosionArt.meta.directions > 1) { - for (let dir = 0; dir < explosionArt.meta.directions; dir++) { - const group = explosionArt.frames[dir] - 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 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') -} - -/** - * Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`): - * - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total), - * then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`). - * - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending. - * - Falls back to a procedural blue portal vortex oval in unit test / offline environments. - */ -export function drawTownPortal( - renderer: SpriteRenderer, - x: number, - y: number, - elapsedMs: number, - overlayArtMap: ReadonlyMap, -): void { - const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256) - const openDurationMs = 15 * frameDurationMs // 768 ms - const isOpening = elapsedMs < openDurationMs - const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr') - const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd') - const rawFrameIdx = isOpening - ? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs))) - : Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15 - - let drewSprite = false - if (trArt !== undefined) { - const group = trArt.frames[0] - const frame = group?.[rawFrameIdx] ?? group?.[0] - if (frame !== undefined) { - const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2 - const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2 - renderer.draw(frame, drawX, drawY, { - atlas: trArt.handle, - blendMode: 'normal', - }) - drewSprite = true - } - } - if (hdArt !== undefined) { - const group = hdArt.frames[0] - const frame = group?.[rawFrameIdx] ?? group?.[0] - if (frame !== undefined) { - const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2 - const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2 - renderer.draw(frame, drawX, drawY, { - atlas: hdArt.handle, - blendMode: 'additive', - elevationY: Math.max(0, frame.height - 16), - }) - renderer.setBlendMode('normal') - drewSprite = true - } - } - if (!drewSprite) { - renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { 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 isEthOrSockBase = !item.isGold && - (item.quality === 'normal' || item.quality === 'superior' || item.quality === 'high' || item.quality === 'hi-quality') && - !(item as any).isRuneword && - !(item.item as any)?.isRuneword && - isGroundItemEtherealOrSocketed(item) - - const qColor = item.isGold - ? GROUND_ITEM_QUALITY_COLORS.gold! - : isEthOrSockBase - ? GROUND_ITEM_QUALITY_COLORS.low! - : (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!) - - // 1. Authentic Blizzard flippy ground sprite resolution (strictly fail-fast if missing) - const target = item.isGold - ? { isGold: true, amount: item.amount, code: 'gld' } - : (item.item || (item as any)) - - const sr = resolveGroundItemSpriteRect( - target, - BAKED_UI_MANIFEST.flippyRects, - BAKED_UI_MANIFEST.codeToFlippyFile, - ) - - if (sr === null) { - throw new Error( - `GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST for ground item "${item.name}" (id: "${item.id}")`, - ) - } - - // 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, { - ...(itemsAtlas !== undefined ? { 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, - }) - } - - // 3. Glint / Sparkle 4-Point Star Animation for resting items - // Dropped items display periodic sparkle glints once settled on the ground plane - const isAirborne = item.bounceState !== null && item.bounceState !== undefined && bounceH > 0 - if (!isAirborne) { - 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]) - } - } - } +import { LightGrid } from '../game/engine/light-grid.ts' +import { createIsoTerrain } from '../game/iso-terrain.ts' +import type { LoadedMonsterArt } from '../game/monster-art.ts' +import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' +import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile } from '../game/portal.ts' +import { getMissileTxtData, isAuraSkill } from '../game/skills.ts' +import { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts' +import { buildBounds } from '../render/cull.ts' +import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts' +import { MISSILE_METAS, velocityToDccDirection, type MissileFrameTuple, type MissileMeta } from '../render/missiles-meta.ts' +import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts' +import { SpriteRenderer, type AtlasHandle } from '../render/renderer.ts' +import { RoofFadeController } from '../render/roofs.ts' +import { ViewportCamera } from '../sim/camera.ts' +import { KeyboardInput } from '../sim/input.ts' +import { GameLoop } from '../sim/loop.ts' +import { GroundLabelOverlay } from '../ui/ground-labels.ts' +import type { HudManager } from '../ui/hud-manager.ts' +import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' +import { Minimap, type MinimapLevel } from '../ui/minimap.ts' +import { executeSceneChatCommand, handleActSceneChatCommand } from './backend/chat-commands.ts' +import { DEMO_EXPERIENCE } from './backend/net-simulation.ts' +import { + ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX, + WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf, + getEntranceLabelWorldPos, getWarpLabelWorldPos, hitTestEntrance, hitTestWarp, + isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile, + screenToWorld, subTileToScene, type SceneEntrance, type SceneWarp, + type SceneWaypoint, type ScreenToWorldParams, +} from './backend/scene-links-nav.ts' +import { + CANONICAL_TOWN_STASHES, HERO_CLASSES, ROOM_ACTIVATION_RADIUS, ROOM_DEACTIVATION_RADIUS, + TOWN_LEVEL_IDS, buildStreamingRooms, demoteToNormal, getSharedDropTables, + isTownLevel, normalizeHeroToken, parseDensityMultiplier, parseEliteMultiplier, + promoteToChampion, promoteToUnique, rebudgetMonsterPacks, roomHasWalkableGround, + type DensityMultiplier, type EliteMultiplier, type HeroClassInfo, + type RebudgetOptions, type TownStashEntity, +} from './backend/scene-population.ts' +import { castSkill, executeSkillCast, type SkillCastContext, type SkillCastOutcome } from './backend/skill-caster.ts' +import { + PASSIVE_SKILL_IDS, checkAmmunition, executeNormalAttack, getAttackWeaponInfo, + getSkillMana, getSkillRange, isPassiveSkill, type AttackWeaponInfo, + type AttackWeaponInputItem, type AttackWeaponKind, type AttackWeaponSubtype, +} from './backend/weapon-combat.ts' +import { initBrowserArenaMode } from './frontend/arena-overlay.ts' +import { + ADDITIVE_MONSTER_TOKENS, DEFAULT_BASES, loadCharacterArt, loadMonsterArtMap, + missingMonsterArtIds, type LoadedCharacter, type MonsterArtLoad, +} from './frontend/entity-art-loader.ts' +import { + GROUND_ITEM_QUALITY_COLORS, drawGroundItem, globalItemsAtlasHandle, + loadItemsAtlas, resolveGroundItemFlippyFrame, syncHudGroundDrops, +} from './frontend/ground-item-renderer.ts' +import { + LIGHTING_PRESETS, lightingLabel, normalizeLightingPreset, + type LightingPreset, type LightingPresetConfig, +} from './frontend/lighting-presets.ts' +import { + drawCastOverlay, drawExplosion, drawFireExplosion, drawFireboltProjectile, + drawMissileProjectile, drawTownPortal, loadMissileArtMap, loadOverlayArtMap, + type LoadedMissileArt, type LoadedOverlayArt, +} from './frontend/missile-overlay-renderer.ts' +import { + SceneMouseController, setupMouseControls, type MouseControllerContext, + type PendingInteraction, type SceneMouseControllerOptions, +} from './frontend/mouse-controller.ts' +import { + ADDITIVE_OBJECT_TOKENS, PERSISTENT_ILLUMINATION_TOKENS, computeObjectPhaseOffsetMs, + updateAnimatedObject, updateAnimatedObjects, type AnimatedObjectFrame, + type Drawable, type ObjectDrawable, +} from './frontend/object-animation.ts' +import { + playInventoryFullFeedback, projectNpcLabel, showNotification, + updateGroundItemLabelsOverlay, updateOverheadLabels, + type NpcLabelProjectionParams, type NpcLabelProjectionResult, +} from './frontend/overhead-labels.ts' +import { + DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime, + countLoadedPages, createLevelTransitionController, fetchJson, getPackIndex, + loadPackRuntime, loadRuntimeForLevel, pagesNeededForViewport, param, + preloadRemaining, worldVariantsFor, type MapRuntime, type PackIndex, + type PackIndexEntry, type PackObject, type PackObjectFrame, type PackSceneJson, +} from './frontend/pack-loader.ts' +import { + SAVE_KEY, monsterArtStatusText, monsterPlanStatusText, monsterToTargetHudInfo, + publish, recordMonsterArtLoad, reportMonsterPlan, state, type ActSceneState, +} from './frontend/scene-state.ts' +import { + createActSceneHudManager, createHeroLightingAndMonsterControls, mountChatAndToolbarUi, + populatePackSelectors, updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors, +} from './frontend/toolbar-chat-ui.ts' +import { + applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile, + renderWorldLayers, stepPlayerAndMonsterAnimations, +} from './frontend/world-renderer.ts' +import { FadeOverlay } from './transition.ts' + +// GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ./frontend/ground-item-renderer.ts) +export { + ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, CANONICAL_TOWN_STASHES, + DEMO_QUESTS, DEMO_SKILLS, ENTRANCE_CLICK_RADIUS_PX, GROUND_ITEM_QUALITY_COLORS, + HERO_CLASSES, LIGHTING_PRESETS, LightningTrailManager, MISSILE_METAS, + OVERLAY_METAS, PASSIVE_SKILL_IDS, PERSISTENT_ILLUMINATION_TOKENS, + SceneMouseController, TELEKINESIS_RANGE_PX, TOWN_LEVEL_IDS, WARP_CLICK_RADIUS_PX, + applyDynamicSceneLighting, buildMinimapLevel, buildPackRuntime, buildStreamingRooms, + castSkill, cellOf, checkAmmunition, computeObjectPhaseOffsetMs, countLoadedPages, + demoteToNormal, drawCastOverlay, drawElectricBoltFallback, drawExplosion, + drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile, + drawTownPortal, executeNormalAttack, executeSceneChatCommand, executeSkillCast, + facingOf, fetchJson, getAttackWeaponInfo, getEntranceLabelWorldPos, + getMissileTxtData, getPackIndex, getSharedDropTables, getSkillMana, getSkillRange, + getWarpLabelWorldPos, globalItemsAtlasHandle, handleActSceneChatCommand, + hitTestEntrance, hitTestWarp, initBrowserArenaMode, isAuraSkill, isPassiveSkill, + isPlayerWithinLinkRange, isTownLevel, lightingLabel, loadCharacterArt, + loadItemsAtlas, loadMissileArtMap, loadMonsterArtMap, loadOverlayArtMap, + loadPackRuntime, loadRuntimeForLevel, missingMonsterArtIds, monsterArtStatusText, + monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi, + normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param, + parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback, + playerSubTile, populatePackSelectors, preloadRemaining, projectNpcLabel, + promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks, + recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan, + resolveGroundItemFlippyFrame, resolveGroundItemSpriteRect, + resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, roomHasWalkableGround, + sceneToSubTile, screenToWorld, setupMouseControls, showNotification, state, + stepPlayerAndMonsterAnimations, subTileToScene, syncEngineState, syncHudGroundDrops, + updateAnimatedObject, updateAnimatedObjects, updateGroundItemLabelsOverlay, + updateOverheadLabels, updateSelectorWarningStyle, updateToolbarUrl, + velocityToDccDirection, worldVariantsFor, + type ActSceneState, type AnimatedObjectFrame, type AttackWeaponInfo, + type AttackWeaponInputItem, type AttackWeaponKind, type AttackWeaponSubtype, + type DensityMultiplier, type Drawable, type EliteMultiplier, type HeroClassInfo, + type LightingPreset, type LightingPresetConfig, type LoadedCharacter, + type LoadedMissileArt, type LoadedOverlayArt, type MapRuntime, + type MissileFrameTuple, type MissileMeta, type MonsterArtLoad, + type MouseControllerContext, type NpcLabelProjectionParams, + type NpcLabelProjectionResult, type ObjectDrawable, type OverlayMeta, + type PackIndex, type PackIndexEntry, type PackObject, type PackObjectFrame, + type PackSceneJson, type PendingInteraction, type RebudgetOptions, + type SceneEntrance, type SceneMouseControllerOptions, type SceneWarp, + type SceneWaypoint, type ScreenToWorldParams, type SkillCastContext, + type SkillCastOutcome, type TownStashEntity, } /** * 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 @@ -7822,8 +161,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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 lightingSelect = document.querySelector('#lighting') const densitySelect = document.querySelector('#density') const eliteSelect = document.querySelector('#elite') @@ -7833,11 +170,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st state.densityMultiplier = densityMultiplier state.eliteMultiplier = eliteMultiplier - let reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise = async () => {} - const input = new KeyboardInput() input.attach() - let skippedDraws = 0 let currentHeroToken = normalizeHeroToken(param('hero', param('class', 'so'))) state.heroToken = currentHeroToken @@ -7850,11 +184,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st let character: LoadedCharacter | null = null const dropTables = getEmbeddedDropTables() let hudManager: HudManager | null = null - const soCompositor = new SorceressLayerCompositor( - packEntityBase ?? 'samples/d2-packs', - ) - let activeAppearanceKey = '' - let appearanceSeq = 0 + let engineRef: GameEngine | null = null let monsterArtMap = new Map() let missileArtMap = new Map() @@ -7881,192 +211,52 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ? LIGHTING_PRESETS[currentLightingPreset].labelEn : LIGHTING_PRESETS[currentLightingPreset].labelZh - const syncSorceressAppearance = async (): Promise => { - const hudInst = hudManager ?? ( - typeof window !== 'undefined' - ? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null - : null - ) - if (currentHeroToken !== 'so' || hudInst === null) { - activeAppearanceKey = '' - state.characterAppearanceKey = undefined - state.characterAppearance = undefined - if (hudInst !== null) { - const wCode = hudInst.inventory.equipped.weapon1?.code - state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth') - } - return false - } - - const spec = resolveSorceressAppearance( - hudInst.inventory.equipped, - dropTables, - ) - const key = appearanceKey(spec) - state.weaponClass = spec.wclass - state.characterAppearance = spec - - if (key === activeAppearanceKey) { - return false - } - activeAppearanceKey = key - state.characterAppearanceKey = key - const seq = ++appearanceSeq - - try { - const composited = await soCompositor.composite(spec) - if (seq !== appearanceSeq || currentHeroToken !== 'so') return false - const newHandle = renderer.addIndexedAtlas(composited.r8, composited.width, composited.height) - const groups: AtlasFrame[][] = composited.groups.map(group => - group.map(([x, y, width, height, anchorX, anchorY]) => ({ - x, - y, - width, - height, - anchorX, - anchorY, - })), - ) - if (character?.handle) { - renderer.deleteAtlas(character.handle) - } - character = { - handle: newHandle, - groups, - directions: composited.directions, - walk: composited.walk, - stand: composited.stand, - members: composited.members, - clips: composited.clips, - } - state.character = true - state.characterMembers = composited.members - state.characterAppearanceKey = key - state.characterAppearance = spec - if (playerAnimator.mode !== null) { - const nextClip = composited.clips[playerAnimator.mode] - if (nextClip !== undefined) { - playerAnimator.syncClip(nextClip) - } - } - return true - } catch (err) { - console.warn('Sorceress appearance composite failed:', err) - return false - } - } - - const switchLighting = (preset: string): LightingPreset => { - currentLightingPreset = normalizeLightingPreset(preset) - const cfg = LIGHTING_PRESETS[currentLightingPreset] - lightingEnabled = cfg.enabled - const isEn = getTblLang() === 'ENG' - const modeLabel = isEn ? cfg.labelEn : cfg.labelZh - state.lightingPreset = currentLightingPreset - state.lightingMode = modeLabel - if (lightingSelect !== null && lightingSelect.value !== currentLightingPreset) { - lightingSelect.value = currentLightingPreset - } - try { - const url = new URL(location.href) - if (currentLightingPreset === 'auto') { - url.searchParams.delete('lighting') - } else { - url.searchParams.set('lighting', currentLightingPreset) - } - history.replaceState(null, '', url.toString()) - } catch { - // ignore in non-browser context - } - const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:' - status.textContent = `${msgPrefix}${modeLabel}` - setTimeout(() => { - if (status.textContent.startsWith(msgPrefix)) { - status.textContent = '' - } - }, 2500) - return currentLightingPreset - } + const { + syncSorceressAppearance, + switchLighting, + switchHero, + reloadMonsters, + } = createHeroLightingAndMonsterControls({ + renderer, + dropTables, + playerAnimator, + status, + heroSelect, + lightingSelect, + densitySelect, + eliteSelect, + getRuntime: () => runtime, + getEngine: () => engineRef!, + getHudManager: () => hudManager, + getCurrentHeroToken: () => currentHeroToken, + setCurrentHeroToken: (token) => { currentHeroToken = token }, + getCharacter: () => character, + setCharacter: (char) => { character = char }, + getActivePalette: () => activePalette, + getPackEntityBase: () => packEntityBase, + getPackEntityAct: () => packEntityAct, + getCurrentLightingPreset: () => currentLightingPreset, + setLightingState: (preset, enabled) => { + currentLightingPreset = preset + lightingEnabled = enabled + }, + getMultipliers: () => ({ densityMultiplier, eliteMultiplier }), + setMultipliers: (d, e) => { + densityMultiplier = d + eliteMultiplier = e + }, + setMonsterArtMap: (map) => { monsterArtMap = map }, + }) if (lightingSelect !== null) { lightingSelect.disabled = false lightingSelect.value = currentLightingPreset - lightingSelect.onchange = () => { - switchLighting(lightingSelect.value) - } + lightingSelect.onchange = () => { switchLighting(lightingSelect.value) } } - - 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 - const isEn = getTblLang() === 'ENG' - const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh - status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …` - 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) - } - activeAppearanceKey = '' - if (nextToken === 'so') { - await syncSorceressAppearance() - } else { - state.characterAppearanceKey = undefined - state.characterAppearance = undefined - } - 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 (character?.clips?.nu) { - playerAnimator.play('nu', character.clips.nu, { restart: true }) - } - const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 ' - status.textContent = isEn - ? `${switchedPrefix}${heroDispName} (${String(character.members)} layers).` - : `${switchedPrefix}${heroDispName}(${String(character.members)} 层素材)。` - setTimeout(() => { - if (status.textContent.startsWith(switchedPrefix)) { - status.textContent = '' - } - }, 3000) - return true - } - status.textContent = isEn - ? `Failed to switch hero to ${heroDispName}.` - : `切换角色 ${heroDispName} 失败。` - return false - } - if (heroSelect !== null) { heroSelect.disabled = false heroSelect.value = currentHeroToken - heroSelect.onchange = async () => { - await switchHero(heroSelect.value) - } + heroSelect.onchange = async () => { await switchHero(heroSelect.value) } } if (typeof window !== 'undefined') { @@ -8083,12 +273,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ;(state as any).syncAppearance = syncSorceressAppearance state.reloadMonsters = (opts) => reloadMonsters(opts) } + 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) @@ -8096,16 +285,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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 and exit name plates. The container and the elements outlive a frame so - // the render loop only has to move them. const labelsContainer = document.querySelector('#labels') - - // 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') @@ -8115,16 +295,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st })() 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 }, - ] + 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 }) @@ -8136,9 +311,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st for (const s of runtime.stashes ?? []) { safeZones.push({ x: s.x, y: s.y, radius: 160 }) } - if (!runtime.baseMonsterPacks) { - runtime.baseMonsterPacks = runtime.monsterPacks - } + if (!runtime.baseMonsterPacks) runtime.baseMonsterPacks = runtime.monsterPacks runtime.monsterPacks = rebudgetMonsterPacks(runtime.baseMonsterPacks, { densityMultiplier, eliteMultiplier, @@ -8165,8 +338,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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', @@ -8182,10 +353,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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, @@ -8198,9 +365,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st if (!inv?.equipped) return [] return Object.values(inv.equipped).filter(Boolean) as any }, - // 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, @@ -8211,17 +375,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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) }, }, - } + }, ) + engineRef = engine engine.setDropTables(dropTables) @@ -8240,12 +401,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st ;(window.__d2webActScene as any).lightningTrailManager = lightningTrailManager } - // `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 }) } @@ -8272,220 +429,16 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } } - // In-game chat/console input overlay supporting "/players <1-8>" - if (typeof document !== 'undefined') { - let chatInput = document.querySelector('#d2-chat-input') - if (!chatInput) { - chatInput = document.createElement('input') - chatInput.id = 'd2-chat-input' - chatInput.type = 'text' - chatInput.placeholder = getTblLang() === 'ENG' - ? 'Type /players 1-8 or message, press Enter to send…' - : '输入 /players 1-8 或消息,按 Enter 发送…' - chatInput.className = 'overlay' - chatInput.style.position = 'fixed' - chatInput.style.left = '12px' - chatInput.style.bottom = '56px' - chatInput.style.width = '320px' - chatInput.style.padding = '4px 8px' - chatInput.style.background = 'rgba(11, 9, 6, 0.9)' - chatInput.style.color = '#e8dcc8' - chatInput.style.border = '1px solid #c8a15a' - chatInput.style.borderRadius = '4px' - chatInput.style.font = 'inherit' - chatInput.style.fontSize = '12px' - chatInput.style.zIndex = '10' - chatInput.style.display = 'none' - document.body.appendChild(chatInput) - } - - const openChat = () => { - if (!chatInput) return - chatInput.placeholder = getTblLang() === 'ENG' - ? 'Type /players 1-8 or message, press Enter to send…' - : '输入 /players 1-8 或消息,按 Enter 发送…' - chatInput.style.display = 'block' - chatInput.value = '' - chatInput.focus() - } - - const closeChat = () => { - if (!chatInput) return - chatInput.value = '' - chatInput.blur() - chatInput.style.display = 'none' - } - - chatInput.addEventListener('keydown', (e) => { - e.stopPropagation() - if (e.key === 'Enter') { - e.preventDefault() - const text = chatInput!.value - closeChat() - if (text) { - executeChatCommand(text) - } - } else if (e.key === 'Escape') { - e.preventDefault() - closeChat() - } - }) - - window.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const active = document.activeElement - if ( - active instanceof HTMLInputElement || - active instanceof HTMLSelectElement || - active instanceof HTMLTextAreaElement - ) { - return - } - e.preventDefault() - openChat() - } - }) - } - - reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise => { - if (options?.density !== undefined) { - densityMultiplier = parseDensityMultiplier(options.density) - } - if (options?.elite !== undefined) { - eliteMultiplier = parseEliteMultiplier(options.elite) - } - state.densityMultiplier = densityMultiplier - state.eliteMultiplier = eliteMultiplier - - if (densitySelect !== null) { - densitySelect.value = String(densityMultiplier) - updateSelectorWarningStyle(densitySelect, '1') - } - if (eliteSelect !== null) { - eliteSelect.value = String(eliteMultiplier) - updateSelectorWarningStyle(eliteSelect, '1') - } - try { - if (typeof location !== 'undefined' && typeof history !== 'undefined') { - const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier }) - const newUrl = `${location.pathname}${nextSearch}${location.hash}` - history.replaceState(null, '', newUrl) - } - } catch { - // ignore in non-browser context - } - - if (isTownLevel(runtime.levelId)) { - runtime.monsterPacks = [] - engine.world.monsters.length = 0 - engine.streamingManager = undefined - reportMonsterPlan(runtime) - return - } - - const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks - runtime.baseMonsterPacks = basePacks - runtime.monsterPacks = rebudgetMonsterPacks(basePacks, { - densityMultiplier, - eliteMultiplier, - levelId: runtime.levelId, - seed: WORLD_VARIANT_SEED ^ runtime.levelId, - }) - - // Clear existing monsters - engine.world.monsters.length = 0 - - const player = engine.world.player - const currentSafeZones: SafeZone[] = [ - { x: player.x, y: player.y, radius: 180 }, - ] - for (const wp of runtime.waypoints) { - const pos = subTileToScene(wp.x, wp.y, runtime.grid) - currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - for (const ent of runtime.entrances) { - const pos = subTileToScene(ent.x, ent.y, runtime.grid) - currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) - } - for (const s of runtime.stashes ?? []) { - currentSafeZones.push({ x: s.x, y: s.y, radius: 160 }) - } - - const currentResolvedPacks: 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 nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones) - if (nextRooms.length > 0) { - engine.streamingManager = new MonsterStreamingManager( - engine.world, - engine.terrain, - nextRooms, - { - activationRadius: ROOM_ACTIVATION_RADIUS, - deactivationRadius: ROOM_DEACTIVATION_RADIUS, - safeZones: currentSafeZones, - }, - ) - } else { - engine.streamingManager = undefined - if (currentResolvedPacks.length > 0) { - spawnMonsterPacks( - engine.world, - currentResolvedPacks, - { x: player.x, y: player.y }, - Math.max(runtime.widthPx, runtime.heightPx) / 4, - engine.terrain, - currentSafeZones, - ) - } - } - reportMonsterPlan(runtime) - - if (runtime.palette !== null) { - 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, - runtime.palette, - renderer, - packEntityBase, - packEntityAct, - ) - monsterArtMap = loaded.map - recordMonsterArtLoad(loaded) - } - } - } - - if (densitySelect !== null) { - densitySelect.disabled = false - densitySelect.value = String(densityMultiplier) - updateSelectorWarningStyle(densitySelect, '1') - densitySelect.onchange = () => { - void reloadMonsters({ density: densitySelect.value }) - } - } - if (eliteSelect !== null) { - eliteSelect.disabled = false - eliteSelect.value = String(eliteMultiplier) - updateSelectorWarningStyle(eliteSelect, '1') - eliteSelect.onchange = () => { - void reloadMonsters({ elite: eliteSelect.value }) - } - } - - /* ----------------------------------------------------------------------- * - * World connectivity - * ----------------------------------------------------------------------- */ + mountChatAndToolbarUi({ + engine, + status, + executeChatCommand, + densitySelect, + eliteSelect, + densityMultiplier, + eliteMultiplier, + reloadMonsters: (opts) => reloadMonsters(opts), + }) const fade = new FadeOverlay(document.body) const minimap = new Minimap() @@ -8496,192 +449,76 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } const waypointNetwork = new WaypointNetwork() const portalSlot = new TownPortalSlot() - const townVariantByAct = new Map() - if (isTownLevel(runtime.levelId) && runtime.variant) { - townVariantByAct.set(runtime.act, runtime.variant) - } - - /** - * 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) } - } - - /** 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 - - /** - * Cast a Town Portal from the current non-town level, placing the wilderness - * mouth on a walkable sub-tile next to the player and the town mouth at the - * authentic 1.13c DS1 Object #33 (`Objects.txt` ID 59) Town Portal Spot. - */ - const castTownPortal = (): boolean => { - if (travelling) return false - const isEn = getTblLang() === 'ENG' - if (isTownLevel(runtime.levelId)) { - playInventoryFullFeedback() - showNotification(isEn ? 'Cannot cast Town Portal in town' : '无法在城镇中开启回城传送门', 2000, status) - return false - } - - const here = playerSubTile() - const candidateOffsets = [ - { dx: -2, dy: 2 }, - { dx: -2, dy: 1 }, - { dx: -1, dy: 2 }, - { dx: 0, dy: -2 }, - { dx: 2, dy: -1 }, - { dx: 2, dy: 0 }, - { dx: 1, dy: -1 }, - { dx: 0, dy: 0 }, - ] - let fromX = here.x - let fromY = here.y - for (const off of candidateOffsets) { - const cx = here.x + off.dx - const cy = here.y + off.dy - const scenePos = subTileToScene(cx, cy, runtime.grid) - if (!isBlockedAt(runtime.grid, scenePos.x, scenePos.y)) { - fromX = cx - fromY = cy - break - } - } - - const townLevelId = townLevelForAct(runtime.act) - const townSub = resolveTownPortalSubTile( - townLevelId, - townVariantByAct.get(runtime.act), - ) - portalSlot.cast({ - fromLevelId: runtime.levelId, - fromX, - fromY, - townLevelId, - townX: townSub.x, - townY: townSub.y, - createdAtMs: performance.now(), - }) - state.portal = portalSlot.current() - - if (character?.clips?.['sc']) { - playerAnimator.play('sc', character.clips['sc'], { restart: true }) - } - const townNames = getLevelNames(townLevelId) - status.textContent = isEn - ? `Opened Town Portal to ${townNames.nameEn}` - : `已开启返回 ${townNames.nameZh} 的传送门` - return true + const minimapLevel = (): MinimapLevel => { + minimapCache = buildMinimapLevel(runtime, minimapCache) + return minimapCache.level } - const hudCanvas = document.querySelector('#d2-hud-canvas') - hudManager = hudCanvas !== null - ? new HudManager(hudCanvas, { - onToggleAutomap: () => { - minimap.toggleVisibility() - state.minimapVisible = minimap.visible - }, - onToggleAutomapMode: () => { - minimap.toggleMinimapMode() - state.minimapVisible = minimap.visible - state.automapMode = minimap.mode - state.automapDivisor = minimap.divisor - }, - onCycleAutomapFade: () => { - minimap.cycleFade() - }, - onToggleAutomapNames: () => { - minimap.toggleNames() - }, - onResetAutomapPan: () => { - minimap.resetPan() - }, - onPanAutomap: (dx, dy) => { - return minimap.pan(dx, dy) - }, - onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => { - minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight) - }, - onWaypointTeleport: (targetAct, slug, levelId) => { - void teleportViaWaypoint(targetAct, slug, levelId) - }, - onCastTownPortal: () => castTownPortal(), - onDropItemToGround: (item) => { - const player = engine.world.player - const groundItem = engine.dropItem(item, player.x, player.y) - const isEn = getTblLang() === 'ENG' - status.textContent = isEn - ? `Dropped "${groundItem.name}" on ground` - : `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面` - }, - onDropGoldToGround: (amount) => { - const player = engine.world.player - const groundGold = engine.dropGold(amount, player.x, player.y) - if (groundGold) { - const isEn = getTblLang() === 'ENG' - status.textContent = isEn - ? `Dropped ${amount.toLocaleString()} Gold on ground` - : `已将 ${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) - const customCharName = param('charName', '').trim() - if (customCharName) { - hudManager.charSheet.attrs.name = customCharName - } - } catch (err) { - console.error('Failed to initialize HudManager:', err) - } - if (typeof window !== 'undefined' && window.__d2webHud) { - (window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount - } - } + let mouseControllerRef: SceneMouseController | null = null + const transitionController = createLevelTransitionController({ + canvas, + status, + renderer, + engine, + camera, + input, + fade, + minimap, + minimapLevel, + resetMinimapCache: () => { minimapCache = null }, + waypointNetwork, + portalSlot, + playerAnimator, + playerSubTile, + populatePackSelectors, + getRuntime: () => runtime, + setRuntime: (next) => { runtime = next }, + getHudManager: () => hudManager, + clearMouseTarget: () => { mouseControllerRef?.clearTarget() }, + getCharacter: () => character, + getMonsterArtMap: () => monsterArtMap, + setMonsterArtMap: (map) => { monsterArtMap = map }, + setActivePaletteAndPack: (palette, base, act) => { + activePalette = palette + packEntityBase = base + packEntityAct = act + }, + getMultipliers: () => ({ densityMultiplier, eliteMultiplier }), + setBoundsState: (bounds) => { + floorBounds = bounds.floorBounds + shadowBounds = bounds.shadowBounds + wallBounds = bounds.wallBounds + roofBounds = bounds.roofBounds + objectBounds = bounds.objectBounds + roofController = bounds.roofController + }, + }) - /** - * Enter the open Town Portal from the current level: - * - Entering from wilderness transitions to town beside the Town Portal and keeps the portal open. - * - Entering from town transitions back to the wilderness mouth and closes the portal. - */ - const enterTownPortal = (): void => { - if (travelling || runtime.source !== 'pack') return - const dest = portalSlot.enterFrom(runtime.levelId) - state.portal = portalSlot.current() - if (dest !== null) { - void travel(dest.levelId, WARP_FADE_MS, { x: dest.x, y: dest.y }) - } - } + hudManager = await createActSceneHudManager({ + runtime, + engine, + minimap, + status, + currentHeroToken, + onWaypointTeleport: (targetAct, slug, levelId) => { + void transitionController.teleportViaWaypoint(targetAct, slug, levelId) + }, + onCastTownPortal: () => transitionController.castTownPortal(), + }) if (typeof window !== 'undefined') { ;(window as any).__d2webPortalSlot = portalSlot window.__d2webMinimap = minimap if (window.__d2webActScene) { - window.__d2webActScene.castTownPortal = castTownPortal - window.__d2webActScene.enterTownPortal = enterTownPortal + window.__d2webActScene.castTownPortal = () => transitionController.castTownPortal() + window.__d2webActScene.enterTownPortal = () => { transitionController.enterTownPortal() } window.__d2webActScene.getPortal = () => portalSlot.current() window.__d2webActScene.getMinimap = () => minimap } } - const mouseController = new SceneMouseController({ + const mouseController = setupMouseControls({ canvas, engine, camera, @@ -8693,521 +530,23 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st playerAnimator, getCharacter: () => character, portalSlot, - onEnterPortal: enterTownPortal, - onCastTownPortal: castTownPortal, - onEnterWarp: (warp) => { - void travel(warp.toLevelId, WARP_FADE_MS) - }, - onEnterEntrance: (entrance) => { - void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) - }, - isTravelling: () => travelling, + onEnterPortal: () => { transitionController.enterTownPortal() }, + onCastTownPortal: () => transitionController.castTownPortal(), + onEnterWarp: (warp) => { void transitionController.travel(warp.toLevelId, WARP_FADE_MS) }, + onEnterEntrance: (entrance) => { void transitionController.travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) }, + isTravelling: () => transitionController.isTravelling(), labelsContainer, getMonsterArtMap: () => monsterArtMap, }) - mouseController.attach() + mouseControllerRef = mouseController if (groundLabelOverlay !== null) { - groundLabelOverlay.setOnPickupCallback((item) => { - mouseController.pickupGroundItem(item) - }) - groundLabelOverlay.setOnHoverCallback((item) => { - mouseController.hoveredGroundItem = item - }) + groundLabelOverlay.setOnPickupCallback((item) => { mouseController.pickupGroundItem(item) }) + groundLabelOverlay.setOnHoverCallback((item) => { mouseController.hoveredGroundItem = item }) } if (typeof window !== 'undefined') { (window as any).__d2webMouseController = mouseController } - /** - * 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) - - /** - * 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 - if (isTownLevel(next.levelId) && next.variant) { - townVariantByAct.set(next.act, next.variant) - } - 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 }) - } - if (!next.baseMonsterPacks) { - next.baseMonsterPacks = next.monsterPacks - } - next.monsterPacks = rebudgetMonsterPacks(next.baseMonsterPacks, { - densityMultiplier, - eliteMultiplier, - levelId: next.levelId, - seed: WORLD_VARIANT_SEED ^ next.levelId, - }) - 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') { - const nextParams = new URLSearchParams(location.search) - nextParams.set('act', String(next.act)) - nextParams.set('level', next.variant) - history.replaceState(null, '', `?${nextParams.toString()}`) - 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 - } - mouseController?.clearTarget() - minimapCache = null - const arrivedCell = cellOf(next.grid, engine.world.player.x, engine.world.player.y) - minimap.reveal(minimapLevel(), arrivedCell.x, arrivedCell.y, engine.world.player.x, engine.world.player.y, true) - } - - /** - * 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 - mouseController.clearTarget() - const fromLevelId = runtime.levelId - const isEn = getTblLang() === 'ENG' - try { - await fade.out(fadeMs) - const next = await loadRuntimeForLevel( - runtime.base, - toLevelId, - { width: canvas.width, height: canvas.height }, - runtime.charBases, - fromSide, - isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined, - ) - if (next === null) { - status.textContent = isEn - ? `Level ${String(toLevelId)} not found in pack.` - : `资源包里没有关卡 ${String(toLevelId)}。` - return - } - await next.loadPages(renderer, next.priorityPages, () => { - state.pagesLoaded = countLoadedPages(next.pages) - }) - let effectiveOverride = override - if (isTownLevel(next.levelId) && next.variant) { - townVariantByAct.set(next.act, next.variant) - const openPortal = portalSlot.current() - if (openPortal !== null && openPortal.townLevelId === next.levelId) { - const exactTownSub = resolveTownPortalSubTile(next.levelId, next.variant) - if (openPortal.townX !== exactTownSub.x || openPortal.townY !== exactTownSub.y) { - const wasHeadingToPortal = - effectiveOverride !== undefined && - effectiveOverride.x === openPortal.townX && - effectiveOverride.y === openPortal.townY - portalSlot.cast({ - ...openPortal, - townX: exactTownSub.x, - townY: exactTownSub.y, - }) - if (wasHeadingToPortal) { - effectiveOverride = exactTownSub - } - } - } - } - // 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. Every walkable link has both ends, so a missing - // way back is a broken world graph, never a reason to drop the player in - // the middle of the map. - const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId) - ?? next.warps.find(warp => warp.toLevelId === fromLevelId) - if (effectiveOverride === undefined && back === undefined) { - throw new Error(`level ${String(toLevelId)} has no link back to level ${String(fromLevelId)}`) - } - const rawLanding = effectiveOverride ?? { x: back!.arriveX, y: back!.arriveY } - let landing = rawLanding - const activeMouthInNext = portalSlot.mouthInLevel(next.levelId) - if ( - activeMouthInNext !== null && - activeMouthInNext.x === rawLanding.x && - activeMouthInNext.y === rawLanding.y - ) { - const besidePos = subTileToScene(rawLanding.x + 1, rawLanding.y + 1, next.grid) - if (!isBlockedAt(next.grid, besidePos.x, besidePos.y)) { - landing = { x: rawLanding.x + 1, y: rawLanding.y + 1 } - } - } - await swapLevel(next, landing) - preloadRemaining(next, renderer) - } catch (err) { - status.textContent = isEn - ? `Level transition failed: ${(err as Error).message}` - : `切换关卡失败:${(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 - mouseController.clearTarget() - const isEn = getTblLang() === 'ENG' - try { - await fade.out(WARP_FADE_MS) - const index = await getPackIndex(runtime.base) - if (index === null) { - status.textContent = isEn ? 'Failed to read pack index.' : '无法读取资源包索引。' - 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 = isEn - ? `Waypoint level not found in pack: ${slug}` - : `资源包里没有传送点关卡:${slug}` - return - } - - // The session's copy of the level, exactly as walking there would load it - // (a random copy could belong to another generated variant of the act). - const levelIds = [...new Set(pool.map(entry => entry.levelId))] - const poolLevelId = levelIds.length === 1 ? levelIds[0] : undefined - if (poolLevelId === undefined && pool.length !== 1) { - throw new Error(`waypoint ${slug}: ${String(pool.length)} baked maps match and they do not name one level`) - } - const chosenEntry = poolLevelId === undefined - ? pool[0]! - : worldVariantsFor(runtime.base, index).entryForLevel(poolLevelId) - if (chosenEntry === null) throw new Error(`waypoint ${slug}: no baked copy of level ${String(poolLevelId)}`) - 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] - if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`) - const landing = { x: wp.arriveX, y: wp.arriveY } - - await swapLevel(next, landing) - preloadRemaining(next, renderer) - minimapCache = null - - const names = getLevelNames(next.levelId ?? next.level) - status.textContent = isEn - ? `Teleported to: ${names.nameEn} (Act ${String(next.act)})` - : `已传送到:${names.nameZh}(Act ${String(next.act)})` - } catch (err) { - status.textContent = isEn - ? `Waypoint teleport failed: ${(err as Error).message}` - : `传送点传送失败:${(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 and portals are - * activated via mouse click (or Telekinesis) rather than keyboard polling. - */ - 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 (!(entrance.toLevelId > 0)) continue - if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue - void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) - 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)) { - const isEn = getTblLang() === 'ENG' - const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level - status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` - } - 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()) { - castTownPortal() - } - } - - /** - * 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 => { - const currentLang = getTblLang() - // 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 = `${currentLang}:${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'], - toLevelId?: number, - ): MinimapMarker => { - const targetName = - toLevelId !== undefined && toLevelId > 0 - ? levelDisplayName(toLevelId, currentLang) - : undefined - return { - cellX: Math.floor(x / SUB_TILES_PER_TILE), - cellY: Math.floor(y / SUB_TILES_PER_TILE), - kind, - ...(toLevelId !== undefined ? { toLevelId } : {}), - ...(targetName !== undefined ? { targetName } : {}), - } - } - const resolvedLevelName = - runtime.levelId > 0 - ? levelDisplayName(runtime.levelId, currentLang) || runtime.level - : runtime.level - const level: MinimapLevel = { - key, - cellsX: runtime.grid.cellsX, - cellsY: runtime.grid.cellsY, - blocked: runtime.grid.blocked, - gridWidth: runtime.grid.gridWidth, - originX: runtime.grid.originX, - originY: runtime.grid.originY, - ...(runtime.automap !== undefined ? { automap: runtime.automap } : {}), - levelId: runtime.levelId, - levelName: resolvedLevelName, - difficulty: currentLang === 'ENG' ? 'Normal' : '普通', - preRevealTown: isTownLevel(runtime.levelId), - markers: [ - ...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit', entrance.toLevelId)), - ...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit', warp.toLevelId)), - ...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')), - ], - } - minimapCache = { key, level } - return level - } - const loop = new GameLoop({ tickRate: 25, onTick: () => { @@ -9229,120 +568,30 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st }) lightningTrailManager.tick() - 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 + const animState = stepPlayerAndMonsterAnimations({ + engine, + runtime, + character, + playerAnimator, + monsterAnimators, + monsterArtMap, + hudManager, + audioManager, + environment, + lightingEnabled, + syncSorceressAppearance, + effectiveMovement, + playerMoving, + playerWalkFrame, + monsterWalkFrame, + }) + playerMoving = animState.playerMoving + playerWalkFrame = animState.playerWalkFrame + monsterWalkFrame = animState.monsterWalkFrame - // 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') - void syncSorceressAppearance() - } - - // 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 (art.clips['dt'] !== undefined && art.clips['dt'].frames > 0 && !monster.dropRolled) { - monster.deathGated = true - } - if (monster.state === 'dead' || monster.animMode === 'dt') { - if (animator.mode !== 'dt' && animator.mode !== 'dd') { - const clip = art.clips['dt'] - if (clip !== undefined && clip.frames > 0) { - animator.play('dt', clip) - monster.animMode = 'dt' - monster.deathGated = true - } else { - const ddClip = art.clips['dd'] - if (ddClip !== undefined) animator.play('dd', ddClip) - monster.animMode = 'dd' - engine.triggerMonsterDrop(monster) - } - } else if (animator.mode === 'dt' && animator.finished) { - const clip = art.clips['dd'] - if (clip !== undefined) animator.play('dd', clip) - monster.animMode = 'dd' - engine.triggerMonsterDrop(monster) - } - } 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) - } - if (monster.state === 'dead' || monster.animMode === 'dt' || !monster.frozenTicks || monster.frozenTicks <= 0) { - animator.tick() - } - } else if ((monster.state === 'dead' || monster.animMode === 'dt') && !monster.dropRolled) { - engine.triggerMonsterDrop(monster) - } - } - 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) - // Update ground item bounce parabolic physics and trigger drop SFX at dropsfxframe - engine.groundItems.updateBounces(performance.now(), audioManager) - if (lightingEnabled) { - environment.tick(runtime.levelId, runtime.act) - } - - // 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.toggleVisibility() - checkLinks() + transitionController.checkLinks() + const player = engine.world.player const walked = cellOf(runtime.grid, player.x, player.y) minimap.reveal(minimapLevel(), walked.x, walked.y, player.x, player.y) const dialogPanel = document.querySelector('#dialog') @@ -9355,722 +604,47 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st } }, 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 }, lightingEnabled ? [0, 0, 0] : [0.03, 0.03, 0.04]) - if (lightingEnabled) { - const pSub = playerSubTile() - lightGrid.centerOnSubTile(pSub.x, pSub.y) - lightGrid.clear() - lightGrid.updateObstacles(runtime.grid) - - // Resolve ambient from active lighting preset override or 1.13c Environment engine - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - const overrideAmb = presetCfg.overrideAmbient - const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity - const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave' - - // Authentic 1.13c player torch light in isometric sub-tile space: - // 20-22 sub-tiles = ~240-260px horizontal screen radius (~500px diameter pool around hero) - const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - const basePlayerRadius = isDarkCave ? 20 : 22 - lightGrid.addLight({ - subTileX: pSub.x, - subTileY: pSub.y, - radius: basePlayerRadius + flicker, - intensity: 242, - red: 255, - green: 234, - blue: 196, - }) - - // Active missile lights (with skillId fallback for all spells including Glacial Spike '55') - const skillToMissile: Readonly> = { - '36': 'firebolt', - '38': 'chargedbolt', - '39': 'icebolt', - '45': 'iceblast', - '47': 'fireball', - '49': 'lightning', - '53': 'chainlightning', - '55': 'glacialspike', - '56': 'meteor', - '64': 'frozenorb', - '67': 'teeth', - '84': 'bonespear', - '93': 'bonespirit', - } - for (const shot of engine.projectiles) { - const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) - if (!mType) continue - const mData = getMissileTxtData(mType) - const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) - if (lightUnits > 0) { - const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const shotSubX = Math.round((dy + dx) / 2) - const shotSubY = Math.round((dy - dx) / 2) - const isCold = mData.blue > mData.red - lightGrid.addLight({ - subTileX: shotSubX, - subTileY: shotSubY, - radius: Math.max(14, lightUnits * 2), - intensity: 250, - red: isCold ? Math.max(120, mData.red) : mData.red, - green: isCold ? Math.max(175, mData.green) : mData.green, - blue: mData.blue, - }) - } - } - - // Active explosion lights - for (const exp of engine.explosions) { - const expData = getMissileTxtData(exp.missileType) - const lightUnits = expData && expData.light > 0 ? expData.light : 8 - const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const expSubX = Math.round((dy + dx) / 2) - const expSubY = Math.round((dy - dx) / 2) - const isColdExp = exp.missileType.includes('ice') || exp.missileType.includes('freeze') || exp.missileType.includes('blizzard') || exp.missileType.includes('orb') - lightGrid.addLight({ - subTileX: expSubX, - subTileY: expSubY, - radius: Math.round(lightUnits * 2.5), - intensity: 255, - red: isColdExp ? 130 : (expData?.red ?? 255), - green: isColdExp ? 185 : (expData?.green ?? 160), - blue: isColdExp ? 255 : (expData?.blue ?? 50), - }) - } - - // Environmental static/animated lights from Objects.txt Lit0..2 (standing torches TO, braziers FB/B2, waypoints WP, shrines SH, beams) - if (runtime.objectDrawables) { - for (const obj of runtime.objectDrawables) { - const tok = (obj.token ?? '').trim().toUpperCase() - const isTorchOrFire = - tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || - tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || - tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || - tok === 'F8' || tok === '1Y' || tok === 'FL' - const isWaypoint = - tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || - tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' - const isShrineOrBeam = - tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || - obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) - - if (isTorchOrFire || isWaypoint || isShrineOrBeam) { - const dx = obj.x - engine.world.player.x - const dy = obj.y - engine.world.player.y - if (dx * dx + dy * dy < 950 * 950) { - const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH - const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT - const objSubX = Math.round((ody + odx) / 2) - const objSubY = Math.round((ody - odx) / 2) - const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0 - lightGrid.addLight({ - subTileX: objSubX, - subTileY: objSubY, - radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker, - intensity: isTorchOrFire ? 175 : 160, - red: isWaypoint ? 120 : 255, - green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205), - blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175), - }) - } - } - } - } - - const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId) - if (activePortalMouthForLight !== null) { - const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 - lightGrid.addLight({ - subTileX: activePortalMouthForLight.x, - subTileY: activePortalMouthForLight.y, - radius: 19 + portalFlicker, - intensity: 235, - red: 64, - green: 110, - blue: 255, - }) - } - - const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5 - const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185) - const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175) - const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165) - const ambNorm = effectiveAmbIntensity / 255 - const ambR = (effR / 255) * ambNorm - const ambG = (effG / 255) * ambNorm - const ambB = (effB / 255) * ambNorm - - renderer.setLighting({ - ambient: [ambR, ambG, ambB], - originSubX: lightGrid.originSubX, - originSubY: lightGrid.originSubY, - sceneOriginX: runtime.grid.originX, - sceneOriginY: runtime.grid.originY, - lightmap: lightGrid.exportTextureBuffer(), - }) - - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = effectiveAmbIntensity - state.ambientColor = [effR, effG, effB] - } else { - renderer.setLighting(null) - const presetCfg = LIGHTING_PRESETS[currentLightingPreset] - state.lightingPreset = currentLightingPreset - state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh - state.ambientIntensity = 255 - state.ambientColor = [255, 255, 255] - } - 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, - elevationY: Math.max(0, nextWall!.frame.height - 40), - }) - } - } - 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', - elevationY: Math.max(0, nextObj!.frame.height - 16), - }) - } - } - 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, - elevationY: Math.max(0, frame.height - 16), - }) - state.characterFrames += 1 - state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height } - } - } - }) - - mouseController.syncMonsterTargetHud(camX, camY) - const activeTargetMonster = mouseController.getActiveTargetMonster() - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' && monster.corpseTicks <= 0) { - monster.drawBounds = undefined - continue - } - if (monster.state === 'dead') { - monster.drawBounds = undefined - } - pushEntity(monster.x, monster.y, () => { - const isTarget = monster === activeTargetMonster && monster.state !== 'dead' && monster.hp > 0 - 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 - if (monster.state !== 'dead') { - monster.drawBounds = { - x: drawX, - y: drawY, - w: frame.width, - h: frame.height, - } - } - const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 - 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, - elevationY: Math.max(0, frame.height - 16), - ...(isFrozen - ? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 } - : isChilled - ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } - : isTarget - ? { tint: [1.25, 1.25, 1.25, 1.0] as const } - : {}), - }) - 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') { - monster.drawBounds = { - x: monster.x - MARKER_WIDTH / 2, - y: monster.y - MARKER_HEIGHT, - w: MARKER_WIDTH, - h: MARKER_HEIGHT, - } - 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 isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 - const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0 - const tint = isFrozen - ? ([0.25, 0.6, 1.0, 1.0] as const) - : isChilled - ? ([0.4, 0.65, 1.0, 1.0] as const) - : isTarget - ? ([1.0, 0.35, 0.35, 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]) - }) - } - - // Lingering lightning and chain lightning trail slices (1.13c pCltDoFunc = 8, CltSubMissile1 = lightninghit) - for (const slice of lightningTrailManager.getSlices()) { - pushEntity(slice.x, slice.y, () => { - lightningTrailManager.renderSlice(renderer, slice, missileArtMap) - }, 0.2) - } - - 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') : - (mType === 'lightningbolt' || mType === 'lightning' || mType === 'chainlightning' ? (missileArtMap.get('lightningstrike') ?? missileArtMap.get('chainlightning')) : undefined))) - : undefined - if (art !== undefined) { - drawMissileProjectile(renderer, shot, art, missileArtMap) - } else if (mType === 'lightningbolt' || mType === 'chainlightning' || mType === 'lightning') { - drawMissileProjectile(renderer, shot, undefined, 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 === 'explodingarrowexp' - ? missileArtMap.get('meteorexplode') ?? missileArtMap.get('ExpArrowExplode') - : 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, - ) - } - - const activePortalMouth = portalSlot.mouthInLevel(runtime.levelId) - const activePortalPos = - activePortalMouth !== null - ? subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) - : null - if (activePortalMouth !== null && activePortalPos !== null) { - const portalElapsedMs = Math.max(0, renderStarted - (activePortalMouth.createdAtMs ?? 0)) - pushEntity(activePortalPos.x, activePortalPos.y, () => { - drawTownPortal(renderer, activePortalPos.x, activePortalPos.y, portalElapsedMs, overlayArtMap) - }) - } - - const groundSimTime = (engine.world.tick + ((engine.world as any).alpha ?? 0)) * 40 - for (const gItem of engine.groundItems.all) { - pushEntity(gItem.x, gItem.y, () => { - drawGroundItem(renderer, gItem, groundSimTime, 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) - if (lightingEnabled) { - renderer.setLighting(null) - } - // Last in the batch, so the panel sits over every world layer. - const isEnRender = getTblLang() === 'ENG' - const automapEntities = [ - ...engine.npcEntities - .filter( - npc => - getTownNpcDescriptorByName(npc.def.name) !== undefined || - npc.def.name === 'Kaelan' || - npc.def.name === 'Hadriel' || - npc.def.name === 'Flavie', - ) - .map(npc => ({ - x: npc.x, - y: npc.y, - kind: 'npc' as const, - name: isEnRender ? npc.def.name : npcDisplayName(npc.def.name), - })), - ...engine.world.monsters - .filter(m => m.state !== 'dead' && m.hp > 0 && m.stats.rank === 'unique') - .map(m => ({ - x: m.x, - y: m.y, - kind: 'boss' as const, - name: monsterDisplayName(m.stats), - })), - ] - const automapPortals = - activePortalMouth !== null && activePortalPos !== null - ? [ - { - x: activePortalPos.x, - y: activePortalPos.y, - kind: 'blue' as const, - label: isEnRender - ? `Town Portal -> ${getLevelNames(activePortalMouth.toLevelId).nameEn}` - : `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`, - }, - ] - : [] - const automapStashes = (runtime.stashes ?? []).map(s => ({ - x: s.x, - y: s.y, - label: isEnRender ? s.nameEn : s.nameZh, - })) - minimap.draw( + const res = renderWorldLayers({ + frameMs, + started, + runtime, 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 }, - { - playerWorldX: engine.world.player.x, - playerWorldY: engine.world.player.y, - leftPanelOpen: hudManager !== null && hudManager.leftPanel !== 'none', - rightPanelOpen: hudManager !== null && hudManager.rightPanel !== 'none', - entities: automapEntities, - portals: automapPortals, - stashes: automapStashes, - levelId: runtime.levelId, - }, - ) - renderer.flush() - if (hudManager !== null) { - mouseController.syncMonsterTargetHud(camX, camY) - hudManager.render(renderStarted, playerMoving) - } - - // The labels are recycled rather than rebuilt: `innerHTML = ''` plus one - // `createElement` per NPC/exit per frame forced a full style recalculation - // and made the DOM, not the GPU, the slowest part of some town frames. - if (labelsContainer !== null) { - mouseController.updateOverheadLabels(camX, camY, activePortalMouth, activePortalPos) - } - - if (groundLabelOverlay !== null) { - const showLabels = Boolean(hudManager?.showGroundLabels) - let hoveredItem = mouseController?.hoveredGroundItem ?? null - if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) { - if (mouseController) mouseController.hoveredGroundItem = null - hoveredItem = null - } - - if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) { - 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 } - } - if (showLabels) { - const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen) - const resolvedLayouts = resolveLadderCollisions(initialLayouts) - groundLabelOverlay.update(true, resolvedLayouts) - } else if (hoveredItem !== null) { - const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen) - groundLabelOverlay.update(true, initialLayouts) - } - } else { - groundLabelOverlay.update(false, []) - } - } - - state.portal = portalSlot.current() - state.minimapVisible = minimap.visible - state.automapMode = minimap.mode - state.automapDivisor = minimap.divisor - state.automapAtlasesLoaded = minimap.atlasesLoadedCount - state.automapCellCounts = minimap.lastRenderStats.cellCounts - state.automapRevealedRooms = minimap.revealedRoomsCount - state.automapSpritesDrawn = minimap.spritesDrawnLastFrame - state.automapCrosshairsDrawn = minimap.crosshairsDrawnLastFrame - state.automapWarpLabels = minimap.warpLabelsLastFrame - state.automapHudText = minimap.hudTextLastFrame - state.automapViewport = minimap.viewportLastFrame - 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) - if (isEnRender) { - hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? 'pack' : 'mpq'}` - + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} cells · ${String(runtime.floors.length)} floors / ${String(runtime.shadows?.length ?? 0)} shadows / ${String(runtime.walls.length)} walls` - + ` · pages ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` - + ` · objects ${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)} quads / ${String(state.drawCalls)} draws (culled ${String(culledDraws)})` - + (skippedDraws > 0 ? ` · pending ${String(skippedDraws)}` : '') - + monsterPlanStatusText(state.monstersPlanned) - + monsterArtStatusText({ - missing: state.missingMonsterArt, - errors: state.monsterArtErrors, - layerFailures: state.monsterArtLayerFailures, - }) - } else { - 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, - }) - } - } + engine, + camera, + canvas, + hud, + labelsContainer, + groundLabelOverlay, + hudManager, + mouseController, + minimap, + minimapLevel, + portalSlot, + lightGrid, + environment, + currentLightingPreset, + lightingEnabled, + floorBounds, + shadowBounds, + wallBounds, + roofBounds, + objectBounds, + roofController, + character, + playerAnimator, + playerMoving, + playerWalkFrame, + monsterWalkFrame, + monsterAnimators, + monsterArtMap, + missileArtMap, + overlayArtMap, + itemsAtlasHandle, + lightningTrailManager, + loop, + hudNextMs, + }) + hudNextMs = res.hudNextMs }, }) @@ -10080,7 +654,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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])) @@ -10129,9 +702,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill hudManager.syncPublishedState() } - if (currentHeroToken === 'so') { - await syncSorceressAppearance() - } + if (currentHeroToken === 'so') await syncSorceressAppearance() } const initialWalked = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y) @@ -10164,444 +735,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st 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) - } + 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 - } - - const isEn = getTblLang() === 'ENG' - - /** 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: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'), - 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), - isEn - ? `${ACT_NAMES_EN[act] ?? `Act ${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 - ? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI') - : isEn - ? `${scene?.name ?? entry.levelName} (${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. */ @@ -10618,88 +758,17 @@ async function boot(): Promise { const lightingSelect = document.querySelector('#lighting') const langSwitch = document.querySelector('#lang-switch') - if (langSwitch !== null && typeof location !== 'undefined') { - const targetLang: TblLang = isEn ? 'CHI' : 'ENG' - langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN' - langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) - langSwitch.addEventListener('click', () => { - langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) - }) - } - - if (lightingSelect !== null) { - lightingSelect.disabled = false - lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto')) - lightingSelect.onchange = () => { - const params = new URLSearchParams(location.search) - if (lightingSelect.value === 'auto') { - params.delete('lighting') - } else { - params.set('lighting', lightingSelect.value) - } - location.search = params.toString() - } - } - 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() - } - } - const densitySelect = document.querySelector('#density') - const eliteSelect = document.querySelector('#elite') - if (densitySelect !== null) { - densitySelect.disabled = false - densitySelect.value = String(parseDensityMultiplier(param('density', '1'))) - updateSelectorWarningStyle(densitySelect, '1') - } - if (eliteSelect !== null) { - eliteSelect.disabled = false - eliteSelect.value = String(parseEliteMultiplier(param('elite', '1'))) - updateSelectorWarningStyle(eliteSelect, '1') - } - 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() + const act = wireBootToolbarSelectors({ + isEn, + heroSelect, + actSelect, + sceneSelect, + variantSelect, + lightingSelect, + langSwitch, }) - // 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 { @@ -10728,7 +797,6 @@ async function boot(): Promise { location.reload() }, }) - // The first frame waits only for the spawn-area pages. await runtime.loadPages(renderer, runtime.priorityPages, () => { state.pagesLoaded = countLoadedPages(runtime.pages) }) diff --git a/src/scene/backend/chat-commands.ts b/src/scene/backend/chat-commands.ts new file mode 100644 index 0000000..5b94719 --- /dev/null +++ b/src/scene/backend/chat-commands.ts @@ -0,0 +1,77 @@ +/** + * Processes chat and console commands for ActScene (e.g. "/players <1-8>"). + * + * Ground truth rules (1.13c parity): + * - Accepts "/players N" or "players N" where N is an integer between 1 and 8. + * - Calling without a number queries the current setting. + * - Out-of-range numbers display an error notification. + */ + +export interface ChatCommandEngine { + setPlayers: (players: number, partyPlayers?: number) => void + gamePlayers: number +} + +export interface SceneChatCommandResult { + readonly handled: boolean + readonly message: string | null +} + +export type ChatCommandOutcome = SceneChatCommandResult + +/** + * Pure simulation helper for scene chat commands (`/players <1-8>`). + */ +export function executeSceneChatCommand( + rawCommand: string, + engine: ChatCommandEngine, +): SceneChatCommandResult { + const trimmed = rawCommand.trim() + const match = trimmed.match(/^\/?players(?:\s+(\d+))?$/i) + if (match) { + if (match[1] !== undefined) { + const n = parseInt(match[1], 10) + if (n >= 1 && n <= 8) { + engine.setPlayers(n) + return { + handled: true, + message: `Players set to ${engine.gamePlayers}`, + } + } + return { + handled: false, + message: `Invalid players count: ${n}. Range is 1-8.`, + } + } + return { + handled: true, + message: `Players currently set to ${engine.gamePlayers}`, + } + } + return { handled: false, message: null } +} + +/** + * Processes chat and console commands for ActScene (e.g. "/players <1-8>"). + * + * Ground truth rules (1.13c parity): + * - Accepts "/players N" or "players N" where N is an integer between 1 and 8. + * - Calling without a number queries the current setting. + * - Out-of-range numbers display an error notification. + * + * @param rawCommand - The input command string. + * @param engine - GameEngine instance. + * @param statusEl - Optional status notification sink. + * @returns True if command was recognized and handled; false otherwise. + */ +export function handleActSceneChatCommand( + rawCommand: string, + engine: { setPlayers: (players: number, partyPlayers?: number) => void; gamePlayers: number }, + statusEl?: { textContent: string | null } | null, +): boolean { + const res = executeSceneChatCommand(rawCommand, engine) + if (res.message !== null && statusEl) { + statusEl.textContent = res.message + } + return res.handled +} diff --git a/src/scene/backend/net-simulation.ts b/src/scene/backend/net-simulation.ts new file mode 100644 index 0000000..0730cb1 --- /dev/null +++ b/src/scene/backend/net-simulation.ts @@ -0,0 +1,212 @@ +import type { SpriteSheet } from '../../formats/sprite.ts' +import { monsterStatsFromTable, tickCombatMulti } from '../../game/combat.ts' +import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../../game/combat.ts' +import { D2Rng } from '../../game/d2-rng.ts' +import { executeDropPipeline, type DropTables } from '../../game/drop-pipeline.ts' +import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts' +import { findIsometricDropPosition, getPickupEdgeDistance } from '../../game/ground-items.ts' +import { Inventory, type Item } from '../../game/items.ts' +import { getMonsterTreasureClass, type Difficulty } from '../../game/monsters.ts' +import { QuestLog } from '../../game/quests.ts' +import { parseTable } from '../../game/tables.ts' +import { computeLockstepHash, type InputFrame, type LockstepSimulation } from '../../net/lockstep.ts' + +/** World seed: fixed, because both peers must build the same world. */ +export const NET_SEED = 0x0d2c0de + +/** Raw members the scene needs. */ +export interface NetSources { + readonly ds1: Uint8Array + readonly libraries: readonly Uint8Array[] + readonly palette: Uint8Array + readonly paletteName: string + readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null + readonly monstats: Uint8Array | null +} + +/** The monster table used when the archive has none, so the page is never empty. */ +export const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([ + 'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP', + 'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12', + 'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20', +].join('\n'))) + +/** Fallback experience thresholds: the co-op sandbox is not about levelling. */ +export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000] + +export function createNetSimulation( + world: CombatWorld, + options: CombatOptions, + terrain: CombatTerrain, + xpTable: readonly number[], + numPeers: number, + seed: number = NET_SEED, + dropTables: DropTables = getEmbeddedDropTables(), + difficulty: Difficulty = 'normal', +) { + const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4)) + const quests = new QuestLog([]) + const ground: { x: number; y: number; item: Item }[] = [] + + world.pendingKills = world.pendingKills ?? [] + for (let i = 0; i < world.monsters.length; i += 1) { + const monster = world.monsters[i]! + if (monster.dwInitSeed === undefined) { + monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1) + } + } + + const mapTerrain = 'widthPx' in terrain + ? (terrain as any) + : { ...terrain, widthPx: 10000, heightPx: 10000 } + + const simulation: LockstepSimulation = { + advance: (inputs: readonly InputFrame[]) => { + const frames: CombatInput[] = inputs.map(input => ({ + movement: input.movement, + attack: input.attack, + })) + tickCombatMulti(world, frames, options, terrain, xpTable) + + const killEvents = [ + ...(world.pendingKills ?? []), + ...world.events.filter(e => e.kind === 'kill'), + ] + if (world.pendingKills !== undefined) { + world.pendingKills.length = 0 + } + + const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1)) + + for (const event of killEvents) { + if (event.kind !== 'kill') continue + const monster = event.monsterIndex !== undefined + ? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex) + : world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10) + if (monster?.dropRolled) continue + if (monster) { + monster.dropRolled = true + } + + const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim() + const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal' + const isMinion = monsterRank === 'minion' + const monsterType = isMinion + ? 3 + : (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1)) + + const suDef = event.superUniqueId + ? (dropTables.superUniques.get(event.superUniqueId) ?? + (() => { + const target = event.superUniqueId!.trim().toLowerCase() + for (const [k, v] of dropTables.superUniques) { + if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) { + return v + } + } + return undefined + })()) + : undefined + + let tcName = '' + if (!isMinion && suDef) { + tcName = typeof suDef.getTreasureClass === 'function' + ? suDef.getTreasureClass(difficulty) + : suDef.treasureClass + } + + const monsterKind = + dropTables.monsterKinds.get(subjectId) ?? + dropTables.monsterKinds.get(`${subjectId}1`) ?? + (suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined) + + if (!tcName && monsterKind) { + tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType) + } + if (!tcName && !monsterKind && !suDef) { + tcName = 'Act 1 H2H A' + } + if (!tcName) continue + + const kindLevel = typeof (monsterKind as any)?.level === 'number' + ? (monsterKind as any).level + : (monsterKind as any)?.level?.[difficulty] + const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1) + const monsterIndex = monster?.index ?? event.monsterIndex ?? 0 + const monsterSeed = + monster?.dwInitSeed ?? + ((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1)) + + const droppedItems = executeDropPipeline(dropTables, { + tcName, + nLevel, + monsterType, + difficulty, + gamePlayers: activePlayers, + partyPlayers: activePlayers, + playerMf: 0, + playerGf: 0, + isBoss: isMinion ? false : Boolean(monsterKind?.boss), + isNoRatio: Boolean(monsterKind?.noRatio), + monsterRng: new D2Rng(monsterSeed), + }) + + const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y })) + const alivePlayers = world.players.filter(p => p.alive) + const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players + let reachableFrom: { x: number; y: number } | undefined + if (refPlayers.length > 0) { + let bestPlayer = refPlayers[0]! + let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y) + for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) { + const p = refPlayers[pIdx]! + const d = Math.hypot(p.x - event.x, p.y - event.y) + if (d < bestDist) { + bestDist = d + bestPlayer = p + } + } + reachableFrom = { x: bestPlayer.x, y: bestPlayer.y } + } + for (const drop of droppedItems) { + const dropPos = findIsometricDropPosition( + event.x, + event.y, + burstOccupied, + mapTerrain, + undefined, + undefined, + undefined, + undefined, + reachableFrom, + ) + burstOccupied.push(dropPos) + ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) + } + } + + inputs.forEach((input, index) => { + if (input.pickup && ground.length > 0) { + const player = world.players[index] + if (player) { + let bestIndex = -1 + let bestEdgeDist = 64 + for (let gIdx = 0; gIdx < ground.length; gIdx += 1) { + const g = ground[gIdx]! + const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y) + if (edgeDist <= bestEdgeDist) { + bestEdgeDist = edgeDist + bestIndex = gIdx + } + } + if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) { + ground.splice(bestIndex, 1) + } + } + } + }) + }, + hash: () => computeLockstepHash(world, inventories, ground, quests.all), + } + return { simulation, inventories, quests, ground } +} diff --git a/src/scene/backend/scene-links-nav.ts b/src/scene/backend/scene-links-nav.ts new file mode 100644 index 0000000..52b664a --- /dev/null +++ b/src/scene/backend/scene-links-nav.ts @@ -0,0 +1,489 @@ +import { + ORTHO_CELL_HEIGHT, + ORTHO_CELL_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + type CollisionGrid, +} from '../../game/d2map.ts' +import { + SEAM_TRIGGER_SUBTILES, + WARP_TRIGGER_SUBTILES, + type SceneEntrance, + type SceneWarp, + type SceneWaypoint, +} from '../../game/level-links.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import { SUB_TILES_PER_TILE } from '../../game/map.ts' +import { getTblLang } from '../../i18n/lang.ts' +import { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from '../transition.ts' + +export { + SEAM_FADE_MS, + SEAM_TRIGGER_SUBTILES, + WARP_FADE_MS, + WARP_TRIGGER_SUBTILES, + subTileToScene, + type SceneEntrance, + type SceneWarp, + type SceneWaypoint, +} + +/** Maximum range in scene pixels for Telekinesis (Skill 43) warp activation. */ +export const TELEKINESIS_RANGE_PX = 320 +/** Radial proximity click tolerance in scene pixels for a SceneWarp. */ +export const WARP_CLICK_RADIUS_PX = 56 +/** Radial proximity click tolerance in scene pixels for a SceneEntrance. */ +export const ENTRANCE_CLICK_RADIUS_PX = 56 + +/** + * Convert scene pixel coordinates back to isometric sub-tile coordinates. + */ +export function sceneToSubTile( + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const dx = (worldX - grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (worldY - grid.originY) / ORTHO_SUB_TILE_HEIGHT + return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) } +} + +/** + * Compute the scene coordinate for a `SceneWarp` overhead destination badge. + */ +export function getWarpLabelWorldPos( + warp: SceneWarp, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const anchor = subTileToScene(warp.x, warp.y, grid) + if (warp.selectDX > 0 && warp.selectDY > 0) { + return { + x: anchor.x + warp.selectX + warp.selectDX * 0.5, + y: anchor.y + warp.selectY - 6, + } + } + return { + x: anchor.x, + y: anchor.y - 48, + } +} + +/** + * Compute the scene coordinate for a `SceneEntrance` overhead destination badge. + */ +export function getEntranceLabelWorldPos( + entrance: SceneEntrance, + grid: { readonly originX: number; readonly originY: number }, +): { x: number; y: number } { + const anchor = subTileToScene(entrance.x, entrance.y, grid) + return { + x: anchor.x, + y: anchor.y - 48, + } +} + +/** + * Hit-test a world-space pointer position against a `SceneWarp` (`LvlWarp.txt` + * selection bounding box when non-zero, overhead badge footprint, plus radial + * proximity to anchor, walkable arrival point, and overhead label). + */ +export function hitTestWarp( + warp: SceneWarp, + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { + hit: boolean + dist: number + anchorPos: { x: number; y: number } + arrivePos: { x: number; y: number } +} { + const anchorPos = subTileToScene(warp.x, warp.y, grid) + const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) + if (!(warp.toLevelId > 0)) { + return { + hit: false, + dist: Infinity, + anchorPos, + arrivePos, + } + } + const labelPos = getWarpLabelWorldPos(warp, grid) + + let boxDist = Infinity + let inBox = false + if (warp.selectDX > 0 && warp.selectDY > 0) { + const minX = anchorPos.x + warp.selectX + const maxX = minX + warp.selectDX + const minY = anchorPos.y + warp.selectY + const maxY = minY + warp.selectDY + if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { + inBox = true + const cx = (minX + maxX) * 0.5 + const cy = (minY + maxY) * 0.5 + boxDist = Math.hypot(worldX - cx, worldY - cy) + } + } + + const badgeText = getTblLang() === 'ENG' + ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}` + : `通往 ${getLevelNames(warp.toLevelId).nameZh}` + const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) + const inBadge = + Math.abs(worldX - labelPos.x) <= badgeHalfWidth && + worldY >= labelPos.y - 28 && + worldY <= labelPos.y + 8 + const badgeDist = inBadge + ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 + : Infinity + + const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) + const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) + const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) + const distAnchorBody = Math.hypot(worldX - anchorPos.x, worldY - (anchorPos.y - 48)) + const radialDist = Math.min(distAnchor, distArrive, distLabel, distAnchorBody) + const inRadius = radialDist <= WARP_CLICK_RADIUS_PX + + return { + hit: inBox || inBadge || inRadius, + dist: Math.min(boxDist, badgeDist, radialDist), + anchorPos, + arrivePos, + } +} + +/** + * Hit-test a world-space pointer position against a `SceneEntrance` (overhead + * badge footprint plus radial proximity to entrance anchor, walkable arrival + * point, and overhead label). + */ +export function hitTestEntrance( + entrance: SceneEntrance, + worldX: number, + worldY: number, + grid: { readonly originX: number; readonly originY: number }, +): { + hit: boolean + dist: number + anchorPos: { x: number; y: number } + arrivePos: { x: number; y: number } +} { + const anchorPos = subTileToScene(entrance.x, entrance.y, grid) + const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid) + if (!(entrance.toLevelId > 0)) { + return { + hit: false, + dist: Infinity, + anchorPos, + arrivePos, + } + } + const labelPos = getEntranceLabelWorldPos(entrance, grid) + + const badgeText = getTblLang() === 'ENG' + ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}` + : `通往 ${getLevelNames(entrance.toLevelId).nameZh}` + const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5)) + const inBadge = + Math.abs(worldX - labelPos.x) <= badgeHalfWidth && + worldY >= labelPos.y - 28 && + worldY <= labelPos.y + 8 + const badgeDist = inBadge + ? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1 + : Infinity + + const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y) + const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y) + const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y) + const distArriveBody = Math.hypot(worldX - arrivePos.x, worldY - (arrivePos.y - 48)) + const radialDist = Math.min(distAnchor, distArrive, distLabel, distArriveBody) + + return { + hit: inBadge || radialDist <= ENTRANCE_CLICK_RADIUS_PX, + dist: Math.min(badgeDist, radialDist), + anchorPos, + arrivePos, + } +} + +/** + * Check whether the player is within trigger range (`WARP_TRIGGER_SUBTILES` or + * 80px proximity radius) of a warp or entrance's anchor or walkable arrival point. + */ +export function isPlayerWithinLinkRange( + playerX: number, + playerY: number, + link: { readonly x: number; readonly y: number; readonly arriveX: number; readonly arriveY: number }, + grid: { readonly originX: number; readonly originY: number }, + triggerSubTiles = WARP_TRIGGER_SUBTILES, +): boolean { + const sub = sceneToSubTile(playerX, playerY, grid) + const nearAnchorSub = Math.abs(sub.x - link.x) <= triggerSubTiles && Math.abs(sub.y - link.y) <= triggerSubTiles + const nearArriveSub = Math.abs(sub.x - link.arriveX) <= triggerSubTiles && Math.abs(sub.y - link.arriveY) <= triggerSubTiles + if (nearAnchorSub || nearArriveSub) return true + + const anchorPos = subTileToScene(link.x, link.y, grid) + const arrivePos = subTileToScene(link.arriveX, link.arriveY, grid) + const distAnchor = Math.hypot(playerX - anchorPos.x, playerY - anchorPos.y) + const distArrive = Math.hypot(playerX - arrivePos.x, playerY - arrivePos.y) + return Math.min(distAnchor, distArrive) <= 80 +} + +/** + * Resolve an obstacle-aware immediate steering target toward `navTarget` on `grid`. + * When the direct sub-tile line between the player and `navTarget` is unobstructed, + * returns `navTarget` directly. When blocked by dungeon walls in `grid.blocked`, + * computes an 8-directional shortest walkable sub-tile path (A*) around corners and + * returns the furthest visible waypoint along that path. + */ +export function resolveObstacleAwareSteerTarget( + playerX: number, + playerY: number, + navTarget: { readonly x: number; readonly y: number }, + grid: CollisionGrid | undefined, +): { x: number; y: number } { + if (!grid || !grid.blocked || grid.blocked.length === 0 || grid.gridWidth <= 0 || grid.cellsY <= 0) { + return navTarget + } + + const gridW = grid.gridWidth + const gridH = grid.cellsY * SUB_TILES_PER_TILE + const startSub = sceneToSubTile(playerX, playerY, grid) + const rawEndSub = sceneToSubTile(navTarget.x, navTarget.y, grid) + + if (startSub.x < 0 || startSub.y < 0 || startSub.x >= gridW || startSub.y >= gridH) { + return navTarget + } + const endSub = { + x: Math.max(0, Math.min(gridW - 1, rawEndSub.x)), + y: Math.max(0, Math.min(gridH - 1, rawEndSub.y)), + } + if (startSub.x === endSub.x && startSub.y === endSub.y) { + return navTarget + } + + const isSubTileBlocked = (sx: number, sy: number): boolean => { + if (sx < 0 || sy < 0 || sx >= gridW || sy >= gridH) return true + return grid.blocked[sy * gridW + sx] !== 0 + } + + const hasWallNeighbor = (sx: number, sy: number): boolean => + isSubTileBlocked(sx - 1, sy) || + isSubTileBlocked(sx + 1, sy) || + isSubTileBlocked(sx, sy - 1) || + isSubTileBlocked(sx, sy + 1) + + const hasClearLine = (x0: number, y0: number, x1: number, y1: number, requireClearance: boolean): boolean => { + let cx = x0 + let cy = y0 + const dx = Math.abs(x1 - x0) + const dy = Math.abs(y1 - y0) + const sx = x0 < x1 ? 1 : -1 + const sy = y0 < y1 ? 1 : -1 + let err = dx - dy + let step = 0 + + while (true) { + if (step > 0) { + if (isSubTileBlocked(cx, cy)) return false + if (requireClearance && (cx !== x1 || cy !== y1) && hasWallNeighbor(cx, cy)) return false + } + if (cx === x1 && cy === y1) break + const prevX = cx + const prevY = cy + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + cx += sx + } + if (e2 < dx) { + err += dx + cy += sy + } + if (cx !== prevX && cy !== prevY) { + if (isSubTileBlocked(cx, prevY) || isSubTileBlocked(prevX, cy)) return false + } + step += 1 + } + return true + } + + if (hasClearLine(startSub.x, startSub.y, endSub.x, endSub.y, false)) { + return navTarget + } + + // Bounded 8-directional A* search on sub-tile grid + const maxExpanded = 4096 + const gScore = new Map() + const parent = new Map() + const closed = new Set() + const open: Array<{ key: number; x: number; y: number; g: number; f: number }> = [] + + const startKey = startSub.y * gridW + startSub.x + const endKey = endSub.y * gridW + endSub.x + const heuristic = (x: number, y: number): number => Math.hypot(endSub.x - x, endSub.y - y) + + gScore.set(startKey, 0) + open.push({ key: startKey, x: startSub.x, y: startSub.y, g: 0, f: heuristic(startSub.x, startSub.y) }) + + const dirs: ReadonlyArray = [ + [1, 0, 1], + [-1, 0, 1], + [0, 1, 1], + [0, -1, 1], + [1, 1, Math.SQRT2], + [1, -1, Math.SQRT2], + [-1, 1, Math.SQRT2], + [-1, -1, Math.SQRT2], + ] + + let reachedKey: number | null = null + let bestFallbackKey: number | null = null + let bestFallbackDist = heuristic(startSub.x, startSub.y) + let bestFallbackG = Infinity + let expanded = 0 + + while (open.length > 0 && expanded < maxExpanded) { + let bestIdx = 0 + for (let i = 1; i < open.length; i += 1) { + if (open[i]!.f < open[bestIdx]!.f) bestIdx = i + } + const cur = open[bestIdx]! + open[bestIdx] = open[open.length - 1]! + open.pop() + + if (closed.has(cur.key)) continue + closed.add(cur.key) + expanded += 1 + + if (cur.key === endKey) { + reachedKey = cur.key + break + } + + if (cur.key !== startKey) { + const hCur = heuristic(cur.x, cur.y) + if ( + hCur < bestFallbackDist - 1e-6 || + (Math.abs(hCur - bestFallbackDist) <= 1e-6 && cur.g < bestFallbackG) + ) { + bestFallbackDist = hCur + bestFallbackG = cur.g + bestFallbackKey = cur.key + } + } + + for (const [dx, dy, baseCost] of dirs) { + const nx = cur.x + dx + const ny = cur.y + dy + if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue + const nKey = ny * gridW + nx + if (closed.has(nKey)) continue + if (nKey !== endKey && isSubTileBlocked(nx, ny)) continue + if (dx !== 0 && dy !== 0) { + if (isSubTileBlocked(cur.x + dx, cur.y) || isSubTileBlocked(cur.x, cur.y + dy)) continue + } + + const wallPenalty = (nKey !== endKey && hasWallNeighbor(nx, ny)) ? 0.6 : 0 + const tentativeG = cur.g + baseCost + wallPenalty + const prevG = gScore.get(nKey) + if (prevG === undefined || tentativeG < prevG) { + gScore.set(nKey, tentativeG) + parent.set(nKey, cur.key) + open.push({ key: nKey, x: nx, y: ny, g: tentativeG, f: tentativeG + heuristic(nx, ny) }) + } + } + } + + const finalKey = reachedKey ?? bestFallbackKey + if (finalKey === null) { + return navTarget + } + + const path: Array<{ x: number; y: number }> = [] + let curr: number | undefined = finalKey + while (curr !== undefined) { + const py = Math.floor(curr / gridW) + const px = curr - py * gridW + path.push({ x: px, y: py }) + if (curr === startKey) break + curr = parent.get(curr) + } + path.reverse() + + if (path.length <= 1) { + return navTarget + } + + let targetIdx = 1 + for (let i = path.length - 1; i >= 2; i -= 1) { + const pt = path[i]! + if (hasClearLine(startSub.x, startSub.y, pt.x, pt.y, true)) { + targetIdx = i + break + } + } + + if (targetIdx === path.length - 1 && reachedKey !== null) { + return navTarget + } + + const wp = path[targetIdx]! + const wpScene = subTileToScene(wp.x, wp.y, grid) + if (Math.hypot(wpScene.x - playerX, wpScene.y - playerY) <= 10 && targetIdx + 1 < path.length) { + const nextWp = path[targetIdx + 1]! + return subTileToScene(nextWp.x, nextWp.y, grid) + } + return wpScene +} + +/** + * 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 +} + +/** + * The cell containing a scene point. + * + * @param grid - the collision grid. + * @param x - scene x. + * @param y - scene y. + * @returns rounded-down cell coordinates. + */ +export 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), + } +} + +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 } +} diff --git a/src/scene/backend/scene-population.ts b/src/scene/backend/scene-population.ts new file mode 100644 index 0000000..4747995 --- /dev/null +++ b/src/scene/backend/scene-population.ts @@ -0,0 +1,616 @@ +import type { MonsterPack, MonsterStats, SafeZone } from '../../game/combat.ts' +import { + ORTHO_CELL_HEIGHT, + ORTHO_CELL_WIDTH, + cellAt, + isBlockedAt, + levelSeed, + type CollisionGrid, +} from '../../game/d2map.ts' +import type { DropTables } from '../../game/drop-pipeline.ts' +import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts' +import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../../game/level-links.ts' +import { allocatePacksToRooms, type MapRoom } from '../../game/monster-rooms.ts' +import type { StreamingRoomDef } from '../../game/monster-streaming.ts' +import { + ELITE_HEALTH_MULTIPLIER, + MONUMOD_CONSTANTS, + applyEliteModifiers, + enrichMonsterPacks, + rollEliteModifiers, +} from '../../game/monsters.ts' +import { Rng } from '../../game/rng.ts' +import { subTileToScene } from '../transition.ts' + +/** Base paths tried in order when `?base=` is absent. */ +export const DEFAULT_BASES = ['samples/d2', '/diablo2/data'] + +/** + * 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. + */ +export 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. + */ +export 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. + */ +export const ROOM_DEACTIVATION_RADIUS = 2400 + +/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */ +export const TOWN_LEVEL_IDS = new Set([1, 40, 75, 103, 109]) + +export function isTownLevel(levelId: number | undefined | null): boolean { + if (levelId === undefined || levelId === null || levelId <= 0) return false + return TOWN_LEVEL_IDS.has(levelId) +} + +export type DensityMultiplier = 0.5 | 1 | 2 | 4 | 8 +export type EliteMultiplier = 0.5 | 1 | 2 | 4 | 'all' + +export function parseDensityMultiplier(raw: string | number | null | undefined): DensityMultiplier { + if (raw === null || raw === undefined) return 1 + const num = typeof raw === 'number' ? raw : parseFloat(String(raw).trim()) + if (num === 0.5 || num === 1 || num === 2 || num === 4 || num === 8) { + return num as DensityMultiplier + } + return 1 +} + +export function parseEliteMultiplier(raw: string | number | null | undefined): EliteMultiplier { + if (raw === null || raw === undefined) return 1 + const str = String(raw).trim().toLowerCase() + if (str === 'all') return 'all' + const num = parseFloat(str) + if (num === 0.5 || num === 1 || num === 2 || num === 4) { + return num as EliteMultiplier + } + return 1 +} + +export function promoteToChampion(pack: MonsterPack, rng: Rng): MonsterPack { + const leaderBase = pack.members[0] + if (!leaderBase) return pack + const count = rng.int(2, 4) + const mods = rollEliteModifiers('champion', rng) + const members: MonsterStats[] = [] + for (let i = 0; i < count; i += 1) { + const base = pack.members[i % pack.members.length] ?? leaderBase + const scaledBase: MonsterStats = { + ...base, + hp: Math.max(1, Math.round(base.hp * ELITE_HEALTH_MULTIPLIER.champion)), + } + members.push(applyEliteModifiers(scaledBase, 'champion', mods)) + } + return { + ...pack, + members, + } +} + +export function promoteToUnique(pack: MonsterPack, rng: Rng): MonsterPack { + const leaderBase = pack.members[0] + if (!leaderBase) return pack + const minionCount = rng.int(3, 6) + const mods = rollEliteModifiers('unique', rng) + const scaledLeader: MonsterStats = { + ...leaderBase, + hp: Math.max(1, Math.round(leaderBase.hp * ELITE_HEALTH_MULTIPLIER.unique)), + } + const leader = applyEliteModifiers(scaledLeader, 'unique', mods) + const members: MonsterStats[] = [leader] + for (let i = 0; i < minionCount; i += 1) { + const minionBase = pack.members[(i + 1) % pack.members.length] ?? leaderBase + const scaledMinion: MonsterStats = { + ...minionBase, + hp: Math.max(1, Math.round(minionBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))), + } + members.push(applyEliteModifiers(scaledMinion, 'minion', [])) + } + return { + ...pack, + members, + } +} + +export function demoteToNormal(pack: MonsterPack): MonsterPack { + const members: MonsterStats[] = pack.members.map(m => { + let hp = m.hp + let level = m.level + if (m.rank === 'champion') { + hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion)) + if (level !== undefined) { + level = Math.max(1, level - 2) + } + } else if (m.rank === 'unique') { + hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique)) + if (level !== undefined) { + level = Math.max(1, level - 3) + } + } else if (m.rank === 'minion') { + hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100))) + if (level !== undefined) { + level = Math.max(1, level - 3) + } + } + return { + ...m, + hp, + ...(level !== undefined ? { level } : {}), + rank: 'normal', + modifiers: [], + } + }) + return { + ...pack, + members, + } +} + +export interface RebudgetOptions { + readonly densityMultiplier?: number | string | undefined + readonly eliteMultiplier?: number | string | undefined + readonly levelId?: number | undefined + readonly seed?: number | undefined +} + +export function rebudgetMonsterPacks( + basePacks: readonly MonsterPack[], + options?: RebudgetOptions, +): MonsterPack[] { + const levelId = options?.levelId + if (levelId !== undefined && isTownLevel(levelId)) { + return [] + } + if (!basePacks || basePacks.length === 0) { + return [] + } + + const dropTables = getEmbeddedDropTables() + const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques) + + const density = parseDensityMultiplier(options?.densityMultiplier) + const elite = parseEliteMultiplier(options?.eliteMultiplier) + + if (density === 1 && elite === 1) { + return enrichedBasePacks.map(pack => ({ + ...pack, + members: pack.members.map(m => ({ ...m })), + })) + } + + const rng = new Rng(options?.seed ?? 0x9e37) + + // Separate fixed / landmark packs from regular wild packs + const fixedPacks = enrichedBasePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined) + const regularPacks = enrichedBasePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined) + + // 1. Density scaling on regular packs + let scaledRegularPacks: MonsterPack[] = [] + if (regularPacks.length > 0) { + const targetCount = Math.max(1, Math.round(regularPacks.length * density)) + if (targetCount <= regularPacks.length) { + for (let i = 0; i < targetCount; i += 1) { + const idx = Math.floor((i * regularPacks.length) / targetCount) + const src = regularPacks[idx]! + scaledRegularPacks.push({ + ...src, + members: src.members.map(m => ({ ...m })), + }) + } + } else { + for (let i = 0; i < targetCount; i += 1) { + const src = regularPacks[i % regularPacks.length]! + scaledRegularPacks.push({ + ...src, + members: src.members.map(m => ({ ...m })), + }) + } + } + } + + // 2. Elite scaling on regular packs + if (elite === 'all') { + scaledRegularPacks = scaledRegularPacks.map((pack, idx) => { + const normalPack = demoteToNormal(pack) + const packRng = rng.fork(`all-elite-${idx}`) + const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) + return isChampion ? promoteToChampion(normalPack, packRng) : promoteToUnique(normalPack, packRng) + }) + } else if (elite !== 1) { + const currentEliteIndices: number[] = [] + const currentNormalIndices: number[] = [] + for (let i = 0; i < scaledRegularPacks.length; i += 1) { + const leaderRank = scaledRegularPacks[i]?.members[0]?.rank + if (leaderRank === 'champion' || leaderRank === 'unique') { + currentEliteIndices.push(i) + } else { + currentNormalIndices.push(i) + } + } + + if (currentEliteIndices.length > 0) { + const targetEliteCount = Math.min(scaledRegularPacks.length, Math.max(0, Math.round(currentEliteIndices.length * elite))) + if (targetEliteCount < currentEliteIndices.length) { + const toDemoteCount = currentEliteIndices.length - targetEliteCount + for (let i = 0; i < toDemoteCount; i += 1) { + const idx = currentEliteIndices[currentEliteIndices.length - 1 - i]! + scaledRegularPacks[idx] = demoteToNormal(scaledRegularPacks[idx]!) + } + } else if (targetEliteCount > currentEliteIndices.length) { + const toPromoteCount = Math.min(currentNormalIndices.length, targetEliteCount - currentEliteIndices.length) + for (let i = 0; i < toPromoteCount; i += 1) { + const idx = currentNormalIndices[i]! + const packRng = rng.fork(`promote-${idx}`) + const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) + scaledRegularPacks[idx] = isChampion + ? promoteToChampion(scaledRegularPacks[idx]!, packRng) + : promoteToUnique(scaledRegularPacks[idx]!, packRng) + } + } + } else if (elite > 1 && currentNormalIndices.length > 0) { + const targetEliteCount = Math.min( + scaledRegularPacks.length, + Math.max(1, Math.round(scaledRegularPacks.length * 0.05 * elite)), + ) + const toPromote = Math.min(currentNormalIndices.length, targetEliteCount) + for (let i = 0; i < toPromote; i += 1) { + const idx = currentNormalIndices[i]! + const packRng = rng.fork(`promote-zero-${idx}`) + const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100) + scaledRegularPacks[idx] = isChampion + ? promoteToChampion(scaledRegularPacks[idx]!, packRng) + : promoteToUnique(scaledRegularPacks[idx]!, packRng) + } + } + } + + return [ + ...fixedPacks.map(p => ({ ...p, members: p.members.map(m => ({ ...m })) })), + ...scaledRegularPacks, + ] +} + +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: 235, 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' +} + +/** 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 coordinates 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, + }, +} + +/** 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 +} + +/** + * Sample a scene-pixel rectangle on a 7×7 interior grid and ask whether any + * point is walkable. + * + * An axis-aligned rectangle over the isometric diamond includes the four empty + * triangles outside the map; without this check those corners become rooms, + * `allocatePacksToRooms` deals packs into them, and `spawnMonsterPacks` fails + * every placement attempt against void space. + */ +export 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 +} + +export interface StreamingRoomLayoutInput { + readonly grid: CollisionGrid + readonly widthPx: number + readonly heightPx: number + readonly level: string +} + +/** + * Cuts a level into streaming rooms and allocates its monster packs to them. + */ +export function buildStreamingRooms( + runtime: StreamingRoomLayoutInput, + 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 + + 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, + ) + + const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2) + rooms.push({ + 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 roomPackMap = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level)) + const defs: StreamingRoomDef[] = [] + for (const room of rooms) { + const roomPacks = roomPackMap.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 +} + +export interface SafeZoneSourceRuntime { + readonly grid: CollisionGrid + readonly waypoints: readonly SceneWaypoint[] + readonly entrances: readonly SceneEntrance[] + readonly warps: readonly SceneWarp[] +} + +/** + * Build the safe-zone list around the landing point, waypoints, entrances, and warps. + */ +export function buildLevelSafeZones( + runtime: SafeZoneSourceRuntime, + landingPoint: { readonly x: number; readonly y: number }, +): SafeZone[] { + const safeZones: SafeZone[] = [{ x: landingPoint.x, y: landingPoint.y, radius: 160 }] + 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 warp of runtime.warps) { + const pos = subTileToScene(warp.x, warp.y, runtime.grid) + safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) + } + return safeZones +} diff --git a/src/scene/backend/skill-caster.ts b/src/scene/backend/skill-caster.ts new file mode 100644 index 0000000..1b24705 --- /dev/null +++ b/src/scene/backend/skill-caster.ts @@ -0,0 +1,1770 @@ +import type { ActorAnimator, AnimClipMeta } from '../../game/actor-animator.ts' +import { damageMonster, type Monster } from '../../game/combat.ts' +import { isBlockedAt, isMissileBlockedAt, type CollisionGrid } from '../../game/d2map.ts' +import type { GameEngine } from '../../game/engine.ts' +import { triggerFlippyBounce } from '../../game/ground-items.ts' +import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../../game/level-links.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import { monsterDisplayName } from '../../game/monsters.ts' +import type { TownPortalSlot } from '../../game/portal.ts' +import { + ISO_GROUND_ASPECT_RATIO, calculateBlazeDuration, calculateBlazePatchDuration, + calculateBoneArmorCapacity, calculateBonePrisonStats, calculateBoneSpearDamage, + calculateBoneSpiritDamage, calculateBoneWallStats, calculateChainLightningJumps, + calculateChargedBoltCount, calculateChillingArmorDuration, calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusPx, calculateCurseEffect, calculateEnchantAttackRating, + calculateEnchantDamage, calculateEnchantDuration, calculateEnergyShieldAbsorbPct, + calculateEnergyShieldDuration, calculateFireWallPatches, calculateFistOfTheHeavensDamage, + calculateFrostNovaChillLength, calculateFrostNovaDamage, calculateFrozenArmorDuration, + calculateGlacialSpikeFreezeLength, calculateIceBlastFreezeLength, + calculateInfernoContinuousMana256, calculateInfernoRangePx, calculateIronMaidenReturnPct, + calculateMeteorResidualFireDamage, calculatePoisonDaggerAttackRatingBonus, + calculatePoisonDaggerDamage, calculatePoisonExplosionDamage, calculatePoisonNovaDamage, + calculateReviveMaxCount, calculateShiverArmorDuration, calculateSkeletalMageMaxCount, + calculateSkeletonMaxCount, calculateSkillDamage, calculateStaticFieldDamage, + calculateStaticFieldRadiusPx, calculateTeethCount, calculateTeethDamage, + calculateTelekinesisDamage, calculateTerrorDistance, calculateThunderStormDamage, + calculateThunderStormDuration, calculateThunderStormInterval, getBatch1SkillDef, + getMissileTxtData, getSkillCastOverlay, getSkillCooldownTicks, hasLineOfSight, + isBatch1Skill, isTelekinesisItemAllowed, +} from '../../game/skills.ts' +import { getTblLang } from '../../i18n/lang.ts' +import { getInventoryGoldLimit, itemToUiInventoryItem } from '../../ui/inventory.ts' +import { getSkillName as getSkillDisplayName } from '../../ui/skill-tree-panel.ts' +import { subTileToScene } from '../transition.ts' +import { TELEKINESIS_RANGE_PX, facingOf, hitTestWarp } from './scene-links-nav.ts' +import type { TownStashEntity } from './scene-population.ts' +import { + executeNormalAttack, getAttackWeaponInfo, getSkillMana, + type HudCombatStateLike, type SkillCastOutcome, +} from './weapon-combat.ts' + +let hydraGroupSeqCounter = 0 + +export interface SkillCastRuntimeLike { + readonly level: string + readonly levelId: number + readonly act: number + readonly grid: CollisionGrid + readonly waypoints?: readonly SceneWaypoint[] + readonly stashes?: readonly TownStashEntity[] + readonly warps?: readonly SceneWarp[] + readonly entrances?: readonly SceneEntrance[] +} + +export interface SkillCastCharacterLike { + readonly clips?: Readonly> +} + +export interface SkillCastContext { + engine: GameEngine + runtime: SkillCastRuntimeLike + hudManager: HudCombatStateLike | null + status: { textContent: string | null } + playerAnimator?: ActorAnimator | undefined + character?: SkillCastCharacterLike | null | undefined + equippedWeapon?: any | null | undefined + equippedAmmo?: any | null | undefined + hasAmmo?: boolean | undefined + waypointNetwork?: any | undefined + openStash?: ((stash: any) => void) | undefined + portalSlot?: TownPortalSlot | undefined + onEnterPortal?: (() => void) | undefined + onCastTownPortal?: (() => boolean) | undefined + onEnterWarp?: ((warp: SceneWarp) => void) | undefined + onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined + onInventoryFullFeedback?: (() => void) | undefined + onNotify?: ((msg: string) => void) | undefined +} + +export { type SkillCastOutcome } +export type SkillCastRuntime = SkillCastRuntimeLike +export type SkillCastHudAdapter = HudCombatStateLike +export type StatusSink = { textContent: string | null } +export type SkillCastBackendContext = Omit & { status?: StatusSink | null } + +/** + * Pure outcome wrapper around `castSkill` for callers that inspect `{ casted, animMode, statusMessage }`. + */ +export function executeSkillCast( + skillId: number, + targetX: number, + targetY: number, + context: Omit & { status?: { textContent: string | null } | null }, +): SkillCastOutcome { + const statusSink = context.status ?? { textContent: null as string | null } + const casted = castSkill(skillId, targetX, targetY, { + ...context, + status: statusSink, + }) + return { + casted, + animMode: casted ? (skillId === 0 ? 'a1' : 'sc') : null, + statusMessage: statusSink.textContent, + } +} + +export function castSkill( + skillId: number, + targetX: number, + targetY: number, + context: SkillCastContext, +): boolean { + const { engine, runtime, hudManager, status, playerAnimator, character } = context + const player = engine.world.player + const isEn = (hudManager?.lang ?? getTblLang()) === 'ENG' + const globalBag = globalThis as Record + + // 1. Cooldown check + if (player.cooldown > 0) { + return false + } + + // 1b. Shield requirement check for Smite (97) and Holy Shield (117) + if (skillId === 97 || skillId === 117) { + const shield = + (context as any).equippedShield ?? + hudManager?.inventory?.equipped?.weapon2 ?? + globalBag.__d2webHudInstance?.inventory?.equipped?.weapon2 + if (!shield) { + status.textContent = isEn + ? (skillId === 97 ? 'Smite requires a shield!' : 'Holy Shield requires a shield!') + : (skillId === 97 ? '重击需要装备盾牌!' : '圣盾需要装备盾牌!') + return false + } + } + + // 2. Mana check & deduction + let isFirstInfernoCast = false + if (skillId === 41) { + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1) + const currentMana = hudManager !== null ? hudManager.mana : player.mana + + if (!player.infernoChanneling) { + // Starting cast cost: 6 mana (1.13c Skills.txt) + const startCost = 6 + if (currentMana < startCost) { + status.textContent = isEn ? 'Not enough mana!' : '法力不足!' + engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) + return false + } + player.mana = Math.max(0, player.mana - startCost) + if (hudManager !== null) { + hudManager.mana = player.mana + hudManager.syncPublishedState?.() + } + player.infernoChanneling = true + player.infernoManaRemainder256 = 0 + isFirstInfernoCast = true + } else { + // Continuous channeling frame: drain authentically via calculateInfernoContinuousMana256 (256ths) + if (currentMana <= 0) { + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + status.textContent = isEn ? 'Not enough mana!' : '法力不足!' + engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) + return false + } + const drain256 = calculateInfernoContinuousMana256(slvl) + const total256 = (player.infernoManaRemainder256 ?? 0) + drain256 + const wholeMana = Math.floor(total256 / 256) + player.infernoManaRemainder256 = total256 % 256 + if (wholeMana > 0) { + player.mana = Math.max(0, player.mana - wholeMana) + if (hudManager !== null) { + hudManager.mana = player.mana + hudManager.syncPublishedState?.() + } + } + if (player.mana <= 0) { + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + } + } + } else { + // Reset inferno channeling state when casting another skill + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + + const manaCost = getSkillMana(skillId, hudManager) + const currentMana = hudManager !== null ? hudManager.mana : player.mana + if (currentMana < manaCost) { + status.textContent = isEn ? 'Not enough mana!' : '法力不足!' + engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any) + return false + } + + // 3. Deduct mana + if (manaCost > 0) { + player.mana = Math.max(0, player.mana - manaCost) + if (hudManager !== null) { + hudManager.mana = Math.max(0, hudManager.mana - manaCost) + hudManager.syncPublishedState?.() + } + } + } + + // 4. Face cursor direction + player.facing = facingOf(targetX - player.x, targetY - player.y) + + // 4b. Trigger character animation + if (playerAnimator !== undefined && character?.clips !== undefined) { + if (skillId === 0) { + const clip = character.clips['a1'] ?? character.clips['a2'] + if (clip !== undefined) playerAnimator.play('a1', clip, { restart: true }) + } else { + const clip = character.clips['sc'] ?? character.clips['a1'] + if (clip !== undefined) playerAnimator.play('sc', clip, { restart: true }) + } + } + + // 5. Spawn cast overlay if defined for this skill + if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) { + const castOverlayName = getSkillCastOverlay(skillId) + if (castOverlayName) { + engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player', { isCastOverlay: true }) + } + } + + // 6. Dispatch skill + if (skillId === 0) { + const outcome = executeNormalAttack(targetX, targetY, { + engine, + hudLike: hudManager, + equippedWeapon: context.equippedWeapon, + equippedAmmo: context.equippedAmmo, + hasAmmo: context.hasAmmo, + }) + if (outcome.statusMessage !== null) { + status.textContent = outcome.statusMessage + } + return outcome.casted + } + + 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 = isEn ? 'Cast Teleport' : '已施展传送' + } else { + status.textContent = isEn ? 'Cannot teleport there' : '无法传送到该位置' + } + return true + } + + if (skillId === 42) { + // Static Field (Skill 42): Instantaneous 360° area discharge + 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 = isEn + ? `Cast Static Field (${hitCount} targets)` + : `施展静态力场,削弱 ${hitCount} 个目标` + return true + } + + if (skillId === 43) { + // Telekinesis (Skill 43): 1.13c ground truth + const slvl = (hudManager as any)?.getSkillLevel?.(43) ?? 1 + const dist = Math.hypot(targetX - player.x, targetY - player.y) + const maxRange = 600 + const refundTelekinesisMana = (): void => { + const manaCost = getSkillMana(43, hudManager) + if (manaCost > 0) { + player.mana = Math.min(player.maxMana ?? player.mana + manaCost, player.mana + manaCost) + if (hudManager !== null) { + hudManager.mana = Math.min(hudManager.maxMana ?? hudManager.mana + manaCost, hudManager.mana + manaCost) + hudManager.syncPublishedState?.() + } + } + } + + if (dist > maxRange) { + refundTelekinesisMana() + status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!' + return false + } + + // 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) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + 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 = isEn + ? `Telekinesis hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}` + : `心灵传动命中:${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) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + if (isTelekinesisItemAllowed(nearbyGroundItem)) { + if (nearbyGroundItem.isGold) { + const res = engine.pickupGold(nearbyGroundItem.id) + if (res.success) { + if (hudManager?.inventory) { + const playerLevel = engine.world.player?.level ?? 1 + const goldCap = getInventoryGoldLimit(playerLevel) + hudManager.inventory.playerLevel = playerLevel + hudManager.inventory.gold = Math.min(goldCap, hudManager.inventory.gold + res.amount) + hudManager.syncPublishedState?.() + } + if (res.remaining && res.remaining > 0) { + status.textContent = isEn + ? `Telekinesis picked up gold: ${res.amount} (${res.remaining} remaining)` + : `心灵传动拾取金币:${res.amount}(地面剩余:${res.remaining})` + } else { + status.textContent = isEn + ? `Telekinesis picked up gold: ${res.amount}` + : `心灵传动拾取金币:${res.amount}` + } + } else { + engine.metrics.inventoryRefusals += 1 + triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now()) + const playFull = context.onInventoryFullFeedback ?? globalBag.__d2PlayInventoryFullFeedback + if (typeof playFull === 'function') playFull() + status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。' + const notify = context.onNotify ?? globalBag.__d2ShowNotification + if (typeof notify === 'function') { + notify(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。') + } + } + } else { + if (hudManager?.inventory) { + const autoBelted = Boolean(hudManager.belt?.autoPlacePotion?.(nearbyGroundItem.item)) + if (!autoBelted) { + const uiItem = itemToUiInventoryItem(nearbyGroundItem.item, engine.dropTables) + const placed = hudManager.inventory.autoPlaceInGrid(uiItem) + if (!placed) { + engine.metrics.inventoryRefusals += 1 + triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now()) + status.textContent = isEn ? 'I am overburdened.' : '包裹已满。' + 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 = isEn + ? `Telekinesis picked up: ${nearbyGroundItem.name}` + : `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}` + } else { + engine.pickupItem(nearbyGroundItem.id) + status.textContent = isEn + ? `Telekinesis picked up: ${nearbyGroundItem.name}` + : `心灵传动拾取物品:${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 = isEn ? 'Cannot pick up equipment with Telekinesis!' : '心灵传动无法拾取装备!' + } + player.cooldown = 0 + engine.metrics.casts += 1 + return true + } + + // 3. Interactive objects check (waypoints, stash, chests, portals, warps) + if (runtime.waypoints) { + const clickedWp = runtime.waypoints.find(wp => Math.hypot(wp.arriveX - targetX, wp.arriveY - targetY) <= 80) + if (clickedWp) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + if (context.waypointNetwork?.activate(clickedWp.waypointId)) { + status.textContent = isEn ? `Waypoint activated: ${runtime.level}` : `传送点已激活:${runtime.level}` + } + if (hudManager?.worldPanels) { + 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) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + if (context.openStash) { + context.openStash(clickedStash) + } else if (hudManager && hudManager.leftPanel !== 'stash') { + hudManager.toggleLeftPanel?.('stash') + } + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Telekinesis opened Stash' : '心灵传动打开储物箱' + return true + } + } + + if (context.portalSlot && context.onEnterPortal && runtime.grid) { + const mouth = context.portalSlot.mouthInLevel(runtime.levelId) + if (mouth) { + const portalPos = subTileToScene(mouth.x, mouth.y, runtime.grid) + const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, targetY)) + if (Math.hypot(portalPos.x - targetX, clampedPortalY - targetY) <= 64) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Telekinesis entered Town Portal' : '心灵传动进入传送门' + context.onEnterPortal() + return true + } + } + } + + if (runtime.warps && runtime.grid) { + let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const warp of runtime.warps) { + const res = hitTestWarp(warp, targetX, targetY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + if (bestWarp !== null) { + const warpPlayerDist = Math.min( + Math.hypot(bestWarp.anchorPos.x - player.x, bestWarp.anchorPos.y - player.y), + Math.hypot(bestWarp.arrivePos.x - player.x, bestWarp.arrivePos.y - player.y), + ) + if (warpPlayerDist <= TELEKINESIS_RANGE_PX) { + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + const destName = getLevelNames(bestWarp.warp.toLevelId).nameZh + status.textContent = isEn + ? `Telekinesis triggered warp: To ${levelDisplayName(bestWarp.warp.toLevelId, 'ENG')}` + : `心灵传动触发入口:通往 ${destName}` + context.onEnterWarp?.(bestWarp.warp) + return true + } + refundTelekinesisMana() + status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!' + return false + } + } + + // 4. Generic ground cast / feedback + engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Telekinesis' : '施展心灵传动' + return true + } + + if (skillId === 219 || skillId === 220) { + // Scroll of Town Portal (219) / Book of Town Portal (220) + if (context.onCastTownPortal) { + const ok = context.onCastTownPortal() + if (!ok) return false + player.cooldown = 10 + engine.metrics.casts += 1 + return true + } + return false + } + + if (skillId === 59) { + // Blizzard (Skill 59) + 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, + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + }) + + player.cooldown = 45 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Blizzard' : '施展暴风雪' + return true + } + + if (skillId === 56) { + // Meteor (Skill 56) + 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, + fromPlayer: true, + missileType: 'meteorcenter', + slvl, + fireDuration, + residualDamage: fireDmg, + subMissileIdx: 0, + }) + + player.cooldown = 30 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Meteor' : '施展陨石' + return true + } + + if (skillId === 51) { + // Fire Wall (Skill 51) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(51) ?? 1) + const dmgRange = calculateSkillDamage(51, slvl) + const fireWallDamage = Math.round((dmgRange.min + dmgRange.max) / 2) + const patches = calculateFireWallPatches(player.x, player.y, targetX, targetY, { + count: 15, + spacing: 24, + isBlocked: (x, y) => { + if (runtime?.grid) { + return isMissileBlockedAt(runtime.grid, x, y) + } + if ((runtime as any)?.walkable) { + return !(runtime as any).walkable.isWalkable(x, y) + } + if ((runtime as any)?.collision) { + return !(runtime as any).collision.isWalkable(x, y) + } + return false + }, + }) + + for (const patch of patches) { + engine.projectiles.push({ + skillId: '51', + x: patch.x, + y: patch.y, + vx: 0, + vy: 0, + damage: fireWallDamage, + ttl: 90, + fireDuration: 90, + fromPlayer: true, + missileType: 'firewall', + pierce: true, + aoeRadius: 24, + }) + } + + player.cooldown = 35 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Fire Wall' : '施展火墙' + return true + } + + if (skillId === 46) { + // Blaze (Skill 46) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(46) ?? 1) + const duration = calculateBlazeDuration(slvl) + const patchDuration = calculateBlazePatchDuration(slvl) + const dmgRange = calculateSkillDamage(46, slvl) + const blazeDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + + player.blazeTicks = duration + player.blazeSlvl = slvl + player.blazeDamage = blazeDmg + player.blazePatchDuration = patchDuration + player.lastBlazeX = undefined + player.lastBlazeY = undefined + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Blaze' : '施展烈火之径' + return true + } + + if (skillId === 41) { + // Inferno (Skill 41) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1) + const dmgRange = calculateSkillDamage(41, slvl) + const infernoDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + const rangePx = calculateInfernoRangePx(slvl) + const dx = targetX - player.x + const dy = targetY - player.y + const len = Math.hypot(dx, dy) + const speed = 12 + const vx = len > 0 ? (dx / len) * speed : speed + const vy = len > 0 ? (dy / len) * speed : 0 + const ttl = Math.max(8, Math.min(60, Math.round(rangePx / speed))) + + const flameVariant = (engine.metrics.casts % 2 === 0) ? 'infernoflame1' : 'infernoflame2' + engine.projectiles.push({ + skillId: '41', + x: player.x, + y: player.y, + vx, + vy, + damage: infernoDmg, + ttl, + fromPlayer: true, + missileType: flameVariant, + pierce: true, + aoeRadius: 20, + }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Inferno' : '施展地狱火' + return true + } + + if (skillId === 40 || skillId === 50 || skillId === 60) { + for (const ov of engine.overlays) { + if ( + (ov.overlayName === 'frozenarmor' || + ov.overlayName === 'shiverarmor' || + ov.overlayName === 'chillingarmor') && + ov.casterId === 'player' + ) { + ov.expired = true + } + } + } + + if (skillId === 40) { + const slvl = 1 + const duration = calculateFrozenArmorDuration(slvl) + player.coldArmorType = 'frozenarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + player.cooldown = 0 + status.textContent = isEn ? 'Frozen Armor active' : '冰封装甲已激活' + engine.metrics.casts += 1 + return true + } + + if (skillId === 50) { + const slvl = 1 + const duration = calculateShiverArmorDuration(slvl) + player.coldArmorType = 'shiverarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + player.cooldown = 0 + status.textContent = isEn ? 'Shiver Armor active' : '碎冰装甲已激活' + engine.metrics.casts += 1 + return true + } + + if (skillId === 60) { + const slvl = 1 + const duration = calculateChillingArmorDuration(slvl) + player.coldArmorType = 'chillingarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + player.cooldown = 0 + status.textContent = isEn ? 'Chilling Armor active' : '寒冰装甲已激活' + engine.metrics.casts += 1 + return true + } + + if (skillId === 57) { + // Thunder Storm (Skill 57) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(57) ?? 1) + const lmSlvl = (hudManager as any)?.getSkillLevel?.(63) ?? 0 + const duration = calculateThunderStormDuration(slvl) + const interval = calculateThunderStormInterval(slvl) + const dmgRange = calculateThunderStormDamage(slvl, { lightningMastery: lmSlvl }) + const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + + player.thunderstormTicks = duration + player.thunderstormInterval = interval + player.thunderstormNextStrike = interval + player.thunderstormDamage = tsDmg + player.thunderstormDamageRange = dmgRange + player.thunderstormSlvl = slvl + + engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Thunder Storm active' : '雷云风暴已激活' + return true + } + + if (skillId === 58) { + // Energy Shield (Skill 58) + const slvl = 1 + const duration = calculateEnergyShieldDuration(slvl) + const absorbPct = calculateEnergyShieldAbsorbPct(slvl) + + player.energyshieldTicks = duration + player.energyshieldPct = absorbPct + player.energyshieldSlvl = slvl + + engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Energy Shield active' : '能量护盾已激活' + return true + } + + if (skillId === 52) { + // Enchant (Skill 52) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(52) ?? 1) + const warmthLvl = (hudManager as any)?.getSkillLevel?.(37) ?? 0 + const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 + const duration = calculateEnchantDuration(slvl) + const arBonus = calculateEnchantAttackRating(slvl) + const dmg = calculateEnchantDamage(slvl, { warmth: warmthLvl }, fireMasterySlvl) + + player.enchantTicks = duration + player.enchantAttackRatingPercent = arBonus + player.enchantDamage = { min: dmg.min, max: dmg.max } + player.fireMasterySlvl = fireMasterySlvl + player.isSorceress = true + + engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: false }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Enchant active' : '强化已激活' + return true + } + + if (skillId === 62) { + // Hydra (Skill 62) + const offsets = [ + { dx: -6, dy: -11 }, + { dx: 0, dy: -7 }, + { dx: 4, dy: -15 }, + ] + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(62) ?? 1) + const dmgRange = calculateSkillDamage(62, slvl) + const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2) + + const MAX_ACTIVE_HYDRA_GROUPS = 6 + const activeTurrets = engine.projectiles.filter( + p => (p.isTurret || p.missileType === 'hydra_turret') && p.ttl > 0, + ) + const existingGroupIds = Array.from( + new Set(activeTurrets.map(p => (p as any).groupId ?? 0)), + ).sort((a, b) => a - b) + + if (existingGroupIds.length >= MAX_ACTIVE_HYDRA_GROUPS) { + const groupsToEvictCount = existingGroupIds.length - MAX_ACTIVE_HYDRA_GROUPS + 1 + const evictGids = new Set(existingGroupIds.slice(0, groupsToEvictCount)) + engine.projectiles = engine.projectiles.filter( + p => !((p.isTurret || p.missileType === 'hydra_turret') && evictGids.has((p as any).groupId ?? 0)), + ) + } + + const currentGroupId = ++hydraGroupSeqCounter + 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, + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + fireCadenceTicks: 25, + headIndex: i, + groupId: currentGroupId, + spawnMissileType: 'hydra', + pierce: false, + } as any) + } + + player.cooldown = 40 + engine.metrics.casts += 1 + status.textContent = isEn ? 'Cast Hydra' : '施展九头海蛇' + return true + } + + if (skillId === 68) { + // Bone Armor + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(68) ?? 1) + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const capacity = calculateBoneArmorCapacity(slvl, { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl }) + + ;(player as any).boneArmorHp = capacity + ;(player as any).boneArmorMaxHp = capacity + + engine.spawnOverlay('bonearmor_cast', { x: player.x, y: player.y }, 'player') + engine.spawnOverlay('bonearmor_front', { x: player.x, y: player.y }, 'player') + engine.spawnOverlay('bonearmor_back', { x: player.x, y: player.y }, 'player') + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Cast Bone Armor, absorbs ${capacity} physical damage` + : `施展白骨装甲,吸收 ${capacity} 点物理伤害` + return true + } + + if (skillId === 73) { + // Poison Dagger: requires dagger weapon equipped + const weapon = + context.equippedWeapon ?? + hudManager?.inventory?.equipped?.weapon1 ?? + globalBag.__d2webHudInstance?.inventory?.equipped?.weapon1 + const weaponInfo = getAttackWeaponInfo(weapon) + if (weaponInfo.subtype !== 'dagger') { + status.textContent = isEn ? 'Poison Dagger requires a dagger equipped!' : '淬毒匕首需要装备匕首类武器!' + return false + } + + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(73) ?? 1) + const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 + const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 + const pDmg = calculatePoisonDaggerDamage(slvl, { + poisonExplosion: poisonExplosionBlvl, + poisonNova: poisonNovaBlvl, + }) + const arBonus = calculatePoisonDaggerAttackRatingBonus(slvl) + + player.cooldown = weaponInfo.cooldown + player.swingTicks = weaponInfo.swingTicks + + let targetMonster: Monster | null = null + let bestTargetDist = 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 <= weaponInfo.reach) { + const distCursor = Math.hypot(monster.x - targetX, monster.y - targetY) + if (distCursor <= bestTargetDist) { + bestTargetDist = distCursor + targetMonster = monster + } + } + } + + if (targetMonster !== null) { + const baseDmg = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2) + damageMonster(engine.world, targetMonster.index, baseDmg) + ;(targetMonster as any).statusEffect = 'poison' + ;(targetMonster as any).statusDuration = pDmg.durationFrames + ;(targetMonster as any).poisonDamagePerTick = pDmg.minRate / 256 + damageMonster(engine.world, targetMonster.index, pDmg.minDamage) + engine.metrics.playerHits += 1 + engine.metrics.casts += 1 + const mName = monsterDisplayName(targetMonster.stats) + status.textContent = isEn + ? `Poison Dagger (+${arBonus}% AR) hit: ${mName}, dealt ${pDmg.minDamage}-${pDmg.maxDamage} poison damage` + : `淬毒匕首(+${arBonus}%命中)命中:${targetMonster.stats.name || targetMonster.stats.id},造成 ${pDmg.minDamage}-${pDmg.maxDamage} 点毒素伤害` + } else { + engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) + engine.metrics.casts += 1 + status.textContent = isEn + ? `Poison Dagger (+${arBonus}% AR): missed target` + : `淬毒匕首(+${arBonus}%命中):未命中目标` + } + return true + } + + if (skillId === 74) { + // Corpse Explosion + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(74) ?? 1) + const radiusPx = calculateCorpseExplosionRadiusPx(slvl) + + let targetCorpse: Monster | null = null + let bestCorpseDist = 80 + for (const monster of engine.world.monsters) { + if (monster.state !== 'dead' && monster.hp > 0) continue + if ((monster as any).corpseConsumed) continue + if ((monster.stats as any).isBoss || (monster.stats as any).type === 'boss') continue + const d = Math.hypot(monster.x - targetX, monster.y - targetY) + if (d <= bestCorpseDist) { + bestCorpseDist = d + targetCorpse = monster + } + } + + if (!targetCorpse) { + status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!' + return false + } + + ;(targetCorpse as any).corpseConsumed = true + const cx = targetCorpse.x + const cy = targetCorpse.y + + engine.spawnOverlay('corpseexplosion', { x: cx, y: cy }) + + const baseHp = targetCorpse.stats.hp ?? 1000 + const ceDmg = calculateCorpseExplosionDamage(baseHp) + + let hitCount = 0 + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) + if (dist <= radiusPx) { + const totalDmg = Math.max(1, ceDmg.phys + ceDmg.fire) + damageMonster(engine.world, monster.index, totalDmg) + engine.metrics.castHits += 1 + hitCount++ + } + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Corpse Explosion (Radius ${(radiusPx / 40).toFixed(1)} yds) detonated corpse, hit ${hitCount} targets` + : `尸体爆炸(范围 ${(radiusPx / 40).toFixed(1)} 码)消灭尸体,命中 ${hitCount} 个目标` + return true + } + + if (skillId === 78) { + // Bone Wall + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(78) ?? 1) + const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const wallStats = calculateBoneWallStats(slvl, { boneArmor: boneArmorBlvl, bonePrison: bonePrisonBlvl }) + + const dx = targetX - player.x + const dy = (targetY - player.y) / ISO_GROUND_ASPECT_RATIO + const len = Math.hypot(dx, dy) || 1 + const perpX = -dy / len + const perpY = dx / len + const spacing = 24 + + for (let i = -2; i <= 2; i++) { + const segX = targetX + perpX * (i * spacing) + const segY = targetY + perpY * (i * spacing * ISO_GROUND_ASPECT_RATIO) + engine.spawnOverlay('bonecast', { x: segX, y: segY }) + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Cast Bone Wall (Life ${wallStats.hp}, Duration ${wallStats.durationSeconds}s, ${wallStats.segmentCount} segments)` + : `施展骨墙(生命 ${wallStats.hp},持续 ${wallStats.durationSeconds} 秒,${wallStats.segmentCount} 骨节)` + return true + } + + if (skillId === 83) { + // Poison Explosion + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(83) ?? 1) + const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 + const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 + const peDmg = calculatePoisonExplosionDamage(slvl, { + poisonDagger: poisonDaggerBlvl, + poisonNova: poisonNovaBlvl, + }) + + let targetCorpse: Monster | null = null + let bestCorpseDist = 80 + for (const monster of engine.world.monsters) { + if (monster.state !== 'dead' && monster.hp > 0) continue + if ((monster as any).corpseConsumed) continue + const d = Math.hypot(monster.x - targetX, monster.y - targetY) + if (d <= bestCorpseDist) { + bestCorpseDist = d + targetCorpse = monster + } + } + + if (!targetCorpse) { + status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!' + return false + } + + ;(targetCorpse as any).corpseConsumed = true + const cx = targetCorpse.x + const cy = targetCorpse.y + + engine.spawnOverlay('poisoncorpseexplosion', { x: cx, y: cy }) + engine.spawnOverlay('poisonexplosioncloud', { x: cx, y: cy }) + + let hitCount = 0 + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) + if (dist <= 80) { + ;(monster as any).statusEffect = 'poison' + ;(monster as any).statusDuration = peDmg.durationFrames + damageMonster(engine.world, monster.index, peDmg.minDamage) + engine.metrics.castHits += 1 + hitCount++ + } + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Poison Explosion (Duration ${peDmg.durationSeconds.toFixed(1)}s, Damage ${peDmg.minDamage}-${peDmg.maxDamage}) hit ${hitCount} targets` + : `毒爆(持续 ${peDmg.durationSeconds.toFixed(1)} 秒,伤害 ${peDmg.minDamage}-${peDmg.maxDamage})命中 ${hitCount} 个目标` + return true + } + + if (skillId === 88) { + // Bone Prison + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(88) ?? 1) + const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const prisonStats = calculateBonePrisonStats(slvl, { boneArmor: boneArmorBlvl, boneWall: boneWallBlvl }) + + const ringRadius = 40 + for (let i = 0; i < prisonStats.segmentCount; i++) { + const angle = (i / prisonStats.segmentCount) * 2 * Math.PI + const segX = targetX + Math.cos(angle) * ringRadius + const segY = targetY + Math.sin(angle) * (ringRadius * ISO_GROUND_ASPECT_RATIO) + engine.spawnOverlay('bonecast', { x: segX, y: segY }) + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Cast Bone Prison (Life ${prisonStats.hp}, Duration ${prisonStats.durationSeconds}s, ${prisonStats.segmentCount} segments)` + : `施展骨牢(生命 ${prisonStats.hp},持续 ${prisonStats.durationSeconds} 秒,${prisonStats.segmentCount} 骨柱)` + return true + } + + if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(skillId)) { + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(skillId) ?? 1) + const curseEffect = calculateCurseEffect(skillId, slvl) + + engine.spawnOverlay('cursecast', { x: targetX, y: targetY }) + + const getCurseOverhead = (id: number): string => { + switch (id) { + case 66: return 'curseamplifydamage' + case 71: return 'cursedimvision' + case 72: return 'curseweaken' + case 76: return 'curseironmaiden' + case 77: return 'curseterror' + case 81: return 'curseconfuse' + case 82: return 'cursereversevampire' + case 86: return 'curseattract' + case 87: return 'cursedecrepify' + case 91: return 'curselowerresist' + default: return 'curseamplifydamage' + } + } + + if (skillId === 86) { + let targetMonster: Monster | null = null + let bestDist = curseEffect.radiusPx + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) + if (dist <= bestDist) { + bestDist = dist + targetMonster = monster + } + } + + if (!targetMonster) { + status.textContent = isEn ? 'Requires a valid target!' : '需要有效的目标!' + return false + } + + const isBossOrChamp = Boolean( + (targetMonster.stats as any).isBoss || + (targetMonster.stats as any).type === 'boss' || + targetMonster.stats.rank === 'champion' || + targetMonster.stats.rank === 'unique', + ) + if (isBossOrChamp) { + status.textContent = isEn ? 'Bosses and Champions are immune to Attract!' : '首领和头目免疫吸引!' + return false + } + + if (targetMonster.curse?.curseType === 2 || targetMonster.curse?.name === 'attract') { + status.textContent = isEn ? 'Target is already Attracted!' : '目标已被吸引!' + return false + } + + targetMonster.curse = { + name: 'attract', + slvl, + curseType: 2, + ticksRemaining: curseEffect.durationFrames, + overlayName: 'curseattract', + } + engine.spawnOverlay('curseattract', { x: targetMonster.x, y: targetMonster.y }, `monster-${targetMonster.index}`, { loop: true }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Attract (Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit target` + : `吸引(持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中目标` + return true + } + + let affectedCount = 0 + const overheadOverlay = getCurseOverhead(skillId) + + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) + if (dist <= curseEffect.radiusPx) { + if (monster.curse?.curseType === 2 || monster.curse?.name === 'attract') { + continue + } + + const isBossOrChamp = Boolean( + (monster.stats as any).isBoss || + (monster.stats as any).type === 'boss' || + monster.stats.rank === 'champion' || + monster.stats.rank === 'unique', + ) + if ([71, 77, 81].includes(skillId) && isBossOrChamp) { + continue + } + + monster.curse = { + name: curseEffect.stateName, + slvl, + curseType: 1, + ticksRemaining: curseEffect.durationFrames, + overlayName: overheadOverlay, + casterX: player.x, + casterY: player.y, + returnPct: skillId === 76 ? calculateIronMaidenReturnPct(slvl) : undefined, + } + + if (skillId === 77) { + const fleeDx = monster.x - player.x + const fleeDy = monster.y - player.y + const fleeLen = Math.hypot(fleeDx, fleeDy) || 1 + const fleeDistPx = calculateTerrorDistance(slvl) * (40 * (2 / 3)) + monster.curse.fleeTarget = { + x: monster.x + (fleeDx / fleeLen) * fleeDistPx, + y: monster.y + (fleeDy / fleeLen) * fleeDistPx, + } + } + + engine.spawnOverlay(overheadOverlay, { x: monster.x, y: monster.y }, `monster-${monster.index}`, { loop: true }) + affectedCount++ + } + } + + player.cooldown = 0 + engine.metrics.casts += 1 + const curseName = isEn ? getSkillDisplayName(skillId, 'ENG') : curseEffect.skillName + status.textContent = isEn + ? `${curseName} (Radius ${(curseEffect.radiusPx / 40).toFixed(1)} yds, Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit ${affectedCount} targets` + : `${curseEffect.skillName}(范围 ${(curseEffect.radiusPx / 40).toFixed(1)} 码,持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中 ${affectedCount} 个目标` + return true + } + + // Necromancer Summoning Spells Tree + if ([69, 70, 75, 79, 80, 85, 89, 90, 94, 95].includes(skillId)) { + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(skillId) ?? 1) + + if (skillId === 69) { + status.textContent = isEn + ? `Skeleton Mastery (Level ${slvl}): Passively increases life and damage of raised skeletons and revived monsters` + : `支配骷髅(等级 ${slvl}):被动提升骷髅与复活怪物的生命与伤害` + return true + } + if (skillId === 79) { + status.textContent = isEn + ? `Golem Mastery (Level ${slvl}): Passively increases life and velocity of Golems` + : `支配石魔(等级 ${slvl}):被动提升石魔的生命与移动速度` + return true + } + if (skillId === 89) { + status.textContent = isEn + ? `Summon Resist (Level ${slvl}): Passively increases elemental resistances of all summons` + : `召唤物抗性(等级 ${slvl}):被动提升所有召唤物的元素抗性` + return true + } + + if (skillId === 70 || skillId === 80 || skillId === 95) { + let targetCorpse: Monster | null = null + let bestCorpseDist = 80 + for (const monster of engine.world.monsters) { + if (monster.state !== 'dead' && monster.hp > 0) continue + if ((monster as any).corpseConsumed) continue + if ((monster.stats as any)?.isBoss || (monster.stats as any)?.type === 'boss') continue + const d = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO) + if (d <= bestCorpseDist) { + bestCorpseDist = d + targetCorpse = monster + } + } + + if (!targetCorpse) { + status.textContent = isEn + ? 'Requires an unconsumed monster corpse (non-Boss)!' + : '需要未被消耗的怪物尸体(非首领)!' + return false + } + + ;(targetCorpse as any).corpseConsumed = true + targetCorpse.corpseTicks = 0 + + engine.spawnOverlay('bonecast', { x: targetCorpse.x, y: targetCorpse.y }) + + player.cooldown = 0 + engine.metrics.casts += 1 + + if (skillId === 70) { + const maxSkel = calculateSkeletonMaxCount(slvl) + status.textContent = isEn + ? `Raise Skeleton (Level ${slvl}, Max ${maxSkel}) summoned` + : `复生骷髅(等级 ${slvl},上限 ${maxSkel} 只)召唤成功` + } else if (skillId === 80) { + const maxMage = calculateSkeletalMageMaxCount(slvl) + status.textContent = isEn + ? `Raise Skeletal Mage (Level ${slvl}, Max ${maxMage}) summoned` + : `复生骷髅法师(等级 ${slvl},上限 ${maxMage} 只)召唤成功` + } else { + const maxRevive = calculateReviveMaxCount(slvl) + status.textContent = isEn + ? `Revive (Level ${slvl}, Max ${maxRevive}, Duration 180s) summoned` + : `重生(等级 ${slvl},上限 ${maxRevive} 只,持续 180 秒)召唤成功` + } + return true + } + + if (skillId === 90) { + engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Iron Golem (Level ${slvl}, inherits item properties and thorns) summoned` + : `钢铁石魔(等级 ${slvl},继承装备属性与反伤)召唤成功` + return true + } + + if (skillId === 75) { + engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Clay Golem (Level ${slvl}, slows enemies on hit) summoned` + : `粘土石魔(等级 ${slvl},附带减速攻击与受击减速)召唤成功` + return true + } + + if (skillId === 85) { + engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Blood Golem (Level ${slvl}, 70% life link) summoned` + : `鲜血石魔(等级 ${slvl},附带 70% 生命链接与伤害分担)召唤成功` + return true + } + + if (skillId === 94) { + engine.spawnOverlay('bonecast', { x: targetX, y: targetY }) + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = isEn + ? `Fire Golem (Level ${slvl}, Holy Fire aura & fire absorb) summoned` + : `火焰石魔(等级 ${slvl},附带圣火脉冲与火焰吸收)召唤成功` + return true + } + } + + // Projectile Skills + let speed = 450 + let range = 450 + let damage = 28 + let delay = 0 + let skillName = isEn ? 'Skill' : '技能' + 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 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(47) ?? 1) + const fireBoltBlvl = (hudManager as any)?.getSkillLevel?.(36) ?? 0 + const meteorBlvl = (hudManager as any)?.getSkillLevel?.(56) ?? 0 + const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 + const dmgRange = calculateSkillDamage(47, slvl, { fireBolt: fireBoltBlvl, meteor: meteorBlvl, fireMastery: fireMasterySlvl }) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + aoeRadius = 80 + missileType = 'fireball' + skillName = '火球' + } else if (skillId === 39) { + const missile = getMissileTxtData('icebolt') + speed = missile.speedPxPerSec || 300 + range = missile.distancePx || 600 + damage = 14 + delay = 0 + statusEffect = 'chill' + statusDuration = 50 + missileType = 'icebolt' + skillName = '冰弹' + } else if (skillId === 45) { + const missile = getMissileTxtData('iceblast') + speed = missile.speedPxPerSec || 300 + range = missile.distancePx || 600 + const slvl = (hudManager as any)?.getSkillLevel?.(45) ?? 1 + const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const glacialSpikeBlvl = (hudManager as any)?.getSkillLevel?.(55) ?? 0 + const synergies = { + iceBolt: iceBoltBlvl, + blizzard: blizzardBlvl, + frozenOrb: frozenOrbBlvl, + glacialSpike: glacialSpikeBlvl, + } + const dmgRange = calculateSkillDamage(45, slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + statusEffect = 'freeze' + statusDuration = calculateIceBlastFreezeLength(slvl, synergies) + missileType = 'iceblast' + skillName = '冰风暴' + } else if (skillId === 55) { + const missile = getMissileTxtData('glacialspike') + speed = missile.speedPxPerSec || 400 + range = missile.distancePx || 640 + const slvl = (hudManager as any)?.getSkillLevel?.(55) ?? 1 + const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 + const iceBlastBlvl = (hudManager as any)?.getSkillLevel?.(45) ?? 0 + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const synergies = { + iceBolt: iceBoltBlvl, + iceBlast: iceBlastBlvl, + blizzard: blizzardBlvl, + frozenOrb: frozenOrbBlvl, + } + const dmgRange = calculateSkillDamage(55, slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + aoeRadius = 64 + statusEffect = 'freeze' + statusDuration = calculateGlacialSpikeFreezeLength(slvl, synergies) + missileType = 'glacialspike' + skillName = '冰尖柱' + } else if (skillId === 38) { + const missile = getMissileTxtData('chargedbolt') + speed = missile.speedPxPerSec || 300 + range = missile.distancePx || 1176 + const slvl = (hudManager as any)?.getSkillLevel?.(38) ?? 1 + const lightningBlvl = (hudManager as any)?.getSkillLevel?.(49) ?? 0 + const dmgRange = calculateSkillDamage(38, slvl, { lightning: lightningBlvl } as any) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + missileType = 'chargedbolt' + jitter = true + multiCount = calculateChargedBoltCount(slvl) + spreadAngle = 0.55 + skillName = '充能弹' + } else if (skillId === 67) { + const missile = getMissileTxtData('teeth') + speed = missile.speedPxPerSec || 400 + range = missile.distancePx || 800 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(67) ?? 1) + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 + const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 + const synergies = { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpear: boneSpearBlvl, boneSpirit: boneSpiritBlvl } + const dmgRange = calculateTeethDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + missileType = 'teeth' + multiCount = calculateTeethCount(slvl) + spreadAngle = Math.min(1.2, 0.25 + multiCount * 0.04) + nextDelay = 4 + skillName = '牙' + } else if (skillId === 84) { + const missile = getMissileTxtData('bonespear') + speed = missile.speedPxPerSec || 600 + range = missile.distancePx || 960 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(84) ?? 1) + const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 + const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpirit: boneSpiritBlvl } + const dmgRange = calculateBoneSpearDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + pierce = true + missileType = 'bonespear' + skillName = '骨矛' + } else if (skillId === 93) { + const missile = getMissileTxtData('bonespirit') + speed = missile.speedPxPerSec || 300 + range = missile.distancePx || 1536 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(93) ?? 1) + const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, boneSpear: boneSpearBlvl, bonePrison: bonePrisonBlvl } + const dmgRange = calculateBoneSpiritDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + homing = true + missileType = 'bonespirit' + skillName = '骨魂' + } else if (skillId === 92) { + const missile = getMissileTxtData('poisonnova') + speed = missile.speedPxPerSec || 300 + range = missile.distancePx || 360 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(92) ?? 1) + const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 + const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 + const synergies = { poisonDagger: poisonDaggerBlvl, poisonExplosion: poisonExplosionBlvl } + const pnDmg = calculatePoisonNovaDamage(slvl, synergies) + damage = Math.round((pnDmg.minDamage + pnDmg.maxDamage) / 2) + delay = 0 + statusEffect = 'poison' + statusDuration = pnDmg.durationFrames + missileType = 'poisonnova' + isNova = true + multiCount = 64 + nextDelay = 4 + 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 === 44) { + const missile = getMissileTxtData('frostnova') + speed = missile.speedPxPerSec || 600 + range = missile.distancePx || 336 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(44) ?? 1) + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const synergies = { blizzard: blizzardBlvl, frozenOrb: frozenOrbBlvl } + const dmgRange = calculateFrostNovaDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + statusEffect = 'chill' + statusDuration = calculateFrostNovaChillLength(slvl) + missileType = 'frostnova' + isNova = true + multiCount = 64 + nextDelay = 4 + 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 === 121) { + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(121) ?? 1) + const holyShockBlvl = (hudManager as any)?.getSkillLevel?.(118) ?? 0 + const fohDmg = calculateFistOfTheHeavensDamage(slvl, { holyShock: holyShockBlvl }) + damage = Math.round((fohDmg.lightningMin + fohDmg.lightningMax) / 2) + speed = 0 + range = 40 + delay = 25 + skillName = '天堂之拳' + missileType = 'handofgod' + engine.spawnOverlay('handofgod', { x: targetX, y: targetY }) + multiCount = 6 + spreadAngle = Math.PI * 2 + isUndeadOnly = true + } 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 * (skillId === 67 || skillId === 38 ? ISO_GROUND_ASPECT_RATIO : 1)) + 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, nextDelay, + }) + } + } 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 === 44 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10)) + engine.metrics.casts += 1 + const displaySkillName = isEn ? getSkillDisplayName(skillId, 'ENG') : skillName + status.textContent = isEn ? `Cast skill: ${displaySkillName}` : `施展技能:${skillName}` + return true +} diff --git a/src/scene/backend/weapon-combat.ts b/src/scene/backend/weapon-combat.ts new file mode 100644 index 0000000..39c3f50 --- /dev/null +++ b/src/scene/backend/weapon-combat.ts @@ -0,0 +1,510 @@ +import { damageMonster, type Monster } from '../../game/combat.ts' +import type { GameEngine } from '../../game/engine.ts' +import { monsterDisplayName } from '../../game/monsters.ts' +import { + getBatch1SkillDef, + getMissileTxtData, + getSkillManaCost, + isBatch1Skill, + type Projectile, +} from '../../game/skills.ts' +import { getTblLang } from '../../i18n/lang.ts' + +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', +]) + +const DAGGER_CODES = new Set([ + 'dgr', 'dir', 'kri', 'bld', + '9dg', '9di', '9kr', '9bl', + '7dg', '7di', '7kr', '7bl', +]) + +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 === 'knif' || + type === 'tkni' || + DAGGER_CODES.has(code) || + ((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor') + ) { + subtype = 'dagger' + reach = 48 + } else 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 === '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 } | 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 interface HudCombatStateLike { + mana: number + maxMana?: number + lang?: string + leftPanel?: string + toggleLeftPanel?: (panel: any) => void + syncPublishedState?: () => void + getSkillLevel?: (skillId: number) => number + hotkeys?: { + readonly availableSkills?: ReadonlyArray<{ readonly skillId: number; readonly manaCost: number }> + } + inventory?: { + playerLevel?: number + gold: number + readonly equipped?: { + readonly weapon1?: AttackWeaponInputItem | null + readonly weapon2?: any + } + autoPlaceInGrid: (item: any) => boolean + readonly items?: readonly any[] + readonly grid?: readonly any[] + readonly gridItems?: readonly any[] + } + belt?: { + autoPlacePotion?: (item: any) => boolean + } + worldPanels?: { + selectedActTab: number + setCurrentWaypoint: (waypointId: number | null, levelId: number | null) => void + } +} + +export function getSkillRange( + skillId: number, + hudLike?: HudCombatStateLike | null | undefined, + equippedWeapon?: AttackWeaponInputItem | null | undefined, +): number { + if (skillId === 0) { + const hud = hudLike ?? ((globalThis as Record).__d2webHudInstance as HudCombatStateLike | null | undefined) + 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 57: return 340 + case 58: return 0 + case 59: return 450 + case 62: return 450 + case 63: return 0 + case 64: return 480 + case 219: + case 220: return 0 + default: return 450 + } +} + +export function getSkillMana(skillId: number, hudLike?: HudCombatStateLike | null): number { + if (skillId === 0) return 0 + if (hudLike !== null && hudLike !== undefined) { + const entry = hudLike.hotkeys?.availableSkills?.find(s => s.skillId === skillId) + if (entry !== undefined) return entry.manaCost + } + if (isBatch1Skill(skillId)) { + return getSkillManaCost(skillId, 1) + } + switch (skillId) { + case 41: return 6 + case 45: return 6 + case 46: return 11 + case 51: return 22 + case 55: return 10 + case 73: return 3 + case 78: return 17 + case 83: return 8 + case 84: return 7 + case 88: return 27 + case 93: return 12 + case 92: return 20 + case 12: return 4 + case 16: return 5 + case 20: return 8 + case 21: return 4 + case 40: return 7 + case 42: return 9 + case 59: return 23 + case 60: return 12 + case 62: return 20 + case 64: return 30 + case 56: return 17 + case 49: return 11 + case 38: return 3 + case 43: return 7 + case 57: return 19 + case 58: return 5 + case 63: return 0 + case 219: + case 220: return 0 + default: return 10 + } +} + +export interface SkillCastOutcome { + readonly casted: boolean + readonly animMode: 'a1' | 'sc' | null + readonly statusMessage: string | null +} + +/** + * Pure simulation helper for normal weapon/unarmed attack (`skillId === 0`). + */ +export function executeNormalAttack( + targetX: number, + targetY: number, + context: { + readonly engine: GameEngine + readonly hudLike?: HudCombatStateLike | null | undefined + readonly equippedWeapon?: AttackWeaponInputItem | null | undefined + readonly equippedAmmo?: any | null | undefined + readonly hasAmmo?: boolean | undefined + }, +): SkillCastOutcome { + const { engine, hudLike } = context + const player = engine.world.player + const isEn = (hudLike?.lang ?? getTblLang()) === 'ENG' + const globalHud = (globalThis as Record).__d2webHudInstance as HudCombatStateLike | null | undefined + + const weapon = + context.equippedWeapon ?? + hudLike?.inventory?.equipped?.weapon1 ?? + globalHud?.inventory?.equipped?.weapon1 ?? + null + const weaponInfo = getAttackWeaponInfo(weapon) + + if (weaponInfo.isRanged) { + const ammo = + context.equippedAmmo ?? + hudLike?.inventory?.equipped?.weapon2 ?? + globalHud?.inventory?.equipped?.weapon2 ?? + null + const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudLike?.inventory) + if (!hasAmmo) { + return { + casted: false, + animMode: 'a1', + statusMessage: isEn + ? (weaponInfo.ammoType === 'arrow' ? 'Out of arrows!' : 'Out of bolts!') + : (weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'), + } + } + + 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 + return { + casted: true, + animMode: 'a1', + statusMessage: isEn + ? (weaponInfo.subtype === 'bow' ? 'Attack: Shoot Arrow' : 'Attack: Shoot Bolt') + : (weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'), + } + } + + // 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) + let statusMessage: string | null = null + if (targetMonster !== null) { + damageMonster(engine.world, targetMonster.index, damage) + engine.metrics.playerHits += 1 + if (isEn) { + const attackLabel = weaponInfo.kind === 'unarmed' ? 'Unarmed Attack' : 'Attack' + statusMessage = `${attackLabel} hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}` + } else { + const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击' + statusMessage = `${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 { + casted: true, + animMode: 'a1', + statusMessage, + } +} diff --git a/src/scene/frontend/arena-overlay.ts b/src/scene/frontend/arena-overlay.ts new file mode 100644 index 0000000..c0ca066 --- /dev/null +++ b/src/scene/frontend/arena-overlay.ts @@ -0,0 +1,252 @@ +import type { WorldArena as WorldArenaType } from '../../game/engine/world-arena.ts' + +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 engineBase = ['..', '..', 'game', 'engine'].join('/') + const { getSharedDataRegistry } = (await import( + /* @vite-ignore */ `${engineBase}/data-registry.ts` + )) as typeof import('../../game/engine/data-registry.ts') + const { WorldArena } = (await import( + /* @vite-ignore */ `${engineBase}/world-arena.ts` + )) as typeof import('../../game/engine/world-arena.ts') + + const registry = await getSharedDataRegistry('samples/d2') + let arena: WorldArenaType = 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: WorldArenaType) => { + 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() +} diff --git a/src/scene/frontend/entity-art-loader.ts b/src/scene/frontend/entity-art-loader.ts new file mode 100644 index 0000000..3b653e8 --- /dev/null +++ b/src/scene/frontend/entity-art-loader.ts @@ -0,0 +1,342 @@ +import type { Palette } from '../../formats/pal.ts' +import type { AnimClipMeta } from '../../game/actor-animator.ts' +import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../../game/monster-art.ts' +import { resolveMonsterArtSpec } from '../../game/monster-mapping.ts' +import type { AtlasFrame } from '../../render/atlas.ts' +import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts' +import { normalizeHeroToken } from '../backend/scene-population.ts' + +export { ADDITIVE_MONSTER_TOKENS } + +/** Base locations tried in order when `?base=` is absent. */ +export const DEFAULT_BASES = ['samples/d2', '/diablo2/data'] + +/** A composited character, uploaded as its own atlas page. */ +export 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> +} + +export 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 +} + +export 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. + */ +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 +} + +/** + * 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. + */ +export 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 { + 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()}` + 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 } : {}), + layerFailures: [], + }) + return + } + } catch (err) { + console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`) + } + + 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()) + } + + if (fallbackSpecs.length > 0) { + for (const [, 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 + + 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) { + errors += 1 + console.error(`monster art loading error: ${(err as Error).message}`) + } + return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures } +} + +export 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) { + 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 + } + + 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 + } + } + } + + if (heroToken !== 'so') { + return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so') + } + + console.warn(`character art unavailable for ${heroToken} in pre-baked packs`) + return null +} diff --git a/src/scene/frontend/ground-item-renderer.ts b/src/scene/frontend/ground-item-renderer.ts new file mode 100644 index 0000000..f0293ef --- /dev/null +++ b/src/scene/frontend/ground-item-renderer.ts @@ -0,0 +1,212 @@ +import type { GameEngine } from '../../game/engine.ts' +import { + calculateBounceHeight, + type GroundItemEntity, +} from '../../game/ground-items.ts' +import type { AtlasFrame } from '../../render/atlas.ts' +import type { + AtlasHandle, + SpriteRenderer, +} from '../../render/renderer.ts' +import { BAKED_UI_MANIFEST } from '../../ui/baked-ui-meta.ts' +import { isGroundItemEtherealOrSocketed } from '../../ui/ground-labels.ts' +import type { HudManager } from '../../ui/hud-manager.ts' +import { resolveGroundItemSpriteRect } from '../../ui/inventory.ts' + +export type SpriteRect = NonNullable> + +/** 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 +} + +/** + * Resolve the authentic Blizzard flippy ground sprite rect for a ground item entity. + */ +export function resolveGroundItemFlippyFrame(item: GroundItemEntity): SpriteRect | null { + const target = item.isGold + ? { isGold: true, amount: item.amount, code: 'gld' } + : (item.item || (item as any)) + + return resolveGroundItemSpriteRect( + target, + BAKED_UI_MANIFEST.flippyRects, + BAKED_UI_MANIFEST.codeToFlippyFile, + ) +} + +/** + * Synchronize HUD ground drops state if needed. + */ +export function syncHudGroundDrops( + _engine: GameEngine, + hudManager: HudManager | null, +): void { + hudManager?.syncPublishedState?.() +} + +/** + * 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 isEthOrSockBase = !item.isGold && + (item.quality === 'normal' || item.quality === 'superior' || item.quality === 'high' || item.quality === 'hi-quality') && + !(item as any).isRuneword && + !(item.item as any)?.isRuneword && + isGroundItemEtherealOrSocketed(item) + + const qColor = item.isGold + ? GROUND_ITEM_QUALITY_COLORS.gold! + : isEthOrSockBase + ? GROUND_ITEM_QUALITY_COLORS.low! + : (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!) + + // 1. Authentic Blizzard flippy ground sprite resolution (strictly fail-fast if missing) + const target = item.isGold + ? { isGold: true, amount: item.amount, code: 'gld' } + : (item.item || (item as any)) + + const sr = resolveGroundItemSpriteRect( + target, + BAKED_UI_MANIFEST.flippyRects, + BAKED_UI_MANIFEST.codeToFlippyFile, + ) + + if (sr === null) { + throw new Error( + `GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST for ground item "${item.name}" (id: "${item.id}")`, + ) + } + + // 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, { + ...(itemsAtlas !== undefined ? { 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, + }) + } + + // 3. Glint / Sparkle 4-Point Star Animation for resting items + // Dropped items display periodic sparkle glints once settled on the ground plane + const isAirborne = item.bounceState !== null && item.bounceState !== undefined && bounceH > 0 + if (!isAirborne) { + 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]) + } + } + } +} diff --git a/src/scene/frontend/lighting-presets.ts b/src/scene/frontend/lighting-presets.ts new file mode 100644 index 0000000..9bbadb5 --- /dev/null +++ b/src/scene/frontend/lighting-presets.ts @@ -0,0 +1,320 @@ +import { + ORTHO_SUB_TILE_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + type CollisionGrid, +} from '../../game/d2map.ts' +import type { GameEngine } from '../../game/engine.ts' +import type { Environment } from '../../game/engine/environment.ts' +import type { LightGrid } from '../../game/engine/light-grid.ts' +import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts' +import type { TownPortalSlot } from '../../game/portal.ts' +import { getMissileTxtData } from '../../game/skills.ts' +import { getTblLang, type TblLang } from '../../i18n/lang.ts' +import type { SpriteRenderer } from '../../render/renderer.ts' +import type { ObjectDrawable } from './object-animation.ts' +import type { ActSceneState } from './scene-state.ts' + +export type LightingPreset = + | 'auto' + | 'noon' + | 'morning' + | 'sunset' + | 'night' + | 'cave' + | 'hell' + | 'eclipse' + | 'off' + +export interface LightingPresetConfig { + readonly id: LightingPreset + readonly labelZh: string + readonly labelEn: string + readonly enabled: boolean + readonly overrideAmbient?: { + readonly intensity: number + readonly red: number + readonly green: number + readonly blue: number + } +} + +export const LIGHTING_PRESETS: Readonly> = { + auto: { + id: 'auto', + labelZh: '自动 (原版 1.13c)', + labelEn: 'Auto (Original 1.13c)', + enabled: true, + }, + noon: { + id: 'noon', + labelZh: '正午 (明亮日光)', + labelEn: 'Noon (Bright Daylight)', + enabled: true, + overrideAmbient: { intensity: 255, red: 255, green: 255, blue: 255 }, + }, + morning: { + id: 'morning', + labelZh: '清晨 (晨曦暖金)', + labelEn: 'Morning (Warm Dawn)', + enabled: true, + overrideAmbient: { intensity: 190, red: 225, green: 198, blue: 145 }, + }, + sunset: { + id: 'sunset', + labelZh: '黄昏 (暮色紫金)', + labelEn: 'Sunset (Dusk Purple-Gold)', + enabled: true, + overrideAmbient: { intensity: 145, red: 210, green: 155, blue: 198 }, + }, + night: { + id: 'night', + labelZh: '午夜 (幽暗蓝月)', + labelEn: 'Midnight (Moonlit Blue)', + enabled: true, + overrideAmbient: { intensity: 38, red: 115, green: 140, blue: 240 }, + }, + cave: { + id: 'cave', + labelZh: '深渊地牢 (火炬暗黑)', + labelEn: 'Deep Dungeon (Torchlight)', + enabled: true, + overrideAmbient: { intensity: 5, red: 185, green: 175, blue: 165 }, + }, + hell: { + id: 'hell', + labelZh: '地狱血月 (第四幕猩红)', + labelEn: 'Hell Blood Moon (Act IV Crimson)', + enabled: true, + overrideAmbient: { intensity: 72, red: 255, green: 62, blue: 52 }, + }, + eclipse: { + id: 'eclipse', + labelZh: '蚀日之咒 (幽蓝暗蚀)', + labelEn: 'Solar Eclipse (Dark Blue)', + enabled: true, + overrideAmbient: { intensity: 28, red: 45, green: 85, blue: 245 }, + }, + off: { + id: 'off', + labelZh: '关闭光照 (全亮无光影)', + labelEn: 'Lighting Off (Full Bright)', + enabled: false, + }, +} + +export function lightingLabel( + cfg: LightingPresetConfig, + lang: TblLang = getTblLang(), +): string { + return lang === 'ENG' ? cfg.labelEn : cfg.labelZh +} + +export function normalizeLightingPreset(raw: string | null | undefined): LightingPreset { + const clean = (raw ?? '').trim().toLowerCase() + if (clean === '0' || clean === 'false' || clean === 'off' || clean === 'none') return 'off' + if (clean === '1' || clean === 'true' || clean === 'auto' || clean === '') return 'auto' + if (clean in LIGHTING_PRESETS) return clean as LightingPreset + return 'auto' +} + +export interface SceneLightingPassContext { + readonly lightingEnabled: boolean + readonly currentLightingPreset: LightingPreset + readonly playerSubTile: () => { x: number; y: number } + readonly lightGrid: LightGrid + readonly environment: Environment + readonly runtime: { + readonly levelId: number + readonly grid: CollisionGrid + readonly objectDrawables?: readonly ObjectDrawable[] + } + readonly engine: GameEngine + readonly portalSlot: TownPortalSlot + readonly renderer: SpriteRenderer + readonly state: ActSceneState +} + +/** + * Compute and upload the 1.13c sub-tile lightmap pass for the active frame. + */ +export function applySceneLightingPass(ctx: SceneLightingPassContext): void { + const { + lightingEnabled, + currentLightingPreset, + playerSubTile, + lightGrid, + environment, + runtime, + engine, + portalSlot, + renderer, + state, + } = ctx + + if (lightingEnabled) { + const pSub = playerSubTile() + lightGrid.centerOnSubTile(pSub.x, pSub.y) + lightGrid.clear() + lightGrid.updateObstacles(runtime.grid) + + const presetCfg = LIGHTING_PRESETS[currentLightingPreset] + const overrideAmb = presetCfg.overrideAmbient + const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity + const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave' + + const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 + const basePlayerRadius = isDarkCave ? 20 : 22 + lightGrid.addLight({ + subTileX: pSub.x, + subTileY: pSub.y, + radius: basePlayerRadius + flicker, + intensity: 242, + red: 255, + green: 234, + blue: 196, + }) + + const skillToMissile: Readonly> = { + '36': 'firebolt', + '38': 'chargedbolt', + '39': 'icebolt', + '45': 'iceblast', + '47': 'fireball', + '49': 'lightning', + '53': 'chainlightning', + '55': 'glacialspike', + '56': 'meteor', + '64': 'frozenorb', + '67': 'teeth', + '84': 'bonespear', + '93': 'bonespirit', + } + for (const shot of engine.projectiles) { + const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) + if (!mType) continue + const mData = getMissileTxtData(mType) + const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) + if (lightUnits > 0) { + const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const shotSubX = Math.round((dy + dx) / 2) + const shotSubY = Math.round((dy - dx) / 2) + const isCold = mData.blue > mData.red + lightGrid.addLight({ + subTileX: shotSubX, + subTileY: shotSubY, + radius: Math.max(14, lightUnits * 2), + intensity: 250, + red: isCold ? Math.max(120, mData.red) : mData.red, + green: isCold ? Math.max(175, mData.green) : mData.green, + blue: mData.blue, + }) + } + } + + for (const exp of engine.explosions) { + const expData = getMissileTxtData(exp.missileType) + const lightUnits = expData && expData.light > 0 ? expData.light : 8 + const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const expSubX = Math.round((dy + dx) / 2) + const expSubY = Math.round((dy - dx) / 2) + const isColdExp = + exp.missileType.includes('ice') || + exp.missileType.includes('freeze') || + exp.missileType.includes('blizzard') || + exp.missileType.includes('orb') + lightGrid.addLight({ + subTileX: expSubX, + subTileY: expSubY, + radius: Math.round(lightUnits * 2.5), + intensity: 255, + red: isColdExp ? 130 : (expData?.red ?? 255), + green: isColdExp ? 185 : (expData?.green ?? 160), + blue: isColdExp ? 255 : (expData?.blue ?? 50), + }) + } + + if (runtime.objectDrawables) { + for (const obj of runtime.objectDrawables) { + const tok = (obj.token ?? '').trim().toUpperCase() + const isTorchOrFire = + tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || + tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || + tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || + tok === 'F8' || tok === '1Y' || tok === 'FL' + const isWaypoint = + tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || + tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' + const isShrineOrBeam = + tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || + obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) + + if (isTorchOrFire || isWaypoint || isShrineOrBeam) { + const dx = obj.x - engine.world.player.x + const dy = obj.y - engine.world.player.y + if (dx * dx + dy * dy < 950 * 950) { + const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const objSubX = Math.round((ody + odx) / 2) + const objSubY = Math.round((ody - odx) / 2) + const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0 + lightGrid.addLight({ + subTileX: objSubX, + subTileY: objSubY, + radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker, + intensity: isTorchOrFire ? 175 : 160, + red: isWaypoint ? 120 : 255, + green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205), + blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175), + }) + } + } + } + } + + const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId) + if (activePortalMouthForLight !== null) { + const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 + lightGrid.addLight({ + subTileX: activePortalMouthForLight.x, + subTileY: activePortalMouthForLight.y, + radius: 19 + portalFlicker, + intensity: 235, + red: 64, + green: 110, + blue: 255, + }) + } + + const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5 + const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185) + const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175) + const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165) + const ambNorm = effectiveAmbIntensity / 255 + const ambR = (effR / 255) * ambNorm + const ambG = (effG / 255) * ambNorm + const ambB = (effB / 255) * ambNorm + + renderer.setLighting({ + ambient: [ambR, ambG, ambB], + originSubX: lightGrid.originSubX, + originSubY: lightGrid.originSubY, + sceneOriginX: runtime.grid.originX, + sceneOriginY: runtime.grid.originY, + lightmap: lightGrid.exportTextureBuffer(), + }) + + state.lightingPreset = currentLightingPreset + state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh + state.ambientIntensity = effectiveAmbIntensity + state.ambientColor = [effR, effG, effB] + } else { + renderer.setLighting(null) + const presetCfg = LIGHTING_PRESETS[currentLightingPreset] + state.lightingPreset = currentLightingPreset + state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh + state.ambientIntensity = 255 + state.ambientColor = [255, 255, 255] + } +} diff --git a/src/scene/frontend/missile-overlay-renderer.ts b/src/scene/frontend/missile-overlay-renderer.ts new file mode 100644 index 0000000..81f2995 --- /dev/null +++ b/src/scene/frontend/missile-overlay-renderer.ts @@ -0,0 +1,664 @@ +import type { ActiveExplosion } from '../../game/engine.ts' +import { + getMissileTxtData, + type ActiveOverlay, + type Projectile, +} from '../../game/skills.ts' +import type { AtlasFrame } from '../../render/atlas.ts' +import { + LightningTrailManager, + drawElectricBoltFallback, +} from '../../render/missile-renderer.ts' +import { + MISSILE_METAS, + velocityToDccDirection, + type MissileFrameTuple, + type MissileMeta, +} from '../../render/missiles-meta.ts' +import { + OVERLAY_METAS, + type OverlayMeta, +} from '../../render/overlays-meta.ts' +import type { + AtlasHandle, + SpriteRenderer, +} from '../../render/renderer.ts' + +export { + LightningTrailManager, + MISSILE_METAS, + OVERLAY_METAS, + drawElectricBoltFallback, + getMissileTxtData, + velocityToDccDirection, + type MissileFrameTuple, + type MissileMeta, + type OverlayMeta, +} + +/** + * 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('meteorexplode')) { + const meteorArt = map.get('meteorexplode')! + if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt) + } + 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' || + missileArt.meta.name === 'firewall' || + missileArt.meta.name === 'groundfirebig') && + frameCount === 37 + ) { + // For meteorfire & firewall: 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)). + // Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade. + const defaultDuration = + missileArt.meta.name === 'meteorfire' + ? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15 + : shot.ttl > 0 + ? (shot.ageTicks ?? 0) + shot.ttl + : 90 + const duration = shot.fireDuration ?? defaultDuration + const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl) + const EXTINCTION_TICKS = 12 + if (shot.ttl <= EXTINCTION_TICKS) { + frameIndex = Math.max(0, Math.min(11, shot.ttl - 1)) + } else { + 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 isGroundFlame = + missileArt.meta.name === 'meteorfire' || + missileArt.meta.name === 'firewall' || + missileArt.meta.name === 'groundfirebig' + const isExtinguishing = isGroundFlame && shot.ttl <= 12 + const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 + const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 + const opacity = isExtinguishing ? decayRatio : 1.0 + + const drawW = frame.width * scale + const drawH = frame.height * scale + const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2 + const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2 + const drawX = shot.x + baseAnchorX * scale + const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude + + const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1 + renderer.draw(frame, drawX, drawY, { + atlas: missileArt.handle, + ...(isAdditive ? { blendMode: 'additive' } : {}), + ...(isExtinguishing + ? { + width: drawW, + height: drawH, + tint: [1, 1, 1, opacity], + } + : {}), + }) + + // 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') { + const age = shot.ageTicks ?? 0 + const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3 + const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5 + + // 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base) + renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5]) + renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75]) + renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9]) + + // 2. Serpentine segmented neck rising from the lava pit + const seg1Y = y - 7 + wobble * 0.3 + const seg1X = x + swayX * 0.3 + renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85]) + renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9]) + + const seg2Y = y - 14 + wobble * 0.6 + const seg2X = x + swayX * 0.7 + renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85]) + renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9]) + + // 3. Serpent head crown with flaming mane & glowing eyes + const headY = y - 22 + wobble + const headX = x + swayX + // Flaming crest / mane + renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6]) + renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85]) + renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95]) + // Dragon/serpent snout & jaw + renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9]) + // Glowing incandescent eye + renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0]) + + // 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest + const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt') + if (art !== undefined) { + const group = art.frames[0] + const frameCount = Math.max(1, art.meta.framesPerDirection) + const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount + const frame = group?.[frameIndex] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2 + const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: art.handle, + blendMode: 'additive', + }) + } + } + return + } + + if ( + shot.missileType === 'meteorfire' || + shot.missileType === 'firewall' || + shot.missileType === 'groundfirebig' + ) { + const isExtinguishing = shot.ttl <= 12 + const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 + const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 + const alpha = isExtinguishing ? decayRatio : 1.0 + renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha]) + renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha]) + renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha]) + 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 + + const isLightningMissile = + shot.missileType === 'lightningbolt' || + shot.missileType === 'chainlightning' || + shot.missileType === 'lightning' || + shot.missileType === 'lightninghit' + + if (isLightningMissile) { + drawElectricBoltFallback(renderer, x, y) + return + } + + // 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) + if (explosionArt.meta.directions > 1) { + for (let dir = 0; dir < explosionArt.meta.directions; dir++) { + const group = explosionArt.frames[dir] + 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 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') +} + +/** + * Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`): + * - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total), + * then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`). + * - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending. + * - Falls back to a procedural blue portal vortex oval in unit test / offline environments. + */ +export function drawTownPortal( + renderer: SpriteRenderer, + x: number, + y: number, + elapsedMs: number, + overlayArtMap: ReadonlyMap, +): void { + const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256) + const openDurationMs = 15 * frameDurationMs // 768 ms + const isOpening = elapsedMs < openDurationMs + const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr') + const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd') + const rawFrameIdx = isOpening + ? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs))) + : Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15 + + let drewSprite = false + if (trArt !== undefined) { + const group = trArt.frames[0] + const frame = group?.[rawFrameIdx] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2 + const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: trArt.handle, + blendMode: 'normal', + }) + drewSprite = true + } + } + if (hdArt !== undefined) { + const group = hdArt.frames[0] + const frame = group?.[rawFrameIdx] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2 + const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: hdArt.handle, + blendMode: 'additive', + elevationY: Math.max(0, frame.height - 16), + }) + renderer.setBlendMode('normal') + drewSprite = true + } + } + if (!drewSprite) { + renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' }) + renderer.setBlendMode('normal') + } +} diff --git a/src/scene/frontend/mouse-controller.ts b/src/scene/frontend/mouse-controller.ts new file mode 100644 index 0000000..9268152 --- /dev/null +++ b/src/scene/frontend/mouse-controller.ts @@ -0,0 +1,1525 @@ +import type { ActorAnimator } from '../../game/actor-animator.ts' +import type { Monster } from '../../game/combat.ts' +import type { GameEngine, NpcEntity } from '../../game/engine.ts' +import { + isWithinPickupBounds, + triggerFlippyBounce, + type GroundItemEntity, +} from '../../game/ground-items.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import type { LoadedMonsterArt } from '../../game/monster-art.ts' +import { getTownNpcDescriptorByName } from '../../game/npc-table.ts' +import type { TownPortalSlot, WaypointNetwork } from '../../game/portal.ts' +import { npcDialog } from '../../game/quests.ts' +import { isAuraSkill } from '../../game/skills.ts' +import { getTblLang } from '../../i18n/lang.ts' +import type { ViewportCamera } from '../../sim/camera.ts' +import type { KeyboardInput } from '../../sim/input.ts' +import type { HudManager } from '../../ui/hud-manager.ts' +import { + getInventoryGoldLimit, + itemToUiInventoryItem, +} from '../../ui/inventory.ts' +import { + WARP_TRIGGER_SUBTILES, + facingOf, + hitTestEntrance, + hitTestWarp, + isPlayerWithinLinkRange, + resolveObstacleAwareSteerTarget, + screenToWorld, + subTileToScene, + type SceneEntrance, + type SceneWarp, + type SceneWaypoint, +} from '../backend/scene-links-nav.ts' +import { + isTownLevel, + type TownStashEntity, +} from '../backend/scene-population.ts' +import { castSkill } from '../backend/skill-caster.ts' +import { + getAttackWeaponInfo, + getSkillRange, + isPassiveSkill, +} from '../backend/weapon-combat.ts' +import type { LoadedCharacter } from './entity-art-loader.ts' +import { + playInventoryFullFeedback, + showNotification, + updateOverheadLabels as updateSceneOverheadLabels, +} from './overhead-labels.ts' +import { FEET_HEIGHT, type MapRuntime } from './pack-loader.ts' +import { monsterToTargetHudInfo } from './scene-state.ts' + +export { playInventoryFullFeedback, showNotification } + +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: 'portal'; readonly worldPos: { readonly x: number; readonly y: number } } + | { readonly kind: 'warp'; readonly warp: SceneWarp; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } + | { readonly kind: 'entrance'; readonly entrance: SceneEntrance; readonly anchorPos: { readonly x: number; readonly y: number }; readonly arrivePos: { readonly x: number; readonly y: number } } + | { 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 + readonly portalSlot?: TownPortalSlot | undefined + readonly onEnterPortal?: (() => void) | undefined + readonly onCastTownPortal?: (() => boolean) | undefined + readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined + readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined + readonly isTravelling?: (() => boolean) | undefined + readonly labelsContainer?: HTMLElement | null | undefined + readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined +} + +export type MouseControllerContext = SceneMouseControllerOptions + +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 + readonly portalSlot?: TownPortalSlot | undefined + readonly onEnterPortal?: (() => void) | undefined + readonly onCastTownPortal?: (() => boolean) | undefined + readonly onEnterWarp?: ((warp: SceneWarp) => void) | undefined + readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined + readonly isTravelling?: (() => boolean) | undefined + readonly labelsContainer?: HTMLElement | null | undefined + readonly getMonsterArtMap?: (() => ReadonlyMap) | undefined + + navTarget: { x: number; y: number } | null = null + pendingInteraction: PendingInteraction | null = null + activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'warp' | 'entrance' | 'monster' = 'none' + isLeftMouseDown = false + isRightMouseDown = false + lastClientPos: { x: number; y: number } | null = null + hoveredMonster: Monster | null = null + lockedMonster: Monster | null = null + hoveredGroundItem: GroundItemEntity | null = null + hoveredWarp: SceneWarp | null = null + hoveredEntrance: SceneEntrance | null = null + readonly labelPool: HTMLDivElement[] = [] + + private stuckTicks = 0 + private lastDist = -1 + private lastPlayerPos: { x: number; y: number } | null = null + + 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.portalSlot = options.portalSlot + this.onEnterPortal = options.onEnterPortal + this.onCastTownPortal = options.onCastTownPortal + this.onEnterWarp = options.onEnterWarp + this.onEnterEntrance = options.onEnterEntrance + this.isTravelling = options.isTravelling + this.labelsContainer = options.labelsContainer ?? null + this.getMonsterArtMap = options.getMonsterArtMap + ;(this.engine as any).dialog = null + if (this.labelsContainer) { + this.updateOverheadLabels() + } + } + + /** + * Hit-test world coordinates against living, killable, non-town monsters using + * their rendered sprite bounding box (`m.drawBounds`, loaded sprite frame 0 from + * `monsterArtMap` before first render, or rank/boss-aware fallback footprint), + * selecting the frontmost monster in isometric depth (`m.y` descending, + * tie-breaking by center distance). + */ + findMonsterAtWorld(worldX: number, worldY: number): Monster | null { + const runtime = this.getRuntime?.() + if (runtime && isTownLevel(runtime.levelId)) return null + const monsters = this.engine?.world?.monsters ?? [] + const artMap = this.getMonsterArtMap?.() + let bestMonster: Monster | null = null + let bestDepthY = -Infinity + let bestCenterDist = Infinity + + for (const m of monsters) { + if (m.state === 'dead' || m.hp <= 0) continue + if (m.stats?.killable === false || m.stats?.inTown === true) continue + + const bounds = m.drawBounds + let minX: number + let maxX: number + let minY: number + let maxY: number + if (bounds !== undefined && bounds.w > 0 && bounds.h > 0) { + minX = bounds.x - 4 + maxX = bounds.x + bounds.w + 4 + minY = bounds.y - 4 + maxY = bounds.y + bounds.h + 4 + } else { + const art = artMap?.get(m.stats.id.toLowerCase()) + const standGroup = art !== undefined ? (art.groups[art.standOffset] ?? art.groups[0]) : undefined + const frame = standGroup?.[0] + if (frame !== undefined && frame.width > 0 && frame.height > 0) { + const drawX = frame.anchorX !== undefined ? m.x + frame.anchorX : m.x - frame.width / 2 + const drawY = frame.anchorY !== undefined ? m.y + frame.anchorY : m.y - frame.height + FEET_HEIGHT / 2 + minX = drawX - 4 + maxX = drawX + frame.width + 4 + minY = drawY - 4 + maxY = drawY + frame.height + 4 + } else if (m.stats?.isBoss === true || m.stats?.rank === 'boss') { + minX = m.x - 36 + maxX = m.x + 36 + minY = m.y - 96 + maxY = m.y + 8 + } else { + minX = m.x - 24 + maxX = m.x + 24 + minY = m.y - 64 + maxY = m.y + 8 + } + } + + if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { + const cx = (minX + maxX) * 0.5 + const cy = (minY + maxY) * 0.5 + const centerDist = Math.hypot(worldX - cx, worldY - cy) + if ( + bestMonster === null || + m.y > bestDepthY + 1 || + (Math.abs(m.y - bestDepthY) <= 1 && centerDist < bestCenterDist) + ) { + bestMonster = m + bestDepthY = m.y + bestCenterDist = centerDist + } + } + } + + return bestMonster + } + + /** + * Resolve the currently targeted monster (either target-locked by held left-click + * or hovered under the mouse cursor), or `null` if none / dead. + */ + getActiveTargetMonster(): Monster | null { + if ( + this.isLeftMouseDown && + this.activeClickType === 'monster' && + this.lockedMonster !== null + ) { + if ( + this.lockedMonster.state !== 'dead' && + this.lockedMonster.hp > 0 && + this.lockedMonster.stats?.killable !== false && + this.lockedMonster.stats?.inTown !== true + ) { + return this.lockedMonster + } + this.lockedMonster = null + } + if (this.hoveredMonster !== null) { + if ( + this.hoveredMonster.state !== 'dead' && + this.hoveredMonster.hp > 0 && + this.hoveredMonster.stats?.killable !== false && + this.hoveredMonster.stats?.inTown !== true + ) { + return this.hoveredMonster + } + this.hoveredMonster = null + } + return null + } + + /** + * Synchronize `hoveredMonster` and `hudManager.targetMonster` with the current + * pointer position and monster states. + */ + syncMonsterTargetHud( + camX = this.engine.world.player.x, + camY = this.engine.world.player.y - 16, + ): Monster | null { + let hudIntercepted = false + if (this.lastClientPos !== null) { + if (this.hudManager !== null) { + const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) + hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y) + } + if (hudIntercepted) { + this.hoveredMonster = null + } else { + const rect = this.canvas.getBoundingClientRect() + const zoom = this.camera?.zoom ?? 1 + const worldAtPointer = screenToWorld({ + clientX: this.lastClientPos.x, + clientY: this.lastClientPos.y, + rect, + canvas: this.canvas, + camX, + camY, + cameraZoom: zoom, + }) + this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) + } + } + + if (hudIntercepted && !(this.isLeftMouseDown && this.activeClickType === 'monster')) { + if (this.hudManager !== null) { + this.hudManager.targetMonster = null + } + return null + } + + const activeTarget = this.getActiveTargetMonster() + if (this.hudManager !== null) { + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + return activeTarget + } + + private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean { + 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) + if (Math.min(distFeet, distBody) <= 36) return true + } + + const runtime = this.getRuntime() + if (runtime?.grid) { + for (const wp of runtime.waypoints ?? []) { + const pos = subTileToScene(wp.x, wp.y, runtime.grid) + if (Math.hypot(pos.x - worldX, pos.y - worldY) <= 48) return true + } + } + + if (this.findNearestStash(worldX, worldY, 48) !== null) return true + + if (runtime?.grid) { + const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null + if (activePortalMouth !== null) { + const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) + const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) + if (Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) <= 48) return true + } + } + + if (this.findMonsterAtWorld(worldX, worldY) !== null) return true + + 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) + if (Math.min(distFeet, distBody) <= 36) return true + } + + if (this.engine.groundItems?.findAtPoint(worldX, worldY, 32)) return true + + return false + } + + syncLinkHoverAtWorld(worldX: number, worldY: number): void { + if (this.isHigherPriorityEntityAt(worldX, worldY)) { + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.canvas?.style) { + this.canvas.style.cursor = '' + } + return + } + + const runtime = this.getRuntime() + let bestWarp: { warp: SceneWarp; dist: number } | null = null + let bestEntrance: { entrance: SceneEntrance; dist: number } | null = null + if (runtime?.grid) { + for (const warp of runtime.warps ?? []) { + const res = hitTestWarp(warp, worldX, worldY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist } + } + } + for (const entrance of runtime.entrances ?? []) { + const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) + if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { + bestEntrance = { entrance, dist: res.dist } + } + } + } + if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { + this.hoveredWarp = bestWarp.warp + this.hoveredEntrance = null + } else if (bestEntrance !== null) { + this.hoveredWarp = null + this.hoveredEntrance = bestEntrance.entrance + } else { + this.hoveredWarp = null + this.hoveredEntrance = null + } + if (this.canvas?.style) { + this.canvas.style.cursor = (this.hoveredWarp !== null || this.hoveredEntrance !== null) ? 'pointer' : '' + } + } + + /** + * Render recycled overhead labels for on-screen NPCs, Town Portals, + * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. + */ + updateOverheadLabels( + camX = this.engine.world.player.x, + camY = this.engine.world.player.y - 16, + activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, + activePortalPosOverride?: { readonly x: number; readonly y: number } | null, + ): void { + updateSceneOverheadLabels( + this, + camX, + camY, + activePortalMouthOverride, + activePortalPosOverride, + ) + } + + 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) + const isEn = getTblLang() === 'ENG' + this.status.textContent = isEn + ? `Open ${stash.nameEn}` + : `打开私人储物箱 (${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) + const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) + const isEn = getTblLang() === 'ENG' + if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { + this.navTarget = null + this.pendingInteraction = null + if (this.hoveredGroundItem?.id === item.id) { + this.hoveredGroundItem = 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) { + const playerLevel = this.engine.world.player?.level ?? 1 + const goldCap = getInventoryGoldLimit(playerLevel) + this.hudManager.inventory.playerLevel = playerLevel + this.hudManager.inventory.gold = Math.min(goldCap, this.hudManager.inventory.gold + res.amount) + this.hudManager.syncPublishedState() + } + if (res.remaining && res.remaining > 0) { + this.status.textContent = isEn + ? `Picked up Gold: ${res.amount} (Remaining on ground: ${res.remaining})` + : `拾起金币:${res.amount}(地面剩余:${res.remaining})` + } else { + this.status.textContent = isEn + ? `Picked up Gold: ${res.amount}` + : `拾起金币:${res.amount}` + } + } else { + this.engine.metrics.inventoryRefusals += 1 + triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now()) + this.playInventoryFullFeedback() + this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。' + this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。') + } + } else { + if (this.hudManager) { + const autoBelted = Boolean(this.hudManager.belt?.autoPlacePotion?.(item.item)) + if (!autoBelted) { + const uiItem = itemToUiInventoryItem(item.item, this.engine.dropTables) + const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem) + if (!placed) { + this.engine.metrics.inventoryRefusals += 1 + triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now()) + this.playInventoryFullFeedback() + this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') + 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 = isEn + ? `Picked up item: ${item.name}` + : `拾起物品:${item.nameZh || item.name}` + } else { + const res = this.engine.pickupItem(item.id) + if (!res.success && res.reason === 'full') { + this.playInventoryFullFeedback() + this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。') + } else if (res.success) { + this.status.textContent = isEn + ? `Picked up item: ${item.name}` + : `拾起物品:${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 + this.lastPlayerPos = { x: player.x, y: player.y } + } + } + + /** + * Play authentic Diablo II audio rejection feedback when inventory is full. + * Low descending refusal tone paired with delayed secondary touch landing thud. + */ + playInventoryFullFeedback(): void { + playInventoryFullFeedback() + } + + /** + * Display prominent Diablo II style on-screen notification banner with auto fade-out. + */ + showNotification(message: string, durationMs = 2000): void { + showNotification(message, durationMs, this.status) + } + + 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 + if (this.isTravelling?.()) 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 + } + if (this.hudManager.worldPanels?.npcMenu) { + this.hudManager.worldPanels.npcMenu = null + this.hudManager.syncPublishedState?.() + } + } + + 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) + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + } 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) + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + } 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)) { + this.hoveredMonster = null + this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (!(this.isLeftMouseDown && this.activeClickType === 'monster')) { + this.hudManager.targetMonster = null + } + if (this.canvas.style) { + this.canvas.style.cursor = '' + } + if (this.labelsContainer) { + this.updateOverheadLabels() + } + return + } + } + + const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) + this.hoveredMonster = this.findMonsterAtWorld(worldX, worldY) + const activeTarget = this.getActiveTargetMonster() + if (this.hudManager !== null) { + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null + this.syncLinkHoverAtWorld(worldX, worldY) + if (this.labelsContainer) { + this.updateOverheadLabels() + } + + if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') { + 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')) { + this.navTarget = { x: worldX, y: worldY } + } + } + } + + readonly onPointerLeave = (e: PointerEvent): void => { + const target = e.relatedTarget as HTMLElement | null + if (target && target.closest?.('.d2-ground-label')) { + return + } + this.lastClientPos = null + this.hoveredMonster = null + this.lockedMonster = null + this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.hudManager !== null) { + this.hudManager.targetMonster = null + } + if (this.canvas.style) { + this.canvas.style.cursor = '' + } + if (this.labelsContainer) { + this.updateOverheadLabels() + } + } + + readonly onPointerUp = (e: PointerEvent): void => { + if (e.button === 0) { + this.isLeftMouseDown = false + this.activeClickType = 'none' + this.lockedMonster = null + } else if (e.button === 2) { + this.isRightMouseDown = false + if (!this.isLeftMouseDown) { + this.activeClickType = 'none' + } + } else { + this.isLeftMouseDown = false + this.isRightMouseDown = false + this.activeClickType = 'none' + this.lockedMonster = null + } + this.syncMonsterTargetHud() + const player = this.engine.world.player + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + } + + handleRightClickWorld(worldX: number, worldY: number): void { + const player = this.engine.world.player + 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 - player.x, worldY - player.y) + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + return + } + + const range = getSkillRange(rightSkillId, this.hudManager) + + // 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px + const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) + let clickedMonster: Monster | null = hoveredAtClick + if (clickedMonster === null) { + let bestMonDist = 36 + for (const m of this.engine.world.monsters) { + if (m.state === 'dead' || m.hp <= 0) continue + if (m.stats?.killable === false || m.stats?.inTown === true) 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' + this.hoveredMonster = hoveredAtClick + 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 } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + 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 + this.lastPlayerPos = { x: player.x, y: player.y } + } + } else { + this.navTarget = null + this.pendingInteraction = null + this.castSkill(rightSkillId, worldX, worldY) + } + } + + interactWithNpc(npc: NpcEntity): void { + const player = this.engine.world.player + 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) + const runtime = this.getRuntime() + const actIndex = Math.max(0, (runtime.act ?? 1) - 1) + const descriptor = getTownNpcDescriptorByName(npc.def.name, actIndex) + this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}` + + if (descriptor) { + if (descriptor.canHeal) { + if (player.maxHp !== undefined) player.hp = player.maxHp + if (this.hudManager) { + this.hudManager.hp = this.hudManager.maxHp + this.hudManager.mana = this.hudManager.maxMana + this.hudManager.stamina = this.hudManager.maxStamina + } + } + if (this.hudManager) { + if (this.status && 'hidden' in this.status) { + this.status.hidden = true + } + let menuX = 400 + let menuY = 180 + const rect = this.canvas?.getBoundingClientRect?.() + if ( + rect && + rect.width > 0 && + rect.height > 0 && + typeof this.hudManager.clientToLogical === 'function' + ) { + const camX = this.engine.world.player.x + const camY = this.engine.world.player.y - 16 + const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1 + const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1 + const zoom = this.camera?.zoom ?? 1 + // D2Client.dll 0x6faf6880 (0x6faf68de: add edi, -150; cmp eax, 20): + // Project 150 world pixels above NPC feet so the dialog box sits cleanly above the NPC sprite + const anchorDomX = rect.left + rect.width * 0.5 + (npc.x - camX) * zoom * scaleX + const anchorDomY = rect.top + rect.height * 0.5 + (npc.y - 150 - camY) * zoom * scaleY + const logicalAnchor = this.hudManager.clientToLogical(anchorDomX, anchorDomY) + menuX = Math.round(logicalAnchor.x) + menuY = Math.max(20, Math.round(logicalAnchor.y)) + } else if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') { + const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) + menuX = logical.x + menuY = logical.y + } + if (this.hudManager.worldPanels) { + if (this.engine.dropTables) { + this.hudManager.worldPanels.vendorSession.setDropTables(this.engine.dropTables) + } + this.hudManager.worldPanels.vendorCharLevel = (player as any).level ?? this.hudManager.worldPanels.vendorCharLevel ?? 85 + this.hudManager.worldPanels.openNpcMenu(descriptor, menuX, menuY) + } + this.hudManager.syncPublishedState?.() + } + } + } + + handleLeftClickWorld(worldX: number, worldY: number): void { + const player = this.engine.world.player + this.lockedMonster = null + + // 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 + this.interactWithNpc(clickedNpc) + } else { + ;(this.engine as any).dialog = null + this.pendingInteraction = { kind: 'npc', npc: clickedNpc } + this.navTarget = { x: clickedNpc.x, y: clickedNpc.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.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)) { + const isEn = getTblLang() === 'ENG' + const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level + this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` + } + 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 } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.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 } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + return + } + + // 3b. Check Town Portal: within ~48 px of portal vertical oval [portalPos.y - 80, portalPos.y] + const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null + if (activePortalMouth !== null) { + const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) + const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY)) + const minPortalDist = Math.hypot(portalPos.x - worldX, clampedPortalY - worldY) + if (minPortalDist <= 48) { + this.activeClickType = 'portal' + const distToPlayer = Math.hypot(portalPos.x - player.x, portalPos.y - player.y) + if (distToPlayer <= 80) { // OperateRange = 2 tiles (~80 px) + this.navTarget = null + this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(portalPos.x - player.x, portalPos.y - player.y) + this.onEnterPortal?.() + } else { + this.pendingInteraction = { kind: 'portal', worldPos: portalPos } + this.navTarget = { x: portalPos.x, y: portalPos.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + return + } + } + + // 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px + const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY) + let clickedMonster: Monster | null = hoveredAtClick + if (clickedMonster === null) { + let bestMonDist = 36 + for (const m of this.engine.world.monsters) { + if (m.state === 'dead' || m.hp <= 0) continue + if (m.stats?.killable === false || m.stats?.inTown === true) 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' + this.lockedMonster = clickedMonster + this.hoveredMonster = hoveredAtClick + 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 } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + return + } + + // 5. Check Ground Item: within ~32 px + const clickedItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) + if (clickedItem) { + this.pickupGroundItem(clickedItem) + return + } + + // 5b. Check SceneWarp & SceneEntrance (LvlWarp.txt bounding box + radial proximity) + if (runtime.grid) { + let bestWarp: { warp: SceneWarp; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const warp of runtime.warps ?? []) { + const res = hitTestWarp(warp, worldX, worldY, runtime.grid) + if (res.hit && (bestWarp === null || res.dist < bestWarp.dist)) { + bestWarp = { warp, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + + let bestEntrance: { entrance: SceneEntrance; dist: number; anchorPos: { x: number; y: number }; arrivePos: { x: number; y: number } } | null = null + for (const entrance of runtime.entrances ?? []) { + const res = hitTestEntrance(entrance, worldX, worldY, runtime.grid) + if (res.hit && (bestEntrance === null || res.dist < bestEntrance.dist)) { + bestEntrance = { entrance, dist: res.dist, anchorPos: res.anchorPos, arrivePos: res.arrivePos } + } + } + + if (bestWarp !== null && (bestEntrance === null || bestWarp.dist <= bestEntrance.dist)) { + this.activeClickType = 'warp' + ;(this.engine as any).dialog = null + const { warp, anchorPos, arrivePos } = bestWarp + if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const isEn = getTblLang() === 'ENG' + this.status.textContent = isEn + ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` + : `进入:${getLevelNames(warp.toLevelId).nameZh}` + this.onEnterWarp?.(warp) + } else { + this.pendingInteraction = { kind: 'warp', warp, anchorPos, arrivePos } + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + return + } + + if (bestEntrance !== null) { + this.activeClickType = 'entrance' + ;(this.engine as any).dialog = null + const { entrance, anchorPos, arrivePos } = bestEntrance + if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const isEn = getTblLang() === 'ENG' + this.status.textContent = isEn + ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` + : `进入:${getLevelNames(entrance.toLevelId).nameZh}` + this.onEnterEntrance?.(entrance) + } else { + this.pendingInteraction = { kind: 'entrance', entrance, anchorPos, arrivePos } + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + this.lastDist = Math.hypot(arrivePos.x - player.x, arrivePos.y - player.y) + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + 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 + this.lastPlayerPos = { x: player.x, y: player.y } + } + + 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), + portalSlot: this.portalSlot, + onEnterPortal: this.onEnterPortal, + onCastTownPortal: this.onCastTownPortal, + onEnterWarp: this.onEnterWarp, + onEnterEntrance: this.onEnterEntrance, + }) + } + + clearTarget(): void { + this.navTarget = null + this.pendingInteraction = null + this.activeClickType = 'none' + this.isLeftMouseDown = false + this.isRightMouseDown = false + this.lastClientPos = null + this.hoveredMonster = null + this.lockedMonster = null + this.hoveredGroundItem = null + this.hoveredWarp = null + this.hoveredEntrance = null + if (this.hudManager !== null) { + this.hudManager.targetMonster = null + } + if (this.canvas?.style) { + this.canvas.style.cursor = '' + } + this.stuckTicks = 0 + this.lastDist = -1 + this.lastPlayerPos = null + ;(this.engine as any).dialog = null + const player = this.engine.world.player + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + if (this.labelsContainer) { + this.updateOverheadLabels() + } + } + + tick(): { movement: { x: number; y: number }; attacking: boolean } { + if (this.isTravelling?.()) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + return { movement: { x: 0, y: 0 }, attacking: false } + } + + const keyMovement = this.input.movement() + const isKeyMoving = keyMovement.x !== 0 || keyMovement.y !== 0 + + if (isKeyMoving) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + const player = this.engine.world.player + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + return { movement: keyMovement, attacking: false } + } + + const player = this.engine.world.player + if (!player.alive || (!this.isLeftMouseDown && !this.isRightMouseDown && !this.input.shiftHeld)) { + if (player.infernoChanneling) { + player.infernoChanneling = false + player.infernoManaRemainder256 = 0 + } + } + + // Shift-Hold Stand Still Priority in tick() + if (this.input.shiftHeld) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = 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') { + const locked = this.lockedMonster + if (locked !== null) { + if (locked.state === 'dead' || locked.hp <= 0 || locked.stats?.killable === false) { + this.lockedMonster = null + this.pendingInteraction = null + this.navTarget = null + } else if (this.pendingInteraction === null) { + const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 + const range = getSkillRange(leftSkillId, this.hudManager) + const distToPlayer = Math.hypot(locked.x - player.x, locked.y - player.y) + if (distToPlayer <= range) { + if (player.cooldown === 0) { + player.facing = facingOf(locked.x - player.x, locked.y - player.y) + this.castSkill(leftSkillId, locked.x, locked.y) + } + } else { + this.pendingInteraction = { kind: 'monster', monster: locked, skillId: leftSkillId } + this.navTarget = { x: locked.x, y: locked.y } + this.lastDist = distToPlayer + this.stuckTicks = 0 + this.lastPlayerPos = { x: player.x, y: player.y } + } + } + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + } else 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 && + rightSkillId !== 43 && + rightSkillId !== 219 && + rightSkillId !== 220 + ) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = 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 + this.lastPlayerPos = null + this.interactWithNpc(npc) + } 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 + this.lastPlayerPos = null + player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) + if (this.waypointNetwork.activate(waypoint.waypointId)) { + const rt = this.getRuntime() + const isEn = getTblLang() === 'ENG' + const lvlLabel = isEn ? levelDisplayName(rt.levelId, 'ENG') : rt.level + this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` + } + 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.lastPlayerPos = null + this.openStash(stash) + } else { + this.navTarget = { x: stash.x, y: stash.y } + } + } else if (this.pendingInteraction.kind === 'portal') { + const { worldPos } = this.pendingInteraction + const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y) + if (dist <= 80) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y) + this.onEnterPortal?.() + } else { + this.navTarget = { x: worldPos.x, y: worldPos.y } + } + } else if (this.pendingInteraction.kind === 'warp') { + const { warp, anchorPos, arrivePos } = this.pendingInteraction + const runtime = this.getRuntime() + if (isPlayerWithinLinkRange(player.x, player.y, warp, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const isEn = getTblLang() === 'ENG' + this.status.textContent = isEn + ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}` + : `进入:${getLevelNames(warp.toLevelId).nameZh}` + this.onEnterWarp?.(warp) + } else { + this.navTarget = { x: arrivePos.x, y: arrivePos.y } + } + } else if (this.pendingInteraction.kind === 'entrance') { + const { entrance, anchorPos, arrivePos } = this.pendingInteraction + const runtime = this.getRuntime() + if (isPlayerWithinLinkRange(player.x, player.y, entrance, runtime.grid, WARP_TRIGGER_SUBTILES)) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = null + this.isLeftMouseDown = false + this.activeClickType = 'none' + player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y) + const isEn = getTblLang() === 'ENG' + this.status.textContent = isEn + ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}` + : `进入:${getLevelNames(entrance.toLevelId).nameZh}` + this.onEnterEntrance?.(entrance) + } else { + this.navTarget = { x: arrivePos.x, y: arrivePos.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 + this.lastPlayerPos = 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 + this.lastPlayerPos = 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 } + } + } + if (this.hudManager !== null) { + const activeTarget = this.getActiveTargetMonster() + this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null + } + } else if (this.pendingInteraction.kind === 'ground-item') { + const item = this.pendingInteraction.item + const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) + if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) { + this.navTarget = null + this.pendingInteraction = null + this.lastPlayerPos = 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 + this.lastPlayerPos = null + return { movement: { x: 0, y: 0 }, attacking: false } + } + + const playerStep = this.lastPlayerPos !== null + ? Math.hypot(player.x - this.lastPlayerPos.x, player.y - this.lastPlayerPos.y) + : Infinity + this.lastPlayerPos = { x: player.x, y: player.y } + + if (this.lastDist >= 0 && Math.abs(this.lastDist - dist) < 0.5 && playerStep < 0.5) { + this.stuckTicks++ + if (this.stuckTicks >= 12) { + this.navTarget = null + this.pendingInteraction = null + this.stuckTicks = 0 + this.lastDist = -1 + this.lastPlayerPos = null + return { movement: { x: 0, y: 0 }, attacking: false } + } + } else { + this.stuckTicks = 0 + this.lastDist = dist + } + + const steerTarget = resolveObstacleAwareSteerTarget( + player.x, + player.y, + this.navTarget, + this.getRuntime()?.grid, + ) + const steerDx = steerTarget.x - player.x + const steerDy = steerTarget.y - player.y + const steerDist = Math.hypot(steerDx, steerDy) + const normDist = steerDist > 1e-4 ? steerDist : dist + const moveDx = steerDist > 1e-4 ? steerDx : dx + const moveDy = steerDist > 1e-4 ? steerDy : dy + + const runFactor = (this.hudManager && this.hudManager.isRunning) ? 1.5 : 1.0 + return { + movement: { + x: (moveDx / normDist) * runFactor, + y: (moveDy / normDist) * runFactor, + }, + attacking: false, + } + } + + this.lastPlayerPos = null + 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('pointerleave', this.onPointerLeave) + 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('pointerleave', this.onPointerLeave) + 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) + } + + this.isLeftMouseDown = false + this.isRightMouseDown = false + this.lastClientPos = null + this.clearTarget() + for (const label of this.labelPool) { + label.hidden = true + } + } +} + +export function setupMouseControls(context: MouseControllerContext): SceneMouseController { + const controller = new SceneMouseController(context) + controller.attach() + return controller +} diff --git a/src/scene/frontend/object-animation.ts b/src/scene/frontend/object-animation.ts new file mode 100644 index 0000000..1992876 --- /dev/null +++ b/src/scene/frontend/object-animation.ts @@ -0,0 +1,136 @@ +import type { AnimatableTile, AnimatedFrame } from '../../game/animated-tiles.ts' +import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts' +import type { AtlasFrame } from '../../render/atlas.ts' + +/** One tile to draw. */ +export 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 +} diff --git a/src/scene/frontend/overhead-labels.ts b/src/scene/frontend/overhead-labels.ts new file mode 100644 index 0000000..e0154d4 --- /dev/null +++ b/src/scene/frontend/overhead-labels.ts @@ -0,0 +1,483 @@ +import type { Monster } from '../../game/combat.ts' +import type { GameEngine } from '../../game/engine.ts' +import type { GroundItemEntity } from '../../game/ground-items.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import { getTownNpcDescriptorByName, npcDisplayName } from '../../game/npc-table.ts' +import type { TownPortalSlot } from '../../game/portal.ts' +import { getTblLang } from '../../i18n/lang.ts' +import type { ViewportCamera } from '../../sim/camera.ts' +import { + computeInitialGroundLabelLayouts, + resolveLadderCollisions, + type GroundLabelOverlay, +} from '../../ui/ground-labels.ts' +import type { HudManager } from '../../ui/hud-manager.ts' +import { + getEntranceLabelWorldPos, + getWarpLabelWorldPos, + screenToWorld, + subTileToScene, + type SceneEntrance, + type SceneWarp, +} from '../backend/scene-links-nav.ts' +import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts' +import { + monsterArtStatusText, + monsterPlanStatusText, +} from './scene-state.ts' + +export { monsterArtStatusText, monsterPlanStatusText } + +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 } +} + +/** + * Play authentic Diablo II audio rejection feedback when inventory is full. + * Low descending refusal tone paired with delayed secondary touch landing thud. + */ +export function 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. + */ +export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void { + if (status) { + 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 = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", 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) + } + } +} + +;(globalThis as unknown as { + __d2PlayInventoryFullFeedback?: () => void + __d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void +}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback +;(globalThis as unknown as { + __d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void +}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => { + showNotification(message, durationMs, status as HTMLElement | undefined) +} + +export function allocateLabelElement( + container: HTMLElement, + labelPool: HTMLDivElement[], +): HTMLDivElement { + let label: HTMLDivElement + if (typeof document !== 'undefined' && typeof document.createElement === 'function') { + label = document.createElement('div') + } else { + label = { + tagName: 'DIV', + textContent: '', + hidden: false, + className: '', + dataset: {}, + style: {}, + } as unknown as HTMLDivElement + } + label.style.position = 'absolute' + label.style.transform = 'translate(-50%, -100%)' + 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) + if (typeof container.appendChild === 'function') { + container.appendChild(label) + } else if (Array.isArray((container as any).children)) { + ;(container as any).children.push(label) + } + return label +} + +export interface OverheadLabelsControllerHost { + readonly labelsContainer?: HTMLElement | null | undefined + readonly canvas: HTMLCanvasElement + readonly camera: ViewportCamera + readonly engine: GameEngine + readonly hudManager: HudManager | null + readonly portalSlot?: TownPortalSlot | undefined + readonly labelPool: HTMLDivElement[] + lastClientPos: { x: number; y: number } | null + hoveredMonster: Monster | null + hoveredGroundItem: GroundItemEntity | null + hoveredWarp: SceneWarp | null + hoveredEntrance: SceneEntrance | null + getRuntime(): MapRuntime + findMonsterAtWorld(worldX: number, worldY: number): Monster | null + syncLinkHoverAtWorld(worldX: number, worldY: number): void +} + +/** + * Render recycled overhead labels for on-screen NPCs, Town Portals, + * `runtime.warps`, and `runtime.entrances` into `labelsContainer`. + */ +export function updateOverheadLabels( + host: OverheadLabelsControllerHost, + camX = host.engine.world.player.x, + camY = host.engine.world.player.y - 16, + activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null, + activePortalPosOverride?: { readonly x: number; readonly y: number } | null, +): void { + const container = host.labelsContainer + if (!container) return + + const runtime = host.getRuntime() + let labelCount = 0 + const rect = host.canvas.getBoundingClientRect() + const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1 + const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1 + const zoom = host.camera?.zoom ?? 1 + const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px` + + if (host.lastClientPos !== null) { + let hudIntercepted = false + if (host.hudManager !== null) { + const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y) + hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y) + } + if (hudIntercepted) { + host.hoveredMonster = null + host.hoveredGroundItem = null + host.hoveredWarp = null + host.hoveredEntrance = null + if (host.canvas?.style) { + host.canvas.style.cursor = '' + } + } else { + const worldAtPointer = screenToWorld({ + clientX: host.lastClientPos.x, + clientY: host.lastClientPos.y, + rect, + canvas: host.canvas, + camX, + camY, + cameraZoom: zoom, + }) + host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y) + host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null + host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y) + } + } + + const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase() + const currentLang = getTblLang() + const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1) + for (const npc of host.engine.npcEntities ?? []) { + if (npc.def.name.toLowerCase() === 'dummy') continue + const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc) + const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name + if ( + activeNpcMenuName && + (npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName) + ) { + continue + } + 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) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY + + if (domX <= -100 || domX >= rect.width + 100) continue + if (domY <= -100 || domY >= rect.height + 100) continue + const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) + if (label.textContent !== npcLabelText) label.textContent = npcLabelText + label.className = 'd2-overhead-label d2-npc-label' + if (label.dataset) { + label.dataset.kind = 'npc' + delete label.dataset.toLevelId + delete label.dataset.hovered + } + label.style.color = '#c8a15a' + label.style.background = 'transparent' + label.style.border = 'none' + label.style.padding = '0' + label.style.borderRadius = '0' + label.style.fontWeight = 'normal' + label.style.boxShadow = 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + + const portalMouth = activePortalMouthOverride !== undefined + ? activePortalMouthOverride + : (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null) + const portalPos = activePortalPosOverride !== undefined + ? activePortalPosOverride + : (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null) + + if (portalMouth !== null && portalPos !== null) { + const topX = portalPos.x + const topY = portalPos.y - 94 + const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY + if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) { + const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) + const portalLabelText = currentLang === 'ENG' + ? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}` + : `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}` + if (label.textContent !== portalLabelText) label.textContent = portalLabelText + label.className = 'd2-overhead-label d2-portal-label' + if (label.dataset) { + label.dataset.kind = 'portal' + label.dataset.toLevelId = String(portalMouth.toLevelId) + delete label.dataset.hovered + } + label.style.color = '#6cb4ff' + label.style.background = 'transparent' + label.style.border = 'none' + label.style.padding = '0' + label.style.borderRadius = '0' + label.style.fontWeight = 'normal' + label.style.boxShadow = 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + } + + if (runtime?.grid) { + for (const warp of runtime.warps ?? []) { + if (!(warp.toLevelId > 0)) continue + const pos = getWarpLabelWorldPos(warp, runtime.grid) + const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY + if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue + const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) + const badgeText = currentLang === 'ENG' + ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}` + : `通往 ${getLevelNames(warp.toLevelId).nameZh}` + if (label.textContent !== badgeText) label.textContent = badgeText + const isHovered = host.hoveredWarp === warp + label.className = isHovered + ? 'd2-overhead-label d2-exit-badge is-hovered' + : 'd2-overhead-label d2-exit-badge' + if (label.dataset) { + label.dataset.kind = 'warp' + label.dataset.toLevelId = String(warp.toLevelId) + label.dataset.hovered = isHovered ? 'true' : 'false' + } + label.style.color = isHovered ? '#fff3c4' : '#f0d080' + label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' + label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' + label.style.padding = '2px 7px' + label.style.borderRadius = '3px' + label.style.fontWeight = isHovered ? 'bold' : 'normal' + label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' + label.style.left = `${domX}px` + label.style.top = `${domY - 2}px` + label.style.fontSize = fontSizePx + label.hidden = false + labelCount += 1 + } + + for (const entrance of runtime.entrances ?? []) { + if (!(entrance.toLevelId > 0)) continue + const pos = getEntranceLabelWorldPos(entrance, runtime.grid) + const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY + if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue + const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool) + const badgeText = currentLang === 'ENG' + ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}` + : `通往 ${getLevelNames(entrance.toLevelId).nameZh}` + if (label.textContent !== badgeText) label.textContent = badgeText + const isHovered = host.hoveredEntrance === entrance + label.className = isHovered + ? 'd2-overhead-label d2-exit-badge is-hovered' + : 'd2-overhead-label d2-exit-badge' + if (label.dataset) { + label.dataset.kind = 'entrance' + label.dataset.toLevelId = String(entrance.toLevelId) + label.dataset.hovered = isHovered ? 'true' : 'false' + } + label.style.color = isHovered ? '#fff3c4' : '#f0d080' + label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)' + label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)' + label.style.padding = '2px 7px' + label.style.borderRadius = '3px' + label.style.fontWeight = isHovered ? 'bold' : 'normal' + label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none' + 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 < host.labelPool.length; index += 1) { + const unused = host.labelPool[index]! + unused.hidden = true + if (unused.dataset && unused.dataset.hovered === 'true') { + unused.dataset.hovered = 'false' + } + if (unused.className.includes('is-hovered')) { + unused.className = unused.className.replace(/\s*is-hovered\b/g, '') + } + } +} + +/** + * Project and update floating ground item labels (Alt mode or single hovered item). + */ +export function updateGroundItemLabelsOverlay( + groundLabelOverlay: GroundLabelOverlay | null, + hudManager: HudManager | null, + mouseController: { hoveredGroundItem: GroundItemEntity | null } | null, + engine: GameEngine, + canvas: HTMLCanvasElement, + camera: ViewportCamera, + camX: number, + camY: number, +): void { + if (groundLabelOverlay === null) return + const showLabels = Boolean(hudManager?.showGroundLabels) + let hoveredItem = mouseController?.hoveredGroundItem ?? null + if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) { + if (mouseController) mouseController.hoveredGroundItem = null + hoveredItem = null + } + + if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) { + 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 } + } + if (showLabels) { + const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen) + const resolvedLayouts = resolveLadderCollisions(initialLayouts) + groundLabelOverlay.update(true, resolvedLayouts) + } else if (hoveredItem !== null) { + const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen) + groundLabelOverlay.update(true, initialLayouts) + } + } else { + groundLabelOverlay.update(false, []) + } +} diff --git a/src/scene/frontend/pack-loader.ts b/src/scene/frontend/pack-loader.ts new file mode 100644 index 0000000..6e34e67 --- /dev/null +++ b/src/scene/frontend/pack-loader.ts @@ -0,0 +1,1273 @@ +import type { AutomapLayerData } from '../../formats/automap-table.ts' +import type { Palette } from '../../formats/pal.ts' +import { WorldVariants, WORLD_VARIANT_SEED, type ActLayout } from '../../game/act-variants.ts' +import type { ActorAnimator } from '../../game/actor-animator.ts' +import { + DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, + frameDurationMsFromAnimSpeed, +} from '../../game/animated-tiles.ts' +import { + spawnMonsterPacks, + type MonsterPack, + type MonsterStats, + type SafeZone, +} from '../../game/combat.ts' +import { + ORTHO_CELL_HEIGHT, + ORTHO_CELL_WIDTH, + cellCentre, + findIsoSpawn, + isBlockedAt, + type CollisionGrid, +} from '../../game/d2map.ts' +import type { GameEngine } from '../../game/engine.ts' +import { createIsoTerrain } from '../../game/iso-terrain.ts' +import { + SEAM_TRIGGER_SUBTILES, + WARP_TRIGGER_SUBTILES, + type SceneEntrance, + type SceneWarp, + type SceneWaypoint, +} from '../../game/level-links.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import type { LoadedMonsterArt } from '../../game/monster-art.ts' +import { MonsterStreamingManager } from '../../game/monster-streaming.ts' +import { buildNpcDef, hasPackedSprite } from '../../game/npc.ts' +import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts' +import { + TownPortalSlot, + WaypointNetwork, + resolveTownPortalSubTile, + townLevelForAct, +} from '../../game/portal.ts' +import type { Side } from '../../game/variants.ts' +import { getTblLang } from '../../i18n/lang.ts' +import type { AtlasFrame } from '../../render/atlas.ts' +import { buildBounds } from '../../render/cull.ts' +import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts' +import { RoofFadeController } from '../../render/roofs.ts' +import { ViewportCamera } from '../../sim/camera.ts' +import type { KeyboardInput } from '../../sim/input.ts' +import type { HudManager } from '../../ui/hud-manager.ts' +import type { Minimap, MinimapLevel } from '../../ui/minimap.ts' +import { cellOf } from '../backend/scene-links-nav.ts' +import { + CANONICAL_TOWN_STASHES, + ROOM_ACTIVATION_RADIUS, + ROOM_DEACTIVATION_RADIUS, + buildStreamingRooms, + isTownLevel, + rebudgetMonsterPacks, + type DensityMultiplier, + type EliteMultiplier, + type TownStashEntity, +} from '../backend/scene-population.ts' +import { + FadeOverlay, + SEAM_FADE_MS, + WARP_FADE_MS, + subTileToScene, +} from '../transition.ts' +import { + DEFAULT_BASES, + loadMonsterArtMap, + type LoadedCharacter, +} from './entity-art-loader.ts' +import { + PERSISTENT_ILLUMINATION_TOKENS, + computeObjectPhaseOffsetMs, + type AnimatedObjectFrame, + type Drawable, + type ObjectDrawable, +} from './object-animation.ts' +import { + playInventoryFullFeedback, + showNotification, +} from './overhead-labels.ts' +import { + recordMonsterArtLoad, + reportMonsterPlan, + state, +} from './scene-state.ts' + +/** Pack locations tried in order when `?pack=` is absent. */ +export const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs'] +/** Act count the page offers. */ +export const ACT_COUNT = 5 +/** Walk speed in scene pixels per second (cells are 80×40). */ +export const WALK_SPEED = 170 +/** Character collision box at the feet, in scene pixels. */ +export const FEET_WIDTH = 20 +export const FEET_HEIGHT = 10 +/** Atlas pages fetched at once while filling in the background. */ +export const PAGE_CONCURRENCY = 3 +/** Placeholder marker size, in scene pixels. */ +export const MARKER_WIDTH = 16 +export const MARKER_HEIGHT = 26 + +/** 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 + readonly levelId: number + readonly entryPath: string + readonly entrances: readonly SceneEntrance[] + readonly warps: readonly SceneWarp[] + readonly waypoints: readonly SceneWaypoint[] + 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[] + readonly shadows?: readonly Drawable[] + readonly walls: readonly Drawable[] + readonly roofs: readonly Drawable[] + readonly animatedDrawables?: readonly Drawable[] | undefined + readonly animSpeed?: number | undefined + readonly frameDurationMs?: number | undefined + readonly objectDrawables: readonly ObjectDrawable[] + readonly animatedObjects?: readonly ObjectDrawable[] | undefined + readonly automap?: AutomapLayerData | undefined + readonly spawn: { x: number; y: number } + readonly npcs: readonly PackObject[] + monsterPacks: readonly MonsterPack[] + baseMonsterPacks?: readonly MonsterPack[] | undefined + readonly monsterTypes: readonly string[] + readonly objects: number + readonly frames: number + readonly notes: readonly string[] + readonly pages: (AtlasHandle | null)[] + readonly objectPages: (AtlasHandle | null)[] + readonly charBases: readonly string[] + readonly palette: Palette | null + readonly priorityPages: readonly number[] + readonly loadOrder: readonly number[] + loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise +} + +/** + * Find a query parameter. + * + * @param key - parameter name. + * @param fallback - value when absent. + * @returns the value. + */ +export function param(key: string, fallback: string): string { + if (typeof location === 'undefined') return fallback + 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. + */ +export 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. */ +export interface PackIndex { + readonly palettes?: Record + readonly levels: readonly PackIndexEntry[] + readonly actLayouts?: Readonly> +} + +/** One baked map in the index. */ +export 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 + readonly ds1?: string + readonly actVariant?: number + readonly fidelity?: 'exact' | 'layout' | 'approximation' +} + +/** Object frame inside an object atlas page. */ +export 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. */ +export 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. */ +export interface PackSceneJson { + readonly act: number + readonly levelId?: number + readonly levelName: string + readonly ds1: string + readonly fidelity?: 'exact' | 'layout' | 'approximation' + readonly cellsX: number + readonly cellsY: number + readonly originX: number + readonly originY: number + readonly widthPx: number + readonly heightPx: number + readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[] + readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[] + readonly frames: readonly (readonly number[])[] + readonly framePlacement: readonly (readonly number[])[] + readonly floors: readonly (readonly number[])[] + readonly shadows?: readonly (readonly number[])[] + readonly walls: readonly (readonly number[])[] + readonly roofs?: readonly (readonly number[])[] + readonly objects: readonly PackObject[] + readonly npcs?: readonly PackObject[] + readonly animSpeed?: number | undefined + readonly frameDurationMs?: number | undefined + 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 + readonly entrances?: readonly SceneEntrance[] + readonly warps?: readonly SceneWarp[] + readonly waypoints?: readonly SceneWaypoint[] + readonly automap?: AutomapLayerData | undefined + readonly stats: { readonly missingTiles: number; readonly walkable: number } +} + +/** The pack index, fetched once. */ +export 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. + */ +export 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 + } +} + +/** The per-session choice of level copies, one chooser per pack. */ +export const worldVariantsCache = new Map>() + +/** + * The pack's copy chooser, created on first use. + * + * @param packBase - directory holding `index.json`. + * @param index - the pack's already-fetched index. + * @returns the chooser shared by every load from this pack. + */ +export function worldVariantsFor(packBase: string, index: PackIndex): WorldVariants { + let variants = worldVariantsCache.get(packBase) + if (variants === undefined) { + variants = new WorldVariants(index, WORLD_VARIANT_SEED) + worldVariantsCache.set(packBase, variants) + } + return variants +} + +/** + * Build the runtime for a prebaked pack. + */ +export 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 variants = worldVariantsFor(packBase, index) + const wanted = wantedLevel === '' + ? undefined + : forAct.find(candidate => candidate.label === wantedLevel) + ?? forAct.find(candidate => candidate.slug === wantedLevel) + if (wanted !== undefined) variants.pin(wanted) + const entry = wanted ?? variants.startEntry(act) + if (entry === null) return null + return await buildPackRuntime(packBase, index, entry, viewport, charBases) +} + +/** + * Build the runtime for one level, named by id rather than by label. + */ +export async function loadRuntimeForLevel( + packBase: string, + levelId: number, + viewport: { width: number; height: number }, + charBases: readonly string[], + fromSide?: Side, + preferredVariantLabel?: string, +): Promise { + const index = await getPackIndex(packBase) + if (index === null) return null + const candidates = index.levels.filter(entry => entry.levelId === levelId) + const preferred = preferredVariantLabel !== undefined && fromSide === undefined + ? candidates.find( + c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel, + ) + : undefined + const entry = preferred ?? worldVariantsFor(packBase, index).entryForLevel(levelId, fromSide) + if (entry === null) return null + return await buildPackRuntime(packBase, index, entry, viewport, charBases) +} + +/** + * Turn one index entry into a runtime. + */ +export 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 && entry.actVariant === 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) + } + } + + 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, + } + let spawn = scene.spawn === null || scene.spawn === undefined + ? findIsoSpawn(grid) + : { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 } + if (effectiveLevelId === 1 && param('gameName', '') !== '') { + const warriv = (scene.npcs ?? []).find( + n => n.id === 155 || (n.name ?? '').toLowerCase().includes('warriv'), + ) + const anchorX = (warriv?.x ?? stashes[0]?.x ?? 4512) - 32 + const anchorY = (warriv?.y ?? stashes[0]?.y ?? 1720) + 32 + let foundTownCenter: { x: number; y: number } | null = null + for (let r = 0; r <= 12 && !foundTownCenter; r++) { + for (let dy = -r; dy <= r && !foundTownCenter; dy++) { + for (let dx = -r; dx <= r && !foundTownCenter; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue + const px = anchorX + dx * 16 + const py = anchorY + dy * 16 + if (!isBlockedAt(grid, px, py)) { + foundTownCenter = { x: px, y: py } + } + } + } + } + if (foundTownCenter !== null) { + spawn = foundTownCenter + } + } + if (spawn === null) throw new Error(`${entry.label}: the map has no walkable spawn`) + + 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), + ...(scene.automap !== undefined ? { automap: scene.automap } : {}), + spawn, + npcs: scene.npcs ?? [], + monsterPacks: scene.monsters?.packs ?? [], + baseMonsterPacks: 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: getTblLang() === 'ENG' + ? [ + `Initial pages ${priority.join(',')}, total ${String(pages.length)} pages`, + ...(scene.monsters?.packs && scene.monsters.packs.length > 0 + ? [`Monsters: ${String(scene.monsters.budget)} / ${String(scene.monsters.packs.length)} packs (${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join(', ')})`] + : []), + ] + : [ + `首屏页 ${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. + */ +export function pagesNeededForViewport( + floors: readonly Drawable[], + walls: readonly Drawable[], + spawn: { x: number; y: number }, + viewport: { width: number; height: number }, + zoom: number, +): number[] { + 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. + */ +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. + */ +export 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}`) + }) +} + +export interface LevelTransitionBoundsState { + floorBounds: Float32Array + shadowBounds: Float32Array + wallBounds: Float32Array + roofBounds: Float32Array + objectBounds: Float32Array + roofController: RoofFadeController +} + +export interface LevelTransitionControllerContext { + readonly canvas: HTMLCanvasElement + readonly status: HTMLElement + readonly renderer: SpriteRenderer + readonly engine: GameEngine + readonly camera: ViewportCamera + readonly input: KeyboardInput + readonly fade: FadeOverlay + readonly minimap: Minimap + readonly minimapLevel: () => MinimapLevel + readonly resetMinimapCache: () => void + readonly waypointNetwork: WaypointNetwork + readonly portalSlot: TownPortalSlot + readonly playerAnimator: ActorAnimator + readonly playerSubTile: (engine: GameEngine, runtime: MapRuntime) => { x: number; y: number } + readonly populatePackSelectors: (packBase: string, current: { act: number; label: string }) => Promise + readonly getRuntime: () => MapRuntime + readonly setRuntime: (next: MapRuntime) => void + readonly getHudManager: () => HudManager | null + readonly clearMouseTarget: () => void + readonly getCharacter: () => LoadedCharacter | null + readonly getMonsterArtMap: () => Map + readonly setMonsterArtMap: (map: Map) => void + readonly setActivePaletteAndPack: (palette: Palette, base: string | undefined, act: number | undefined) => void + readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier } + readonly setBoundsState: (bounds: LevelTransitionBoundsState) => void +} + +export interface LevelTransitionController { + readonly isTravelling: () => boolean + readonly castTownPortal: () => boolean + readonly enterTownPortal: () => void + readonly registerWaypoints: (map: MapRuntime) => void + readonly swapLevel: (next: MapRuntime, arrive: { x: number; y: number }) => Promise + readonly travel: (toLevelId: number, fadeMs: number, override?: { x: number; y: number }, fromSide?: Side) => Promise + readonly teleportViaWaypoint: (targetAct: number, slug: string, targetLevelId?: number) => Promise + readonly checkLinks: () => void +} + +export function createLevelTransitionController( + ctx: LevelTransitionControllerContext, +): LevelTransitionController { + const initialRuntime = ctx.getRuntime() + const townVariantByAct = new Map() + if (isTownLevel(initialRuntime.levelId) && initialRuntime.variant) { + townVariantByAct.set(initialRuntime.act, initialRuntime.variant) + } + let travelling = false + let lastContactWaypointId: number | null = null + + const registerWaypoints = (map: MapRuntime): void => { + for (const waypoint of map.waypoints) { + ctx.waypointNetwork.register({ + waypointId: waypoint.waypointId, + levelId: map.levelId, + act: map.act, + name: map.level, + x: waypoint.arriveX, + y: waypoint.arriveY, + }) + } + } + registerWaypoints(initialRuntime) + + const castTownPortal = (): boolean => { + if (travelling) return false + const runtime = ctx.getRuntime() + const isEn = getTblLang() === 'ENG' + if (isTownLevel(runtime.levelId)) { + playInventoryFullFeedback() + showNotification(isEn ? 'Cannot cast Town Portal in town' : '无法在城镇中开启回城传送门', 2000, ctx.status) + return false + } + + const here = ctx.playerSubTile(ctx.engine, runtime) + const candidateOffsets = [ + { dx: -2, dy: 2 }, + { dx: -2, dy: 1 }, + { dx: -1, dy: 2 }, + { dx: 0, dy: -2 }, + { dx: 2, dy: -1 }, + { dx: 2, dy: 0 }, + { dx: 1, dy: -1 }, + { dx: 0, dy: 0 }, + ] + let fromX = here.x + let fromY = here.y + for (const off of candidateOffsets) { + const cx = here.x + off.dx + const cy = here.y + off.dy + const scenePos = subTileToScene(cx, cy, runtime.grid) + if (!isBlockedAt(runtime.grid, scenePos.x, scenePos.y)) { + fromX = cx + fromY = cy + break + } + } + + const townLevelId = townLevelForAct(runtime.act) + const townSub = resolveTownPortalSubTile(townLevelId, townVariantByAct.get(runtime.act)) + ctx.portalSlot.cast({ + fromLevelId: runtime.levelId, + fromX, + fromY, + townLevelId, + townX: townSub.x, + townY: townSub.y, + createdAtMs: performance.now(), + }) + state.portal = ctx.portalSlot.current() + + const character = ctx.getCharacter() + if (character?.clips?.['sc']) { + ctx.playerAnimator.play('sc', character.clips['sc'], { restart: true }) + } + const townNames = getLevelNames(townLevelId) + ctx.status.textContent = isEn + ? `Opened Town Portal to ${townNames.nameEn}` + : `已开启返回 ${townNames.nameZh} 的传送门` + return true + } + + const swapLevel = async (next: MapRuntime, arrive: { x: number; y: number }): Promise => { + const prevRuntime = ctx.getRuntime() + ctx.clearMouseTarget() + for (const page of prevRuntime.pages) if (page !== null) ctx.renderer.deleteAtlas(page) + for (const page of prevRuntime.objectPages) if (page !== null) ctx.renderer.deleteAtlas(page) + for (const art of ctx.getMonsterArtMap().values()) ctx.renderer.deleteAtlas(art.handle) + ctx.setMonsterArtMap(new Map()) + + ctx.setRuntime(next) + if (isTownLevel(next.levelId) && next.variant) townVariantByAct.set(next.act, next.variant) + ctx.setBoundsState({ + 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), + }) + + ctx.camera.mapWidthPx = next.widthPx + ctx.camera.mapHeightPx = next.heightPx + ctx.engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, { + width: FEET_WIDTH, + height: FEET_HEIGHT, + }) + + const landing = subTileToScene(arrive.x, arrive.y, next.grid) + ctx.engine.world.player.x = landing.x + ctx.engine.world.player.y = landing.y + + const defs = next.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) + ctx.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 + ctx.engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n), frame: n.frame }) + } + + ctx.engine.world.monsters.length = 0 + ctx.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 }) + } + if (!next.baseMonsterPacks) next.baseMonsterPacks = next.monsterPacks + const { densityMultiplier, eliteMultiplier } = ctx.getMultipliers() + next.monsterPacks = rebudgetMonsterPacks(next.baseMonsterPacks, { + densityMultiplier, + eliteMultiplier, + levelId: next.levelId, + seed: WORLD_VARIANT_SEED ^ next.levelId, + }) + 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 + const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones) + if (nextRooms.length > 0) { + ctx.engine.streamingManager = new MonsterStreamingManager( + ctx.engine.world, + ctx.engine.terrain, + nextRooms, + { + activationRadius: ROOM_ACTIVATION_RADIUS, + deactivationRadius: ROOM_DEACTIVATION_RADIUS, + safeZones: nextSafeZones, + }, + ) + } else { + ctx.engine.streamingManager = undefined + if (nextResolvedPacks.length > 0) { + spawnMonsterPacks(ctx.engine.world, nextResolvedPacks, landing, spread, ctx.engine.terrain, nextSafeZones) + } + } + reportMonsterPlan(next) + + if (next.palette !== null) { + ctx.renderer.setPalette(next.palette.rgb) + const packBase = next.source === 'pack' ? next.base : undefined + const packAct = next.source === 'pack' ? next.act : undefined + ctx.setActivePaletteAndPack(next.palette, packBase, packAct) + 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, + next.palette, + ctx.renderer, + packBase, + packAct, + ) + ctx.setMonsterArtMap(loaded.map) + recordMonsterArtLoad(loaded) + } + } + + if (next.source === 'pack') { + const nextParams = new URLSearchParams(location.search) + nextParams.set('act', String(next.act)) + nextParams.set('level', next.variant) + history.replaceState(null, '', `?${nextParams.toString()}`) + void ctx.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 + ctx.engine.levelId = effectiveLvlId + ctx.engine.isTown = [1, 40, 75, 103, 109].includes(effectiveLvlId) + const names = getLevelNames(effectiveLvlId) + const hudManager = ctx.getHudManager() + 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 + } + ctx.clearMouseTarget() + ctx.resetMinimapCache() + const arrivedCell = cellOf(next.grid, ctx.engine.world.player.x, ctx.engine.world.player.y) + ctx.minimap.reveal(ctx.minimapLevel(), arrivedCell.x, arrivedCell.y, ctx.engine.world.player.x, ctx.engine.world.player.y, true) + } + + const travel = async ( + toLevelId: number, + fadeMs: number, + override?: { x: number; y: number }, + fromSide?: Side, + ): Promise => { + const runtime = ctx.getRuntime() + if (travelling || runtime.source !== 'pack') return + travelling = true + ctx.clearMouseTarget() + const fromLevelId = runtime.levelId + const isEn = getTblLang() === 'ENG' + try { + await ctx.fade.out(fadeMs) + const next = await loadRuntimeForLevel( + runtime.base, + toLevelId, + { width: ctx.canvas.width, height: ctx.canvas.height }, + runtime.charBases, + fromSide, + isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined, + ) + if (next === null) { + ctx.status.textContent = isEn + ? `Level ${String(toLevelId)} not found in pack.` + : `资源包里没有关卡 ${String(toLevelId)}。` + return + } + await next.loadPages(ctx.renderer, next.priorityPages, () => { + state.pagesLoaded = countLoadedPages(next.pages) + }) + let effectiveOverride = override + if (isTownLevel(next.levelId) && next.variant) { + townVariantByAct.set(next.act, next.variant) + const openPortal = ctx.portalSlot.current() + if (openPortal !== null && openPortal.townLevelId === next.levelId) { + const exactTownSub = resolveTownPortalSubTile(next.levelId, next.variant) + if (openPortal.townX !== exactTownSub.x || openPortal.townY !== exactTownSub.y) { + const wasHeadingToPortal = + effectiveOverride !== undefined && + effectiveOverride.x === openPortal.townX && + effectiveOverride.y === openPortal.townY + ctx.portalSlot.cast({ + ...openPortal, + townX: exactTownSub.x, + townY: exactTownSub.y, + }) + if (wasHeadingToPortal) effectiveOverride = exactTownSub + } + } + } + const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId) + ?? next.warps.find(warp => warp.toLevelId === fromLevelId) + if (effectiveOverride === undefined && back === undefined) { + throw new Error(`level ${String(toLevelId)} has no link back to level ${String(fromLevelId)}`) + } + const rawLanding = effectiveOverride ?? { x: back!.arriveX, y: back!.arriveY } + let landing = rawLanding + const activeMouthInNext = ctx.portalSlot.mouthInLevel(next.levelId) + if ( + activeMouthInNext !== null && + activeMouthInNext.x === rawLanding.x && + activeMouthInNext.y === rawLanding.y + ) { + const besidePos = subTileToScene(rawLanding.x + 1, rawLanding.y + 1, next.grid) + if (!isBlockedAt(next.grid, besidePos.x, besidePos.y)) { + landing = { x: rawLanding.x + 1, y: rawLanding.y + 1 } + } + } + await swapLevel(next, landing) + preloadRemaining(next, ctx.renderer) + } catch (err) { + ctx.status.textContent = isEn + ? `Level transition failed: ${(err as Error).message}` + : `切换关卡失败:${(err as Error).message}` + } finally { + await ctx.fade.in(fadeMs) + travelling = false + } + } + + const enterTownPortal = (): void => { + const runtime = ctx.getRuntime() + if (travelling || runtime.source !== 'pack') return + const dest = ctx.portalSlot.enterFrom(runtime.levelId) + state.portal = ctx.portalSlot.current() + if (dest !== null) void travel(dest.levelId, WARP_FADE_MS, { x: dest.x, y: dest.y }) + } + + const teleportViaWaypoint = async ( + targetAct: number, + slug: string, + targetLevelId?: number, + ): Promise => { + const runtime = ctx.getRuntime() + if (travelling || runtime.source !== 'pack') return + travelling = true + ctx.clearMouseTarget() + const isEn = getTblLang() === 'ENG' + try { + await ctx.fade.out(WARP_FADE_MS) + const index = await getPackIndex(runtime.base) + if (index === null) { + ctx.status.textContent = isEn ? 'Failed to read pack index.' : '无法读取资源包索引。' + 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) { + ctx.status.textContent = isEn + ? `Waypoint level not found in pack: ${slug}` + : `资源包里没有传送点关卡:${slug}` + return + } + + const levelIds = [...new Set(pool.map(entry => entry.levelId))] + const poolLevelId = levelIds.length === 1 ? levelIds[0] : undefined + if (poolLevelId === undefined && pool.length !== 1) { + throw new Error(`waypoint ${slug}: ${String(pool.length)} baked maps match and they do not name one level`) + } + const chosenEntry = poolLevelId === undefined + ? pool[0]! + : worldVariantsFor(runtime.base, index).entryForLevel(poolLevelId) + if (chosenEntry === null) throw new Error(`waypoint ${slug}: no baked copy of level ${String(poolLevelId)}`) + const next = await buildPackRuntime( + runtime.base, + index, + chosenEntry, + { width: ctx.canvas.width, height: ctx.canvas.height }, + runtime.charBases, + ) + await next.loadPages(ctx.renderer, next.priorityPages, () => { + state.pagesLoaded = countLoadedPages(next.pages) + }) + + const wp = next.waypoints[0] + if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`) + const landing = { x: wp.arriveX, y: wp.arriveY } + + await swapLevel(next, landing) + preloadRemaining(next, ctx.renderer) + ctx.resetMinimapCache() + + const names = getLevelNames(next.levelId ?? next.level) + ctx.status.textContent = isEn + ? `Teleported to: ${names.nameEn} (Act ${String(next.act)})` + : `已传送到:${names.nameZh}(Act ${String(next.act)})` + } catch (err) { + ctx.status.textContent = isEn + ? `Waypoint teleport failed: ${(err as Error).message}` + : `传送点传送失败:${(err as Error).message}` + } finally { + await ctx.fade.in(WARP_FADE_MS) + travelling = false + } + } + + const checkLinks = (): void => { + const runtime = ctx.getRuntime() + if (travelling || runtime.source !== 'pack') return + const here = ctx.playerSubTile(ctx.engine, runtime) + 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 (!(entrance.toLevelId > 0)) continue + if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue + void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side) + 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 + const hudManager = ctx.getHudManager() + if (hudManager) { + hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, runtime.levelId) + hudManager.worldPanels.selectedActTab = runtime.act + if (hudManager.leftPanel !== 'waypoint') hudManager.toggleLeftPanel('waypoint') + } + } + if (ctx.waypointNetwork.activate(waypoint.waypointId)) { + const isEn = getTblLang() === 'ENG' + const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level + ctx.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}` + } + if (ctx.input.takeWaypoint()) { + const town = ctx.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 (ctx.input.takePortal()) castTownPortal() + } + + return { + isTravelling: () => travelling, + castTownPortal, + enterTownPortal, + registerWaypoints, + swapLevel, + travel, + teleportViaWaypoint, + checkLinks, + } +} diff --git a/src/scene/frontend/scene-state.ts b/src/scene/frontend/scene-state.ts new file mode 100644 index 0000000..d95f295 --- /dev/null +++ b/src/scene/frontend/scene-state.ts @@ -0,0 +1,406 @@ +import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../game/animated-tiles.ts' +import type { SorceressAppearanceSpec } from '../../game/char-appearance.ts' +import type { Monster, MonsterStats } from '../../game/combat.ts' +import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts' +import type { NpcEntity } from '../../game/engine.ts' +import { enrichMonsterStats } from '../../game/monsters.ts' +import type { OpenPortal } from '../../game/portal.ts' +import { getTblLang, type TblLang } from '../../i18n/lang.ts' +import type { + AutomapOverlayLabel, + AutomapViewportMetrics, + Minimap, +} from '../../ui/minimap.ts' +import type { MonsterTargetHudInfo } from '../../ui/world-panels.ts' +import type { DensityMultiplier, EliteMultiplier } from '../backend/scene-population.ts' +import type { LightingPreset } from './lighting-presets.ts' +import type { MapRuntime } from './pack-loader.ts' +import { countLoadedPages } from './pack-loader.ts' + +/** + * `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. + */ +export 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. + */ +export 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', 'stf', 'orb', 'bow', 'xbw'). */ + weaponClass?: string | null | undefined + /** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */ + playerClip?: string | null | undefined + /** Canonical appearance cache key for dynamic modular paperdoll compositing. */ + characterAppearanceKey?: string | undefined + /** Active Sorceress 16-component appearance and palette shift spec. */ + characterAppearance?: SorceressAppearanceSpec | 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. + */ + 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 + /** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */ + lightingPreset?: LightingPreset | undefined + /** Active lighting mode ('Glide 1.13c' or undefined). */ + lightingMode?: string | undefined + /** Ambient light intensity [0..255]. */ + ambientIntensity?: number | undefined + /** Ambient color [R, G, B] in [0..255]. */ + ambientColor?: readonly [number, number, number] | undefined + /** 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. */ + 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. */ + monstersPlanned: number + /** Active monster density multiplier (0.5, 1, 2, 4, 8). */ + densityMultiplier?: DensityMultiplier | undefined + /** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */ + eliteMultiplier?: EliteMultiplier | undefined + /** Dynamic monster re-budget and re-stream method without page reload. */ + reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void + /** Active player Town Portal (if open). */ + portal?: OpenPortal | null | undefined + /** Whether the automap/minimap is visible. */ + minimapVisible?: boolean | undefined + /** Active automap mode ('minimap' | 'full'). */ + automapMode?: 'minimap' | 'full' | undefined + /** Active automap projection divisor (20 for minimap, 10 for full). */ + automapDivisor?: 10 | 20 | undefined + /** Number of loaded automap sprite atlases (0..8). */ + automapAtlasesLoaded?: number | undefined + /** Pre-baked/active automap cell counts per layer. */ + automapCellCounts?: { + floors: number + walls: number + objects: number + extras: number + total: number + } | undefined + /** Revealed DRLG room count for the active level. */ + automapRevealedRooms?: number | undefined + /** Automap sprites drawn last frame per layer. */ + automapSpritesDrawn?: { + floors: number + walls: number + objects: number + extras: number + portals: number + total: number + } | undefined + /** Automap 12-segment crosshairs drawn last frame. */ + automapCrosshairsDrawn?: { + player: number + party: number + npcs: number + mercenaries: number + bosses: number + total: number + } | undefined + /** Revealed warp/stash Font6 labels on the automap. */ + automapWarpLabels?: AutomapOverlayLabel[] | undefined + /** Top-right gold Font16 Level Name + Difficulty HUD readout. */ + automapHudText?: { + levelName: string + difficulty: string + x: number + y1: number + y2: number + } | null | undefined + /** Active automap viewport metrics. */ + automapViewport?: AutomapViewportMetrics | undefined +} + +declare global { + interface Window { + /** Act scene state for automation and debugging. */ + __d2webAct?: ActSceneState + __d2webActs?: ActSceneState + __d2webMinimap?: Minimap + __d2webActScene?: { + switchHero: (token: string) => Promise + getHero: () => string + switchLighting?: (preset: string) => LightingPreset + getLighting?: () => LightingPreset + reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise | void + setPlayers?: (count: number, partyCount?: number) => void + executeCommand?: (cmd: string) => boolean + castTownPortal?: () => boolean + enterTownPortal?: () => void + getPortal?: () => OpenPortal | null + getMinimap?: () => Minimap + syncAppearance?: () => Promise + } + } +} + +export 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, + densityMultiplier: 1, eliteMultiplier: 1, +} +if (typeof window !== 'undefined') { + window.__d2webAct = state + window.__d2webActs = state +} + +export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo { + const dropTables = getEmbeddedDropTables() + const stats = + monster.stats.isDemon === undefined && + monster.stats.isUndead === undefined && + monster.stats.nameKey === undefined + ? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques) + : monster.stats + const r = stats.rank + const statsAny = stats as MonsterStats & { + readonly descStr?: string + readonly subType?: string + readonly affixes?: readonly string[] + } + return { + name: stats.name || stats.id, + ...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}), + hp: monster.hp, + maxHp: stats.hp, + rank: + r === 'champion' + ? 'champion' + : r === 'unique' + ? 'unique' + : (r as string) === 'superunique' + ? 'superunique' + : r === 'minion' + ? 'minion' + : r === 'boss' || stats.isBoss === true + ? 'boss' + : 'normal', + ...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}), + ...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}), + ...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}), + ...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}), + ...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}), + ...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}), + ...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}), + ...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}), + ...(stats.resists !== undefined ? { resists: stats.resists } : {}), + ...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}), + } +} + +export interface MonsterArtDiagnosticState { + readonly missing: readonly string[] + readonly errors: number + readonly layerFailures: number +} + +/** + * 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: MonsterArtDiagnosticState, + lang: TblLang = getTblLang(), +): string { + const isEn = lang === 'ENG' + const parts: string[] = [] + if (load.missing.length > 0) { + const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、') + const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : '' + parts.push( + isEn + ? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})` + : `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`, + ) + } + if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`) + if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${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, lang: TblLang = getTblLang()): string { + if (lang === 'ENG') { + return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}` + } + return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}` +} + +/** + * Publish a level's planned population into {@link ActSceneState}. + */ +export 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}. + */ +export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): 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)`, + ) + } +} + +/** + * Copy a runtime's description into the published state. + * + * @param runtime - the runtime. + * @param base - location the data came from. + */ +export 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 +} diff --git a/src/scene/frontend/toolbar-chat-ui.ts b/src/scene/frontend/toolbar-chat-ui.ts new file mode 100644 index 0000000..f17bf7f --- /dev/null +++ b/src/scene/frontend/toolbar-chat-ui.ts @@ -0,0 +1,858 @@ +import type { Palette } from '../../formats/pal.ts' +import { WORLD_VARIANT_SEED } from '../../game/act-variants.ts' +import type { ActorAnimator } from '../../game/actor-animator.ts' +import { + SorceressLayerCompositor, + appearanceKey, + resolveSorceressAppearance, +} from '../../game/char-appearance.ts' +import type { CharacterClassCode } from '../../game/classes.ts' +import { + spawnMonsterPacks, + type MonsterPack, + type SafeZone, +} from '../../game/combat.ts' +import { cellCentre } from '../../game/d2map.ts' +import type { DropTables } from '../../game/drop-pipeline.ts' +import type { GameEngine } from '../../game/engine.ts' +import { + ACT_NAMES_EN, + ACT_NAMES_ZH, + getLevelNames, + sceneDisplayName, + variantDisplayLabel, +} from '../../game/level-names-zh.ts' +import type { LoadedMonsterArt } from '../../game/monster-art.ts' +import { MonsterStreamingManager } from '../../game/monster-streaming.ts' +import { + buildLangSwitchHref, + getTblLang, + type TblLang, +} from '../../i18n/lang.ts' +import type { AtlasFrame } from '../../render/atlas.ts' +import type { SpriteRenderer } from '../../render/renderer.ts' +import { cleanChiItemName } from '../../ui/ground-labels.ts' +import { HudManager } from '../../ui/hud-manager.ts' +import type { Minimap } from '../../ui/minimap.ts' +import { handleActSceneChatCommand } from '../backend/chat-commands.ts' +import { subTileToScene } from '../backend/scene-links-nav.ts' +import { + HERO_CLASSES, + ROOM_ACTIVATION_RADIUS, + ROOM_DEACTIVATION_RADIUS, + buildStreamingRooms, + isTownLevel, + normalizeHeroToken, + parseDensityMultiplier, + parseEliteMultiplier, + rebudgetMonsterPacks, + type DensityMultiplier, + type EliteMultiplier, +} from '../backend/scene-population.ts' +import { + DEFAULT_BASES, + loadCharacterArt, + loadMonsterArtMap, + type LoadedCharacter, +} from './entity-art-loader.ts' +import { + LIGHTING_PRESETS, + normalizeLightingPreset, + type LightingPreset, +} from './lighting-presets.ts' +import { + ACT_COUNT, + fetchJson, + param, + type MapRuntime, + type PackIndex, +} from './pack-loader.ts' +import { + recordMonsterArtLoad, + reportMonsterPlan, + state, +} from './scene-state.ts' + +export { handleActSceneChatCommand } + +export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void { + if (!select) return + const isNonDefault = select.value !== defaultValue + select.classList.toggle('non-default', isNonDefault) + if (isNonDefault) { + select.style.border = '1px solid #e74c3c' + } else { + select.style.border = '' + } +} + +export function updateToolbarUrl( + currentUrlOrSearch: string | URL, + options: { density?: number | string | undefined; elite?: number | string | undefined }, +): string { + const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://') + const url = isUrl + ? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href) + : new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`) + + if (options.density !== undefined) { + const density = parseDensityMultiplier(options.density) + if (density === 1) { + url.searchParams.delete('density') + } else { + url.searchParams.set('density', String(density)) + } + } + + if (options.elite !== undefined) { + const elite = parseEliteMultiplier(options.elite) + if (elite === 1) { + url.searchParams.delete('elite') + } else { + url.searchParams.set('elite', String(elite)) + } + } + + if (isUrl) { + return url.toString() + } + return url.search +} + +export interface MountChatAndToolbarUiOptions { + readonly engine: GameEngine + readonly status: HTMLElement + readonly executeChatCommand: (rawCommand: string) => boolean + readonly densitySelect: HTMLSelectElement | null + readonly eliteSelect: HTMLSelectElement | null + readonly densityMultiplier: number + readonly eliteMultiplier: number | 'all' + readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise +} + +/** + * Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire + * toolbar density/elite selectors. + */ +export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void { + const { + executeChatCommand, + densitySelect, + eliteSelect, + densityMultiplier, + eliteMultiplier, + reloadMonsters, + } = options + + // In-game chat/console input overlay supporting "/players <1-8>" + if (typeof document !== 'undefined') { + let chatInput = document.querySelector('#d2-chat-input') + if (!chatInput) { + chatInput = document.createElement('input') + chatInput.id = 'd2-chat-input' + chatInput.type = 'text' + chatInput.placeholder = getTblLang() === 'ENG' + ? 'Type /players 1-8 or message, press Enter to send…' + : '输入 /players 1-8 或消息,按 Enter 发送…' + chatInput.className = 'overlay' + chatInput.style.position = 'fixed' + chatInput.style.left = '12px' + chatInput.style.bottom = '56px' + chatInput.style.width = '320px' + chatInput.style.padding = '4px 8px' + chatInput.style.background = 'rgba(11, 9, 6, 0.9)' + chatInput.style.color = '#e8dcc8' + chatInput.style.border = '1px solid #c8a15a' + chatInput.style.borderRadius = '4px' + chatInput.style.font = 'inherit' + chatInput.style.fontSize = '12px' + chatInput.style.zIndex = '10' + chatInput.style.display = 'none' + document.body.appendChild(chatInput) + } + + const openChat = () => { + if (!chatInput) return + chatInput.placeholder = getTblLang() === 'ENG' + ? 'Type /players 1-8 or message, press Enter to send…' + : '输入 /players 1-8 或消息,按 Enter 发送…' + chatInput.style.display = 'block' + chatInput.value = '' + chatInput.focus() + } + + const closeChat = () => { + if (!chatInput) return + chatInput.value = '' + chatInput.blur() + chatInput.style.display = 'none' + } + + chatInput.addEventListener('keydown', (e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + const text = chatInput!.value + closeChat() + if (text) { + executeChatCommand(text) + } + } else if (e.key === 'Escape') { + e.preventDefault() + closeChat() + } + }) + + window.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const active = document.activeElement + if ( + active instanceof HTMLInputElement || + active instanceof HTMLSelectElement || + active instanceof HTMLTextAreaElement + ) { + return + } + e.preventDefault() + openChat() + } + }) + } + + if (densitySelect !== null) { + densitySelect.disabled = false + densitySelect.value = String(densityMultiplier) + updateSelectorWarningStyle(densitySelect, '1') + densitySelect.onchange = () => { + void reloadMonsters({ density: densitySelect.value }) + } + } + if (eliteSelect !== null) { + eliteSelect.disabled = false + eliteSelect.value = String(eliteMultiplier) + updateSelectorWarningStyle(eliteSelect, '1') + eliteSelect.onchange = () => { + void reloadMonsters({ elite: eliteSelect.value }) + } + } +} + +export interface HeroLightingAndMonsterControlsContext { + readonly renderer: SpriteRenderer + readonly dropTables: DropTables + readonly playerAnimator: ActorAnimator + readonly status: HTMLElement + readonly heroSelect: HTMLSelectElement | null + readonly lightingSelect: HTMLSelectElement | null + readonly densitySelect: HTMLSelectElement | null + readonly eliteSelect: HTMLSelectElement | null + readonly getRuntime: () => MapRuntime + readonly getEngine: () => GameEngine + readonly getHudManager: () => HudManager | null + readonly getCurrentHeroToken: () => string + readonly setCurrentHeroToken: (token: string) => void + readonly getCharacter: () => LoadedCharacter | null + readonly setCharacter: (char: LoadedCharacter | null) => void + readonly getActivePalette: () => Palette | null + readonly getPackEntityBase: () => string | undefined + readonly getPackEntityAct: () => number | undefined + readonly getCurrentLightingPreset: () => LightingPreset + readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void + readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier } + readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void + readonly setMonsterArtMap: (map: Map) => void +} + +export interface HeroLightingAndMonsterControls { + readonly syncSorceressAppearance: () => Promise + readonly switchLighting: (preset: string) => LightingPreset + readonly switchHero: (newHeroToken: string) => Promise + readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise +} + +export function createHeroLightingAndMonsterControls( + ctx: HeroLightingAndMonsterControlsContext, +): HeroLightingAndMonsterControls { + const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs') + let activeAppearanceKey = '' + let appearanceSeq = 0 + + const syncSorceressAppearance = async (): Promise => { + const hudInst = ctx.getHudManager() ?? ( + typeof window !== 'undefined' + ? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null + : null + ) + const currentHeroToken = ctx.getCurrentHeroToken() + if (currentHeroToken !== 'so' || hudInst === null) { + activeAppearanceKey = '' + state.characterAppearanceKey = undefined + state.characterAppearance = undefined + if (hudInst !== null) { + const wCode = hudInst.inventory.equipped.weapon1?.code + state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth') + } + return false + } + + const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables) + const key = appearanceKey(spec) + state.weaponClass = spec.wclass + state.characterAppearance = spec + + if (key === activeAppearanceKey) return false + activeAppearanceKey = key + state.characterAppearanceKey = key + const seq = ++appearanceSeq + + try { + const composited = await soCompositor.composite(spec) + if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false + const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height) + const groups: AtlasFrame[][] = composited.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, + y, + width, + height, + anchorX, + anchorY, + })), + ) + const prevChar = ctx.getCharacter() + if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle) + ctx.setCharacter({ + handle: newHandle, + groups, + directions: composited.directions, + walk: composited.walk, + stand: composited.stand, + members: composited.members, + clips: composited.clips, + }) + state.character = true + state.characterMembers = composited.members + state.characterAppearanceKey = key + state.characterAppearance = spec + if (ctx.playerAnimator.mode !== null) { + const nextClip = composited.clips[ctx.playerAnimator.mode] + if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip) + } + return true + } catch (err) { + console.warn('Sorceress appearance composite failed:', err) + return false + } + } + + const switchLighting = (preset: string): LightingPreset => { + const nextPreset = normalizeLightingPreset(preset) + const cfg = LIGHTING_PRESETS[nextPreset] + ctx.setLightingState(nextPreset, cfg.enabled) + const isEn = getTblLang() === 'ENG' + const modeLabel = isEn ? cfg.labelEn : cfg.labelZh + state.lightingPreset = nextPreset + state.lightingMode = modeLabel + if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) { + ctx.lightingSelect.value = nextPreset + } + try { + const url = new URL(location.href) + if (nextPreset === 'auto') url.searchParams.delete('lighting') + else url.searchParams.set('lighting', nextPreset) + history.replaceState(null, '', url.toString()) + } catch { + // ignore in non-browser context + } + const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:' + ctx.status.textContent = `${msgPrefix}${modeLabel}` + setTimeout(() => { + if (ctx.status.textContent.startsWith(msgPrefix)) ctx.status.textContent = '' + }, 2500) + return nextPreset + } + + const switchHero = async (newHeroToken: string): Promise => { + const nextToken = normalizeHeroToken(newHeroToken) + if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true + const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so + const isEn = getTblLang() === 'ENG' + const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh + ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …` + const runtime = ctx.getRuntime() + const nextChar = await loadCharacterArt( + runtime.charBases, + ctx.getActivePalette(), + ctx.renderer, + ctx.getPackEntityBase(), + ctx.getPackEntityAct(), + nextToken, + ) + if (nextChar !== null) { + const prevChar = ctx.getCharacter() + if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle) + ctx.setCharacter(nextChar) + ctx.setCurrentHeroToken(nextToken) + state.heroToken = nextToken + state.heroName = nextInfo.nameEn + state.character = true + state.characterMembers = nextChar.members + const hudManager = ctx.getHudManager() + if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode) + activeAppearanceKey = '' + if (nextToken === 'so') await syncSorceressAppearance() + else { + state.characterAppearanceKey = undefined + state.characterAppearance = undefined + } + if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.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 + } + const curChar = ctx.getCharacter() + if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true }) + const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 ' + ctx.status.textContent = isEn + ? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).` + : `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。` + setTimeout(() => { + if (ctx.status.textContent.startsWith(switchedPrefix)) ctx.status.textContent = '' + }, 3000) + return true + } + ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。` + return false + } + + const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise => { + let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers() + if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density) + if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite) + ctx.setMultipliers(densityMultiplier, eliteMultiplier) + state.densityMultiplier = densityMultiplier + state.eliteMultiplier = eliteMultiplier + + if (ctx.densitySelect !== null) { + ctx.densitySelect.value = String(densityMultiplier) + updateSelectorWarningStyle(ctx.densitySelect, '1') + } + if (ctx.eliteSelect !== null) { + ctx.eliteSelect.value = String(eliteMultiplier) + updateSelectorWarningStyle(ctx.eliteSelect, '1') + } + try { + if (typeof location !== 'undefined' && typeof history !== 'undefined') { + const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier }) + const newUrl = `${location.pathname}${nextSearch}${location.hash}` + history.replaceState(null, '', newUrl) + } + } catch { + // ignore in non-browser context + } + + const runtime = ctx.getRuntime() + const engine = ctx.getEngine() + if (isTownLevel(runtime.levelId)) { + runtime.monsterPacks = [] + engine.world.monsters.length = 0 + engine.streamingManager = undefined + reportMonsterPlan(runtime) + return + } + + const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks + runtime.baseMonsterPacks = basePacks + runtime.monsterPacks = rebudgetMonsterPacks(basePacks, { + densityMultiplier, + eliteMultiplier, + levelId: runtime.levelId, + seed: WORLD_VARIANT_SEED ^ runtime.levelId, + }) + + engine.world.monsters.length = 0 + + const player = engine.world.player + const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }] + for (const wp of runtime.waypoints) { + const pos = subTileToScene(wp.x, wp.y, runtime.grid) + currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) + } + for (const ent of runtime.entrances) { + const pos = subTileToScene(ent.x, ent.y, runtime.grid) + currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 }) + } + for (const s of runtime.stashes ?? []) { + currentSafeZones.push({ x: s.x, y: s.y, radius: 160 }) + } + + const currentResolvedPacks: 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 nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones) + if (nextRooms.length > 0) { + engine.streamingManager = new MonsterStreamingManager( + engine.world, + engine.terrain, + nextRooms, + { + activationRadius: ROOM_ACTIVATION_RADIUS, + deactivationRadius: ROOM_DEACTIVATION_RADIUS, + safeZones: currentSafeZones, + }, + ) + } else { + engine.streamingManager = undefined + if (currentResolvedPacks.length > 0) { + spawnMonsterPacks( + engine.world, + currentResolvedPacks, + { x: player.x, y: player.y }, + Math.max(runtime.widthPx, runtime.heightPx) / 4, + engine.terrain, + currentSafeZones, + ) + } + } + reportMonsterPlan(runtime) + + if (runtime.palette !== null) { + 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, + runtime.palette, + ctx.renderer, + ctx.getPackEntityBase(), + ctx.getPackEntityAct(), + ) + ctx.setMonsterArtMap(loaded.map) + recordMonsterArtLoad(loaded) + } + } + } + + return { + syncSorceressAppearance, + switchLighting, + switchHero, + reloadMonsters, + } +} + +export async function createActSceneHudManager(options: { + readonly runtime: MapRuntime + readonly engine: GameEngine + readonly minimap: Minimap + readonly status: HTMLElement + readonly currentHeroToken: string + readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void + readonly onCastTownPortal: () => void +}): Promise { + const { + runtime, + engine, + minimap, + status, + currentHeroToken, + onWaypointTeleport, + onCastTownPortal, + } = options + const hudCanvas = document.querySelector('#d2-hud-canvas') + const hudManager = hudCanvas !== null + ? new HudManager(hudCanvas, { + onToggleAutomap: () => { + minimap.toggleVisibility() + state.minimapVisible = minimap.visible + }, + onToggleAutomapMode: () => { + minimap.toggleMinimapMode() + state.minimapVisible = minimap.visible + state.automapMode = minimap.mode + state.automapDivisor = minimap.divisor + }, + onCycleAutomapFade: () => { minimap.cycleFade() }, + onToggleAutomapNames: () => { minimap.toggleNames() }, + onResetAutomapPan: () => { minimap.resetPan() }, + onPanAutomap: (dx, dy) => minimap.pan(dx, dy), + onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => { + minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight) + }, + onWaypointTeleport: (targetAct, slug, levelId) => { + onWaypointTeleport(targetAct, slug, levelId) + }, + onCastTownPortal: () => onCastTownPortal(), + onDropItemToGround: (item) => { + const player = engine.world.player + const groundItem = engine.dropItem(item, player.x, player.y) + const isEn = getTblLang() === 'ENG' + status.textContent = isEn + ? `Dropped "${groundItem.name}" on ground` + : `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面` + }, + onDropGoldToGround: (amount) => { + const player = engine.world.player + const groundGold = engine.dropGold(amount, player.x, player.y) + if (groundGold) { + const isEn = getTblLang() === 'ENG' + status.textContent = isEn + ? `Dropped ${amount.toLocaleString()} Gold on ground` + : `已将 ${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) + const customCharName = param('charName', '').trim() + if (customCharName) hudManager.charSheet.attrs.name = customCharName + } catch (err) { + console.error('Failed to initialize HudManager:', err) + } + if (typeof window !== 'undefined' && window.__d2webHud) { + (window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount + } + } + return hudManager +} + +export function wireBootToolbarSelectors(options: { + readonly isEn: boolean + readonly heroSelect: HTMLSelectElement | null + readonly actSelect: HTMLSelectElement + readonly sceneSelect: HTMLSelectElement + readonly variantSelect: HTMLSelectElement + readonly lightingSelect: HTMLSelectElement | null + readonly langSwitch: HTMLAnchorElement | null +}): number { + const { + isEn, + heroSelect, + actSelect, + sceneSelect, + variantSelect, + lightingSelect, + langSwitch, + } = options + + if (langSwitch !== null && typeof location !== 'undefined') { + const targetLang: TblLang = isEn ? 'CHI' : 'ENG' + langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN' + langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) + langSwitch.addEventListener('click', () => { + langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search) + }) + } + + if (lightingSelect !== null) { + lightingSelect.disabled = false + lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto')) + lightingSelect.onchange = () => { + const params = new URLSearchParams(location.search) + if (lightingSelect.value === 'auto') params.delete('lighting') + else params.set('lighting', lightingSelect.value) + location.search = params.toString() + } + } + 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() + } + } + const densitySelect = document.querySelector('#density') + const eliteSelect = document.querySelector('#elite') + if (densitySelect !== null) { + densitySelect.disabled = false + densitySelect.value = String(parseDensityMultiplier(param('density', '1'))) + updateSelectorWarningStyle(densitySelect, '1') + } + if (eliteSelect !== null) { + eliteSelect.disabled = false + eliteSelect.value = String(parseEliteMultiplier(param('elite', '1'))) + updateSelectorWarningStyle(eliteSelect, '1') + } + actSelect.disabled = false + sceneSelect.disabled = true + variantSelect.disabled = true + + 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() + }) + return act +} + +/** + * 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. + */ +export 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 + } + + const isEn = getTblLang() === 'ENG' + + /** 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: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'), + 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), + isEn + ? `${ACT_NAMES_EN[act] ?? `Act ${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 + ? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI') + : isEn + ? `${scene?.name ?? entry.levelName} (${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) } +} diff --git a/src/scene/frontend/world-renderer.ts b/src/scene/frontend/world-renderer.ts new file mode 100644 index 0000000..24fc1e0 --- /dev/null +++ b/src/scene/frontend/world-renderer.ts @@ -0,0 +1,1095 @@ +import type { AudioManager } from '../../audio/manager.ts' +import { ActorAnimator } from '../../game/actor-animator.ts' +import { updateAnimatableTiles } from '../../game/animated-tiles.ts' +import { facingToDirection } from '../../game/character.ts' +import { + ORTHO_CELL_HEIGHT, + ORTHO_CELL_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + type CollisionGrid, +} from '../../game/d2map.ts' +import { GameEngine, syncEngineState } from '../../game/engine.ts' +import type { Environment } from '../../game/engine/environment.ts' +import type { LightGrid } from '../../game/engine/light-grid.ts' +import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts' +import { SUB_TILES_PER_TILE } from '../../game/map.ts' +import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../../game/monster-art.ts' +import { monsterDisplayName } from '../../game/monsters.ts' +import { getTownNpcDescriptorByName, npcDisplayName } from '../../game/npc-table.ts' +import type { TownPortalSlot } from '../../game/portal.ts' +import { getMissileTxtData } from '../../game/skills.ts' +import { getTblLang } from '../../i18n/lang.ts' +import { intersects, viewportRect } from '../../render/cull.ts' +import type { LightningTrailManager } from '../../render/missile-renderer.ts' +import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts' +import type { RoofFadeController } from '../../render/roofs.ts' +import type { ViewportCamera } from '../../sim/camera.ts' +import type { GameLoop } from '../../sim/loop.ts' +import type { GroundLabelOverlay } from '../../ui/ground-labels.ts' +import type { HudManager } from '../../ui/hud-manager.ts' +import type { Minimap, MinimapLevel, MinimapMarker } from '../../ui/minimap.ts' +import { subTileToScene } from '../backend/scene-links-nav.ts' +import { isTownLevel } from '../backend/scene-population.ts' +import type { LoadedCharacter } from './entity-art-loader.ts' +import { drawGroundItem } from './ground-item-renderer.ts' +import { + LIGHTING_PRESETS, + type LightingPreset, +} from './lighting-presets.ts' +import { + drawCastOverlay, + drawExplosion, + drawMissileProjectile, + drawTownPortal, + type LoadedMissileArt, + type LoadedOverlayArt, +} from './missile-overlay-renderer.ts' +import type { SceneMouseController } from './mouse-controller.ts' +import { + ADDITIVE_OBJECT_TOKENS, + updateAnimatedObjects, + type Drawable, +} from './object-animation.ts' +import { updateGroundItemLabelsOverlay } from './overhead-labels.ts' +import { + FEET_HEIGHT, + MARKER_HEIGHT, + MARKER_WIDTH, + countLoadedPages, + type MapRuntime, +} from './pack-loader.ts' +import { + HUD_INTERVAL_MS, + monsterArtStatusText, + monsterPlanStatusText, + state, +} from './scene-state.ts' + +/** + * The cell containing a scene point. + * + * @param grid - the collision grid. + * @param x - scene x. + * @param y - scene y. + * @returns rounded-down cell coordinates. + */ +export 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), + } +} + +/** + * Where the player is, in sub-tiles. + */ +export function playerSubTile(engine: GameEngine, runtime: MapRuntime): { 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) } +} + +/** + * Build or reuse the cached `MinimapLevel` for `runtime`. + */ +export function buildMinimapLevel( + runtime: MapRuntime, + minimapCache: { key: string; level: MinimapLevel } | null, +): { key: string; level: MinimapLevel } { + const currentLang = getTblLang() + const key = `${currentLang}:${runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath}` + if (minimapCache !== null && minimapCache.key === key) return minimapCache + const marker = ( + x: number, + y: number, + kind: MinimapMarker['kind'], + toLevelId?: number, + ): MinimapMarker => { + const targetName = + toLevelId !== undefined && toLevelId > 0 + ? levelDisplayName(toLevelId, currentLang) + : undefined + return { + cellX: Math.floor(x / SUB_TILES_PER_TILE), + cellY: Math.floor(y / SUB_TILES_PER_TILE), + kind, + ...(toLevelId !== undefined ? { toLevelId } : {}), + ...(targetName !== undefined ? { targetName } : {}), + } + } + const resolvedLevelName = + runtime.levelId > 0 + ? levelDisplayName(runtime.levelId, currentLang) || runtime.level + : runtime.level + const level: MinimapLevel = { + key, + cellsX: runtime.grid.cellsX, + cellsY: runtime.grid.cellsY, + blocked: runtime.grid.blocked, + gridWidth: runtime.grid.gridWidth, + originX: runtime.grid.originX, + originY: runtime.grid.originY, + ...(runtime.automap !== undefined ? { automap: runtime.automap } : {}), + levelId: runtime.levelId, + levelName: resolvedLevelName, + difficulty: currentLang === 'ENG' ? 'Normal' : '普通', + preRevealTown: isTownLevel(runtime.levelId), + markers: [ + ...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit', entrance.toLevelId)), + ...runtime.warps.map(warp => marker(warp.x, warp.y, 'exit', warp.toLevelId)), + ...runtime.waypoints.map(waypoint => marker(waypoint.x, waypoint.y, 'waypoint')), + ], + } + return { key, level } +} + +export interface StepAnimationsContext { + readonly engine: GameEngine + readonly runtime: MapRuntime + readonly character: LoadedCharacter | null + readonly playerAnimator: ActorAnimator + readonly monsterAnimators: Map + readonly monsterArtMap: ReadonlyMap + readonly hudManager: HudManager | null + readonly audioManager: AudioManager + readonly environment: Environment + readonly lightingEnabled: boolean + readonly syncSorceressAppearance: () => Promise + readonly effectiveMovement: { readonly x: number; readonly y: number } + playerMoving: boolean + playerWalkFrame: number + monsterWalkFrame: number +} + +export function stepPlayerAndMonsterAnimations(ctx: StepAnimationsContext): { + playerMoving: boolean + playerWalkFrame: number + monsterWalkFrame: number +} { + const { + engine, + runtime, + character, + playerAnimator, + monsterAnimators, + monsterArtMap, + hudManager, + audioManager, + environment, + lightingEnabled, + syncSorceressAppearance, + effectiveMovement, + } = ctx + + const player = engine.world.player + const playerMoving = effectiveMovement.x !== 0 || effectiveMovement.y !== 0 + const playerWalkFrame = playerMoving ? (ctx.playerWalkFrame + 1) % 24 : 0 + const monsterWalkFrame = (ctx.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 { + 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') + void syncSorceressAppearance() + } + + // 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 (art.clips['dt'] !== undefined && art.clips['dt'].frames > 0 && !monster.dropRolled) { + monster.deathGated = true + } + if (monster.state === 'dead' || monster.animMode === 'dt') { + if (animator.mode !== 'dt' && animator.mode !== 'dd') { + const clip = art.clips['dt'] + if (clip !== undefined && clip.frames > 0) { + animator.play('dt', clip) + monster.animMode = 'dt' + monster.deathGated = true + } else { + const ddClip = art.clips['dd'] + if (ddClip !== undefined) animator.play('dd', ddClip) + monster.animMode = 'dd' + engine.triggerMonsterDrop(monster) + } + } else if (animator.mode === 'dt' && animator.finished) { + const clip = art.clips['dd'] + if (clip !== undefined) animator.play('dd', clip) + monster.animMode = 'dd' + engine.triggerMonsterDrop(monster) + } + } 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) + } + if ( + monster.state === 'dead' || + monster.animMode === 'dt' || + !monster.frozenTicks || + monster.frozenTicks <= 0 + ) { + animator.tick() + } + } else if ((monster.state === 'dead' || monster.animMode === 'dt') && !monster.dropRolled) { + engine.triggerMonsterDrop(monster) + } + } + 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) + // Update ground item bounce parabolic physics and trigger drop SFX at dropsfxframe + engine.groundItems.updateBounces(performance.now(), audioManager) + if (lightingEnabled) { + environment.tick(runtime.levelId, runtime.act) + } + + return { playerMoving, playerWalkFrame, monsterWalkFrame } +} + +export interface DynamicLightingContext { + readonly renderer: SpriteRenderer + readonly engine: GameEngine + readonly runtime: MapRuntime + readonly lightGrid: LightGrid + readonly environment: Environment + readonly portalSlot: TownPortalSlot + readonly currentLightingPreset: LightingPreset + readonly lightingEnabled: boolean +} + +export function applyDynamicSceneLighting(ctx: DynamicLightingContext): void { + const { + renderer, + engine, + runtime, + lightGrid, + environment, + portalSlot, + currentLightingPreset, + lightingEnabled, + } = ctx + + if (lightingEnabled) { + const pSub = playerSubTile(engine, runtime) + lightGrid.centerOnSubTile(pSub.x, pSub.y) + lightGrid.clear() + lightGrid.updateObstacles(runtime.grid) + + const presetCfg = LIGHTING_PRESETS[currentLightingPreset] + const overrideAmb = presetCfg.overrideAmbient + const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity + const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave' + + const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 + const basePlayerRadius = isDarkCave ? 20 : 22 + lightGrid.addLight({ + subTileX: pSub.x, + subTileY: pSub.y, + radius: basePlayerRadius + flicker, + intensity: 242, + red: 255, + green: 234, + blue: 196, + }) + + const skillToMissile: Readonly> = { + '36': 'firebolt', + '38': 'chargedbolt', + '39': 'icebolt', + '45': 'iceblast', + '47': 'fireball', + '49': 'lightning', + '53': 'chainlightning', + '55': 'glacialspike', + '56': 'meteor', + '64': 'frozenorb', + '67': 'teeth', + '84': 'bonespear', + '93': 'bonespirit', + } + for (const shot of engine.projectiles) { + const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) + if (!mType) continue + const mData = getMissileTxtData(mType) + const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) + if (lightUnits > 0) { + const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const shotSubX = Math.round((dy + dx) / 2) + const shotSubY = Math.round((dy - dx) / 2) + const isCold = mData.blue > mData.red + lightGrid.addLight({ + subTileX: shotSubX, + subTileY: shotSubY, + radius: Math.max(14, lightUnits * 2), + intensity: 250, + red: isCold ? Math.max(120, mData.red) : mData.red, + green: isCold ? Math.max(175, mData.green) : mData.green, + blue: mData.blue, + }) + } + } + + for (const exp of engine.explosions) { + const expData = getMissileTxtData(exp.missileType) + const lightUnits = expData && expData.light > 0 ? expData.light : 8 + const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const expSubX = Math.round((dy + dx) / 2) + const expSubY = Math.round((dy - dx) / 2) + const isColdExp = + exp.missileType.includes('ice') || + exp.missileType.includes('freeze') || + exp.missileType.includes('blizzard') || + exp.missileType.includes('orb') + lightGrid.addLight({ + subTileX: expSubX, + subTileY: expSubY, + radius: Math.round(lightUnits * 2.5), + intensity: 255, + red: isColdExp ? 130 : (expData?.red ?? 255), + green: isColdExp ? 185 : (expData?.green ?? 160), + blue: isColdExp ? 255 : (expData?.blue ?? 50), + }) + } + + if (runtime.objectDrawables) { + for (const obj of runtime.objectDrawables) { + const tok = (obj.token ?? '').trim().toUpperCase() + const isTorchOrFire = + tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || + tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || + tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || + tok === 'F8' || tok === '1Y' || tok === 'FL' + const isWaypoint = + tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || + tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' + const isShrineOrBeam = + tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || + obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) + + if (isTorchOrFire || isWaypoint || isShrineOrBeam) { + const dx = obj.x - engine.world.player.x + const dy = obj.y - engine.world.player.y + if (dx * dx + dy * dy < 950 * 950) { + const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH + const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const objSubX = Math.round((ody + odx) / 2) + const objSubY = Math.round((ody - odx) / 2) + const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0 + lightGrid.addLight({ + subTileX: objSubX, + subTileY: objSubY, + radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker, + intensity: isTorchOrFire ? 175 : 160, + red: isWaypoint ? 120 : 255, + green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205), + blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175), + }) + } + } + } + } + + const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId) + if (activePortalMouthForLight !== null) { + const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0 + lightGrid.addLight({ + subTileX: activePortalMouthForLight.x, + subTileY: activePortalMouthForLight.y, + radius: 19 + portalFlicker, + intensity: 235, + red: 64, + green: 110, + blue: 255, + }) + } + + const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5 + const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185) + const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175) + const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165) + const ambNorm = effectiveAmbIntensity / 255 + const ambR = (effR / 255) * ambNorm + const ambG = (effG / 255) * ambNorm + const ambB = (effB / 255) * ambNorm + + renderer.setLighting({ + ambient: [ambR, ambG, ambB], + originSubX: lightGrid.originSubX, + originSubY: lightGrid.originSubY, + sceneOriginX: runtime.grid.originX, + sceneOriginY: runtime.grid.originY, + lightmap: lightGrid.exportTextureBuffer(), + }) + + state.lightingPreset = currentLightingPreset + state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh + state.ambientIntensity = effectiveAmbIntensity + state.ambientColor = [effR, effG, effB] + } else { + renderer.setLighting(null) + const presetCfg = LIGHTING_PRESETS[currentLightingPreset] + state.lightingPreset = currentLightingPreset + state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh + state.ambientIntensity = 255 + state.ambientColor = [255, 255, 255] + } +} + +export interface RenderWorldLayersContext { + readonly frameMs: number + readonly started: number + readonly runtime: MapRuntime + readonly renderer: SpriteRenderer + readonly engine: GameEngine + readonly camera: ViewportCamera + readonly canvas: HTMLCanvasElement + readonly hud: HTMLElement + readonly labelsContainer: HTMLElement | null + readonly groundLabelOverlay: GroundLabelOverlay | null + readonly hudManager: HudManager | null + readonly mouseController: SceneMouseController + readonly minimap: Minimap + readonly minimapLevel: () => MinimapLevel + readonly portalSlot: TownPortalSlot + readonly lightGrid: LightGrid + readonly environment: Environment + readonly currentLightingPreset: LightingPreset + readonly lightingEnabled: boolean + readonly floorBounds: Float32Array + readonly shadowBounds: Float32Array + readonly wallBounds: Float32Array + readonly roofBounds: Float32Array + readonly objectBounds: Float32Array + readonly roofController: RoofFadeController + readonly character: LoadedCharacter | null + readonly playerAnimator: ActorAnimator + readonly playerMoving: boolean + readonly playerWalkFrame: number + readonly monsterWalkFrame: number + readonly monsterAnimators: ReadonlyMap + readonly monsterArtMap: ReadonlyMap + readonly missileArtMap: Map + readonly overlayArtMap: ReadonlyMap + readonly itemsAtlasHandle: AtlasHandle | undefined + readonly lightningTrailManager: LightningTrailManager + readonly loop: GameLoop + hudNextMs: number +} + +export function renderWorldLayers(ctx: RenderWorldLayersContext): { hudNextMs: number } { + const { + frameMs, + started, + runtime, + renderer, + engine, + camera, + canvas, + hud, + labelsContainer, + groundLabelOverlay, + hudManager, + mouseController, + minimap, + minimapLevel, + portalSlot, + lightGrid, + environment, + currentLightingPreset, + lightingEnabled, + floorBounds, + shadowBounds, + wallBounds, + roofBounds, + objectBounds, + roofController, + character, + playerAnimator, + playerMoving, + playerWalkFrame, + monsterWalkFrame, + monsterAnimators, + monsterArtMap, + missileArtMap, + overlayArtMap, + itemsAtlasHandle, + lightningTrailManager, + loop, + } = ctx + + 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 = countLoadedPages(runtime.pages) + 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 }, lightingEnabled ? [0, 0, 0] : [0.03, 0.03, 0.04]) + + applyDynamicSceneLighting({ + renderer, + engine, + runtime, + lightGrid, + environment, + portalSlot, + currentLightingPreset, + lightingEnabled, + }) + + let 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 + + 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 + + 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, + elevationY: Math.max(0, nextWall!.frame.height - 40), + }) + } + } + 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', + elevationY: Math.max(0, nextObj!.frame.height - 16), + }) + } + } + 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, + elevationY: Math.max(0, frame.height - 16), + }) + state.characterFrames += 1 + state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height } + } + } + }) + + mouseController.syncMonsterTargetHud(camX, camY) + const activeTargetMonster = mouseController.getActiveTargetMonster() + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' && monster.corpseTicks <= 0) { + monster.drawBounds = undefined + continue + } + if (monster.state === 'dead') { + monster.drawBounds = undefined + } + pushEntity(monster.x, monster.y, () => { + const isTarget = monster === activeTargetMonster && monster.state !== 'dead' && monster.hp > 0 + 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 + if (monster.state !== 'dead') { + monster.drawBounds = { + x: drawX, + y: drawY, + w: frame.width, + h: frame.height, + } + } + const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 + 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, + elevationY: Math.max(0, frame.height - 16), + ...(isFrozen + ? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 } + : isChilled + ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } + : isTarget + ? { tint: [1.25, 1.25, 1.25, 1.0] as const } + : {}), + }) + 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') { + monster.drawBounds = { + x: monster.x - MARKER_WIDTH / 2, + y: monster.y - MARKER_HEIGHT, + w: MARKER_WIDTH, + h: MARKER_HEIGHT, + } + 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 isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0 + const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0 + const tint = isFrozen + ? ([0.25, 0.6, 1.0, 1.0] as const) + : isChilled + ? ([0.4, 0.65, 1.0, 1.0] as const) + : isTarget + ? ([1.0, 0.35, 0.35, 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) { + 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 slice of lightningTrailManager.getSlices()) { + pushEntity(slice.x, slice.y, () => { + lightningTrailManager.renderSlice(renderer, slice, missileArtMap) + }, 0.2) + } + + 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') : + (mType === 'lightningbolt' || mType === 'lightning' || mType === 'chainlightning' ? (missileArtMap.get('lightningstrike') ?? missileArtMap.get('chainlightning')) : undefined))) + : undefined + if (art !== undefined) { + drawMissileProjectile(renderer, shot, art, missileArtMap) + } else if (mType === 'lightningbolt' || mType === 'chainlightning' || mType === 'lightning') { + drawMissileProjectile(renderer, shot, undefined, 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 === 'explodingarrowexp' + ? missileArtMap.get('meteorexplode') ?? missileArtMap.get('ExpArrowExplode') + : 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, + ) + } + + const activePortalMouth = portalSlot.mouthInLevel(runtime.levelId) + const activePortalPos = + activePortalMouth !== null + ? subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid) + : null + if (activePortalMouth !== null && activePortalPos !== null) { + const portalElapsedMs = Math.max(0, renderStarted - (activePortalMouth.createdAtMs ?? 0)) + pushEntity(activePortalPos.x, activePortalPos.y, () => { + drawTownPortal(renderer, activePortalPos.x, activePortalPos.y, portalElapsedMs, overlayArtMap) + }) + } + + const groundSimTime = (engine.world.tick + ((engine.world as any).alpha ?? 0)) * 40 + for (const gItem of engine.groundItems.all) { + pushEntity(gItem.x, gItem.y, () => { + drawGroundItem(renderer, gItem, groundSimTime, itemsAtlasHandle) + }) + } + + entities.sort((a, b) => a.depth - b.depth) + + for (const entity of entities) { + drawUpToDepth(entity.depth) + entity.draw() + } + drawUpToDepth(Infinity) + drawRoofs(runtime.roofs, roofBounds) + if (lightingEnabled) { + renderer.setLighting(null) + } + + const isEnRender = getTblLang() === 'ENG' + const automapEntities = [ + ...engine.npcEntities + .filter( + npc => + getTownNpcDescriptorByName(npc.def.name) !== undefined || + npc.def.name === 'Kaelan' || + npc.def.name === 'Hadriel' || + npc.def.name === 'Flavie', + ) + .map(npc => ({ + x: npc.x, + y: npc.y, + kind: 'npc' as const, + name: isEnRender ? npc.def.name : npcDisplayName(npc.def.name), + })), + ...engine.world.monsters + .filter(m => m.state !== 'dead' && m.hp > 0 && m.stats.rank === 'unique') + .map(m => ({ + x: m.x, + y: m.y, + kind: 'boss' as const, + name: monsterDisplayName(m.stats), + })), + ] + const automapPortals = + activePortalMouth !== null && activePortalPos !== null + ? [ + { + x: activePortalPos.x, + y: activePortalPos.y, + kind: 'blue' as const, + label: isEnRender + ? `Town Portal -> ${getLevelNames(activePortalMouth.toLevelId).nameEn}` + : `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`, + }, + ] + : [] + const automapStashes = (runtime.stashes ?? []).map(s => ({ + x: s.x, + y: s.y, + label: isEnRender ? s.nameEn : s.nameZh, + })) + 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 }, + { + playerWorldX: engine.world.player.x, + playerWorldY: engine.world.player.y, + leftPanelOpen: hudManager !== null && hudManager.leftPanel !== 'none', + rightPanelOpen: hudManager !== null && hudManager.rightPanel !== 'none', + entities: automapEntities, + portals: automapPortals, + stashes: automapStashes, + levelId: runtime.levelId, + }, + ) + renderer.flush() + if (hudManager !== null) { + mouseController.syncMonsterTargetHud(camX, camY) + hudManager.render(renderStarted, playerMoving) + } + + if (labelsContainer !== null) { + mouseController.updateOverheadLabels(camX, camY, activePortalMouth, activePortalPos) + } + + updateGroundItemLabelsOverlay( + groundLabelOverlay, + hudManager, + mouseController, + engine, + canvas, + camera, + camX, + camY, + ) + + state.portal = portalSlot.current() + state.minimapVisible = minimap.visible + state.automapMode = minimap.mode + state.automapDivisor = minimap.divisor + state.automapAtlasesLoaded = minimap.atlasesLoadedCount + state.automapCellCounts = minimap.lastRenderStats.cellCounts + state.automapRevealedRooms = minimap.revealedRoomsCount + state.automapSpritesDrawn = minimap.spritesDrawnLastFrame + state.automapCrosshairsDrawn = minimap.crosshairsDrawnLastFrame + state.automapWarpLabels = minimap.warpLabelsLastFrame + state.automapHudText = minimap.hudTextLastFrame + state.automapViewport = minimap.viewportLastFrame + 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 = countLoadedPages(runtime.pages) + state.skippedDraws = skippedDraws + state.renderMs = performance.now() - renderStarted + + let hudNextMs = ctx.hudNextMs + 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) + if (isEnRender) { + hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? 'pack' : 'mpq'}` + + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} cells · ${String(runtime.floors.length)} floors / ${String(runtime.shadows?.length ?? 0)} shadows / ${String(runtime.walls.length)} walls` + + ` · pages ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` + + ` · objects ${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)} quads / ${String(state.drawCalls)} draws (culled ${String(culledDraws)})` + + (skippedDraws > 0 ? ` · pending ${String(skippedDraws)}` : '') + + monsterPlanStatusText(state.monstersPlanned) + + monsterArtStatusText({ + missing: state.missingMonsterArt, + errors: state.monsterArtErrors, + layerFailures: state.monsterArtLayerFailures, + }) + } else { + 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)}` : '') + + monsterPlanStatusText(state.monstersPlanned) + + monsterArtStatusText({ + missing: state.missingMonsterArt, + errors: state.monsterArtErrors, + layerFailures: state.monsterArtLayerFailures, + }) + } + } + return { hudNextMs } +} diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index eb13682..8134500 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -33,9 +33,8 @@ import { decodePal } from '../formats/pal.ts' import { decodePl2 } from '../formats/pl2.ts' import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' -import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts' -import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts' -import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts' +import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts' +import type { CombatOptions, CombatWorld } from '../game/combat.ts' import { parseTable } from '../game/tables.ts' import { ActorAnimator } from '../game/animation.ts' import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts' @@ -47,17 +46,15 @@ import { GameLoop } from '../sim/loop.ts' import { KeyboardInput, directionOf } from '../sim/input.ts' import { NetplaySession } from '../net/netplay.ts' import { socketTransport } from '../net/transport.ts' -import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts' -import { Inventory, type Item } from '../game/items.ts' -import { QuestLog } from '../game/quests.ts' -import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts' -import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' -import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts' -import { D2Rng } from '../game/d2-rng.ts' -import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts' -import { worldToCell } from '../game/engine.ts' +import { + NET_SEED, + DEMO_MONSTERS, + DEMO_EXPERIENCE, + createNetSimulation, + type NetSources, +} from './backend/net-simulation.ts' -import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts' +export { NET_SEED, DEMO_MONSTERS, DEMO_EXPERIENCE, createNetSimulation, type NetSources } /** Collision box at the character's feet. */ const FEET_WIDTH = 20 @@ -72,8 +69,6 @@ const INPUT_DELAY_TICKS = 4 const HASH_INTERVAL = 5 /** Maximum map libraries to merge, matching the campaign scene. */ const MAX_LIBRARIES = 4 -/** World seed: fixed, because both peers must build the same world. */ -const NET_SEED = 0x0d2c0de /** * The page's state, exposed for the harness. Everything the automated checks look @@ -164,23 +159,6 @@ const state: NetState = { } if (typeof window !== 'undefined') window.__d2webNet = state -/** Raw members the scene needs. */ -interface NetSources { - readonly ds1: Uint8Array - readonly libraries: readonly Uint8Array[] - readonly palette: Uint8Array - readonly paletteName: string - readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null - readonly monstats: Uint8Array | null -} - -/** The monster table used when the archive has none, so the page is never empty. */ -const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([ - 'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP', - 'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12', - 'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20', -].join('\n'))) - let running: GameLoop | null = null let input: KeyboardInput | null = null let activeRenderer: SpriteRenderer | null = null @@ -514,9 +492,6 @@ function startScene( + '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。' } -/** Fallback experience thresholds: the co-op sandbox is not about levelling. */ -const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000] - /** * Digest the world. * @@ -622,179 +597,3 @@ if (typeof window !== 'undefined') { /** Whether the page is running inside a document (avoids running under Node). */ if (typeof document !== 'undefined') boot() -export function createNetSimulation( - world: CombatWorld, - options: CombatOptions, - terrain: CombatTerrain, - xpTable: readonly number[], - numPeers: number, - seed: number = NET_SEED, - dropTables: DropTables = getEmbeddedDropTables(), - difficulty: Difficulty = 'normal', -) { - const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4)) - const quests = new QuestLog([]) - const ground: { x: number; y: number; item: Item }[] = [] - - world.pendingKills = world.pendingKills ?? [] - for (let i = 0; i < world.monsters.length; i += 1) { - const monster = world.monsters[i]! - if (monster.dwInitSeed === undefined) { - monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1) - } - } - - const mapTerrain = 'widthPx' in terrain - ? (terrain as any) - : { ...terrain, widthPx: 10000, heightPx: 10000 } - - const simulation: LockstepSimulation = { - advance: (inputs: readonly InputFrame[]) => { - const frames: CombatInput[] = inputs.map(input => ({ - movement: input.movement, - attack: input.attack, - })) - tickCombatMulti(world, frames, options, terrain, xpTable) - - const killEvents = [ - ...(world.pendingKills ?? []), - ...world.events.filter(e => e.kind === 'kill'), - ] - if (world.pendingKills !== undefined) { - world.pendingKills.length = 0 - } - - const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1)) - - for (const event of killEvents) { - if (event.kind !== 'kill') continue - const monster = event.monsterIndex !== undefined - ? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex) - : world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10) - if (monster?.dropRolled) continue - if (monster) { - monster.dropRolled = true - } - - const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim() - const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal' - const isMinion = monsterRank === 'minion' - const monsterType = isMinion - ? 3 - : (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1)) - - const suDef = event.superUniqueId - ? (dropTables.superUniques.get(event.superUniqueId) ?? - (() => { - const target = event.superUniqueId!.trim().toLowerCase() - for (const [k, v] of dropTables.superUniques) { - if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) { - return v - } - } - return undefined - })()) - : undefined - - let tcName = '' - if (!isMinion && suDef) { - tcName = typeof suDef.getTreasureClass === 'function' - ? suDef.getTreasureClass(difficulty) - : suDef.treasureClass - } - - const monsterKind = - dropTables.monsterKinds.get(subjectId) ?? - dropTables.monsterKinds.get(`${subjectId}1`) ?? - (suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined) - - if (!tcName && monsterKind) { - tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType) - } - if (!tcName && !monsterKind && !suDef) { - tcName = 'Act 1 H2H A' - } - if (!tcName) continue - - const kindLevel = typeof (monsterKind as any)?.level === 'number' - ? (monsterKind as any).level - : (monsterKind as any)?.level?.[difficulty] - const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1) - const monsterIndex = monster?.index ?? event.monsterIndex ?? 0 - const monsterSeed = - monster?.dwInitSeed ?? - ((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1)) - - const droppedItems = executeDropPipeline(dropTables, { - tcName, - nLevel, - monsterType, - difficulty, - gamePlayers: activePlayers, - partyPlayers: activePlayers, - playerMf: 0, - playerGf: 0, - isBoss: isMinion ? false : Boolean(monsterKind?.boss), - isNoRatio: Boolean(monsterKind?.noRatio), - monsterRng: new D2Rng(monsterSeed), - }) - - const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y })) - const alivePlayers = world.players.filter(p => p.alive) - const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players - let reachableFrom: { x: number; y: number } | undefined - if (refPlayers.length > 0) { - let bestPlayer = refPlayers[0]! - let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y) - for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) { - const p = refPlayers[pIdx]! - const d = Math.hypot(p.x - event.x, p.y - event.y) - if (d < bestDist) { - bestDist = d - bestPlayer = p - } - } - reachableFrom = { x: bestPlayer.x, y: bestPlayer.y } - } - for (const drop of droppedItems) { - const dropPos = findIsometricDropPosition( - event.x, - event.y, - burstOccupied, - mapTerrain, - undefined, - undefined, - undefined, - undefined, - reachableFrom, - ) - burstOccupied.push(dropPos) - ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) - } - } - - inputs.forEach((input, index) => { - if (input.pickup && ground.length > 0) { - const player = world.players[index] - if (player) { - let bestIndex = -1 - let bestEdgeDist = 64 - for (let gIdx = 0; gIdx < ground.length; gIdx += 1) { - const g = ground[gIdx]! - const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y) - if (edgeDist <= bestEdgeDist) { - bestEdgeDist = edgeDist - bestIndex = gIdx - } - } - if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) { - ground.splice(bestIndex, 1) - } - } - } - }) - }, - hash: () => computeLockstepHash(world, inventories, ground, quests.all), - } - return { simulation, inventories, quests, ground } -}