949 lines
34 KiB
TypeScript
949 lines
34 KiB
TypeScript
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) }
|
||
}
|