408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
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
|
||
}
|