diablo2-web/src/client/world/client-unit.ts

424 lines
12 KiB
TypeScript

/**
* Client Unit Mirror (`src/client/world/client-unit.ts`).
*
* Represents a single authoritative entity (player, monster, object, missile, item, tile)
* tracked by `ClientWorld`.
*/
import {
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
advanceUnitTurnByTicks,
assertDir64,
createUnitTurnState,
setUnitTargetDirection,
snapUnitTurnDirection,
targetUnitTurnTowardPoint,
type UnitTurnState,
} from '../../common/units/direction.ts'
import {
unitTypeFromId,
type ItemData,
type Pt,
type UnitRef,
type UnitSnapshot,
type UnitType,
type UnitTypeName,
} from '../../netproto/index.ts'
import { StatList } from './stat-list.ts'
import { UnitStateSet } from './states.ts'
export interface ClientUnitOverlay {
readonly overlayId: number
readonly token: string
frame: number
readonly totalFrames: number
readonly animRate: number
readonly loop: boolean
readonly blendMode: 0 | 1 | 2 | 3
readonly yOffset: number
readonly startedAtMs: number
ageTicks: number
readonly maxTicks: number
readonly lightRadius: number
readonly lightColor: readonly [number, number, number]
}
export interface UnitCastState {
readonly skillId: number
readonly targetX: number
readonly targetY: number
readonly mode: number
startedAtMs: number
untilMs: number
readonly castOverlayToken?: string | undefined
readonly durationMs?: number | undefined
elapsedMs?: number | undefined
readonly stepSpeed256?: number | undefined
readonly frames?: number | undefined
readonly baseSpeed?: number | undefined
readonly effRate?: number | undefined
}
export function makeUnitKey(unitType: UnitType, id: number): string {
return `${unitTypeFromId(unitType)}:${id}`
}
export class ClientUnit {
readonly key: string
readonly unitType: UnitTypeName
readonly id: number
classId: number
name?: string | undefined
nameZh?: string | undefined
itemCode?: string | undefined
itemQuality?: number | undefined
itemColor?: string | undefined
goldAmount?: number | undefined
x: number
y: number
targetX?: number | undefined
targetY?: number | undefined
targetUnit?: UnitRef | undefined
moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined
velocityPercent?: number | undefined
pathType?: number | undefined
mode: number
private readonly _turnState: UnitTurnState
get direction(): number {
return this._turnState.dir64
}
set direction(v: number) {
this.setDirection(v)
}
get targetDirection(): number {
return this._turnState.targetDir64
}
set targetDirection(v: number) {
this.setTargetDirection(v)
}
get turnState(): Readonly<UnitTurnState> {
return { ...this._turnState }
}
lifePct: number
hitClass?: number | undefined
flags: number
warpId?: number | undefined
objectState?: number | undefined
interactType?: number | undefined
isChampion?: boolean | undefined
isUnique?: boolean | undefined
isSuperUnique?: boolean | undefined
superUniqueId?: number | undefined
bossMods?: readonly number[] | undefined
nameSeed?: number | undefined
compVariants?: readonly number[] | undefined
ownerType?: UnitTypeName | undefined
ownerId?: number | undefined
overheadText?: string | undefined
itemData?: ItemData | undefined
castState?: UnitCastState | undefined
lastSkillId?: number | undefined
overlays: ClientUnitOverlay[] = []
// Missile simulation fields (populated when unitType === 'missile')
missileKey?: string | undefined
vx?: number | undefined
vy?: number | undefined
ageTicks?: number | undefined
maxTicks?: number | undefined
frame?: number | undefined
totalFrames?: number | undefined
animSpeed?: number | undefined
loopAnim?: boolean | undefined
numDirections?: number | undefined
explosionMissile?: string | undefined
alwaysExplode?: boolean | undefined
pierce?: boolean | undefined
hitUnitIds?: Set<number> | undefined
altitude?: number | undefined
originX?: number | undefined
originY?: number | undefined
spiralAngle?: number | undefined
spiralRadius?: number | undefined
jitterIndex?: number | undefined
emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined
subMissileIdx?: number | undefined
startedAtMs?: number | undefined
startedAtTick?: number | undefined
sourceSkillId?: number | undefined
skillLevel?: number | undefined
modeStartedAtMs?: number | undefined
modeUntilMs?: number | undefined
modeDurationMs?: number | undefined
modeElapsedMs?: number | undefined
animStepSpeed256?: number | undefined
animEffRate?: number | undefined
readonly stats = new StatList()
readonly states = new UnitStateSet()
constructor(snapshot: UnitSnapshot) {
this.unitType = unitTypeFromId(snapshot.unitType)
this.id = snapshot.id
this.key = `${this.unitType}:${this.id}`
this.classId = snapshot.classId >= 0 ? snapshot.classId : 0
this.name = snapshot.name
this.x = snapshot.x
this.y = snapshot.y
this.lifePct = snapshot.lifePct ?? 128
const defaultMode =
this.unitType === 'monster'
? this.lifePct <= 0
? 12
: 1
: this.unitType === 'player'
? 1
: 0
this.mode = snapshot.mode ?? defaultMode
const initialDir = snapshot.direction ?? 0
this._turnState = createUnitTurnState(initialDir)
this.flags = snapshot.flags ?? 0
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
this.objectState = snapshot.objectState
this.interactType = snapshot.interactType
this.isChampion = snapshot.isChampion ?? ((this.flags & 0x04) !== 0 ? true : undefined)
this.isUnique = snapshot.isUnique ?? ((this.flags & 0x08) !== 0 ? true : undefined)
this.isSuperUnique = snapshot.isSuperUnique ?? ((this.flags & 0x02) !== 0 ? true : undefined)
this.superUniqueId = snapshot.superUniqueId
this.bossMods = snapshot.bossMods
this.nameSeed = snapshot.nameSeed
this.compVariants = snapshot.compVariants
this.ownerType = snapshot.ownerType !== undefined ? unitTypeFromId(snapshot.ownerType) : undefined
this.ownerId = snapshot.ownerId
if (snapshot.states) {
this.states.replaceAll(snapshot.states)
}
}
get kind(): UnitTypeName {
return this.unitType
}
get dir(): number {
return this.direction
}
set dir(v: number) {
this.setDirection(v)
}
get turnStepDir(): number {
return this._turnState.stepDir
}
get quality(): number | undefined {
return this.itemQuality
}
set quality(v: number | undefined) {
this.itemQuality = v
}
/**
* Snap both `direction` and `targetDirection` immediately (`bInstant = 1`).
*/
setDirection(dir64: number): void {
snapUnitTurnDirection(this._turnState, dir64)
}
/**
* Set `targetDirection` (`bInstant = 0` by default, or `bInstant = 1` when `instant = true`).
*/
setTargetDirection(targetDir64: number, instant = false): void {
setUnitTargetDirection(this._turnState, targetDir64, instant)
}
/**
* Turn toward `(targetX, targetY)` (`bInstant = 0` by default).
*/
faceToward(targetX: number, targetY: number, instant = false): number {
targetUnitTurnTowardPoint(this._turnState, this.x, this.y, targetX, targetY, instant)
return this._turnState.targetDir64
}
/**
* Advance the 25Hz (`40ms`) turn accumulator by `dtMs` milliseconds (`D2Common.#10603`).
*/
advanceTurn(dtMs: number): number {
advanceUnitTurnByDtMs(this._turnState, dtMs)
return this._turnState.dir64
}
advanceTurnByDtMs(dtMs: number): number {
return this.advanceTurn(dtMs)
}
/**
* Advance the unit turn state by one 25Hz tick (`D2Common.#10603`).
*/
stepTurnTick(): number {
return this.stepTurnTicks(1)
}
/**
* Advance the unit turn state by `ticks` 25Hz ticks (`D2Common.#10603`).
*/
stepTurnTicks(ticks = 1): number {
advanceUnitTurnByTicks(this._turnState, ticks)
return this._turnState.dir64
}
/**
* Synchronize `direction` and `targetDirection` from external motion state (`RemoteEntityInterpolator` / `LocalMovementPredictor`).
*/
syncTurnState(dir64: number, targetDir64: number, stepDir?: number): void {
assertDir64(dir64, 'dir64')
assertDir64(targetDir64, 'targetDir64')
if (
stepDir !== undefined &&
(!Number.isInteger(stepDir) ||
(stepDir !== 0 &&
stepDir !== 1 &&
stepDir !== -1 &&
stepDir !== 4 &&
stepDir !== -4 &&
stepDir !== 8 &&
stepDir !== -8))
) {
throw new RangeError(
`stepDir must be a valid 1.13c turn step (0, ±1, ±4, ±8), got ${String(stepDir)}`,
)
}
if (dir64 === targetDir64) {
this._turnState.stepDir = 0
this._turnState.turnTickRemainderMs = 0
} else if (stepDir !== undefined && stepDir !== 0) {
this._turnState.stepDir = stepDir
} else if (this._turnState.targetDir64 !== targetDir64 || this._turnState.stepDir === 0) {
this._turnState.stepDir = TURN_STEP_TABLE_113C[(targetDir64 - dir64) & 0x3f]!
}
this._turnState.dir64 = dir64
this._turnState.targetDir64 = targetDir64
}
updateFromSnapshot(snapshot: UnitSnapshot): void {
if (snapshot.classId >= 0) {
this.classId = snapshot.classId
}
if (snapshot.name !== undefined && snapshot.name.length > 0) {
this.name = snapshot.name
}
if (snapshot.x !== 0 || snapshot.y !== 0) {
this.x = snapshot.x
this.y = snapshot.y
}
if (snapshot.mode !== undefined) {
this.mode = snapshot.mode
}
if (snapshot.direction !== undefined) {
this.setDirection(snapshot.direction)
}
if (snapshot.lifePct !== undefined) {
this.lifePct = snapshot.lifePct
if (snapshot.mode === undefined && this.unitType === 'monster') {
if (this.lifePct <= 0) {
this.mode = 12
} else if (this.mode === 0 || this.mode === 12) {
this.mode = 1
}
}
}
if (snapshot.flags !== undefined) {
this.flags = snapshot.flags
if ((snapshot.flags & 0x04) !== 0) this.isChampion = true
if ((snapshot.flags & 0x08) !== 0) this.isUnique = true
if ((snapshot.flags & 0x02) !== 0) this.isSuperUnique = true
}
if (snapshot.warpId !== undefined) {
this.warpId = snapshot.warpId
} else if (this.unitType === 'tile' && this.warpId === undefined && this.classId >= 0) {
this.warpId = this.classId
}
if (snapshot.objectState !== undefined) {
this.objectState = snapshot.objectState
}
if (snapshot.interactType !== undefined) {
this.interactType = snapshot.interactType
}
if (snapshot.isChampion !== undefined) {
this.isChampion = snapshot.isChampion
}
if (snapshot.isUnique !== undefined) {
this.isUnique = snapshot.isUnique
}
if (snapshot.isSuperUnique !== undefined) {
this.isSuperUnique = snapshot.isSuperUnique
}
if (snapshot.superUniqueId !== undefined) {
this.superUniqueId = snapshot.superUniqueId
}
if (snapshot.bossMods !== undefined) {
this.bossMods = snapshot.bossMods
}
if (snapshot.nameSeed !== undefined) {
this.nameSeed = snapshot.nameSeed
}
if (snapshot.compVariants !== undefined) {
this.compVariants = snapshot.compVariants
}
if (snapshot.ownerType !== undefined) {
this.ownerType = unitTypeFromId(snapshot.ownerType)
}
if (snapshot.ownerId !== undefined) {
this.ownerId = snapshot.ownerId
}
if (snapshot.states !== undefined) {
this.states.replaceAll(snapshot.states)
}
}
applyMove(
mode: 'walk' | 'run' | 'knockback' | 'stop',
to: Pt,
from?: Pt,
target?: UnitRef,
): void {
if (from && (from.x !== 0 || from.y !== 0)) {
this.x = from.x
this.y = from.y
}
if (to.x !== 0 || to.y !== 0) {
if (mode !== 'stop' || Math.hypot(to.x - this.x, to.y - this.y) > 0.15) {
this.faceToward(to.x, to.y, false)
}
}
this.moveMode = mode
if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) {
if (mode === 'stop') {
this.mode = 1
} else if (mode === 'run') {
this.mode = this.unitType === 'monster' ? 15 : 3
} else if (mode === 'knockback') {
this.mode = this.unitType === 'monster' ? 13 : 19
} else {
this.mode = 2
}
}
this.targetX = to.x
this.targetY = to.y
this.targetUnit = target
if (to.x !== 0 || to.y !== 0) {
this.x = to.x
this.y = to.y
}
}
get dead(): boolean {
return this.lifePct <= 0 && (this.unitType === 'monster' || this.unitType === 'player')
}
}