294 lines
11 KiB
TypeScript
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,
|
|
)
|
|
})
|
|
}
|
|
}
|