211 lines
6.3 KiB
TypeScript
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()
|
|
}
|
|
}
|