diablo2-web/src/client/scene/toolbar-chat-ui.ts

949 lines
34 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 type { Palette } from '../../common/formats/pal.ts'
import { WORLD_VARIANT_SEED } from '../../common/world/act-variants.ts'
import type { ActorAnimator } from '../render/actor-animator.ts'
import {
SorceressLayerCompositor,
appearanceKey,
resolveSorceressAppearance,
} from '../../common/units/char-appearance.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts'
import { spawnMonsterPacks } from '../../common/units/monster-rooms.ts'
import type { MonsterPack, SafeZone, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
import { cellCentre } from '../../common/world/d2map.ts'
import type { DropTables } from '../../common/items/drop-tables.ts'
import {
ACT_NAMES_EN,
ACT_NAMES_ZH,
getLevelNames,
sceneDisplayName,
variantDisplayLabel,
} from '../../common/world/level-names-zh.ts'
import type { LoadedMonsterArt } from '../render/monster-art.ts'
import { MonsterStreamingManager } from '../../common/units/monster-streaming.ts'
import {
buildLangSwitchHref,
getTblLang,
type TblLang,
} from '../i18n/lang.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../render/renderer.ts'
import { cleanChiItemName } from '../ui/ground-labels.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { Minimap } from '../ui/minimap.ts'
import { handleActSceneChatCommand } from '../../common/world/chat-commands.ts'
import { subTileToScene } from '../../common/world/scene-links-nav.ts'
import {
HERO_CLASSES,
ROOM_ACTIVATION_RADIUS,
ROOM_DEACTIVATION_RADIUS,
buildStreamingRooms,
isTownLevel,
normalizeHeroToken,
parseDensityMultiplier,
parseEliteMultiplier,
rebudgetMonsterPacks,
type DensityMultiplier,
type EliteMultiplier,
} from '../../common/world/scene-population.ts'
import {
DEFAULT_BASES,
loadCharacterArt,
loadMonsterArtMap,
type LoadedCharacter,
} from './entity-art-loader.ts'
import {
LIGHTING_PRESETS,
normalizeLightingPreset,
type LightingPreset,
} from './lighting-presets.ts'
import {
ACT_COUNT,
fetchJson,
param,
type MapRuntime,
type PackIndex,
} from './pack-loader.ts'
import {
recordMonsterArtLoad,
reportMonsterPlan,
state,
} from './scene-state.ts'
export { handleActSceneChatCommand }
export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void {
if (!select) return
const isNonDefault = select.value !== defaultValue
select.classList.toggle('non-default', isNonDefault)
if (isNonDefault) {
select.style.border = '1px solid #e74c3c'
} else {
select.style.border = ''
}
}
export function updateToolbarUrl(
currentUrlOrSearch: string | URL,
options: { density?: number | string | undefined; elite?: number | string | undefined },
): string {
const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://')
const url = isUrl
? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href)
: new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`)
if (options.density !== undefined) {
const density = parseDensityMultiplier(options.density)
if (density === 1) {
url.searchParams.delete('density')
} else {
url.searchParams.set('density', String(density))
}
}
if (options.elite !== undefined) {
const elite = parseEliteMultiplier(options.elite)
if (elite === 1) {
url.searchParams.delete('elite')
} else {
url.searchParams.set('elite', String(elite))
}
}
if (isUrl) {
return url.toString()
}
return url.search
}
export interface MountChatAndToolbarUiOptions {
readonly engine: GameEngine
readonly status: HTMLElement
readonly executeChatCommand: (rawCommand: string) => boolean
readonly densitySelect: HTMLSelectElement | null
readonly eliteSelect: HTMLSelectElement | null
readonly densityMultiplier: number
readonly eliteMultiplier: number | 'all'
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
}
/**
* Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire
* toolbar density/elite selectors.
*/
export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void {
const {
executeChatCommand,
densitySelect,
eliteSelect,
densityMultiplier,
eliteMultiplier,
reloadMonsters,
} = options
// In-game chat/console input overlay supporting "/players <1-8>"
if (typeof document !== 'undefined') {
let chatInput = document.querySelector<HTMLInputElement>('#d2-chat-input')
if (!chatInput) {
chatInput = document.createElement('input')
chatInput.id = 'd2-chat-input'
chatInput.type = 'text'
chatInput.placeholder = getTblLang() === 'ENG'
? 'Type /players 1-8 or message, press Enter to send…'
: '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.className = 'overlay'
chatInput.style.position = 'fixed'
chatInput.style.left = '12px'
chatInput.style.bottom = '56px'
chatInput.style.width = '320px'
chatInput.style.padding = '4px 8px'
chatInput.style.background = 'rgba(11, 9, 6, 0.9)'
chatInput.style.color = '#e8dcc8'
chatInput.style.border = '1px solid #c8a15a'
chatInput.style.borderRadius = '4px'
chatInput.style.font = 'inherit'
chatInput.style.fontSize = '12px'
chatInput.style.zIndex = '10'
chatInput.style.display = 'none'
document.body.appendChild(chatInput)
}
const openChat = () => {
if (!chatInput) return
chatInput.placeholder = getTblLang() === 'ENG'
? 'Type /players 1-8 or message, press Enter to send…'
: '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.style.display = 'block'
chatInput.value = ''
chatInput.focus()
}
const closeChat = () => {
if (!chatInput) return
chatInput.value = ''
chatInput.blur()
chatInput.style.display = 'none'
}
chatInput.addEventListener('keydown', (e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
const text = chatInput!.value
closeChat()
if (text) {
executeChatCommand(text)
}
} else if (e.key === 'Escape') {
e.preventDefault()
closeChat()
}
})
window.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const active = document.activeElement
if (
active instanceof HTMLInputElement ||
active instanceof HTMLSelectElement ||
active instanceof HTMLTextAreaElement
) {
return
}
e.preventDefault()
openChat()
}
})
}
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(densitySelect, '1')
densitySelect.onchange = () => {
void reloadMonsters({ density: densitySelect.value })
}
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(eliteSelect, '1')
eliteSelect.onchange = () => {
void reloadMonsters({ elite: eliteSelect.value })
}
}
}
export interface HeroLightingAndMonsterControlsContext {
readonly renderer: SpriteRenderer
readonly dropTables: DropTables
readonly playerAnimator: ActorAnimator
readonly status: HTMLElement
readonly heroSelect: HTMLSelectElement | null
readonly lightingSelect: HTMLSelectElement | null
readonly densitySelect: HTMLSelectElement | null
readonly eliteSelect: HTMLSelectElement | null
readonly getRuntime: () => MapRuntime
readonly getEngine: () => GameEngine
readonly getHudManager: () => HudManager | null
readonly getCurrentHeroToken: () => string
readonly setCurrentHeroToken: (token: string) => void
readonly getCharacter: () => LoadedCharacter | null
readonly setCharacter: (char: LoadedCharacter | null) => void
readonly getActivePalette: () => Palette | null
readonly getPackEntityBase: () => string | undefined
readonly getPackEntityAct: () => number | undefined
readonly getCurrentLightingPreset: () => LightingPreset
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
readonly getMonsterArtMap?: (() => Map<string, LoadedMonsterArt>) | undefined
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
}
export interface HeroLightingAndMonsterControls {
readonly heroGen: LoadGeneration
readonly monsterGen: LoadGeneration
readonly lightingGen: LoadGeneration
readonly invalidateMonsterLoads: () => void
readonly syncSorceressAppearance: () => Promise<boolean>
readonly switchLighting: (preset: string) => LightingPreset
readonly switchHero: (newHeroToken: string) => Promise<boolean>
readonly changeHeroClass: (newHeroToken: string) => Promise<boolean>
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
}
export function createHeroLightingAndMonsterControls(
ctx: HeroLightingAndMonsterControlsContext,
): HeroLightingAndMonsterControls {
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
const heroGen = new LoadGeneration()
const monsterGen = new LoadGeneration()
const lightingGen = new LoadGeneration()
let activeAppearanceKey = ''
let appearanceSeq = 0
const invalidateMonsterLoads = (): void => {
monsterGen.invalidate()
}
const syncSorceressAppearance = async (): Promise<boolean> => {
const hudInst = ctx.getHudManager() ?? (
typeof window !== 'undefined'
? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null
: null
)
const currentHeroToken = ctx.getCurrentHeroToken()
if (currentHeroToken !== 'so' || hudInst === null) {
activeAppearanceKey = ''
state.characterAppearanceKey = undefined
state.characterAppearance = undefined
if (hudInst !== null) {
const wCode = hudInst.inventory.equipped.weapon1?.code
state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth')
}
return false
}
const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables)
const key = appearanceKey(spec)
state.weaponClass = spec.wclass
state.characterAppearance = spec
if (key === activeAppearanceKey) return false
activeAppearanceKey = key
state.characterAppearanceKey = key
const seq = ++appearanceSeq
try {
const composited = await soCompositor.composite(spec)
if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false
const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height)
const groups: AtlasFrame[][] = composited.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x,
y,
width,
height,
anchorX,
anchorY,
})),
)
const prevChar = ctx.getCharacter()
if (prevChar?.handle && prevChar.handle !== newHandle) ctx.renderer.deleteAtlas(prevChar.handle)
ctx.setCharacter({
handle: newHandle,
groups,
directions: composited.directions,
walk: composited.walk,
stand: composited.stand,
members: composited.members,
clips: composited.clips,
})
state.character = true
state.characterMembers = composited.members
state.characterAppearanceKey = key
state.characterAppearance = spec
if (ctx.playerAnimator.mode !== null) {
const nextClip = composited.clips[ctx.playerAnimator.mode]
if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip)
}
return true
} catch (err) {
console.warn('Sorceress appearance composite failed:', err)
return false
}
}
const switchLighting = (preset: string): LightingPreset => {
const { gen } = lightingGen.next()
const nextPreset = normalizeLightingPreset(preset)
const cfg = LIGHTING_PRESETS[nextPreset]
ctx.setLightingState(nextPreset, cfg.enabled)
const isEn = getTblLang() === 'ENG'
const modeLabel = isEn ? cfg.labelEn : cfg.labelZh
state.lightingPreset = nextPreset
state.lightingMode = modeLabel
if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) {
ctx.lightingSelect.value = nextPreset
}
try {
const url = new URL(location.href)
if (nextPreset === 'auto') url.searchParams.delete('lighting')
else url.searchParams.set('lighting', nextPreset)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
ctx.status.textContent = `${msgPrefix}${modeLabel}`
setTimeout(() => {
if (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) {
ctx.status.textContent = ''
}
}, 2500)
return nextPreset
}
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const { gen } = heroGen.next()
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
const isEn = getTblLang() === 'ENG'
const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh
ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …`
const runtime = ctx.getRuntime()
const nextChar = await loadCharacterArt(
runtime.charBases,
ctx.getActivePalette(),
ctx.renderer,
ctx.getPackEntityBase(),
ctx.getPackEntityAct(),
nextToken,
)
if (!heroGen.isCurrent(gen)) {
if (nextChar !== null && nextChar.handle && nextChar.handle !== ctx.getCharacter()?.handle) {
ctx.renderer.deleteAtlas(nextChar.handle)
}
return false
}
if (nextChar !== null) {
const prevChar = ctx.getCharacter()
if (prevChar?.handle && prevChar.handle !== nextChar.handle) {
ctx.renderer.deleteAtlas(prevChar.handle)
}
ctx.setCharacter(nextChar)
ctx.setCurrentHeroToken(nextToken)
state.heroToken = nextToken
state.heroName = nextInfo.nameEn
state.character = true
state.characterMembers = nextChar.members
const hudManager = ctx.getHudManager()
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
activeAppearanceKey = ''
if (nextToken === 'so') {
await syncSorceressAppearance()
if (!heroGen.isCurrent(gen)) return false
} else {
state.characterAppearanceKey = undefined
state.characterAppearance = undefined
}
if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.heroSelect.value = nextToken
try {
const url = new URL(location.href)
url.searchParams.set('hero', nextToken)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
const curChar = ctx.getCharacter()
if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true })
const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
ctx.status.textContent = isEn
? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).`
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
setTimeout(() => {
if (heroGen.isCurrent(gen) && ctx.status.textContent.startsWith(switchedPrefix)) {
ctx.status.textContent = ''
}
}, 3000)
return true
}
ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。`
return false
}
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
const { gen } = monsterGen.next()
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
ctx.setMultipliers(densityMultiplier, eliteMultiplier)
state.densityMultiplier = densityMultiplier
state.eliteMultiplier = eliteMultiplier
if (ctx.densitySelect !== null) {
ctx.densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(ctx.densitySelect, '1')
}
if (ctx.eliteSelect !== null) {
ctx.eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(ctx.eliteSelect, '1')
}
try {
if (typeof location !== 'undefined' && typeof history !== 'undefined') {
const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier })
const newUrl = `${location.pathname}${nextSearch}${location.hash}`
history.replaceState(null, '', newUrl)
}
} catch {
// ignore in non-browser context
}
const runtime = ctx.getRuntime()
const engine = ctx.getEngine()
if (isTownLevel(runtime.levelId)) {
runtime.monsterPacks = []
engine.world.monsters.length = 0
engine.streamingManager = undefined
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
ctx.setMonsterArtMap(new Map())
reportMonsterPlan(runtime)
return
}
const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks
runtime.baseMonsterPacks = basePacks
runtime.monsterPacks = rebudgetMonsterPacks(basePacks, {
densityMultiplier,
eliteMultiplier,
levelId: runtime.levelId,
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
})
engine.world.monsters.length = 0
const player = engine.world.player
const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }]
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of runtime.entrances) {
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const s of runtime.stashes ?? []) {
currentSafeZones.push({ x: s.x, y: s.y, radius: 160 })
}
const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
return pack
})
const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: currentSafeZones,
},
)
} else {
engine.streamingManager = undefined
if (currentResolvedPacks.length > 0) {
spawnMonsterPacks(
engine.world,
currentResolvedPacks,
{ x: player.x, y: player.y },
Math.max(runtime.widthPx, runtime.heightPx) / 4,
engine.terrain,
currentSafeZones,
)
}
}
reportMonsterPlan(runtime)
if (runtime.palette !== null) {
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
runtime.palette,
ctx.renderer,
ctx.getPackEntityBase(),
ctx.getPackEntityAct(),
)
if (!monsterGen.isCurrent(gen) || runtime.disposed || ctx.getRuntime() !== runtime) {
for (const art of loaded.map.values()) {
ctx.renderer.deleteAtlas(art.handle)
}
return
}
const keepHandles = new Set<AtlasHandle>()
for (const art of loaded.map.values()) {
keepHandles.add(art.handle)
}
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
if (!keepHandles.has(oldArt.handle)) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
}
ctx.setMonsterArtMap(loaded.map)
recordMonsterArtLoad(loaded)
} else {
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
ctx.setMonsterArtMap(new Map())
}
}
}
return {
heroGen,
monsterGen,
lightingGen,
invalidateMonsterLoads,
syncSorceressAppearance,
switchLighting,
switchHero,
changeHeroClass: switchHero,
reloadMonsters,
respawnMonsters: reloadMonsters,
}
}
export async function changeHeroClass(
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
newHeroToken: string,
): Promise<boolean> {
if ('switchHero' in controlsOrCtx) {
return await controlsOrCtx.switchHero(newHeroToken)
}
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
return await controls.switchHero(newHeroToken)
}
export async function respawnMonsters(
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
options?: { density?: number | string; elite?: number | string },
): Promise<void> {
if ('reloadMonsters' in controlsOrCtx) {
await controlsOrCtx.reloadMonsters(options)
return
}
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
await controls.reloadMonsters(options)
}
export async function createActSceneHudManager(options: {
readonly runtime: MapRuntime
readonly engine: GameEngine
readonly minimap: Minimap
readonly status: HTMLElement
readonly currentHeroToken: string
readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void
readonly onCastTownPortal: () => void
}): Promise<HudManager | null> {
const {
runtime,
engine,
minimap,
status,
currentHeroToken,
onWaypointTeleport,
onCastTownPortal,
} = options
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
const hudManager = hudCanvas !== null
? new HudManager(hudCanvas, {
onToggleAutomap: () => {
minimap.toggleVisibility()
state.minimapVisible = minimap.visible
},
onToggleAutomapMode: () => {
minimap.toggleMinimapMode()
state.minimapVisible = minimap.visible
state.automapMode = minimap.mode
state.automapDivisor = minimap.divisor
},
onCycleAutomapFade: () => { minimap.cycleFade() },
onToggleAutomapNames: () => { minimap.toggleNames() },
onResetAutomapPan: () => { minimap.resetPan() },
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
},
onWaypointTeleport: (targetAct, slug, levelId) => {
onWaypointTeleport(targetAct, slug, levelId)
},
onCastTownPortal: () => onCastTownPortal(),
onDropItemToGround: (item) => {
const player = engine.world.player
const groundItem = engine.dropItem(item, player.x, player.y)
const matchingGround = engine.ground.find((g: { item?: unknown }) => (g.item as unknown) === item)
if (matchingGround) {
;(matchingGround as { groundItemId?: string }).groundItemId = groundItem.id
}
const isEn = getTblLang() === 'ENG'
status.textContent = isEn
? `Dropped "${groundItem.name}" on ground`
: `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面`
},
onDropGoldToGround: (amount) => {
const player = engine.world.player
let groundGold: unknown = true
if (!hudManager?.engine) {
if (hudManager && engine.bag) {
engine.bag.gold = hudManager.inventory.gold
}
groundGold = engine.dropGold(amount, player.x, player.y)
}
if (groundGold) {
const isEn = getTblLang() === 'ENG'
status.textContent = isEn
? `Dropped ${amount.toLocaleString()} Gold on ground`
: `已将 ${amount.toLocaleString()} 金币丢弃在地面`
}
},
})
: null
if (hudManager !== null) {
hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null)
hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null)
hudManager.worldPanels.selectedActTab = runtime.act
try {
await hudManager.init('/ui')
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
const customCharName = param('charName', '').trim()
if (customCharName) hudManager.charSheet.attrs.name = customCharName
} catch (err) {
console.error('Failed to initialize HudManager:', err)
}
if (typeof window !== 'undefined' && window.__d2webHud) {
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
}
}
return hudManager
}
export function wireBootToolbarSelectors(options: {
readonly isEn: boolean
readonly heroSelect: HTMLSelectElement | null
readonly actSelect: HTMLSelectElement
readonly sceneSelect: HTMLSelectElement
readonly variantSelect: HTMLSelectElement
readonly lightingSelect: HTMLSelectElement | null
readonly langSwitch: HTMLAnchorElement | null
}): number {
const {
isEn,
heroSelect,
actSelect,
sceneSelect,
variantSelect,
lightingSelect,
langSwitch,
} = options
if (langSwitch !== null && typeof location !== 'undefined') {
const targetLang: TblLang = isEn ? 'CHI' : 'ENG'
langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN'
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
langSwitch.addEventListener('click', () => {
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
})
}
if (lightingSelect !== null) {
lightingSelect.disabled = false
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
lightingSelect.onchange = () => {
const params = new URLSearchParams(location.search)
if (lightingSelect.value === 'auto') params.delete('lighting')
else params.set('lighting', lightingSelect.value)
location.search = params.toString()
}
}
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
heroSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('hero', heroSelect.value)
location.search = params.toString()
}
}
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(parseDensityMultiplier(param('density', '1')))
updateSelectorWarningStyle(densitySelect, '1')
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(parseEliteMultiplier(param('elite', '1')))
updateSelectorWarningStyle(eliteSelect, '1')
}
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
if (typeof Image !== 'undefined') {
const criticalImages = [
'/ui/ctrlpnl-800.png',
'/ui/globe-life.png',
'/ui/globe-mana.png',
'/ui/globe-poison.png',
'/ui/overlap-left.png',
'/ui/overlap-right.png',
'/ui/minipanel.png',
'/ui/minipanel-btns.png',
'/ui/runbutton.png',
'/ui/menubutton.png',
'/ui/border-left.png',
'/ui/border-right.png',
]
for (const src of criticalImages) {
const img = new Image()
img.src = src
}
}
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
state.act = act
actSelect.value = String(act)
actSelect.addEventListener('change', () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
location.search = params.toString()
})
return act
}
/**
* Fill the level picker from a pack index and wire it to navigation.
*
* Every baked map is one entry, grouped by act. Picking one reloads the page with
* `?act=` and `?level=`, which is the same query the loader already understands,
* so the picker needs no second loading path.
*
* @param packBase - directory holding `index.json`.
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
* so the list marks exactly the map that is on screen.
*/
export async function populatePackSelectors(
packBase: string,
current: { act: number; label: string },
): Promise<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
}
const isEn = getTblLang() === 'ENG'
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
readonly name: string
readonly variants: readonly PackIndex['levels'][number][]
}
const scenesByAct = new Map<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: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'),
variants: [],
}
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
}
/** The suffix after the level slug, which is the DS1's own name fragment. */
const suffixOf = (entry: PackIndex['levels'][number]): string =>
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
const option = document.createElement('option')
option.value = value
option.textContent = text
option.selected = selected
return option
}
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
isEn
? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}`
: `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
const scenes = scenesByAct.get(current.act) ?? []
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
scene.slug,
scene.name,
scene.slug === currentSceneSlug,
)))
sceneSelect.disabled = scenes.length === 0
const populateVariants = (slug: string, selectedLabel: string): void => {
const scene = scenes.find(candidate => candidate.slug === slug)
const variants = scene?.variants ?? []
// A single-variant scene still shows its own DS1 so the third selector is never
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
const label = variants.length > 1
? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI')
: isEn
? `${scene?.name ?? entry.levelName} (${suffix})`
: `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
}
populateVariants(currentSceneSlug, current.label)
variantSelect.dataset.scene = currentSceneSlug
const go = (params: URLSearchParams): void => { location.search = params.toString() }
const navigateToLabel = (label: string, act: number): void => {
const params = new URLSearchParams(location.search)
params.set('act', String(act))
params.set('level', label)
go(params)
}
actSelect.onchange = () => {
const act = Number(actSelect.value)
const first = scenesByAct.get(act)?.[0]
// Keep the address bar and the map in sync: an act with no baked scene yet (there
// are none today) simply leaves the current map alone.
if (first === undefined) return
navigateToLabel(first.variants[0]?.label ?? '', act)
}
sceneSelect.onchange = () => {
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
if (scene === undefined) return
populateVariants(scene.slug, '')
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
}
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
}