diablo2-web/src/client/scene/missile-overlay-renderer.ts

519 lines
19 KiB
TypeScript

import type { ActiveExplosion } from '../../common/units/unit-types.ts'
import {
getMissileTxtData,
type ActiveOverlay,
type Projectile,
} from '../../common/skills/missiles-data.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import {
LightningTrailManager,
} from '../render/missile-renderer.ts'
import {
MISSILE_METAS,
velocityToDccDirection,
type MissileFrameTuple,
type MissileMeta,
} from '../render/missiles-meta.ts'
import {
OVERLAY_METAS,
type OverlayMeta,
} from '../render/overlays-meta.ts'
import type {
AtlasHandle,
SpriteRenderer,
} from '../render/renderer.ts'
import { reportMissingArt } from './world-renderer.ts'
export {
LightningTrailManager,
MISSILE_METAS,
OVERLAY_METAS,
getMissileTxtData,
velocityToDccDirection,
type MissileFrameTuple,
type MissileMeta,
type OverlayMeta,
}
/**
* Packed missile animation asset loaded into GPU memory.
*/
export interface LoadedMissileArt {
readonly meta: MissileMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase.
*/
export async function loadMissileArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedMissileArt>> {
const map = new Map<string, LoadedMissileArt>()
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
const targets = Array.from(new Set(Object.values(MISSILE_METAS).map(m => m.name)))
const baseCandidates = [
'/missiles',
packBase ? `${packBase}/missiles` : '',
'samples/d2-packs/missiles',
].filter(Boolean)
const failures: string[] = []
await Promise.all(
targets.map(async name => {
let loaded = false
let lastReason = ''
const metaHint = MISSILE_METAS[name]
const candidateStems = Array.from(
new Set([name, metaHint?.celFile?.toLowerCase()].filter((s): s is string => Boolean(s))),
)
for (const base of baseCandidates) {
for (const stem of candidateStems) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${stem}.json`),
fetch(`${base}/${stem}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const rawMeta = (await jsonResp.json()) as MissileMeta
const meta: MissileMeta = metaHint && stem !== name ? { ...rawMeta, name } : rawMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const art: LoadedMissileArt = { meta, handle, frames }
map.set(name, art)
if (meta.celFile) {
map.set(meta.celFile, art)
map.set(meta.celFile.toLowerCase(), art)
}
loaded = true
break
}
lastReason = `${base}/${stem}.json (HTTP ${jsonResp.status}), ${base}/${stem}.png (HTTP ${pngResp.status})`
} catch (err) {
lastReason = `${base}/${stem}: ${err instanceof Error ? err.message : String(err)}`
}
}
if (loaded) break
}
if (!loaded) {
failures.push(`${name} [${lastReason || `tried: ${baseCandidates.join(', ')}`}]`)
}
}),
)
if (failures.length > 0) {
const msg = `[loadMissileArtMap] Failed to load ${failures.length} missile atlas(es): ${failures.slice(0, 5).join('; ')}`
console.error(msg)
throw new Error(msg)
}
// Map all alias keys in MISSILE_METAS to the loaded art handle to prevent duplicate atlas uploads
for (const [alias, meta] of Object.entries(MISSILE_METAS)) {
const art = map.get(meta.name) ?? (meta.celFile ? map.get(meta.celFile.toLowerCase()) : undefined)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
return map
}
/**
* Render a Diablo II 1.13c missile projectile using authentic baked DCC sprite atlas.
*/
export function drawMissileProjectile(
renderer: SpriteRenderer,
shot: Projectile,
missileArt?: LoadedMissileArt | null,
missileArtMap?: Map<string, LoadedMissileArt> | null,
): void {
if (shot.missileType === 'blizzardcenter') {
return // Invisible controller missile
}
const resolvedArt =
missileArt ??
(shot.isTurret || shot.missileType === 'hydra_turret'
? (missileArtMap?.get(shot.missileType ?? 'hydra_turret') ?? missileArtMap?.get('hydra'))
: shot.missileType !== undefined
? missileArtMap?.get(shot.missileType)
: undefined)
if (resolvedArt === undefined || resolvedArt === null) {
return
}
const altitude = shot.altitude ?? 0
const dccDir = velocityToDccDirection(shot.vx, shot.vy, resolvedArt.meta.directions)
const frameCount = Math.max(1, resolvedArt.meta.framesPerDirection)
let frameIndex: number
if (resolvedArt.meta.name === 'meteorcenter') {
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
} else if (
(resolvedArt.meta.name === 'meteorfire' ||
resolvedArt.meta.name === 'firewall' ||
resolvedArt.meta.name === 'groundfirebig') &&
frameCount === 37
) {
// For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
const defaultDuration =
resolvedArt.meta.name === 'meteorfire'
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
: shot.ttl > 0
? (shot.ageTicks ?? 0) + shot.ttl
: 90
const duration = shot.fireDuration ?? defaultDuration
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
const EXTINCTION_TICKS = 12
if (shot.ttl <= EXTINCTION_TICKS) {
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
} else {
const animStep = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16)
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
}
} else {
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
frameIndex = Math.floor((elapsed * resolvedArt.meta.animSpeed) / 16) % frameCount
}
const group = resolvedArt.frames[dccDir] ?? resolvedArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame === undefined) {
return
}
const isGroundFlame =
resolvedArt.meta.name === 'meteorfire' ||
resolvedArt.meta.name === 'firewall' ||
resolvedArt.meta.name === 'groundfirebig'
const isExtinguishing = isGroundFlame && shot.ttl <= 12
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
const opacity = isExtinguishing ? decayRatio : 1.0
const drawW = frame.width * scale
const drawH = frame.height * scale
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
const drawX = shot.x + baseAnchorX * scale
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
let isAdditive = false
try {
isAdditive = getMissileTxtData(shot.missileType ?? resolvedArt.meta.name).trans === 1
} catch {
isAdditive = true
}
renderer.draw(frame, drawX, drawY, {
atlas: resolvedArt.handle,
...(isAdditive ? { blendMode: 'additive' } : {}),
...(isExtinguishing
? {
width: drawW,
height: drawH,
tint: [1, 1, 1, opacity],
}
: {}),
})
// Dual Sprite Falling Trajectory & Rendering:
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
if (shot.missileType === 'meteorcenter' && missileArtMap != null && shot.ttl > 0) {
const remainingTicks = shot.ttl
const fallAltitude = remainingTicks * 25
const slideX = -remainingTicks * 15
const meteorX = shot.x + slideX
const meteorY = shot.y
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
// 1. Atmospheric flame tail (`meteortail`, ID 102)
const tailArt = missileArtMap.get('meteortail')
if (tailArt !== undefined) {
const tailGroup = tailArt.frames[0]
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
if (tailFrame !== undefined) {
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
atlas: tailArt.handle,
blendMode: 'additive',
})
}
}
// 2. Falling meteor core fireball (`meteor`, ID 100)
const meteorArt = missileArtMap.get('meteor')
if (meteorArt !== undefined) {
const meteorGroup = meteorArt.frames[0]
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
if (meteorFrame !== undefined) {
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
atlas: meteorArt.handle,
blendMode: 'additive',
})
}
}
}
}
export const drawFireboltProjectile = drawMissileProjectile
/**
* Render a Diablo II 1.13c explosion using authentic baked DCC sprite atlas.
*/
export function drawExplosion(
renderer: SpriteRenderer,
exp: ActiveExplosion,
explosionArt?: LoadedMissileArt | null,
): void {
if (explosionArt === undefined || explosionArt === null) {
return
}
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
if (explosionArt.meta.directions > 1) {
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
const group = explosionArt.frames[dir]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
}
}
return
}
const group = explosionArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
}
}
export const drawFireExplosion = drawExplosion
/**
* Packed overlay animation asset loaded into GPU memory.
*/
export interface LoadedOverlayArt {
readonly meta: OverlayMeta
readonly handle: AtlasHandle
readonly frames: AtlasFrame[][]
}
/**
* Load authentic 1.13c baked cast overlay atlases from /overlays/*.png or packBase.
*/
export async function loadOverlayArtMap(
renderer: SpriteRenderer,
packBase?: string,
): Promise<Map<string, LoadedOverlayArt>> {
const map = new Map<string, LoadedOverlayArt>()
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
const targets = Array.from(new Set(Object.values(OVERLAY_METAS).map(m => m.name)))
const baseCandidates = [
'/overlays',
packBase ? `${packBase}/overlays` : '',
'samples/d2-packs/overlays',
].filter(Boolean)
const failures: string[] = []
await Promise.all(
targets.map(async name => {
let loaded = false
let lastReason = ''
for (const base of baseCandidates) {
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${base}/${name}.json`),
fetch(`${base}/${name}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as OverlayMeta
const blob = await pngResp.blob()
const bitmap = await createImageBitmap(blob)
let handle: AtlasHandle
try {
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
x,
y,
width,
height,
...(anchorX !== undefined ? { anchorX } : {}),
...(anchorY !== undefined ? { anchorY } : {}),
})),
)
const art: LoadedOverlayArt = { meta, handle, frames }
map.set(name, art)
if (meta.celFile) {
map.set(meta.celFile.toLowerCase(), art)
}
loaded = true
break
}
lastReason = `${base}/${name}.json (HTTP ${jsonResp.status}), ${base}/${name}.png (HTTP ${pngResp.status})`
} catch (err) {
lastReason = `${base}/${name}: ${err instanceof Error ? err.message : String(err)}`
}
}
if (!loaded) {
failures.push(`${name} [${lastReason || `tried: ${baseCandidates.join(', ')}`}]`)
}
}),
)
if (failures.length > 0) {
const msg = `[loadOverlayArtMap] Failed to load ${failures.length} overlay atlas(es): ${failures.slice(0, 5).join('; ')}`
console.error(msg)
throw new Error(msg)
}
for (const [alias, meta] of Object.entries(OVERLAY_METAS)) {
const art = map.get(meta.name)
if (art && !map.has(alias)) {
map.set(alias, art)
}
}
return map
}
/**
* Render a Diablo II 1.13c character cast overlay effect:
* - Uses authentic baked DCC sprite atlas with additive blending (Trans === 3) when available.
* - Respects frame anchorX and anchorY offsets to align with caster's origin.
* - Falls back to procedural magic glow in test/offline environments.
* - Dynamic lighting/light mask rendering deferred to issue #395.
*/
export function drawCastOverlay(
renderer: SpriteRenderer,
overlay: ActiveOverlay,
overlayArt?: LoadedOverlayArt | null,
): void {
// 1. Sprite animation with additive blending
if (overlayArt !== undefined && overlayArt !== null) {
const frameCount = Math.max(1, overlayArt.meta.framesPerDirection || overlayArt.meta.frames)
const frameIndex = Math.min(Math.max(0, Math.floor(overlay.frame)), frameCount - 1)
const group = overlayArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? overlay.x + frame.anchorX : overlay.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? overlay.y + frame.anchorY : overlay.y - frame.height / 2
renderer.draw(frame, drawX, drawY, { atlas: overlayArt.handle, blendMode: 'additive' })
renderer.setBlendMode('normal')
return
}
}
reportMissingArt(
'overlay',
overlay.overlayName || 'cast_overlay',
`Missing cast overlay art for ${overlay.overlayName || 'cast_overlay'}`,
)
}
/**
* Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`):
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
*/
export function drawTownPortal(
renderer: SpriteRenderer,
x: number,
y: number,
elapsedMs: number,
overlayArtMap: ReadonlyMap<string, LoadedOverlayArt>,
): void {
const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256)
const openDurationMs = 15 * frameDurationMs // 768 ms
const isOpening = elapsedMs < openDurationMs
const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr')
const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd')
const rawFrameIdx = isOpening
? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs)))
: Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15
let drewSprite = false
if (trArt !== undefined) {
const group = trArt.frames[0]
const frame = group?.[rawFrameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: trArt.handle,
blendMode: 'normal',
})
drewSprite = true
}
}
if (hdArt !== undefined) {
const group = hdArt.frames[0]
const frame = group?.[rawFrameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: hdArt.handle,
blendMode: 'additive',
elevationY: Math.max(0, frame.height - 16),
})
renderer.setBlendMode('normal')
drewSprite = true
}
}
if (!drewSprite) {
reportMissingArt(
'overlay',
isOpening ? 'town_portal_op' : 'town_portal_on',
'Missing town portal sprite atlas in overlayArtMap',
)
}
}