diablo2-web/src/client/motion/interpolate.ts

292 lines
8.8 KiB
TypeScript

/**
* Remote Entity Movement Interpolator (`src/client/motion/interpolate.ts`, Feature F12).
*
* Smoothly advances remote monsters, NPCs, mercenaries, and other players along
* server-issued movement segments:
* - `0x0F PlayerMove` / `0x10 PlayerMoveToUnit`
* - `0x0D PlayerStop`
* - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`)
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
* - `0x6D NPCStop` (`{ unitId, x, y, life }`)
*
* Facings are dir64 values (`common/units/direction.ts`), set once when a segment starts
* (like D2Common 0x6FD5D240) and kept while the segment is walked and after it ends.
*/
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import {
D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC,
D2_TICK_MS,
} from './predictor.ts'
/** Default drift (sub-tiles) beyond which a remote unit snaps to the server position. */
const DEFAULT_REMOTE_SNAP_THRESHOLD = 5.0
export interface RemoteEntityMotionState {
readonly unitType: number
readonly unitId: number
readonly x: number
readonly y: number
readonly targetX: number
readonly targetY: number
readonly speedSubTilesPerSec: number
readonly moving: boolean
readonly running: boolean
/** Facing in the engine's 64-direction space. */
readonly dir64: number
}
export interface RemoteMoveCommand {
readonly unitType: number
readonly unitId: number
readonly currentX?: number
readonly currentY?: number
readonly targetX: number
readonly targetY: number
readonly running?: boolean
readonly moveType?: number
readonly speedSubTilesPerSec?: number
}
export interface RemoteStopCommand {
readonly unitType: number
readonly unitId: number
readonly x: number
readonly y: number
readonly hardSnapThreshold?: number
}
interface MutableRemoteEntityMotion {
unitType: number
unitId: number
x: number
y: number
targetX: number
targetY: number
speedSubTilesPerSec: number
moving: boolean
running: boolean
dir64: number
}
function entityKey(unitType: number, unitId: number): string {
return `${String(unitType)}:${String(unitId >>> 0)}`
}
/**
* Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte into `{ running, speedSubTilesPerSec }`.
* In D2 1.13c:
* - `0x17` (23) or bit `0x10` / `0x02` indicates running
* - `0x01` indicates walking
*/
export function resolveRemoteMoveSpeed(options: {
readonly running?: boolean
readonly moveType?: number
readonly speedSubTilesPerSec?: number
}): { readonly running: boolean; readonly speedSubTilesPerSec: number } {
const running =
options.running ??
(options.moveType !== undefined
? options.moveType === 0x17 ||
options.moveType === 0x18 ||
(options.moveType & 0x02) !== 0
: false)
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }
}
return {
running,
speedSubTilesPerSec: running ? D2_BASE_RUN_SUBTILES_PER_SEC : D2_BASE_WALK_SUBTILES_PER_SEC,
}
}
export class RemoteEntityInterpolator {
private readonly entities = new Map<string, MutableRemoteEntityMotion>()
private readonly snapThreshold: number
constructor(options: { readonly snapThreshold?: number } = {}) {
this.snapThreshold = options.snapThreshold ?? DEFAULT_REMOTE_SNAP_THRESHOLD
}
/**
* Register or hard-snap an entity at `(x, y)`. Without `dir64` a known entity keeps its
* facing (a `0x15` reassign carries no direction); a new one faces dir64 0.
*/
spawnOrSnap(params: {
readonly unitType: number
readonly unitId: number
readonly x: number
readonly y: number
readonly dir64?: number
}): RemoteEntityMotionState {
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
const key = entityKey(params.unitType, params.unitId)
const previous = this.entities.get(key)
const state: MutableRemoteEntityMotion = {
unitType: params.unitType,
unitId: params.unitId >>> 0,
x: params.x,
y: params.y,
targetX: params.x,
targetY: params.y,
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
moving: false,
running: false,
dir64: params.dir64 ?? previous?.dir64 ?? 0,
}
this.entities.set(key, state)
return { ...state }
}
/**
* Apply a remote movement command (`0x0F PlayerMove`, `0x67 NPCMove`, `0x68 NPCMoveToTarget`).
*/
startMove(cmd: RemoteMoveCommand): RemoteEntityMotionState {
const key = entityKey(cmd.unitType, cmd.unitId)
const { running, speedSubTilesPerSec } = resolveRemoteMoveSpeed(cmd)
let state = this.entities.get(key)
const startX = cmd.currentX ?? state?.x ?? cmd.targetX
const startY = cmd.currentY ?? state?.y ?? cmd.targetY
if (!state) {
state = {
unitType: cmd.unitType,
unitId: cmd.unitId >>> 0,
x: startX,
y: startY,
targetX: cmd.targetX,
targetY: cmd.targetY,
speedSubTilesPerSec,
moving: false,
running,
dir64: 0,
}
this.entities.set(key, state)
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const drift = Math.hypot(cmd.currentX - state.x, cmd.currentY - state.y)
if (drift > this.snapThreshold) {
state.x = cmd.currentX
state.y = cmd.currentY
}
}
state.targetX = cmd.targetX
state.targetY = cmd.targetY
state.running = running
state.speedSubTilesPerSec = speedSubTilesPerSec
const dx = state.targetX - state.x
const dy = state.targetY - state.y
const dist = Math.hypot(dx, dy)
if (dist > 1e-4) {
state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, state.targetX, state.targetY, state.dir64)
// If visual position lagged behind server start, slightly boost speed to catch up smoothly
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
if (serverSegDist > 0.25 && dist > serverSegDist) {
const catchupFactor = Math.min(1.5, dist / serverSegDist)
state.speedSubTilesPerSec = speedSubTilesPerSec * catchupFactor
}
}
} else {
state.x = state.targetX
state.y = state.targetY
state.moving = false
}
return { ...state }
}
/**
* Apply a server stop packet (`0x0D PlayerStop`, `0x6D NPCStop`).
*/
stopEntity(cmd: RemoteStopCommand): RemoteEntityMotionState {
const key = entityKey(cmd.unitType, cmd.unitId)
const state = this.entities.get(key)
if (!state) {
return this.spawnOrSnap({
unitType: cmd.unitType,
unitId: cmd.unitId,
x: cmd.x,
y: cmd.y,
})
}
const dx = cmd.x - state.x
const dy = cmd.y - state.y
const dist = Math.hypot(dx, dy)
const snapLimit = cmd.hardSnapThreshold ?? this.snapThreshold
if (dist <= 0.15 || dist > snapLimit) {
state.x = cmd.x
state.y = cmd.y
state.targetX = cmd.x
state.targetY = cmd.y
state.moving = false
} else {
// Smoothly finish the small remaining glide to the authoritative stop point, facing it so
// the unit never slides backwards.
state.targetX = cmd.x
state.targetY = cmd.y
state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, cmd.x, cmd.y, state.dir64)
}
return { ...state }
}
removeEntity(unitType: number, unitId: number): boolean {
return this.entities.delete(entityKey(unitType, unitId))
}
clear(): void {
this.entities.clear()
}
/**
* Advance all interpolated entities by `dtMs` milliseconds. Facings do not change here.
*/
update(dtMs: number): void {
if (dtMs <= 0) return
const dtSec = dtMs / 1000
for (const state of this.entities.values()) {
if (!state.moving) continue
const dx = state.targetX - state.x
const dy = state.targetY - state.y
const dist = Math.hypot(dx, dy)
const step = state.speedSubTilesPerSec * dtSec
if (dist <= step || dist <= 1e-5) {
state.x = state.targetX
state.y = state.targetY
state.moving = false
} else {
const ratio = step / dist
state.x += dx * ratio
state.y += dy * ratio
}
}
}
/**
* Advance all interpolated entities by `ticks` 25Hz ticks (`ticks * 40ms`).
*/
stepTicks(ticks = 1): void {
for (let i = 0; i < ticks; i++) {
this.update(D2_TICK_MS)
}
}
getEntityState(unitType: number, unitId: number): RemoteEntityMotionState | null {
const state = this.entities.get(entityKey(unitType, unitId))
return state ? { ...state } : null
}
getAllStates(): RemoteEntityMotionState[] {
return Array.from(this.entities.values(), s => ({ ...s }))
}
}