292 lines
8.8 KiB
TypeScript
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 }))
|
|
}
|
|
}
|