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