diablo2-web/src/scene/frontend/scene-state.ts

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../game/animated-tiles.ts'
import type { SorceressAppearanceSpec } from '../../game/char-appearance.ts'
import type { Monster, MonsterStats } from '../../game/combat.ts'
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
import type { NpcEntity } from '../../game/engine.ts'
import { enrichMonsterStats } from '../../game/monsters.ts'
import type { OpenPortal } from '../../game/portal.ts'
import { getTblLang, type TblLang } from '../../i18n/lang.ts'
import type {
AutomapOverlayLabel,
AutomapViewportMetrics,
Minimap,
} from '../../ui/minimap.ts'
import type { MonsterTargetHudInfo } from '../../ui/world-panels.ts'
import type { DensityMultiplier, EliteMultiplier } from '../backend/scene-population.ts'
import type { LightingPreset } from './lighting-presets.ts'
import type { MapRuntime } from './pack-loader.ts'
import { countLoadedPages } from './pack-loader.ts'
/**
* `localStorage` key holding the single save slot.
*
* Deliberately the same key the retired `map.html` sandbox used, so a save made
* there still loads here — the snapshot format never differed, only the scene
* that drove it.
*/
export const SAVE_KEY = 'd2web.save'
/**
* How often the HUD text is rewritten, in milliseconds.
*
* The overlay reports averages, so refreshing it at frame rate only buys string
* churn: four `toFixed` calls and ten concatenations that the reader cannot
* follow anyway.
*/
export const HUD_INTERVAL_MS = 250
/** Live scene state, also published for automated checks. */
export interface ActSceneState {
ready: boolean
/** Where the map came from: a prebaked pack or the archives. */
source: 'pack' | 'live' | ''
act: number
level: string
variant: string
variants: string[]
/** @deprecated Use `variant` instead. */
quadrant: string
/** @deprecated Use `variants` instead. */
quadrants: string[]
/** The archive/pack location actually used. */
base: string
cellsX: number
cellsY: number
widthPx: number
heightPx: number
floors: number
shadows?: number
walls: number
frames: number
/** Atlas pages loaded and total. */
pagesLoaded: number
pagesTotal: number
/** Pages that had arrived when the first frame was drawn. */
pagesAtFirstFrame: number
/** Milliseconds from boot to the first drawn frame. */
firstFrameMs: number
/** Page indices the spawn viewport needed, for diagnostics. */
priorityPages: number[]
/** Draws skipped because their page had not arrived yet. */
skippedDraws: number
/** Objects the pack places (their art still needs the DCC/COF decoder). */
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
characterMembers: number
/** Frames the character path has actually drawn; 0 while the marker is in use. */
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
heroToken?: string
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
heroName?: string
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */
weaponClass?: string | null | undefined
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
playerClip?: string | null | undefined
/** Canonical appearance cache key for dynamic modular paperdoll compositing. */
characterAppearanceKey?: string | undefined
/** Active Sorceress 16-component appearance and palette shift spec. */
characterAppearance?: SorceressAppearanceSpec | undefined
/** Milliseconds from boot to the first rendered frame. */
loadMs: number
missing: number
walkable: number
x: number
y: number
facing: number
tick: number
tickRate: number
simTps: number
renderFps: number
fps: number
/**
* GPU draw calls the last frame actually issued.
*/
drawCalls: number
/** Quads the last frame submitted, i.e. what survived culling. */
quadsDrawn: number
/** Draws rejected last frame because their bounds fell outside the viewport. */
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */
lightingPreset?: LightingPreset | undefined
/** Active lighting mode ('Glide 1.13c' or undefined). */
lightingMode?: string | undefined
/** Ambient light intensity [0..255]. */
ambientIntensity?: number | undefined
/** Ambient color [R, G, B] in [0..255]. */
ambientColor?: readonly [number, number, number] | undefined
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
canvasHeight: number
error: string | null
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/** Count of animated map objects in the active map. */
animatedObjects: number
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
frameDurationMs?: number | undefined
/** Monster ids the art loader could not dress, lower case. */
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
/** Monsters the level's own plan produced. */
monstersPlanned: number
/** Active monster density multiplier (0.5, 1, 2, 4, 8). */
densityMultiplier?: DensityMultiplier | undefined
/** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */
eliteMultiplier?: EliteMultiplier | undefined
/** Dynamic monster re-budget and re-stream method without page reload. */
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
/** Active player Town Portal (if open). */
portal?: OpenPortal | null | undefined
/** Whether the automap/minimap is visible. */
minimapVisible?: boolean | undefined
/** Active automap mode ('minimap' | 'full'). */
automapMode?: 'minimap' | 'full' | undefined
/** Active automap projection divisor (20 for minimap, 10 for full). */
automapDivisor?: 10 | 20 | undefined
/** Number of loaded automap sprite atlases (0..8). */
automapAtlasesLoaded?: number | undefined
/** Pre-baked/active automap cell counts per layer. */
automapCellCounts?: {
floors: number
walls: number
objects: number
extras: number
total: number
} | undefined
/** Revealed DRLG room count for the active level. */
automapRevealedRooms?: number | undefined
/** Automap sprites drawn last frame per layer. */
automapSpritesDrawn?: {
floors: number
walls: number
objects: number
extras: number
portals: number
total: number
} | undefined
/** Automap 12-segment crosshairs drawn last frame. */
automapCrosshairsDrawn?: {
player: number
party: number
npcs: number
mercenaries: number
bosses: number
total: number
} | undefined
/** Revealed warp/stash Font6 labels on the automap. */
automapWarpLabels?: AutomapOverlayLabel[] | undefined
/** Top-right gold Font16 Level Name + Difficulty HUD readout. */
automapHudText?: {
levelName: string
difficulty: string
x: number
y1: number
y2: number
} | null | undefined
/** Active automap viewport metrics. */
automapViewport?: AutomapViewportMetrics | undefined
}
declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
__d2webActs?: ActSceneState
__d2webMinimap?: Minimap
__d2webActScene?: {
switchHero: (token: string) => Promise<boolean>
getHero: () => string
switchLighting?: (preset: string) => LightingPreset
getLighting?: () => LightingPreset
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
setPlayers?: (count: number, partyCount?: number) => void
executeCommand?: (cmd: string) => boolean
castTownPortal?: () => boolean
enterTownPortal?: () => void
getPortal?: () => OpenPortal | null
getMinimap?: () => Minimap
syncAppearance?: () => Promise<boolean>
}
}
}
export const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
heroToken: 'so', heroName: 'Sorceress',
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
densityMultiplier: 1, eliteMultiplier: 1,
}
if (typeof window !== 'undefined') {
window.__d2webAct = state
window.__d2webActs = state
;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = state
}
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
const dropTables = getEmbeddedDropTables()
const stats =
monster.stats.isDemon === undefined &&
monster.stats.isUndead === undefined &&
monster.stats.nameKey === undefined
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
: monster.stats
const r = stats.rank
const statsAny = stats as MonsterStats & {
readonly descStr?: string
readonly subType?: string
readonly affixes?: readonly string[]
}
return {
name: stats.name || stats.id,
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
hp: monster.hp,
maxHp: stats.hp,
rank:
r === 'champion'
? 'champion'
: r === 'unique'
? 'unique'
: (r as string) === 'superunique'
? 'superunique'
: r === 'minion'
? 'minion'
: r === 'boss' || stats.isBoss === true
? 'boss'
: 'normal',
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}),
...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}),
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}),
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
}
}
export interface MonsterArtDiagnosticState {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(
load: MonsterArtDiagnosticState,
lang: TblLang = getTblLang(),
): string {
const isEn = lang === 'ENG'
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(
isEn
? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})`
: `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`,
)
}
if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* The HUD fragment describing a level's planned population.
*
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string {
if (lang === 'ENG') {
return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}`
}
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
/**
* Publish a level's planned population into {@link ActSceneState}.
*/
export function reportMonsterPlan(runtime: MapRuntime): void {
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
state.monstersPlanned = planned
if (planned === 0) {
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
}
}
/**
* Publish a load's failures into {@link ActSceneState}.
*/
export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* Copy a runtime's description into the published state.
*
* @param runtime - the runtime.
* @param base - location the data came from.
*/
export function publish(runtime: MapRuntime, base: string): void {
state.source = runtime.source
state.base = base
state.act = runtime.act
state.level = runtime.level
state.variant = runtime.variant
state.variants = [...runtime.variants]
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
state.cellsY = runtime.cellsY
state.widthPx = runtime.widthPx
state.heightPx = runtime.heightPx
state.floors = runtime.floors.length
state.shadows = runtime.shadows?.length ?? 0
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = countLoadedPages(runtime.pages)
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.animatedObjects = runtime.animatedObjects?.length ?? 0
state.animSpeed = runtime.animSpeed
state.frameDurationMs = runtime.frameDurationMs
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}