466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
/**
|
|
* Local Player Movement Predictor (`src/client/motion/predictor.ts`, Feature F12).
|
|
*
|
|
* Steps the local player along an A* / string-pulled waypoint path at 25Hz (40ms per tick)
|
|
* or arbitrary `dtMs` frame deltas, and applies the D2Client 1.13c rules for authoritative
|
|
* server positions:
|
|
* - `0x15 ReassignPlayer` repositions the player and drops its path
|
|
* ({@link LocalMovementPredictor.setPosition}; D2Client 0x6FB5F640 → 0x6FB57E20, no
|
|
* tolerance check).
|
|
* - `0x18` / `0x95` / `0x96` (`LifeMana` with a position) and `0x0D` go through the
|
|
* position tolerance gate D2Client 0x6FADA1B0
|
|
* ({@link LocalMovementPredictor.verifyServerPosition}). The client never moves the local
|
|
* player toward such a position; when the drift is out of tolerance it asks the server to
|
|
* resynchronise with C2S `0x5F`, and the server answers with `0x15` if it disagrees.
|
|
*
|
|
* The target facing is a dir64 (`common/units/direction.ts`), set when a segment starts — on
|
|
* {@link LocalMovementPredictor.setPath} and on each waypoint advance — like the engine's
|
|
* D2Common 0x6FD5D240, and `dir64` rotates toward `targetDir64` at 25Hz (D2Common #10603,
|
|
* D2Client 0x6FACC507).
|
|
*/
|
|
|
|
import {
|
|
advanceUnitTurnByDtMs,
|
|
createUnitTurnState,
|
|
setUnitTargetDirection,
|
|
snapUnitTurnDirection,
|
|
targetUnitTurnTowardPoint,
|
|
type UnitTurnState,
|
|
} from '../../common/units/direction.ts'
|
|
import {
|
|
findPathWorld,
|
|
type PathfindOptions,
|
|
type SubTilePoint,
|
|
type SubTileWalkabilityGrid,
|
|
} from './pathfind.ts'
|
|
|
|
export const D2_TICK_MS = 40 // 25Hz D2 game tick
|
|
export const D2_BASE_WALK_SUBTILES_PER_SEC = 9.0 // 6 yards/s * 1.5 sub-tiles/yard
|
|
export const D2_BASE_RUN_SUBTILES_PER_SEC = 13.5 // 9 yards/s * 1.5 sub-tiles/yard
|
|
|
|
/**
|
|
* D2Client 0x6FADA1B0 tolerance when the caller passes flag 1 (the `0x0D` mode handler
|
|
* D2Client 0x6FAC9830, e.g. its get-hit case at 0x6FAC99FB).
|
|
*/
|
|
const GATE_FLAG1_THRESHOLD = 10
|
|
|
|
/**
|
|
* D2Client 0x6FADA1B0 skips the "client is ahead of the server" exemption once the client is
|
|
* this far from the server position, as a squared distance (D2Common #10769, 0x6FD84580,
|
|
* returns `dx² + dy²` without a square root): 100 = 10 sub-tiles.
|
|
*/
|
|
const GATE_LEAD_MAX_DIST_SQ = 100
|
|
|
|
export interface PredictorConfig {
|
|
readonly initialX?: number
|
|
readonly initialY?: number
|
|
readonly initialDir64?: number
|
|
readonly walkSpeedSubTilesPerSec?: number
|
|
readonly runSpeedSubTilesPerSec?: number
|
|
readonly fasterRunWalkPercent?: number
|
|
}
|
|
|
|
/** Which D2Client 0x6FADA1B0 caller is checking an authoritative position. */
|
|
export type ServerPositionSource =
|
|
/**
|
|
* `0x18` / `0x95` / `0x96` (callers 0x6FB5ED53 / 0x6FB5EC73 / 0x6FB5D9B8): gate flag 0 and
|
|
* target = position + (vx, vy), the server unit's motion vector.
|
|
*/
|
|
| { readonly kind: 'life-mana'; readonly vx: number; readonly vy: number }
|
|
/**
|
|
* `0x0D` for the local player: gate flag 1. D2Game sends the owner `0x0D` only for the
|
|
* death, get-hit, block and dead modes (and message mode 0x13); its stand / walk / run
|
|
* senders skip the owning client (D2MOO `PlrMsg.cpp`, `stru_6FD30708`).
|
|
*/
|
|
| { readonly kind: 'player-stop' }
|
|
|
|
export interface ServerPositionCheck {
|
|
/** Server sub-tile X (u16). */
|
|
readonly x: number
|
|
/** Server sub-tile Y (u16). */
|
|
readonly y: number
|
|
readonly source: ServerPositionSource
|
|
/** Latest `0x8F` pong round trip in ms (D2Client `[0x6FBC9804]`). */
|
|
readonly rttMs: number
|
|
/**
|
|
* Whether the player's room is in town (D2Client `DUNGEON_IsRoomInTown`), which makes the
|
|
* player stand in TN instead of NU.
|
|
*/
|
|
readonly inTown: boolean
|
|
}
|
|
|
|
export type ServerPositionVerdictReason =
|
|
/** X or Y is 0: the gate returns before comparing anything. */
|
|
| 'no-position'
|
|
/** Both axes within the tolerance. */
|
|
| 'within-threshold'
|
|
/** Out of tolerance, but the client is ahead along the server unit's motion. */
|
|
| 'leading-server'
|
|
/** Out of tolerance: the client asks for a resync. */
|
|
| 'exceeded'
|
|
|
|
export interface ServerPositionVerdict {
|
|
/** `request-resync`: send C2S `0x5F` with (`clientX`, `clientY`). The position is untouched. */
|
|
readonly action: 'none' | 'request-resync'
|
|
readonly reason: ServerPositionVerdictReason
|
|
/** The player's integer sub-tile X the gate compared (and reports in `0x5F`). */
|
|
readonly clientX: number
|
|
/** The player's integer sub-tile Y the gate compared (and reports in `0x5F`). */
|
|
readonly clientY: number
|
|
/** Per-axis tolerance in sub-tiles; 0 when the gate returned before computing it. */
|
|
readonly threshold: number
|
|
}
|
|
|
|
export interface PredictorSnapshot {
|
|
/** Predicted logical sub-tile X. */
|
|
readonly logicalX: number
|
|
/** Predicted logical sub-tile Y. */
|
|
readonly logicalY: number
|
|
/** Drawn sub-tile X; always `logicalX` (1.13c draws the simulated position). */
|
|
readonly x: number
|
|
/** Drawn sub-tile Y; always `logicalY`. */
|
|
readonly y: number
|
|
/** True if currently moving along a waypoint path. */
|
|
readonly moving: boolean
|
|
/** True if running (false if walking). */
|
|
readonly running: boolean
|
|
/** Current stepped facing (`D2DynamicPathStrc` `+0x64` `nDirection`, 0..63). */
|
|
readonly dir64: number
|
|
/** Target facing (`D2DynamicPathStrc` `+0x65` `nNewDirection`, 0..63). */
|
|
readonly targetDir64: number
|
|
/** Cached turn step per 25Hz tick (`D2DynamicPathStrc` `+0x66` `nStepDir`: 0, ±1, ±4, ±8). */
|
|
readonly stepDir: number
|
|
/** Current active target waypoint, or null if idle. */
|
|
readonly currentWaypoint: SubTilePoint | null
|
|
/** Remaining waypoints including current target. */
|
|
readonly remainingWaypoints: readonly SubTilePoint[]
|
|
}
|
|
|
|
function assertSubTile(value: number, what: string): void {
|
|
if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
|
|
throw new RangeError(`${what} must be a u16 sub-tile coordinate, got ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
/** D2Common #10769 (0x6FD84580): squared distance, no square root. */
|
|
function distanceSquared(x1: number, y1: number, x2: number, y2: number): number {
|
|
return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
|
|
}
|
|
|
|
export class LocalMovementPredictor {
|
|
private logicalX: number
|
|
private logicalY: number
|
|
private walkSpeed: number
|
|
private runSpeed: number
|
|
private fasterRunWalkPercent: number
|
|
private running = true
|
|
private readonly turnState: UnitTurnState
|
|
private waypoints: SubTilePoint[] = []
|
|
private waypointIndex = 0
|
|
|
|
constructor(config: PredictorConfig = {}) {
|
|
this.logicalX = config.initialX ?? 0
|
|
this.logicalY = config.initialY ?? 0
|
|
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
|
|
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
|
|
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
|
|
this.turnState = createUnitTurnState(config.initialDir64 ?? 0)
|
|
}
|
|
|
|
/**
|
|
* Effective movement speed in sub-tiles per second.
|
|
*/
|
|
getEffectiveSpeedSubTilesPerSec(): number {
|
|
const base = this.running ? this.runSpeed : this.walkSpeed
|
|
if (this.fasterRunWalkPercent === 0) return base
|
|
// D2 diminishing returns on FRW: effectiveFRW = floor(150 * frw / (150 + frw))
|
|
const frw = this.fasterRunWalkPercent
|
|
const effFrw = frw > 0 ? Math.floor((150 * frw) / (150 + frw)) : frw
|
|
return Math.max(1.5, base + D2_BASE_WALK_SUBTILES_PER_SEC * (effFrw / 100))
|
|
}
|
|
|
|
setRunning(running: boolean): void {
|
|
this.running = running
|
|
}
|
|
|
|
setFasterRunWalkPercent(frwPercent: number): void {
|
|
this.fasterRunWalkPercent = frwPercent
|
|
}
|
|
|
|
/**
|
|
* Place the player at `(x, y)` and drop its path, keeping the facing. This is also the
|
|
* `0x15 ReassignPlayer` behaviour: D2Client 0x6FB5F640 → 0x6FB57E20 repositions the unit
|
|
* directly, whatever the packet's flag byte.
|
|
*/
|
|
setPosition(x: number, y: number): void {
|
|
this.logicalX = x
|
|
this.logicalY = y
|
|
this.waypoints = []
|
|
this.waypointIndex = 0
|
|
}
|
|
|
|
/**
|
|
* Instantaneously set both current `dir64` (`+0x64`) and `targetDir64` (`+0x65`) (`bInstant = 1`),
|
|
* e.g. when the server assigns an explicit initial facing.
|
|
*/
|
|
setDirection(dir64: number): void {
|
|
snapUnitTurnDirection(this.turnState, dir64)
|
|
}
|
|
|
|
/**
|
|
* Set the target facing (`+0x65`) and cache `stepDir` (`+0x66`) via the 1.13c turn table
|
|
* (`D2Common.#10425` `0x6FD85270`, `bInstant = 0` by default).
|
|
*/
|
|
setTargetDirection(targetDir64: number, instant = false): void {
|
|
setUnitTargetDirection(this.turnState, targetDir64, instant)
|
|
}
|
|
|
|
/**
|
|
* Face toward world sub-tile `(targetX, targetY)` (`D2Common.#10739` `0x6FD852F0`).
|
|
*/
|
|
facePoint(targetX: number, targetY: number, instant = false): void {
|
|
targetUnitTurnTowardPoint(this.turnState, this.logicalX, this.logicalY, targetX, targetY, instant)
|
|
}
|
|
|
|
/**
|
|
* Follow `path` (world sub-tiles), as returned by `findPathWorld` from the player's cell.
|
|
*
|
|
* 1.13c path points never include the unit's own position (D2MOO `PathMisc.cpp`:
|
|
* `PathPoints[0]` is the next point, the start lives in `tStartCoord`), while our pathfinder
|
|
* returns the start cell first. Leading points in the player's current cell are therefore
|
|
* dropped; otherwise every re-path while moving would first step back to the cell centre.
|
|
* If nothing is left the player stops and keeps its facing.
|
|
*/
|
|
setPath(path: readonly SubTilePoint[], running = this.running): void {
|
|
this.running = running
|
|
const cellX = Math.round(this.logicalX)
|
|
const cellY = Math.round(this.logicalY)
|
|
let start = 0
|
|
while (
|
|
start < path.length &&
|
|
Math.round(path[start]!.x) === cellX &&
|
|
Math.round(path[start]!.y) === cellY
|
|
) {
|
|
start++
|
|
}
|
|
this.waypoints = path.slice(start).map(p => ({ x: p.x, y: p.y }))
|
|
this.waypointIndex = 0
|
|
this.faceCurrentWaypoint()
|
|
}
|
|
|
|
/**
|
|
* Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start
|
|
* moving along it.
|
|
*
|
|
* The path starts from the player's cell, `Math.round` of the logical position, as in
|
|
* `CommandMapper`: `findPathWorld` floors its start, so a raw fractional position of N.5 or
|
|
* more would start the path one cell behind the player, which {@link setPath} cannot drop.
|
|
*/
|
|
moveToWorldSubTile(
|
|
grid: SubTileWalkabilityGrid,
|
|
worldGoalX: number,
|
|
worldGoalY: number,
|
|
options: PathfindOptions & { readonly running?: boolean } = {},
|
|
): readonly SubTilePoint[] {
|
|
if (options.running !== undefined) {
|
|
this.running = options.running
|
|
}
|
|
const result = findPathWorld(
|
|
grid,
|
|
Math.round(this.logicalX),
|
|
Math.round(this.logicalY),
|
|
worldGoalX,
|
|
worldGoalY,
|
|
options,
|
|
)
|
|
this.setPath(result.path, this.running)
|
|
return this.getRemainingWaypoints()
|
|
}
|
|
|
|
stop(): void {
|
|
this.waypoints = []
|
|
this.waypointIndex = 0
|
|
}
|
|
|
|
getRemainingWaypoints(): readonly SubTilePoint[] {
|
|
return this.waypoints.slice(this.waypointIndex)
|
|
}
|
|
|
|
/**
|
|
* Step the predictor by `ticks` 25Hz game ticks (`ticks * 40ms`).
|
|
*/
|
|
stepTicks(ticks = 1): PredictorSnapshot {
|
|
if (!Number.isInteger(ticks) || ticks < 0) {
|
|
throw new RangeError(`ticks must be a non-negative integer, got ${String(ticks)}`)
|
|
}
|
|
for (let i = 0; i < ticks; i++) {
|
|
this.update(D2_TICK_MS)
|
|
}
|
|
return this.getSnapshot()
|
|
}
|
|
|
|
/**
|
|
* Advance predicted movement and 25Hz (`40ms`) turn transitions (`D2Common.#10603` `0x6FD84D70`,
|
|
* `D2Client` `0x6FACC507`) by `dtMs` milliseconds, whether moving or standing.
|
|
*/
|
|
update(dtMs: number): PredictorSnapshot {
|
|
if (!Number.isFinite(dtMs)) {
|
|
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
|
|
}
|
|
if (dtMs <= 0) return this.getSnapshot()
|
|
|
|
const speed = this.getEffectiveSpeedSubTilesPerSec()
|
|
if (speed <= 0) {
|
|
advanceUnitTurnByDtMs(this.turnState, dtMs)
|
|
return this.getSnapshot()
|
|
}
|
|
|
|
let remainingTimeMs = dtMs
|
|
while (remainingTimeMs > 0 && this.waypointIndex < this.waypoints.length) {
|
|
const target = this.waypoints[this.waypointIndex]!
|
|
const dx = target.x - this.logicalX
|
|
const dy = target.y - this.logicalY
|
|
const segDist = Math.hypot(dx, dy)
|
|
const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0
|
|
|
|
if (remainingTimeMs >= segTimeMs) {
|
|
if (segTimeMs > 0) {
|
|
advanceUnitTurnByDtMs(this.turnState, segTimeMs)
|
|
}
|
|
this.logicalX = target.x
|
|
this.logicalY = target.y
|
|
remainingTimeMs -= segTimeMs
|
|
this.waypointIndex++
|
|
this.faceCurrentWaypoint()
|
|
} else {
|
|
const ratio = segTimeMs > 0 ? remainingTimeMs / segTimeMs : 0
|
|
this.logicalX += dx * ratio
|
|
this.logicalY += dy * ratio
|
|
advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
|
|
remainingTimeMs = 0
|
|
}
|
|
}
|
|
|
|
if (this.waypointIndex >= this.waypoints.length) {
|
|
this.waypoints = []
|
|
this.waypointIndex = 0
|
|
}
|
|
|
|
if (remainingTimeMs > 0) {
|
|
advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
|
|
}
|
|
|
|
return this.getSnapshot()
|
|
}
|
|
|
|
/**
|
|
* D2Client 1.13c position tolerance gate 0x6FADA1B0, for the local player.
|
|
*
|
|
* 1. X or Y of 0 → no check.
|
|
* 2. Client position = the player's integer sub-tile (D2Client 0x6FAB1630 / 0x6FAB1660;
|
|
* logical N.0 is the centre of sub-tile N, so `Math.round`).
|
|
* 3. Tolerance: 10 for flag 1 (`player-stop`); otherwise the player's mode base — NU 3,
|
|
* RN 7, anything else (WL, TN, TW, …) 5 — plus `(rttMs + 50) >> 7`.
|
|
* 4. Within tolerance on both axes → no correction.
|
|
* 5. Out of tolerance with flag 0 and target X > 0, where target = position + (vx, vy):
|
|
* if `d²(client, server) < 100` and `d²(client, target) < d²(client, server)` the client
|
|
* is ahead along the server's motion → no correction.
|
|
* 6. Otherwise the local player sends C2S `0x5F` with its own position and does not move.
|
|
*
|
|
* Both "no correction" outcomes are the gate's soft branch, which only corrects units that are
|
|
* off screen at both positions (sprite test 0x6FB1E860); the local player is the camera target
|
|
* and always on screen. The hard branch also skips units without a room when the server
|
|
* position has none either (0x6FAB1400 / 0x6FABC2E0); callers handle "no room" before calling.
|
|
* The gate's early exit for dead units is not modelled: D2Game ignores `0x5F` from a dead
|
|
* player (D2MOO `PlrMsg.cpp`, 0x6FC88530).
|
|
*/
|
|
verifyServerPosition(check: ServerPositionCheck): ServerPositionVerdict {
|
|
assertSubTile(check.x, 'server x')
|
|
assertSubTile(check.y, 'server y')
|
|
if (!Number.isFinite(check.rttMs) || check.rttMs < 0) {
|
|
throw new RangeError(`rttMs must be a finite non-negative number, got ${String(check.rttMs)}`)
|
|
}
|
|
const { source } = check
|
|
if (source.kind === 'life-mana' && (!Number.isInteger(source.vx) || !Number.isInteger(source.vy))) {
|
|
throw new RangeError(`vx/vy must be integers, got ${String(source.vx)}/${String(source.vy)}`)
|
|
}
|
|
|
|
const clientX = Math.round(this.logicalX)
|
|
const clientY = Math.round(this.logicalY)
|
|
if (check.x === 0 || check.y === 0) {
|
|
return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 }
|
|
}
|
|
|
|
const threshold =
|
|
source.kind === 'player-stop'
|
|
? GATE_FLAG1_THRESHOLD
|
|
: this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7)
|
|
const exceeded =
|
|
Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold
|
|
if (!exceeded) {
|
|
return { action: 'none', reason: 'within-threshold', clientX, clientY, threshold }
|
|
}
|
|
|
|
if (source.kind === 'life-mana') {
|
|
const targetX = (check.x + source.vx) & 0xffff
|
|
const targetY = (check.y + source.vy) & 0xffff
|
|
if (targetX > 0) {
|
|
const toServer = distanceSquared(clientX, clientY, check.x, check.y)
|
|
if (
|
|
toServer < GATE_LEAD_MAX_DIST_SQ &&
|
|
distanceSquared(clientX, clientY, targetX, targetY) < toServer
|
|
) {
|
|
return { action: 'none', reason: 'leading-server', clientX, clientY, threshold }
|
|
}
|
|
}
|
|
}
|
|
|
|
return { action: 'request-resync', reason: 'exceeded', clientX, clientY, threshold }
|
|
}
|
|
|
|
getSnapshot(): PredictorSnapshot {
|
|
const currentWaypoint =
|
|
this.waypointIndex < this.waypoints.length ? this.waypoints[this.waypointIndex]! : null
|
|
return {
|
|
logicalX: this.logicalX,
|
|
logicalY: this.logicalY,
|
|
x: this.logicalX,
|
|
y: this.logicalY,
|
|
moving: currentWaypoint !== null,
|
|
running: this.running,
|
|
dir64: this.turnState.dir64,
|
|
targetDir64: this.turnState.targetDir64,
|
|
stepDir: this.turnState.stepDir,
|
|
currentWaypoint,
|
|
remainingWaypoints: this.waypoints.slice(this.waypointIndex),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Face the current waypoint (`bInstant = 0`). The engine sets the target facing whenever a unit
|
|
* starts a segment toward its next path point (D2Common 0x6FD5D240 at 0x6FD5D370), and steps
|
|
* `dir64` at 25Hz via #10603 (0x6FD84D70).
|
|
*/
|
|
private faceCurrentWaypoint(): void {
|
|
const target = this.waypoints[this.waypointIndex]
|
|
if (target === undefined) return
|
|
targetUnitTurnTowardPoint(
|
|
this.turnState,
|
|
this.logicalX,
|
|
this.logicalY,
|
|
target.x,
|
|
target.y,
|
|
false,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The local player's mode as D2Client picks it — NU / TN standing (TN in town), WL / TW
|
|
* walking, RN running — mapped to the 0x6FADA1B0 tolerance base: NU 3, RN 7, others 5.
|
|
*/
|
|
private gateModeBase(inTown: boolean): number {
|
|
if (this.waypointIndex >= this.waypoints.length) return inTown ? 5 : 3
|
|
return this.running ? 7 : 5
|
|
}
|
|
}
|