diablo2-web/src/client/view/unit-drawables.ts

583 lines
22 KiB
TypeScript

import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts'
import type { UnitCastState } from '../world/client-unit.ts'
import { subTileToWorldOrtho } from './viewport-profile.ts'
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
export type DrawableBlendMode = 'normal' | 'additive'
export type SceneUnitKind = UnitKind | 'warp'
export interface UnitOverlayState {
readonly overlayId: number
readonly token?: string | undefined
readonly frame?: number | undefined
readonly totalFrames?: number | undefined
readonly blendMode?: DrawableBlendMode | undefined
readonly yOffset?: number | undefined
}
/**
* Structural unit input compatible with both `UnitSnapshot` (from `src/netproto`)
* and `ClientUnit` (from `src/client/world/unit-table.ts`).
*/
export interface RenderableUnitInput {
readonly kind: SceneUnitKind
readonly id: number
readonly classId: number
readonly areaId?: number | undefined
/** Sub-tile coordinates (may be fractional when interpolated by MotionPredictor). */
readonly x: number
readonly y: number
/** Target sub-tile for moving units / missiles. */
readonly targetX?: number | undefined
readonly targetY?: number | undefined
readonly mode?: number | undefined
/**
* Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept
* by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no
* target is given.
*/
readonly direction?: number | undefined
readonly frame?: number | undefined
readonly lifePct?: number | undefined
readonly name?: string | undefined
readonly code?: string | undefined
readonly itemQuality?: number | undefined
readonly goldAmount?: number | undefined
readonly token?: string | undefined
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
readonly overlays?: readonly UnitOverlayState[] | undefined
readonly states?: ReadonlySet<number> | readonly number[] | undefined
readonly isHovered?: boolean | undefined
readonly isCorpse?: boolean | undefined
readonly quality?: number | undefined
readonly isLocalPlayer?: boolean | undefined
readonly hpRatio?: number | undefined
readonly isChampion?: boolean | undefined
readonly isUnique?: boolean | undefined
readonly isSuperUnique?: boolean | undefined
readonly warpId?: number | undefined
readonly toLevelId?: number | undefined
readonly radiusX?: number | undefined
readonly radiusY?: number | undefined
readonly itemCode?: string | undefined
readonly castState?: UnitCastState | undefined
readonly animMode?: string | undefined
readonly portalOwnerName?: string | undefined
readonly portalAreaId?: number | undefined
readonly isPartyMember?: boolean | undefined
}
export type RenderableUnit = RenderableUnitInput
export interface SceneUnitDrawable {
readonly drawableKind: 'unit'
readonly unitKind: SceneUnitKind
readonly id: number
readonly unitId: number
readonly classId: number
readonly token: string
readonly code?: string | undefined
readonly itemQuality?: number | undefined
readonly goldAmount?: number | undefined
readonly mode: number
/** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */
readonly direction: number
readonly frame: number
readonly subX: number
readonly subY: number
readonly worldX: number
readonly worldY: number
readonly sortY: number
readonly blendMode: DrawableBlendMode
readonly lifePct: number
readonly name: string | undefined
readonly isHovered: boolean
readonly isCorpse: boolean
readonly tintColor: readonly [number, number, number] | null
readonly compositeArmor: Readonly<Record<string, string>> | undefined
readonly overlays: readonly SceneOverlayDrawable[]
readonly warpId?: number | undefined
readonly toLevelId?: number | undefined
readonly radiusX?: number | undefined
readonly radiusY?: number | undefined
readonly itemCode?: string | undefined
readonly castState?: UnitCastState | undefined
readonly animMode?: string | undefined
readonly portalOwnerName?: string | undefined
readonly portalAreaId?: number | undefined
readonly isPartyMember?: boolean | undefined
}
export interface SceneMissileDrawable {
readonly drawableKind: 'missile'
readonly unitId: number
readonly missileClassId: number
readonly missileKey: string
readonly celFile: string
readonly direction: number
readonly frame: number
readonly subX: number
readonly subY: number
readonly worldX: number
readonly worldY: number
readonly sortY: number
readonly blendMode: DrawableBlendMode
readonly trans: number
readonly lightRadius: number
readonly lightColor: readonly [number, number, number]
}
export interface SceneOverlayDrawable {
readonly drawableKind: 'overlay'
readonly ownerUnitId: number
readonly overlayId: number
readonly token: string
readonly frame: number
readonly totalFrames: number
readonly worldX: number
readonly worldY: number
readonly sortY: number
readonly blendMode: DrawableBlendMode
}
export type SceneEntityDrawable =
| SceneUnitDrawable
| SceneMissileDrawable
| SceneOverlayDrawable
/**
* 1.13c `Missiles.txt` row id (D2GS missile unit classId) -> missile key, derived from
* `CANONICAL_113C_MISSILES` (Missiles.txt rows only; client visual aliases excluded).
* Ids without art data are absent: callers must treat them as unknown, never substitute another missile.
*/
export const MISSILE_ID_TO_KEY: Readonly<Record<number, string>> = Object.freeze(
Object.fromEntries(
Object.entries(CANONICAL_113C_MISSILES)
.filter(([, m]) => m.clientAlias !== true)
.map(([key, m]) => [m.id, key] as const),
),
)
export interface OverlayMeta {
readonly overlayId: number
readonly token: string
readonly totalFrames: number
readonly blendMode: DrawableBlendMode
readonly yOffset: number
readonly lightRadius?: number | undefined
readonly lightColor?: readonly [number, number, number] | undefined
}
/**
* Canonical 1.13c `Overlay.txt` / `States.txt` visual metadata for common combat/buff overlays.
*/
export const OVERLAY_METADATA: Readonly<Record<number, OverlayMeta>> = {
1: { overlayId: 1, token: 'Freeze', totalFrames: 15, blendMode: 'additive', yOffset: 0, lightRadius: 8, lightColor: [110, 175, 255] },
2: { overlayId: 2, token: 'Poison', totalFrames: 12, blendMode: 'normal', yOffset: 0 },
8: { overlayId: 8, token: 'Salvation', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 240, 180] },
9: { overlayId: 9, token: 'AmplifyDamage', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 8, lightColor: [255, 90, 60] },
10: { overlayId: 10, token: 'FrozenArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
16: { overlayId: 16, token: 'Enchant', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [255, 140, 60] },
20: { overlayId: 20, token: 'ChillingArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
28: { overlayId: 28, token: 'Conviction', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [120, 255, 140] },
30: { overlayId: 30, token: 'EnergyShield', totalFrames: 16, blendMode: 'additive', yOffset: -36, lightRadius: 11, lightColor: [255, 220, 110] },
32: { overlayId: 32, token: 'BattleOrders', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [110, 180, 255] },
33: { overlayId: 33, token: 'Might', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 215, 110] },
49: { overlayId: 49, token: 'Fanaticism', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 13, lightColor: [255, 110, 90] },
51: { overlayId: 51, token: 'BattleCommand', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 185, 95] },
60: { overlayId: 60, token: 'Decrepify', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 8, lightColor: [140, 160, 255] },
61: { overlayId: 61, token: 'LowerResist', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 9, lightColor: [210, 110, 255] },
88: { overlayId: 88, token: 'ShiverArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
101: { overlayId: 101, token: 'HolyShield', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 11, lightColor: [255, 245, 200] },
}
const PLAYER_CLASS_TOKENS: readonly string[] = ['AM', 'SO', 'NE', 'PA', 'BA', 'DZ', 'AI']
export function resolveUnitToken(unit: RenderableUnitInput): string {
if (unit.token && unit.token.trim() !== '') {
return unit.token.trim()
}
if (unit.kind === 'player') {
const token = PLAYER_CLASS_TOKENS[unit.classId]
if (!token) throw new Error(`resolveUnitToken: unknown player class id ${unit.classId}`)
return token
}
if (unit.kind === 'missile') {
const key = MISSILE_ID_TO_KEY[unit.classId]
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
}
if (unit.kind === 'tile' || unit.kind === 'warp') {
return `WRP_${unit.warpId ?? unit.classId}`
}
if (unit.kind === 'object') {
return `OBJ_${unit.classId}`
}
if (unit.kind === 'item') {
const code = unit.code || unit.itemCode || unit.name
if (code && /^[a-z0-9]{3,4}$/i.test(code.trim())) {
return code.trim().toLowerCase()
}
if (unit.itemCode && unit.itemCode.trim() !== '') {
return unit.itemCode.trim().toLowerCase()
}
const mapped = (BAKED_UI_MANIFEST.itemClassIdToCode as Record<number, string | undefined> | undefined)?.[unit.classId]
if (mapped) return mapped
return `ITM_${unit.classId}`
}
return `MON_${unit.classId}`
}
export function resolveMissileMetadata(
classId: number,
explicitToken?: string,
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
const key =
explicitToken && explicitToken.trim() !== ''
? explicitToken.trim().toLowerCase()
: MISSILE_ID_TO_KEY[classId]
if (!key) return null
return {
key,
entry: getMissileTxtData(key),
}
}
function computeDirection8Or16(
fromX: number,
fromY: number,
toX: number | undefined,
toY: number | undefined,
fallbackDir = 0,
numDirs = 16,
): number {
if (toX === undefined || toY === undefined) {
return ((fallbackDir % numDirs) + numDirs) % numDirs
}
const dx = toX - fromX
const dy = toY - fromY
if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) {
return ((fallbackDir % numDirs) + numDirs) % numDirs
}
const angle = Math.atan2(dy, dx)
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)
return Math.floor(normalized * numDirs) % numDirs
}
/**
* The dir64 facing of a player, monster, object or item. The motion layer sets the facing
* when a unit starts a segment, so `direction` passes through as is; a unit given a target
* (its own, or its active cast's) faces it (D2Common 0x6FD5CF40 via `dir64FromPoints`) and
* keeps `direction` when already there.
*/
function unitFacingDir64(
unit: RenderableUnitInput,
targetX: number | undefined,
targetY: number | undefined,
): number {
const facing = unit.direction ?? 0
assertDir64(facing, `unit ${String(unit.id)} direction`)
if (targetX === undefined || targetY === undefined) return facing
return dir64FromPoints(unit.x, unit.y, targetX, targetY, facing)
}
export interface BuildSceneDrawablesResult {
readonly unitDrawables: readonly SceneUnitDrawable[]
readonly missileDrawables: readonly SceneMissileDrawable[]
readonly overlayDrawables: readonly SceneOverlayDrawable[]
readonly sortedDrawables: readonly SceneEntityDrawable[]
readonly pointLights: readonly PointLightInput[]
}
/**
* Convert a collection of `RenderableUnitInput` or `UnitSnapshot` records into depth-sorted
* scene drawables (`SceneUnitDrawable`, `SceneMissileDrawable`, `SceneOverlayDrawable`) and
* dynamic 1.13c `PointLightInput` entries for `applyLightingInputs`.
*/
export function buildSceneDrawables(
units: Iterable<RenderableUnitInput | UnitSnapshot>,
optionsOrWidth?:
| {
readonly originX?: number | undefined
readonly originY?: number | undefined
readonly tick?: number | undefined
}
| number,
_height?: number,
): BuildSceneDrawablesResult {
const options = typeof optionsOrWidth === 'object' ? optionsOrWidth : undefined
const originX = options?.originX ?? 0
const originY = options?.originY ?? 0
const tick = options?.tick ?? 0
const unitDrawables: SceneUnitDrawable[] = []
const missileDrawables: SceneMissileDrawable[] = []
const overlayDrawables: SceneOverlayDrawable[] = []
const pointLights: PointLightInput[] = []
for (const rawUnit of units) {
const rawAny = rawUnit as Record<string, any>
const resolvedKind: SceneUnitKind =
(rawUnit as RenderableUnitInput).kind ??
(rawAny.unitType === 0 || rawAny.unitType === 'player'
? 'player'
: rawAny.unitType === 1 || rawAny.unitType === 'monster'
? 'monster'
: rawAny.unitType === 2 || rawAny.unitType === 'object'
? 'object'
: rawAny.unitType === 3 || rawAny.unitType === 'missile'
? 'missile'
: rawAny.unitType === 4 || rawAny.unitType === 'item'
? 'item'
: rawAny.unitType === 5 || rawAny.unitType === 'tile'
? 'tile'
: 'monster')
const unit: RenderableUnitInput =
(rawUnit as RenderableUnitInput).kind !== undefined
? (rawUnit as RenderableUnitInput)
: { ...(rawUnit as unknown as RenderableUnitInput), kind: resolvedKind }
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
if (unit.kind === 'missile') {
const resolved = resolveMissileMetadata(unit.classId, unit.token)
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
const { key, entry } = resolved
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
const dir = computeDirection8Or16(
unit.x,
unit.y,
unit.targetX,
unit.targetY,
unit.direction ?? 0,
16,
)
const frame = unit.frame ?? (tick % 8)
const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0
const isCold = entry.blue > entry.red
const lightColor: readonly [number, number, number] = [
isCold ? Math.max(120, entry.red) : entry.red,
isCold ? Math.max(175, entry.green) : entry.green,
entry.blue,
]
const missileDrawable: SceneMissileDrawable = {
drawableKind: 'missile',
unitId: unit.id,
missileClassId: unit.classId,
missileKey: key,
celFile: entry.celFile,
direction: dir,
frame,
subX: unit.x,
subY: unit.y,
worldX,
worldY,
sortY: worldY + 4,
blendMode,
trans: entry.trans ?? 0,
lightRadius: lightUnits > 0 ? Math.max(14, lightUnits * 2) : 0,
lightColor,
}
missileDrawables.push(missileDrawable)
if (lightUnits > 0) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: Math.max(14, lightUnits * 2),
intensity: 250,
r: lightColor[0],
g: lightColor[1],
b: lightColor[2],
})
}
continue
}
// Collect overlays from explicit `overlays` and active `states`
const unitOverlays: SceneOverlayDrawable[] = []
if (unit.overlays) {
for (const ov of unit.overlays) {
const meta = OVERLAY_METADATA[ov.overlayId]
const totalFrames = ov.totalFrames ?? meta?.totalFrames ?? 16
const frame = ov.frame ?? (tick % totalFrames)
const yOffset = ov.yOffset ?? meta?.yOffset ?? 0
const blendMode = ov.blendMode ?? meta?.blendMode ?? 'additive'
const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay',
ownerUnitId: unit.id,
overlayId: ov.overlayId,
token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`,
frame,
totalFrames,
worldX,
worldY: worldY + yOffset,
sortY: worldY + 1,
blendMode,
}
unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable)
if (meta?.lightRadius && meta.lightColor) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: meta.lightRadius,
intensity: 190,
r: meta.lightColor[0],
g: meta.lightColor[1],
b: meta.lightColor[2],
})
}
}
}
let tintColor: readonly [number, number, number] | null = null
if (unit.states) {
for (const stateId of unit.states) {
if (stateId === 1) {
// Freeze / Chill blue tint
tintColor = [90, 155, 255]
} else if (stateId === 2 && !tintColor) {
// Poison green tint
tintColor = [95, 235, 95]
}
const meta = OVERLAY_METADATA[stateId]
if (meta && !unitOverlays.some((o) => o.overlayId === stateId)) {
const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay',
ownerUnitId: unit.id,
overlayId: stateId,
token: meta.token,
frame: tick % meta.totalFrames,
totalFrames: meta.totalFrames,
worldX,
worldY: worldY + meta.yOffset,
sortY: worldY + 1,
blendMode: meta.blendMode,
}
unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable)
if (meta.lightRadius && meta.lightColor) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: meta.lightRadius,
intensity: 190,
r: meta.lightColor[0],
g: meta.lightColor[1],
b: meta.lightColor[2],
})
}
}
}
}
const isCorpse =
Boolean(unit.isCorpse) ||
(unit.lifePct !== undefined && unit.lifePct <= 0 && (unit.kind === 'monster' || unit.kind === 'player'))
const activeCastState = !isCorpse ? unit.castState : undefined
const effectiveTargetX = unit.targetX ?? activeCastState?.targetX
const effectiveTargetY = unit.targetY ?? activeCastState?.targetY
const effectiveMode = activeCastState
? activeCastState.mode || unit.mode || 10
: (unit.mode ?? 0)
const animMode =
unit.animMode ??
(!isCorpse && (effectiveMode === 7 || effectiveMode === 4)
? 'A1'
: !isCorpse && effectiveMode === 10
? 'SC'
: undefined)
if (activeCastState && effectiveMode === 10) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: 10,
intensity: 210,
r: 255,
g: 195,
b: 110,
})
}
const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY)
const isWarp = unit.kind === 'warp' || unit.kind === 'tile'
const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId
const radiusX = isWarp ? (unit.radiusX ?? 28) : unit.radiusX
const radiusY = isWarp ? (unit.radiusY ?? 20) : unit.radiusY
const resolvedName = isWarp
? unit.name && unit.name.trim() !== ''
? unit.name
: `Entrance (${warpId})`
: unit.name
const unitDrawable: SceneUnitDrawable = {
drawableKind: 'unit',
unitKind: unit.kind,
id: unit.id,
unitId: unit.id,
classId: unit.classId,
token: resolveUnitToken(unit),
code: unit.code ?? unit.itemCode,
itemQuality: unit.itemQuality ?? unit.quality,
goldAmount: unit.goldAmount,
mode: effectiveMode,
direction: dir,
frame: unit.frame ?? (unit.kind === 'item' ? 0 : (tick % 8)),
subX: unit.x,
subY: unit.y,
worldX,
worldY,
sortY: isCorpse ? worldY - 16 : worldY,
blendMode: 'normal',
lifePct: unit.lifePct ?? 100,
name: resolvedName,
isHovered: Boolean(unit.isHovered),
isCorpse,
tintColor,
compositeArmor: unit.compositeArmor,
overlays: unitOverlays,
...(warpId !== undefined ? { warpId } : {}),
...(unit.toLevelId !== undefined ? { toLevelId: unit.toLevelId } : {}),
...(radiusX !== undefined ? { radiusX } : {}),
...(radiusY !== undefined ? { radiusY } : {}),
...(unit.goldAmount !== undefined ? { goldAmount: unit.goldAmount } : {}),
...(unit.itemCode !== undefined ? { itemCode: unit.itemCode } : {}),
...(unit.itemQuality !== undefined
? { itemQuality: unit.itemQuality }
: unit.quality !== undefined
? { itemQuality: unit.quality }
: {}),
...(activeCastState !== undefined ? { castState: activeCastState } : {}),
...(animMode !== undefined ? { animMode } : {}),
...(unit.portalOwnerName !== undefined ? { portalOwnerName: unit.portalOwnerName } : {}),
...(unit.portalAreaId !== undefined ? { portalAreaId: unit.portalAreaId } : {}),
...(unit.isPartyMember !== undefined ? { isPartyMember: unit.isPartyMember } : {}),
}
unitDrawables.push(unitDrawable)
}
const sortedDrawables: SceneEntityDrawable[] = [
...unitDrawables,
...missileDrawables,
...overlayDrawables,
].sort((a, b) => a.sortY - b.sortY || a.worldX - b.worldX)
return {
unitDrawables,
missileDrawables,
overlayDrawables,
sortedDrawables,
pointLights,
}
}