583 lines
22 KiB
TypeScript
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,
|
|
}
|
|
}
|