diablo2-web/src/ui/globes.ts

211 lines
6.3 KiB
TypeScript

/**
* Diablo II v1.13c Health & Mana Globes (`Panel\Hlthmana.dc6` & `Panel\overlap.dc6`).
*
* Implements bottom-up liquid clipping proportional to `hp / maxHp` and `mana / maxMana`,
* poison green globe state, demon/angel gargoyle glass relief overlays (`overlap.dc6`),
* and hover/click-to-lock numerical readout (`Life: X / Y`, `Mana: X / Y`).
*/
import type { D2FontRenderer } from './font.ts'
export interface GlobeState {
readonly hp: number
readonly maxHp: number
readonly mana: number
readonly maxMana: number
readonly poisoned?: boolean
}
export interface GlobeBounds {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
/** Canonical 800x600 Life Globe coordinates (`D2Client.dll`). */
export const LIFE_GLOBE_BOUNDS: GlobeBounds = {
x: 28,
y: 515,
width: 80,
height: 80,
}
/** Canonical 800x600 Mana Globe coordinates (`D2Client.dll`). */
export const MANA_GLOBE_BOUNDS: GlobeBounds = {
x: 692,
y: 515,
width: 80,
height: 80,
}
export class GlobesHud {
lifeTextLocked = true
manaTextLocked = true
lifeHovered = false
manaHovered = false
proceduralFallbackCount = 0
constructor() {
this.syncFallbackCountToWindow()
}
private syncFallbackCountToWindow(): void {
if (typeof window !== 'undefined' && window.__d2webHud) {
(window.__d2webHud as any).proceduralFallbackCount = this.proceduralFallbackCount
}
}
/**
* Compute vertical clip rectangle for a globe given current/max values.
*/
static computeClipRect(
bounds: GlobeBounds,
current: number,
max: number,
): { srcY: number; dstY: number; clipH: number; ratio: number } {
const safeMax = Math.max(1, max)
const ratio = Math.max(0, Math.min(1, current / safeMax))
const clipH = Math.round(bounds.height * ratio)
const srcY = bounds.height - clipH
const dstY = bounds.y + srcY
return { srcY, dstY, clipH, ratio }
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.lifeHovered = this.hitGlobe(LIFE_GLOBE_BOUNDS, logicalX, logicalY)
this.manaHovered = this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY)
}
handleClick(logicalX: number, logicalY: number): boolean {
if (this.hitGlobe(LIFE_GLOBE_BOUNDS, logicalX, logicalY)) {
this.lifeTextLocked = !this.lifeTextLocked
return true
}
if (this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY)) {
this.manaTextLocked = !this.manaTextLocked
return true
}
return false
}
private hitGlobe(b: GlobeBounds, x: number, y: number): boolean {
const cx = b.x + b.width / 2
const cy = b.y + b.height / 2
const dx = x - cx
const dy = y - cy
return dx * dx + dy * dy <= 42 * 42
}
draw(
ctx: CanvasRenderingContext2D,
state: GlobeState,
assets: {
lifeImg: HTMLImageElement | null
manaImg: HTMLImageElement | null
poisonImg: HTMLImageElement | null
overlapLeftImg: HTMLImageElement | null
overlapRightImg: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
const lifeClip = GlobesHud.computeClipRect(LIFE_GLOBE_BOUNDS, state.hp, state.maxHp)
const manaClip = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, state.mana, state.maxMana)
this.syncFallbackCountToWindow()
// Dark socket bowl under globes
this.drawSocketBowl(ctx, LIFE_GLOBE_BOUNDS)
this.drawSocketBowl(ctx, MANA_GLOBE_BOUNDS)
// 1. Draw Life Liquid (clipped bottom-up)
const activeLifeImg = state.poisoned && assets.poisonImg ? assets.poisonImg : assets.lifeImg
if (activeLifeImg && lifeClip.clipH > 0) {
ctx.drawImage(
activeLifeImg,
0,
lifeClip.srcY,
LIFE_GLOBE_BOUNDS.width,
lifeClip.clipH,
LIFE_GLOBE_BOUNDS.x,
lifeClip.dstY,
LIFE_GLOBE_BOUNDS.width,
lifeClip.clipH,
)
} else if (lifeClip.clipH > 0) {
this.proceduralFallbackCount += 1
this.syncFallbackCountToWindow()
console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback')
this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414')
}
// 2. Draw Mana Liquid (clipped bottom-up)
if (assets.manaImg && manaClip.clipH > 0) {
ctx.drawImage(
assets.manaImg,
0,
manaClip.srcY,
MANA_GLOBE_BOUNDS.width,
manaClip.clipH,
MANA_GLOBE_BOUNDS.x,
manaClip.dstY,
MANA_GLOBE_BOUNDS.width,
manaClip.clipH,
)
} else if (manaClip.clipH > 0) {
this.proceduralFallbackCount += 1
this.syncFallbackCountToWindow()
console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback')
this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8')
}
// 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`)
if (assets.overlapLeftImg) {
ctx.drawImage(assets.overlapLeftImg, 27, 508)
}
if (assets.overlapRightImg) {
ctx.drawImage(assets.overlapRightImg, 691, 508)
}
// 4. Numerical readouts above globes (`Life: X / Y`, `Mana: X / Y`)
if (this.lifeTextLocked || this.lifeHovered) {
font.drawText(
ctx,
`生命: ${Math.max(0, Math.round(state.hp))} / ${Math.round(state.maxHp)}`,
LIFE_GLOBE_BOUNDS.x + 40,
504,
{ font: 'fontexocet10', color: 'white', align: 'center' },
)
}
if (this.manaTextLocked || this.manaHovered) {
font.drawText(
ctx,
`法力: ${Math.max(0, Math.round(state.mana))} / ${Math.round(state.maxMana)}`,
MANA_GLOBE_BOUNDS.x + 40,
504,
{ font: 'fontexocet10', color: 'white', align: 'center' },
)
}
}
private drawSocketBowl(ctx: CanvasRenderingContext2D, b: GlobeBounds): void {
ctx.save()
ctx.beginPath()
ctx.arc(b.x + b.width / 2, b.y + b.height / 2, b.width / 2 - 2, 0, Math.PI * 2)
ctx.fillStyle = '#080605'
ctx.fill()
ctx.restore()
}
private drawProceduralLiquid(ctx: CanvasRenderingContext2D, b: GlobeBounds, ratio: number, color: string): void {
ctx.save()
ctx.beginPath()
ctx.arc(b.x + b.width / 2, b.y + b.height / 2, b.width / 2 - 2, 0, Math.PI * 2)
ctx.clip()
const fillH = Math.round(b.height * ratio)
ctx.fillStyle = color
ctx.fillRect(b.x, b.y + b.height - fillH, b.width, fillH)
ctx.restore()
}
}