diablo2-web/src/client/scene/arena-overlay.ts

294 lines
11 KiB
TypeScript

import { getSharedDataRegistry } from '../../common/data/data-registry.ts'
import { MpqArchive } from '../../common/mpq/archive.ts'
import { MountedArchives } from '../../common/mpq/mount.ts'
import { memorySource } from '../../common/mpq/source.ts'
export type WorldArenaType = any
export type WorldArenaFactory = (opts: Record<string, unknown>) => WorldArenaType
let registeredWorldArenaFactory: WorldArenaFactory | null = null
export function registerWorldArenaFactory(factory: WorldArenaFactory | null): void {
registeredWorldArenaFactory = factory
}
export async function initBrowserArenaMode(factory?: WorldArenaFactory): Promise<void> {
const createArena = factory ?? registeredWorldArenaFactory
if (!createArena) {
throw new Error(
'[Arena] No WorldArenaFactory registered; registerWorldArenaFactory must be called before initializing arena mode',
)
}
if (typeof window === 'undefined' || typeof document === 'undefined') return
const params = new URLSearchParams(location.search)
if (params.get('arena') !== '1') return
try {
const classParam = params.get('class') || params.get('hero') || 'sor'
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
const autocast = params.get('autocast') !== '0'
const [patchRes, animRes, hitClassRes] = await Promise.all([
fetch('/samples/d2/Patch_D2.mpq'),
fetch('/samples/d2-packs/anim/AnimData.d2'),
fetch('/samples/d2-packs/anim/HitClass.txt'),
])
if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) {
throw new Error(
`Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`,
)
}
const patchBytes = new Uint8Array(await patchRes.arrayBuffer())
const animBytes = new Uint8Array(await animRes.arrayBuffer())
const hitClassBytes = new Uint8Array(await hitClassRes.arrayBuffer())
const patchMpq = MpqArchive.openSync(memorySource(patchBytes, 'Patch_D2.mpq'))
const mounted = new MountedArchives()
mounted.add('Patch_D2.mpq', patchMpq)
const browserSource = {
readBytes(path: string): Uint8Array | null {
const lower = path.trim().replace(/\//g, '\\').toLowerCase()
if (lower.endsWith('animdata.d2')) return animBytes
if (lower.endsWith('hitclass.txt')) return hitClassBytes
return mounted.readFile(path)
},
}
const registry = getSharedDataRegistry(browserSource)
let arena: WorldArenaType = createArena({
registry,
classCode: classParam,
skillId,
slvl,
})
if (autocast) {
arena.triggerCast()
arena.stepTicks(15)
}
const syncWindowArena = () => {
const snap = arena.getDebugState() as ReturnType<typeof arena.getDebugState> & {
switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown
}
const origTrigger = snap.triggerCast
const origStep = snap.stepTicks
snap.triggerCast = (tx?: number, ty?: number) => {
const out = origTrigger(tx, ty)
syncWindowArena()
renderArenaVisualOverlay(arena)
return out
}
snap.stepTicks = (cnt: number) => {
origStep(cnt)
syncWindowArena()
renderArenaVisualOverlay(arena)
}
snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => {
arena = createArena({
registry,
classCode: nextClassCode,
skillId: nextSkillId,
slvl: nextSlvl,
})
arena.triggerCast()
arena.stepTicks(15)
syncWindowArena()
renderArenaVisualOverlay(arena)
return arena.getDebugState()
}
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
}
const renderArenaVisualOverlay = (wArena: WorldArenaType) => {
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
if (!overlayCanvas) {
overlayCanvas = document.createElement('canvas')
overlayCanvas.id = 'arena-skill-overlay'
overlayCanvas.width = window.innerWidth || 960
overlayCanvas.height = window.innerHeight || 600
overlayCanvas.style.cssText =
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
document.body.appendChild(overlayCanvas)
}
const ctx = overlayCanvas.getContext('2d')
if (!ctx) return
const w = overlayCanvas.width
const h = overlayCanvas.height
ctx.clearRect(0, 0, w, h)
const dbg = wArena.getDebugState()
const cx = w * 0.5
const cy = h * 0.52
// 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero
const classHue = ((dbg.skillId * 37) % 360)
ctx.save()
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)`
ctx.lineWidth = 3.5
ctx.beginPath()
ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2)
ctx.stroke()
// Hero class figure + active weapon/spell glow
ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)`
ctx.strokeStyle = '#fff3d1'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#ffe9a8'
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35)
ctx.restore()
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
}
for (const tgt of dbg.targetLog) {
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
const tx = cx + off.dx
const ty = cy + off.dy
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
// Dummy shadow + body marker
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.beginPath()
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = off.color
ctx.strokeStyle = '#f5e6c8'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
// HP bar
ctx.fillStyle = '#1f1914'
ctx.fillRect(tx - 44, ty - 38, 88, 9)
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
ctx.font = 'bold 11px monospace'
ctx.textAlign = 'center'
ctx.fillStyle = '#f2e6d0'
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
if (tgt.lastDamage > 0) {
ctx.fillStyle = '#ffdf59'
ctx.font = 'bold 14px monospace'
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
}
if (tgt.states.length > 0) {
ctx.fillStyle = '#7be3ff'
ctx.font = '10px monospace'
ctx.fillText(tgt.states.join(','), tx, ty + 32)
}
}
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
ctx.save()
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
ctx.strokeStyle = grad
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(cx, cy - 8)
ctx.lineTo(cx, cy - 108)
ctx.stroke()
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
ctx.beginPath()
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
// 4. Draw Active Summoned Pets / Traps
dbg.petsActive.forEach((pet: { monId: string; hp: number }, idx: number) => {
const px = cx - 95 + idx * 48
const py = cy + 44
ctx.fillStyle = '#5ce1e6'
ctx.strokeStyle = '#112a36'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(px, py, 12, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#d7f9ff'
ctx.font = 'bold 10px monospace'
ctx.textAlign = 'center'
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
})
// 5. Top-left 1.13c Skill Verification Telemetry Panel
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
ctx.strokeStyle = '#c8a15a'
ctx.lineWidth = 1.5
ctx.fillRect(14, 62, 470, 122)
ctx.strokeRect(14, 62, 470, 122)
ctx.textAlign = 'left'
ctx.fillStyle = '#f0c76e'
ctx.font = 'bold 13px monospace'
ctx.fillText(
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
24,
82,
)
ctx.fillStyle = '#e8dcc8'
ctx.font = '12px monospace'
ctx.fillText(
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
24,
102,
)
ctx.fillText(
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
24,
122,
)
ctx.fillText(
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
24,
142,
)
ctx.fillStyle = '#8ee38e'
ctx.fillText(
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
24,
162,
)
}
syncWindowArena()
renderArenaVisualOverlay(arena)
} catch (err) {
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
(err as Error)?.stack || err,
)
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
;(window as unknown as { __initBrowserArenaMode?: (factory?: WorldArenaFactory) => Promise<void> }).__initBrowserArenaMode =
initBrowserArenaMode
if (typeof location !== 'undefined' && new URLSearchParams(location.search).get('arena') === '1') {
initBrowserArenaMode().catch((err) => {
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
(err as Error)?.stack || err,
)
})
}
}