Merge branch 'fix/unit-turn-transition' into main (closes #543)

This commit is contained in:
troytt 2026-10-02 13:50:43 +00:00
commit 7658ad94f1
13 changed files with 1893 additions and 191 deletions

View File

@ -113,7 +113,7 @@ export interface CommandMapperDeps {
readonly mapService?: MapService | undefined readonly mapService?: MapService | undefined
readonly automapView?: AutomapView | undefined readonly automapView?: AutomapView | undefined
readonly rateLimiter?: OutboundRateLimiter | undefined readonly rateLimiter?: OutboundRateLimiter | undefined
readonly sendCommand: (cmd: ClientCommand) => void readonly sendCommand: (cmd: ClientCommand, nowMs?: number) => void
/** Optional hook to check if an external UI overlay (e.g. top toolbar / chat input) intercepted the point. */ /** Optional hook to check if an external UI overlay (e.g. top toolbar / chat input) intercepted the point. */
readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
/** Optional callback when Enter key toggles chat input. */ /** Optional callback when Enter key toggles chat input. */
@ -150,7 +150,7 @@ export class CommandMapper {
private readonly mapService: MapService | null private readonly mapService: MapService | null
private readonly automapView: AutomapView | null private readonly automapView: AutomapView | null
private readonly rateLimiter: OutboundRateLimiter private readonly rateLimiter: OutboundRateLimiter
private readonly sendCommand: (cmd: ClientCommand) => void private readonly sendCommand: (cmd: ClientCommand, nowMs?: number) => void
private readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined private readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
private readonly onToggleChatInput?: (() => void) | undefined private readonly onToggleChatInput?: (() => void) | undefined
private readonly onEscapeMenu?: (() => void) | undefined private readonly onEscapeMenu?: (() => void) | undefined
@ -529,11 +529,14 @@ export class CommandMapper {
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) { if (dist > 4) {
this.pendingItemPickupId = hoveredUnit.id this.pendingItemPickupId = hoveredUnit.id
this.sendCommand({ this.sendCommand(
{
type: 'MoveToUnit', type: 'MoveToUnit',
run: isRunning, run: isRunning,
target: unitToRef(hoveredUnit), target: unitToRef(hoveredUnit),
}) },
nowMs,
)
} else { } else {
this.pendingItemPickupId = null this.pendingItemPickupId = null
} }
@ -564,10 +567,13 @@ export class CommandMapper {
nowMs, nowMs,
path, path,
) )
this.sendCommand({ this.sendCommand(
{
type: 'TakeWarp', type: 'TakeWarp',
warpId: hoveredUnit.id, warpId: hoveredUnit.id,
}) },
nowMs,
)
return moveRec return moveRec
} }
@ -575,11 +581,14 @@ export class CommandMapper {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) { if (dist > 4) {
this.sendCommand({ this.sendCommand(
{
type: 'MoveToUnit', type: 'MoveToUnit',
run: isRunning, run: isRunning,
target: unitToRef(hoveredUnit), target: unitToRef(hoveredUnit),
}) },
nowMs,
)
} }
return this.dispatchRateLimited( return this.dispatchRateLimited(
{ {
@ -596,11 +605,14 @@ export class CommandMapper {
const ref = unitToRef(hoveredUnit) const ref = unitToRef(hoveredUnit)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) { if (dist > 4) {
this.sendCommand({ this.sendCommand(
{
type: 'MoveToUnit', type: 'MoveToUnit',
run: isRunning, run: isRunning,
target: ref, target: ref,
}) },
nowMs,
)
} }
return this.dispatchRateLimited( return this.dispatchRateLimited(
{ {
@ -631,14 +643,20 @@ export class CommandMapper {
nowMs, nowMs,
path, path,
) )
this.sendCommand({ this.sendCommand(
{
type: 'NpcInit', type: 'NpcInit',
target: ref, target: ref,
}) },
this.sendCommand({ nowMs,
)
this.sendCommand(
{
type: 'Interact', type: 'Interact',
target: ref, target: ref,
}) },
nowMs,
)
return moveRecord return moveRecord
} }
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack // Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
@ -986,7 +1004,7 @@ export class CommandMapper {
tick(nowMs = performance.now()): ClientCommand | null { tick(nowMs = performance.now()): ClientCommand | null {
const flushed = this.rateLimiter.flushPendingMove(nowMs) const flushed = this.rateLimiter.flushPendingMove(nowMs)
if (flushed) { if (flushed) {
this.sendCommand(flushed) this.sendCommand(flushed, nowMs)
return flushed return flushed
} }
@ -1115,7 +1133,7 @@ export class CommandMapper {
): DispatchedCommandRecord { ): DispatchedCommandRecord {
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs) const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
if (rateLimit.allowed) { if (rateLimit.allowed) {
this.sendCommand(command) this.sendCommand(command, nowMs)
if ( if (
command.type === 'SkillAt' || command.type === 'SkillAt' ||
command.type === 'SkillOn' || command.type === 'SkillOn' ||
@ -1123,10 +1141,50 @@ export class CommandMapper {
command.type === 'CastRightSkillOnLocation' || command.type === 'CastRightSkillOnLocation' ||
command.type === 'CastLeftSkillOnTarget' || command.type === 'CastLeftSkillOnTarget' ||
command.type === 'CastRightSkillOnTarget' || command.type === 'CastRightSkillOnTarget' ||
command.type === 'Interact' ||
command.type === 'OperateObject' ||
command.type === 'NpcInit' ||
command.type === 'Resurrect' command.type === 'Resurrect'
) { ) {
this.world.noteOutgoingCommand(command, nowMs) this.world.noteOutgoingCommand(command, nowMs)
} }
if (this.predictor) {
if (command.type === 'SkillAt') {
this.predictor.stop()
this.predictor.facePoint(command.at.x, command.at.y, false)
} else if (
command.type === 'CastLeftSkillOnLocation' ||
command.type === 'CastRightSkillOnLocation'
) {
this.predictor.stop()
this.predictor.facePoint(command.x, command.y, false)
} else if (
command.type === 'SkillOn' ||
command.type === 'Interact' ||
command.type === 'NpcInit'
) {
this.predictor.stop()
const targetUnit = this.world.getUnit(command.target.unitType, command.target.id)
if (targetUnit) {
this.predictor.facePoint(targetUnit.x, targetUnit.y, false)
}
} else if (
command.type === 'CastLeftSkillOnTarget' ||
command.type === 'CastRightSkillOnTarget'
) {
this.predictor.stop()
const targetUnit = this.world.getUnit(command.unitKind, command.unitId)
if (targetUnit) {
this.predictor.facePoint(targetUnit.x, targetUnit.y, false)
}
} else if (command.type === 'OperateObject') {
this.predictor.stop()
const targetUnit = this.world.getUnit('object', command.objectId)
if (targetUnit) {
this.predictor.facePoint(targetUnit.x, targetUnit.y, false)
}
}
}
} }
return { return {
command, command,

View File

@ -9,11 +9,20 @@
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`) * - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
* - `0x6D NPCStop` (`{ unitId, x, y, life }`) * - `0x6D NPCStop` (`{ unitId, x, y, life }`)
* *
* Facings are dir64 values (`common/units/direction.ts`), set once when a segment starts * Facings are 64-direction values (`common/units/direction.ts`): movement segments and skill/attack
* (like D2Common 0x6FD5D240) and kept while the segment is walked and after it ends. * casts set `targetDir64` via `D2Common.#10425` (`bInstant = 0`), while `update(dtMs)` steps
* `dir64` toward `targetDir64` at 25Hz (`D2Common.#10603`).
*/ */
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts' import {
advanceUnitTurnByDtMs,
assertDir64,
createUnitTurnState,
setUnitTargetDirection,
snapUnitTurnDirection,
targetUnitTurnTowardPoint,
type UnitTurnState,
} from '../../common/units/direction.ts'
import { import {
D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC,
@ -33,10 +42,16 @@ export interface RemoteEntityMotionState {
readonly speedSubTilesPerSec: number readonly speedSubTilesPerSec: number
readonly moving: boolean readonly moving: boolean
readonly running: boolean readonly running: boolean
/** Facing in the engine's 64-direction space. */ /** Current facing in the engine's 64-direction space (`D2DynamicPathStrc+0x38`). */
readonly dir64: number readonly dir64: number
/** Target facing in the engine's 64-direction space (`D2DynamicPathStrc+0x39`). */
readonly targetDir64: number
/** Cached turn step per 25Hz tick (`D2DynamicPathStrc` `+0x66` `nStepDir`: 0, ±1, ±4, ±8). */
readonly stepDir: number
} }
export type RemoteEntityState = RemoteEntityMotionState
export interface RemoteMoveCommand { export interface RemoteMoveCommand {
readonly unitType: number readonly unitType: number
readonly unitId: number readonly unitId: number
@ -67,13 +82,30 @@ interface MutableRemoteEntityMotion {
speedSubTilesPerSec: number speedSubTilesPerSec: number
moving: boolean moving: boolean
running: boolean running: boolean
dir64: number turnState: UnitTurnState
} }
function entityKey(unitType: number, unitId: number): string { function entityKey(unitType: number, unitId: number): string {
return `${String(unitType)}:${String(unitId >>> 0)}` return `${String(unitType)}:${String(unitId >>> 0)}`
} }
function toState(state: MutableRemoteEntityMotion): RemoteEntityMotionState {
return {
unitType: state.unitType,
unitId: state.unitId,
x: state.x,
y: state.y,
targetX: state.targetX,
targetY: state.targetY,
speedSubTilesPerSec: state.speedSubTilesPerSec,
moving: state.moving,
running: state.running,
dir64: state.turnState.dir64,
targetDir64: state.turnState.targetDir64,
stepDir: state.turnState.stepDir,
}
}
/** /**
* Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte into `{ running, speedSubTilesPerSec }`. * Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte into `{ running, speedSubTilesPerSec }`.
* In D2 1.13c: * In D2 1.13c:
@ -111,7 +143,8 @@ export class RemoteEntityInterpolator {
/** /**
* Register or hard-snap an entity at `(x, y)`. Without `dir64` a known entity keeps its * 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. * facing/turn state (a `0x15` reassign carries no direction); a new one faces dir64 0.
* When `dir64` is explicitly provided (`UnitAssign` spawn), snaps both `dir64` and `targetDir64` (`bInstant = 1`).
*/ */
spawnOrSnap(params: { spawnOrSnap(params: {
readonly unitType: number readonly unitType: number
@ -123,6 +156,12 @@ export class RemoteEntityInterpolator {
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64') if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
const key = entityKey(params.unitType, params.unitId) const key = entityKey(params.unitType, params.unitId)
const previous = this.entities.get(key) const previous = this.entities.get(key)
const turnState: UnitTurnState =
params.dir64 !== undefined
? createUnitTurnState(params.dir64)
: previous
? { ...previous.turnState }
: createUnitTurnState(0)
const state: MutableRemoteEntityMotion = { const state: MutableRemoteEntityMotion = {
unitType: params.unitType, unitType: params.unitType,
unitId: params.unitId >>> 0, unitId: params.unitId >>> 0,
@ -133,10 +172,10 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC, speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
moving: false, moving: false,
running: false, running: false,
dir64: params.dir64 ?? previous?.dir64 ?? 0, turnState,
} }
this.entities.set(key, state) this.entities.set(key, state)
return { ...state } return toState(state)
} }
/** /**
@ -161,7 +200,7 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec, speedSubTilesPerSec,
moving: false, moving: false,
running, running,
dir64: 0, turnState: createUnitTurnState(0),
} }
this.entities.set(key, state) this.entities.set(key, state)
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) { } else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
@ -182,7 +221,7 @@ export class RemoteEntityInterpolator {
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (dist > 1e-4) { if (dist > 1e-4) {
state.moving = true state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, state.targetX, state.targetY, state.dir64) targetUnitTurnTowardPoint(state.turnState, state.x, state.y, state.targetX, state.targetY, false)
// If visual position lagged behind server start, slightly boost speed to catch up smoothly // If visual position lagged behind server start, slightly boost speed to catch up smoothly
if (cmd.currentX !== undefined && cmd.currentY !== undefined) { if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY) const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
@ -197,7 +236,7 @@ export class RemoteEntityInterpolator {
state.moving = false state.moving = false
} }
return { ...state } return toState(state)
} }
/** /**
@ -227,15 +266,56 @@ export class RemoteEntityInterpolator {
state.targetY = cmd.y state.targetY = cmd.y
state.moving = false state.moving = false
} else { } else {
// Smoothly finish the small remaining glide to the authoritative stop point, facing it so // Smoothly finish the small remaining glide to the authoritative stop point, turning toward
// the unit never slides backwards. // it via D2Common.#10425 (bInstant = 0) so the unit never slides backwards.
state.targetX = cmd.x state.targetX = cmd.x
state.targetY = cmd.y state.targetY = cmd.y
state.moving = true state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, cmd.x, cmd.y, state.dir64) targetUnitTurnTowardPoint(state.turnState, state.x, state.y, cmd.x, cmd.y, false)
} }
return { ...state } return toState(state)
}
/**
* Set an entity's target direction (`bInstant = 0` by default, or `bInstant = 1` when `instant = true`).
*/
setEntityTargetDirection(
unitType: number,
unitId: number,
targetDir64: number,
instant = false,
): RemoteEntityMotionState | null {
assertDir64(targetDir64, 'targetDir64')
const state = this.entities.get(entityKey(unitType, unitId))
if (!state) return null
if (instant) {
snapUnitTurnDirection(state.turnState, targetDir64)
} else {
setUnitTargetDirection(state.turnState, targetDir64, false)
}
return toState(state)
}
/**
* Turn an entity toward `(targetX, targetY)` (`bInstant = 0` by default).
*/
faceEntityToward(
unitType: number,
unitId: number,
targetX: number,
targetY: number,
instant = false,
): RemoteEntityMotionState | null {
if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) {
throw new RangeError(
`Coordinates must be finite numbers, got (${String(targetX)}, ${String(targetY)})`,
)
}
const state = this.entities.get(entityKey(unitType, unitId))
if (!state) return null
targetUnitTurnTowardPoint(state.turnState, state.x, state.y, targetX, targetY, instant)
return toState(state)
} }
removeEntity(unitType: number, unitId: number): boolean { removeEntity(unitType: number, unitId: number): boolean {
@ -247,12 +327,18 @@ export class RemoteEntityInterpolator {
} }
/** /**
* Advance all interpolated entities by `dtMs` milliseconds. Facings do not change here. * Advance all interpolated entities by `dtMs` milliseconds:
* - Steps every entity's 25Hz turn state (`D2Common.#10603`), whether moving or standing.
* - Advances position for moving entities.
*/ */
update(dtMs: number): void { update(dtMs: number): void {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (dtMs <= 0) return if (dtMs <= 0) return
const dtSec = dtMs / 1000 const dtSec = dtMs / 1000
for (const state of this.entities.values()) { for (const state of this.entities.values()) {
advanceUnitTurnByDtMs(state.turnState, dtMs)
if (!state.moving) continue if (!state.moving) continue
const dx = state.targetX - state.x const dx = state.targetX - state.x
const dy = state.targetY - state.y const dy = state.targetY - state.y
@ -282,10 +368,10 @@ export class RemoteEntityInterpolator {
getEntityState(unitType: number, unitId: number): RemoteEntityMotionState | null { getEntityState(unitType: number, unitId: number): RemoteEntityMotionState | null {
const state = this.entities.get(entityKey(unitType, unitId)) const state = this.entities.get(entityKey(unitType, unitId))
return state ? { ...state } : null return state ? toState(state) : null
} }
getAllStates(): RemoteEntityMotionState[] { getAllStates(): RemoteEntityMotionState[] {
return Array.from(this.entities.values(), s => ({ ...s })) return Array.from(this.entities.values(), s => toState(s))
} }
} }

View File

@ -13,12 +13,20 @@
* player toward such a position; when the drift is out of tolerance it asks the server to * 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. * resynchronise with C2S `0x5F`, and the server answers with `0x15` if it disagrees.
* *
* The facing is a dir64 (`common/units/direction.ts`), set only when a segment starts — on * 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 * {@link LocalMovementPredictor.setPath} and on each waypoint advance — like the engine's
* D2Common 0x6FD5D240, never per frame. * D2Common 0x6FD5D240, and `dir64` rotates toward `targetDir64` at 25Hz (D2Common #10603,
* D2Client 0x6FACC507).
*/ */
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts' import {
advanceUnitTurnByDtMs,
createUnitTurnState,
setUnitTargetDirection,
snapUnitTurnDirection,
targetUnitTurnTowardPoint,
type UnitTurnState,
} from '../../common/units/direction.ts'
import { import {
findPathWorld, findPathWorld,
type PathfindOptions, type PathfindOptions,
@ -46,6 +54,7 @@ const GATE_LEAD_MAX_DIST_SQ = 100
export interface PredictorConfig { export interface PredictorConfig {
readonly initialX?: number readonly initialX?: number
readonly initialY?: number readonly initialY?: number
readonly initialDir64?: number
readonly walkSpeedSubTilesPerSec?: number readonly walkSpeedSubTilesPerSec?: number
readonly runSpeedSubTilesPerSec?: number readonly runSpeedSubTilesPerSec?: number
readonly fasterRunWalkPercent?: number readonly fasterRunWalkPercent?: number
@ -115,8 +124,12 @@ export interface PredictorSnapshot {
readonly moving: boolean readonly moving: boolean
/** True if running (false if walking). */ /** True if running (false if walking). */
readonly running: boolean readonly running: boolean
/** Facing in the engine's 64-direction space (`common/units/direction.ts`). */ /** Current stepped facing (`D2DynamicPathStrc` `+0x64` `nDirection`, 0..63). */
readonly dir64: number 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. */ /** Current active target waypoint, or null if idle. */
readonly currentWaypoint: SubTilePoint | null readonly currentWaypoint: SubTilePoint | null
/** Remaining waypoints including current target. */ /** Remaining waypoints including current target. */
@ -141,7 +154,7 @@ export class LocalMovementPredictor {
private runSpeed: number private runSpeed: number
private fasterRunWalkPercent: number private fasterRunWalkPercent: number
private running = true private running = true
private dir64 = 0 private readonly turnState: UnitTurnState
private waypoints: SubTilePoint[] = [] private waypoints: SubTilePoint[] = []
private waypointIndex = 0 private waypointIndex = 0
@ -151,6 +164,7 @@ export class LocalMovementPredictor {
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0 this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
this.turnState = createUnitTurnState(config.initialDir64 ?? 0)
} }
/** /**
@ -186,11 +200,26 @@ export class LocalMovementPredictor {
} }
/** /**
* Set the facing (dir64), e.g. when the server assigns one. * 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 { setDirection(dir64: number): void {
assertDir64(dir64, 'dir64') snapUnitTurnDirection(this.turnState, dir64)
this.dir64 = 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)
} }
/** /**
@ -261,6 +290,9 @@ export class LocalMovementPredictor {
* Step the predictor by `ticks` 25Hz game ticks (`ticks * 40ms`). * Step the predictor by `ticks` 25Hz game ticks (`ticks * 40ms`).
*/ */
stepTicks(ticks = 1): PredictorSnapshot { 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++) { for (let i = 0; i < ticks; i++) {
this.update(D2_TICK_MS) this.update(D2_TICK_MS)
} }
@ -268,29 +300,44 @@ export class LocalMovementPredictor {
} }
/** /**
* Advance predicted movement by `dtMs` milliseconds. * Advance predicted movement and 25Hz (`40ms`) turn transitions (`D2Common.#10603` `0x6FD84D70`,
* `D2Client` `0x6FACC507`) by `dtMs` milliseconds, whether moving or standing.
*/ */
update(dtMs: number): PredictorSnapshot { 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() if (dtMs <= 0) return this.getSnapshot()
let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * (dtMs / 1000) const speed = this.getEffectiveSpeedSubTilesPerSec()
while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) { 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 target = this.waypoints[this.waypointIndex]!
const dx = target.x - this.logicalX const dx = target.x - this.logicalX
const dy = target.y - this.logicalY const dy = target.y - this.logicalY
const segDist = Math.hypot(dx, dy) const segDist = Math.hypot(dx, dy)
const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0
if (remainingDist >= segDist) { if (remainingTimeMs >= segTimeMs) {
if (segTimeMs > 0) {
advanceUnitTurnByDtMs(this.turnState, segTimeMs)
}
this.logicalX = target.x this.logicalX = target.x
this.logicalY = target.y this.logicalY = target.y
remainingDist -= segDist remainingTimeMs -= segTimeMs
this.waypointIndex++ this.waypointIndex++
this.faceCurrentWaypoint() this.faceCurrentWaypoint()
} else { } else {
const ratio = remainingDist / segDist const ratio = segTimeMs > 0 ? remainingTimeMs / segTimeMs : 0
this.logicalX += dx * ratio this.logicalX += dx * ratio
this.logicalY += dy * ratio this.logicalY += dy * ratio
remainingDist = 0 advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
remainingTimeMs = 0
} }
} }
@ -299,6 +346,10 @@ export class LocalMovementPredictor {
this.waypointIndex = 0 this.waypointIndex = 0
} }
if (remainingTimeMs > 0) {
advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
}
return this.getSnapshot() return this.getSnapshot()
} }
@ -377,20 +428,30 @@ export class LocalMovementPredictor {
y: this.logicalY, y: this.logicalY,
moving: currentWaypoint !== null, moving: currentWaypoint !== null,
running: this.running, running: this.running,
dir64: this.dir64, dir64: this.turnState.dir64,
targetDir64: this.turnState.targetDir64,
stepDir: this.turnState.stepDir,
currentWaypoint, currentWaypoint,
remainingWaypoints: this.waypoints.slice(this.waypointIndex), remainingWaypoints: this.waypoints.slice(this.waypointIndex),
} }
} }
/** /**
* Face the current waypoint. The engine sets the facing whenever a unit starts a segment * Face the current waypoint (`bInstant = 0`). The engine sets the target facing whenever a unit
* toward its next path point (D2Common 0x6FD5D240), not while walking it. * starts a segment toward its next path point (D2Common 0x6FD5D240 at 0x6FD5D370), and steps
* `dir64` at 25Hz via #10603 (0x6FD84D70).
*/ */
private faceCurrentWaypoint(): void { private faceCurrentWaypoint(): void {
const target = this.waypoints[this.waypointIndex] const target = this.waypoints[this.waypointIndex]
if (target === undefined) return if (target === undefined) return
this.dir64 = dir64FromPoints(this.logicalX, this.logicalY, target.x, target.y, this.dir64) targetUnitTurnTowardPoint(
this.turnState,
this.logicalX,
this.logicalY,
target.x,
target.y,
false,
)
} }
/** /**

View File

@ -22,6 +22,7 @@ import {
createD2OnlineFlow, createD2OnlineFlow,
createWsBridgeResolver, createWsBridgeResolver,
decodeD2gsS2cPacket, decodeD2gsS2cPacket,
unitTypeFromId,
unitTypeToId, unitTypeToId,
type CharClassId, type CharClassId,
type CharCreateSpec, type CharCreateSpec,
@ -286,6 +287,7 @@ export class OnlineSession {
const initialViewport = getViewportProfile(currentSettings.viewport) const initialViewport = getViewportProfile(currentSettings.viewport)
this.world = options.world ?? new ClientWorld() this.world = options.world ?? new ClientWorld()
this.world.skipSelfTurnStep = true
this.camera = options.camera ?? new ViewportCamera(initialViewport) this.camera = options.camera ?? new ViewportCamera(initialViewport)
this.hudManager = options.hudManager ?? new HudManager() this.hudManager = options.hudManager ?? new HudManager()
this.hudManager.resize(initialViewport.width, initialViewport.height) this.hudManager.resize(initialViewport.width, initialViewport.height)
@ -393,8 +395,8 @@ export class OnlineSession {
mapService: this.mapService ?? undefined, mapService: this.mapService ?? undefined,
automapView: this.automapView, automapView: this.automapView,
rateLimiter: this.rateLimiter, rateLimiter: this.rateLimiter,
sendCommand: (cmd) => { sendCommand: (cmd, nowMs) => {
this.forwardCommandToAdapter(cmd) this.forwardCommandToAdapter(cmd, nowMs)
}, },
isExternalUiIntercepted: options.isExternalUiIntercepted, isExternalUiIntercepted: options.isExternalUiIntercepted,
onToggleChatInput: options.onToggleChatInput, onToggleChatInput: options.onToggleChatInput,
@ -1072,8 +1074,8 @@ export class OnlineSession {
: (cmd as ClientCommand) : (cmd as ClientCommand)
const res = this.rateLimiter.tryAcquire(normalized, nowMs) const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) { if (res.allowed) {
this.world.noteOutgoingCommand(normalized) this.world.noteOutgoingCommand(normalized, nowMs)
this.forwardCommandToAdapter(normalized) this.forwardCommandToAdapter(normalized, nowMs)
return true return true
} }
return false return false
@ -1131,7 +1133,7 @@ export class OnlineSession {
return this.useSkillOn(unitKind, unitId, 'left', shift) return this.useSkillOn(unitKind, unitId, 'left', shift)
} }
private forwardCommandToAdapter(cmd: ClientCommand): void { private forwardCommandToAdapter(cmd: ClientCommand, nowMs?: number): void {
this.outboundCommandsLog.push(cmd) this.outboundCommandsLog.push(cmd)
if (this.outboundCommandsLog.length > 512) { if (this.outboundCommandsLog.length > 512) {
this.outboundCommandsLog.shift() this.outboundCommandsLog.shift()
@ -1139,16 +1141,36 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') { if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try { try {
this.gameAdapter.send(cmd) this.gameAdapter.send(cmd)
this.world.noteOutgoingCommand(cmd) this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) { if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId) const selfUnit = this.world.getUnit('player', this.world.selfId)
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
this.predictor.setDirection(selfUnit.direction) this.predictor.setTargetDirection(selfUnit.targetDirection, false)
} }
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return
}
} else {
this.world.noteOutgoingCommand(cmd, nowMs)
}
if (
cmd.type === 'SkillAt' ||
cmd.type === 'SkillOn' ||
cmd.type === 'CastLeftSkillOnLocation' ||
cmd.type === 'CastRightSkillOnLocation' ||
cmd.type === 'CastLeftSkillOnTarget' ||
cmd.type === 'CastRightSkillOnTarget' ||
cmd.type === 'Interact' ||
cmd.type === 'OperateObject' ||
cmd.type === 'NpcInit'
) {
const selfUnit = this.world.getUnit('player', this.world.selfId || 1)
if (selfUnit) {
this.predictor.stop()
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
} }
} }
} }
@ -1205,6 +1227,27 @@ export class OnlineSession {
? { x: Number(evAny.targetX), y: Number(evAny.targetY) } ? { x: Number(evAny.targetX), y: Number(evAny.targetY) }
: { x: 0, y: 0 }), : { x: 0, y: 0 }),
} as ServerEvent } as ServerEvent
} else if (evAny.type === 'UnitMove') {
const uType = typeof evAny.unitType === 'number' ? unitTypeFromId(evAny.unitType) : (evAny.unitType ?? 'player')
const uId = Number(evAny.id ?? evAny.unitId ?? 0)
const hasFrom = evAny.from !== undefined || evAny.currentX !== undefined
const from = hasFrom
? {
x: Number(evAny.from?.x ?? evAny.currentX ?? 0),
y: Number(evAny.from?.y ?? evAny.currentY ?? 0),
}
: undefined
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? (from ? from.x : 0))
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? (from ? from.y : 0))
ev = {
...evAny,
type: 'UnitMove',
unitType: uType,
id: uId,
mode: evAny.mode ?? 'walk',
...(from ? { from } : {}),
to: { x: toX, y: toY },
} as ServerEvent
} }
const preMoveUnit = const preMoveUnit =
ev.type === 'UnitMove' ev.type === 'UnitMove'
@ -1228,6 +1271,32 @@ export class OnlineSession {
this.setPhase('ingame') this.setPhase('ingame')
} }
if ((ev as any).type === 'Interact') {
const raw = ev as any
const actorType: UnitType = raw.actor?.unitType ?? raw.unit?.unitType ?? raw.unitType ?? 0
const actorTypeId = unitTypeToId(actorType)
const actorId = Number(
raw.actor?.id ?? raw.unit?.id ?? raw.unitId ?? (this.world.selfId || 1),
)
const unit = this.world.getUnit(actorType, actorId)
if (unit) {
const isLocalPlayer =
actorTypeId === 0 && (actorId === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) {
this.predictor.stop()
this.predictor.setTargetDirection(unit.targetDirection, false)
} else {
this.interpolator.setEntityTargetDirection(
actorTypeId,
actorId,
unit.targetDirection,
false,
)
}
}
return
}
switch (ev.type) { switch (ev.type) {
case 'LoadAct': { case 'LoadAct': {
// 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being // 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
@ -1322,6 +1391,9 @@ export class OnlineSession {
this.predictor.setPosition(u.x, u.y) this.predictor.setPosition(u.x, u.y)
this.camera.snapTo(u.x, u.y) this.camera.snapTo(u.x, u.y)
} }
if (u.direction !== undefined) {
this.predictor.setDirection(u.direction)
}
} else if (u.x !== 0 || u.y !== 0) { } else if (u.x !== 0 || u.y !== 0) {
this.interpolator.spawnOrSnap({ this.interpolator.spawnOrSnap({
unitType: uTypeId, unitType: uTypeId,
@ -1379,7 +1451,7 @@ export class OnlineSession {
case 'UnitMove': { case 'UnitMove': {
const evAny = ev as any const evAny = ev as any
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType) const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1) const evUnitId = Number(evAny.id ?? evAny.unitId ?? (evTypeId === 0 ? 1 : 0))
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
@ -1480,17 +1552,52 @@ export class OnlineSession {
if (unit) { if (unit) {
unit.x = motionState.x unit.x = motionState.x
unit.y = motionState.y unit.y = motionState.y
unit.direction = motionState.dir64 unit.syncTurnState(motionState.dir64, motionState.targetDir64, motionState.stepDir)
}
const worldUnit = this.world.getUnit(ev.unitType, evUnitId)
if (worldUnit && ev.target) {
this.interpolator.setEntityTargetDirection(
evTypeId,
evUnitId,
worldUnit.targetDirection,
false,
)
} }
} }
break break
} }
case 'SkillCast': { case 'SkillCast': {
if (this.world.selfId !== 0) { const raw = ev as any
const selfUnit = this.world.getUnit('player', this.world.selfId) const actorType: UnitType =
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { raw.caster?.unitType ??
this.predictor.setDirection(selfUnit.direction) raw.actor?.unitType ??
raw.unit?.unitType ??
(raw.kind === 'monsterAttack' ? 1 : (raw.unitType ?? raw.attackerType ?? 0))
const actorTypeId = unitTypeToId(actorType)
const actorId = Number(
raw.caster?.id ??
raw.actor?.id ??
raw.unit?.id ??
raw.monsterId ??
raw.unitId ??
raw.attackerId ??
(this.world.selfId || 1),
)
const unit = this.world.getUnit(actorType, actorId)
if (unit) {
const isLocalPlayer =
actorTypeId === 0 && (actorId === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) {
this.predictor.stop()
this.predictor.setTargetDirection(unit.targetDirection, false)
} else {
this.interpolator.setEntityTargetDirection(
actorTypeId,
actorId,
unit.targetDirection,
false,
)
} }
} }
break break
@ -1567,23 +1674,55 @@ export class OnlineSession {
* Step the session by `dtMs` milliseconds: * Step the session by `dtMs` milliseconds:
* - Flushes rate-limited movement commands in `CommandMapper` * - Flushes rate-limited movement commands in `CommandMapper`
* - Steps `LocalMovementPredictor` and `Camera` * - Steps `LocalMovementPredictor` and `Camera`
* - Steps `RemoteEntityInterpolator` and syncs interpolated positions to `ClientWorld` units * - Steps `RemoteEntityInterpolator` and syncs interpolated positions and 25Hz facings to `ClientWorld` units
* - Expires finished one-shot cast/attack animations in `ClientWorld` * - Expires finished one-shot cast/attack animations in `ClientWorld`
*/ */
tick(dtMs = 40, nowMs = performance.now()): void { tick(dtMs = 40, nowMs = performance.now()): void {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (!Number.isFinite(nowMs)) {
throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`)
}
const safeDt = Math.max(0, dtMs) const safeDt = Math.max(0, dtMs)
this.tickCounter++ this.tickCounter++
this.commandMapper.tick(nowMs) this.commandMapper.tick(nowMs)
const selfUnit = this.world.getUnit('player', this.world.selfId || 1)
if (selfUnit) {
const curSnap = this.predictor.getSnapshot()
if (selfUnit.castState && curSnap.moving) {
this.predictor.stop()
}
if (
(!curSnap.moving || selfUnit.castState) &&
selfUnit.targetDirection !== curSnap.targetDir64
) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
const predSnap = this.predictor.update(safeDt) const predSnap = this.predictor.update(safeDt)
if (predSnap.x !== 0 || predSnap.y !== 0) { if (predSnap.x !== 0 || predSnap.y !== 0) {
this.camera.update(predSnap.x, predSnap.y) this.camera.update(predSnap.x, predSnap.y)
} }
if (selfUnit) {
selfUnit.syncTurnState(predSnap.dir64, predSnap.targetDir64, predSnap.stepDir)
}
const selfUnit = for (const unit of this.world.units.values()) {
this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined if (unit.unitType === 'player' && (this.world.selfId === 0 || unit.id === (this.world.selfId || 1))) {
if (!predSnap.moving && selfUnit?.castState) { continue
this.predictor.setDirection(selfUnit.direction) }
const uTypeId = unitTypeToId(unit.unitType)
const state = this.interpolator.getEntityState(uTypeId, unit.id)
if (state) {
if (!state.moving && unit.targetDirection !== state.targetDir64) {
this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false)
}
} else if (!unit.dead) {
unit.advanceTurn(safeDt)
}
} }
this.world.tick(dtMs, nowMs) this.world.tick(dtMs, nowMs)
@ -1594,7 +1733,7 @@ export class OnlineSession {
if (unit) { if (unit) {
unit.x = state.x unit.x = state.x
unit.y = state.y unit.y = state.y
unit.direction = state.dir64 unit.syncTurnState(state.dir64, state.targetDir64, state.stepDir)
if (unit.unitType === 'player' && unit.id !== this.world.selfId) { if (unit.unitType === 'player' && unit.id !== this.world.selfId) {
const unitInTown = this.isSubTileInTown(state.x, state.y) const unitInTown = this.isSubTileInTown(state.x, state.y)
if (state.moving && unit.moveMode !== 'stop') { if (state.moving && unit.moveMode !== 'stop') {
@ -1649,9 +1788,6 @@ export class OnlineSession {
if (this.world.selfId !== 0 && selfClass !== null) { if (this.world.selfId !== 0 && selfClass !== null) {
const selfUnit = this.world.getUnit('player', this.world.selfId) const selfUnit = this.world.getUnit('player', this.world.selfId)
const isSelfDead = this.world.isSelfDead || this.world.isDead const isSelfDead = this.world.isSelfDead || this.world.isDead
if (!predSnap.moving && selfUnit?.castState) {
this.predictor.setDirection(selfUnit.direction)
}
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
const selfInTown = this.isSubTileInTown(selfX, selfY) const selfInTown = this.isSubTileInTown(selfX, selfY)
const neutralMode = selfInTown ? 5 : 1 const neutralMode = selfInTown ? 5 : 1

View File

@ -309,20 +309,23 @@ function computeMissileClipDirection(
} }
/** /**
* The dir64 facing of a player, monster, object or item. The motion layer sets the facing * The dir64 facing of a player, monster, object or item. When `unit.direction` is provided by
* when a unit starts a segment, so `direction` passes through as is; a unit given a target * the motion/world layer (`unit.direction !== undefined`), it is validated and passed through
* (its own, or its active cast's) faces it (D2Common 0x6FD5CF40 via `dir64FromPoints`) and * directly so frame-by-frame turn transitions (`D2Common.#10603`) are never overwritten by the
* keeps `direction` when already there. * destination angle. Only when `unit.direction === undefined` and `targetX`/`targetY` are given
* does it fall back to `dir64FromPoints`.
*/ */
function unitFacingDir64( function unitFacingDir64(
unit: RenderableUnitInput, unit: RenderableUnitInput,
targetX: number | undefined, targetX: number | undefined,
targetY: number | undefined, targetY: number | undefined,
): number { ): number {
const facing = unit.direction ?? 0 if (unit.direction !== undefined) {
assertDir64(facing, `unit ${String(unit.id)} direction`) assertDir64(unit.direction, `unit ${String(unit.id)} direction`)
if (targetX === undefined || targetY === undefined) return facing return unit.direction
return dir64FromPoints(unit.x, unit.y, targetX, targetY, facing) }
if (targetX === undefined || targetY === undefined) return 0
return dir64FromPoints(unit.x, unit.y, targetX, targetY, 0)
} }
export interface BuildSceneDrawablesResult { export interface BuildSceneDrawablesResult {

View File

@ -5,6 +5,17 @@
* tracked by `ClientWorld`. * tracked by `ClientWorld`.
*/ */
import {
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
advanceUnitTurnByTicks,
assertDir64,
createUnitTurnState,
setUnitTargetDirection,
snapUnitTurnDirection,
targetUnitTurnTowardPoint,
type UnitTurnState,
} from '../../common/units/direction.ts'
import { import {
unitTypeFromId, unitTypeFromId,
type ItemData, type ItemData,
@ -73,7 +84,27 @@ export class ClientUnit {
velocityPercent?: number | undefined velocityPercent?: number | undefined
pathType?: number | undefined pathType?: number | undefined
mode: number mode: number
direction: 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 lifePct: number
flags: number flags: number
warpId?: number | undefined warpId?: number | undefined
@ -147,7 +178,8 @@ export class ClientUnit {
? 1 ? 1
: 0 : 0
this.mode = snapshot.mode ?? defaultMode this.mode = snapshot.mode ?? defaultMode
this.direction = snapshot.direction ?? 0 const initialDir = snapshot.direction ?? 0
this._turnState = createUnitTurnState(initialDir)
this.flags = snapshot.flags ?? 0 this.flags = snapshot.flags ?? 0
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined) this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
this.objectState = snapshot.objectState this.objectState = snapshot.objectState
@ -175,7 +207,11 @@ export class ClientUnit {
} }
set dir(v: number) { set dir(v: number) {
this.direction = v this.setDirection(v)
}
get turnStepDir(): number {
return this._turnState.stepDir
} }
get quality(): number | undefined { get quality(): number | undefined {
@ -186,6 +222,88 @@ export class ClientUnit {
this.itemQuality = v 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 { updateFromSnapshot(snapshot: UnitSnapshot): void {
if (snapshot.classId >= 0) { if (snapshot.classId >= 0) {
this.classId = snapshot.classId this.classId = snapshot.classId
@ -201,7 +319,7 @@ export class ClientUnit {
this.mode = snapshot.mode this.mode = snapshot.mode
} }
if (snapshot.direction !== undefined) { if (snapshot.direction !== undefined) {
this.direction = snapshot.direction this.setDirection(snapshot.direction)
} }
if (snapshot.lifePct !== undefined) { if (snapshot.lifePct !== undefined) {
this.lifePct = snapshot.lifePct this.lifePct = snapshot.lifePct
@ -272,6 +390,11 @@ export class ClientUnit {
this.x = from.x this.x = from.x
this.y = from.y 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 this.moveMode = mode
if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) { if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) {
if (mode === 'stop') { if (mode === 'stop') {

View File

@ -303,6 +303,8 @@ export class ClientWorld {
selfId = 0 selfId = 0
frameCount = 0 frameCount = 0
lastRttMs: number | null = null lastRttMs: number | null = null
/** When true, `tick()` skips stepping turn transitions on `selfId` (e.g. driven by `OnlineSession` predictor). */
skipSelfTurnStep = false
private readonly _self = new MutableSelfPlayerState() private readonly _self = new MutableSelfPlayerState()
private _isSelfDead = false private _isSelfDead = false
@ -844,9 +846,10 @@ export class ClientWorld {
unit.targetX = targetX unit.targetX = targetX
unit.targetY = targetY unit.targetY = targetY
unit.lastSkillId = skillId unit.lastSkillId = skillId
// `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the unit.moveMode = 'stop'
// same 1.13c direction table as movement; a target on the unit's own position keeps the facing. // Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) // `unit.direction` toward `unit.targetDirection`.
unit.faceToward(targetX, targetY, false)
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10 const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
@ -927,9 +930,42 @@ export class ClientWorld {
} }
const cur = this._npcInteraction const cur = this._npcInteraction
switch (cmd.type) { switch (cmd.type) {
case 'NpcInit': case 'NpcInit': {
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none', activeWindow: 'none' } this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none', activeWindow: 'none' }
const resolvedSelfId = this.selfId || (this.getUnit('player', 1) ? 1 : 0)
if (resolvedSelfId !== 0) {
const targetUnit = this.getUnit(cmd.target.unitType, cmd.target.id)
if (targetUnit) {
const selfUnit = this.ensureUnit('player', resolvedSelfId)
selfUnit.targetUnit = cmd.target
selfUnit.faceToward(targetUnit.x, targetUnit.y, false)
}
}
break break
}
case 'Interact': {
const resolvedSelfId = this.selfId || (this.getUnit('player', 1) ? 1 : 0)
if (resolvedSelfId !== 0) {
const targetUnit = this.getUnit(cmd.target.unitType, cmd.target.id)
if (targetUnit) {
const selfUnit = this.ensureUnit('player', resolvedSelfId)
selfUnit.targetUnit = cmd.target
selfUnit.faceToward(targetUnit.x, targetUnit.y, false)
}
}
break
}
case 'OperateObject': {
const resolvedSelfId = this.selfId || (this.getUnit('player', 1) ? 1 : 0)
if (resolvedSelfId !== 0) {
const targetUnit = this.getUnit('object', cmd.objectId)
if (targetUnit) {
const selfUnit = this.ensureUnit('player', resolvedSelfId)
selfUnit.faceToward(targetUnit.x, targetUnit.y, false)
}
}
break
}
case 'NpcOpenTrade': case 'NpcOpenTrade':
case 'NpcOpenGamble': { case 'NpcOpenGamble': {
const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble' const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble'
@ -1160,6 +1196,16 @@ export class ClientWorld {
return this._soundQueue.splice(0, this._soundQueue.length) return this._soundQueue.splice(0, this._soundQueue.length)
} }
stepUnitTurns(dtMs = 40): void {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
for (const unit of this._units.values()) {
if (unit.dead) continue
unit.advanceTurn(dtMs)
}
}
private triggerMissileImpact( private triggerMissileImpact(
missile: ClientUnit, missile: ClientUnit,
hitX: number, hitX: number,
@ -1538,7 +1584,10 @@ export class ClientWorld {
} }
tick(dtMs = 40, nowMs?: number): void { tick(dtMs = 40, nowMs?: number): void {
const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0) if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
const safeDt = Math.max(0, dtMs)
const cappedDt = Math.min(200, safeDt) const cappedDt = Math.min(200, safeDt)
this._tickAccumulatorMs += cappedDt this._tickAccumulatorMs += cappedDt
this._simTimeMs += safeDt this._simTimeMs += safeDt
@ -1552,6 +1601,14 @@ export class ClientWorld {
this.stepDiscreteSimulationTick(D2_TICK_MS) this.stepDiscreteSimulationTick(D2_TICK_MS)
} }
for (const unit of this._units.values()) {
if (unit.dead) continue
if (this.skipSelfTurnStep && unit.unitType === 'player' && (unit.id === this.selfId || (this.selfId === 0 && unit.id === 1))) {
continue
}
unit.advanceTurn(safeDt)
}
// Continuous unit movement interpolation across display frames // Continuous unit movement interpolation across display frames
if (safeDt > 0) { if (safeDt > 0) {
const inTown = isTownLevel(this.areaId) const inTown = isTownLevel(this.areaId)
@ -1708,6 +1765,7 @@ export class ClientWorld {
name: ev.name ?? ev.code, name: ev.name ?? ev.code,
x: Number(ev.x ?? 0), x: Number(ev.x ?? 0),
y: Number(ev.y ?? 0), y: Number(ev.y ?? 0),
...(ev.direction !== undefined ? { direction: Number(ev.direction) } : {}),
lifePct, lifePct,
mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode, mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode,
}, },
@ -1718,22 +1776,54 @@ export class ClientWorld {
unitType: unitTypeFromId(ev.unitType ?? 1), unitType: unitTypeFromId(ev.unitType ?? 1),
id: Number(ev.unitId ?? ev.id ?? 0), id: Number(ev.unitId ?? ev.id ?? 0),
} }
} else if (ev.type === 'UnitMove' && !ev.to) { } else if (ev.type === 'UnitMove') {
const evAny = ev as any
const uType = typeof evAny.unitType === 'number' ? unitTypeFromId(evAny.unitType) : (evAny.unitType ?? 'player')
const uId = Number(evAny.id ?? evAny.unitId ?? 0)
const hasFrom = evAny.from !== undefined || evAny.currentX !== undefined
const from = hasFrom
? {
x: Number(evAny.from?.x ?? evAny.currentX ?? 0),
y: Number(evAny.from?.y ?? evAny.currentY ?? 0),
}
: undefined
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? (from ? from.x : 0))
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? (from ? from.y : 0))
ev = { ev = {
type: 'UnitMove', type: 'UnitMove',
unitType: unitTypeFromId(ev.unitType ?? 0), unitType: uType,
id: Number(ev.unitId ?? ev.id ?? 1), id: uId,
mode: ev.mode ?? 'walk', mode: evAny.mode ?? 'walk',
from: { ...(from ? { from } : {}),
x: Number(ev.currentX ?? ev.x ?? 0),
y: Number(ev.currentY ?? ev.y ?? 0),
},
to: { to: {
x: toX,
y: toY,
},
...(evAny.target ? { target: evAny.target } : {}),
...(evAny.velocityPercent !== undefined ? { velocityPercent: Number(evAny.velocityPercent) } : {}),
...(evAny.pathType !== undefined ? { pathType: Number(evAny.pathType) } : {}),
}
} else if (ev.type === 'CastAtCoord') {
ev = {
type: 'SkillCast',
caster: ev.caster ?? ev.unit ?? { unitType: ev.unitType ?? 0, id: Number(ev.unitId ?? ev.id ?? (this.selfId || 1)) },
skill: Number(ev.skill ?? ev.skillId ?? 0),
level: Number(ev.level ?? 1),
target: ev.at ?? ev.target ?? {
x: Number(ev.targetX ?? ev.x ?? 0), x: Number(ev.targetX ?? ev.x ?? 0),
y: Number(ev.targetY ?? ev.y ?? 0), y: Number(ev.targetY ?? ev.y ?? 0),
}, },
...(ev.velocityPercent !== undefined ? { velocityPercent: Number(ev.velocityPercent) } : {}), }
...(ev.pathType !== undefined ? { pathType: Number(ev.pathType) } : {}), } else if (ev.type === 'CastOnUnit') {
ev = {
type: 'SkillCast',
caster: ev.caster ?? ev.unit ?? { unitType: ev.unitType ?? 0, id: Number(ev.unitId ?? ev.id ?? (this.selfId || 1)) },
skill: Number(ev.skill ?? ev.skillId ?? 0),
level: Number(ev.level ?? 1),
target: ev.target ?? {
unitType: ev.targetType ?? ev.targetUnitType ?? 1,
id: Number(ev.targetId ?? ev.targetUnitId ?? 0),
},
} }
} else if (ev.type === 'ChatMessage') { } else if (ev.type === 'ChatMessage') {
ev = { ev = {
@ -1897,7 +1987,9 @@ export class ClientWorld {
} }
case 'UnitMove': { case 'UnitMove': {
const unit = this.ensureUnit(ev.unitType, ev.id) const evAny = ev as any
const uId = Number(ev.id ?? evAny.unitId ?? 0)
const unit = this.ensureUnit(ev.unitType, uId)
if (unit.unitType === 'monster') { if (unit.unitType === 'monster') {
this._monsters.set(unit.id, unit) this._monsters.set(unit.id, unit)
} }
@ -1907,10 +1999,14 @@ export class ClientWorld {
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
unit.x = ev.from.x unit.x = ev.from.x
unit.y = ev.from.y unit.y = ev.from.y
} else if (ev.to.x !== 0 || ev.to.y !== 0) { }
unit.moveMode = ev.mode ?? 'walk'
if (!ev.from || (ev.from.x === 0 && ev.from.y === 0)) {
if (ev.to.x !== 0 || ev.to.y !== 0) {
unit.x = ev.to.x unit.x = ev.to.x
unit.y = ev.to.y unit.y = ev.to.y
} }
}
let targetX = ev.to.x let targetX = ev.to.x
let targetY = ev.to.y let targetY = ev.to.y
if ( if (
@ -1942,8 +2038,10 @@ export class ClientWorld {
if (ev.pathType !== undefined) { if (ev.pathType !== undefined) {
unit.pathType = ev.pathType unit.pathType = ev.pathType
} }
if (prevX !== targetX || prevY !== targetY) { if (unit.moveMode !== 'stop' || Math.hypot(targetX - unit.x, targetY - unit.y) > 0.15) {
unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) if (targetX !== 0 || targetY !== 0) {
unit.faceToward(targetX, targetY, false)
}
} }
if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) {
const inTown = isTownLevel(this.areaId) const inTown = isTownLevel(this.areaId)
@ -2073,6 +2171,31 @@ export class ClientWorld {
break break
} }
case 'Interact': {
const actorType: UnitType = ev.actor?.unitType ?? ev.unit?.unitType ?? ev.unitType ?? 0
const actorId = Number(ev.actor?.id ?? ev.unit?.id ?? ev.unitId ?? (this.selfId || 1))
const unit = this.ensureUnit(actorType, actorId)
let targetX = unit.x
let targetY = unit.y
if (ev.target && typeof ev.target === 'object' && 'x' in ev.target) {
targetX = Number(ev.target.x ?? unit.x)
targetY = Number(ev.target.y ?? unit.y)
} else if (ev.target && typeof ev.target === 'object' && 'unitType' in ev.target) {
unit.targetUnit = ev.target
const targetUnit = this.getUnit(ev.target.unitType, ev.target.id)
if (targetUnit) {
targetX = targetUnit.x
targetY = targetUnit.y
}
} else if (ev.x !== undefined && ev.y !== undefined) {
targetX = Number(ev.x)
targetY = Number(ev.y)
}
unit.moveMode = 'stop'
unit.faceToward(targetX, targetY, false)
break
}
case 'SkillCast': { case 'SkillCast': {
const casterType: UnitType = const casterType: UnitType =
ev.caster?.unitType ?? ev.caster?.unitType ??

View File

@ -140,3 +140,247 @@ export function dir64ToClipDirection(dir64: number, directions: number): number
) )
} }
} }
/** 25Hz game tick period in milliseconds (`1000 / 25 = 40ms`). */
export const D2_TURN_TICK_MS = 40
export const D2_UNIT_TURN_TICK_MS = D2_TURN_TICK_MS
/**
* 64-entry turn step lookup table from `D2Common.dll` 1.13c VA **0x6FDE41C8** (RVA `0x941C8`,
* file offset `0x925C8` / `.rdata` raw offset `0x941C8`), indexed by `diff = (targetDir64 - dir64) & 63`:
* - `diff = 0` → `0`
* - `diff = 1..8` → `+1`
* - `diff = 9..16` → `+4`
* - `diff = 17..31` → `+8`
* - `diff = 32..47` → `-8`
* - `diff = 48..55` → `-4`
* - `diff = 56..63` → `-1`
*/
export const TURN_STEP_TABLE_113C: readonly number[] = [
0, 1, 1, 1, 1, 1, 1, 1, 1, 4, 4, 4, 4, 4, 4, 4,
4, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
-8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8,
-4, -4, -4, -4, -4, -4, -4, -4, -1, -1, -1, -1, -1, -1, -1, -1,
]
export const TURN_STEP_TABLE = TURN_STEP_TABLE_113C
/**
* `D2DynamicPathStrc` facing state (`+0x64` `nDirection`, `+0x65` `nNewDirection`,
* `+0x66` `nStepDir`) plus a sub-tick millisecond accumulator for 25Hz (`40ms`) updates.
*/
export interface UnitTurnState {
/** Current facing (`D2DynamicPathStrc` `+0x64` `nDirection`, integer in `0..63`). */
dir64: number
/** Target facing (`D2DynamicPathStrc` `+0x65` `nNewDirection`, integer in `0..63`). */
targetDir64: number
/** Cached turn step per 25Hz tick (`D2DynamicPathStrc` `+0x66` `nStepDir`: `0, ±1, ±4, ±8`). */
stepDir: number
/** Accumulated fractional milliseconds toward the next 40ms (25Hz) turn tick. */
turnTickRemainderMs: number
}
/**
* Look up the 1.13c turn step (`0, ±1, ±4, ±8`) for turning from `dir64` toward `targetDir64`.
*/
export function computeTurnStepDir64(dir64: number, targetDir64: number): number {
assertDir64(dir64, 'dir64')
assertDir64(targetDir64, 'targetDir64')
return TURN_STEP_TABLE_113C[(targetDir64 - dir64) & 0x3f]!
}
/**
* Create a `UnitTurnState` initialized at `initialDir64` (`bInstant = 1` settled state).
*/
export function createUnitTurnState(initialDir64 = 0): UnitTurnState {
assertDir64(initialDir64, 'initialDir64')
return {
dir64: initialDir64,
targetDir64: initialDir64,
stepDir: 0,
turnTickRemainderMs: 0,
}
}
/**
* Instantaneously snap both `dir64` (`+0x64`) and `targetDir64` (`+0x65`) to `dir64` and clear
* `stepDir` (`+0x66`), matching `bInstant = 1` in `D2Common.#10739` (**0x6FD852F0**).
*/
export function snapUnitTurnDirection(state: UnitTurnState, dir64: number): UnitTurnState {
assertDir64(dir64, 'dir64')
state.dir64 = dir64
state.targetDir64 = dir64
state.stepDir = 0
state.turnTickRemainderMs = 0
return state
}
/**
* Set a unit's target facing (`D2Common` **0x6FD850B0** / ordinal **#10425** **0x6FD85270** /
* **0x6FD5D370**, or **#10739** **0x6FD852F0** with `instant = true`):
* - When `instant = true` (`bInstant = 1`), snaps both `dir64` and `targetDir64` immediately.
* - When `instant = false` (`bInstant = 0`), if `targetDir64 === state.targetDir64` this is a
* no-op so an in-progress turn preserves its cached `stepDir` without decelerating; otherwise
* updates `targetDir64` and caches `stepDir = TURN_STEP_TABLE_113C[(targetDir64 - dir64) & 63]`.
*/
export function setUnitTargetDirection(
state: UnitTurnState,
targetDir64: number,
instant = false,
): UnitTurnState {
assertDir64(state.dir64, 'state.dir64')
assertDir64(state.targetDir64, 'state.targetDir64')
assertDir64(targetDir64, 'targetDir64')
if (
!Number.isInteger(state.stepDir) ||
(state.stepDir !== 0 &&
state.stepDir !== 1 &&
state.stepDir !== -1 &&
state.stepDir !== 4 &&
state.stepDir !== -4 &&
state.stepDir !== 8 &&
state.stepDir !== -8)
) {
throw new RangeError(
`state.stepDir must be a valid 1.13c turn step (0, ±1, ±4, ±8), got ${String(state.stepDir)}`,
)
}
if (!Number.isFinite(state.turnTickRemainderMs) || state.turnTickRemainderMs < 0) {
throw new RangeError(
`state.turnTickRemainderMs must be a finite non-negative number, got ${String(state.turnTickRemainderMs)}`,
)
}
if (instant) {
return snapUnitTurnDirection(state, targetDir64)
}
if (targetDir64 === state.targetDir64) {
return state
}
state.targetDir64 = targetDir64
state.stepDir = TURN_STEP_TABLE_113C[(targetDir64 - state.dir64) & 0x3f]!
if (state.dir64 === state.targetDir64) {
state.turnTickRemainderMs = 0
}
return state
}
/**
* Port of `D2Common.#10739` (**0x6FD852F0**): compute the `dir64` from `(startX, startY)` toward
* `(targetX, targetY)` via `0x6FD5CF40` and apply it with `bInstant = 0` (`instant = false`) or
* `bInstant = 1` (`instant = true`). Coincident points keep the existing target/current facing.
*/
export function targetUnitTurnTowardPoint(
state: UnitTurnState,
startX: number,
startY: number,
targetX: number,
targetY: number,
instant = false,
): UnitTurnState {
assertDir64(state.dir64, 'state.dir64')
assertDir64(state.targetDir64, 'state.targetDir64')
const fallback = instant ? state.dir64 : state.targetDir64
const nextTarget = dir64FromPoints(startX, startY, targetX, targetY, fallback)
return setUnitTargetDirection(state, nextTarget, instant)
}
/**
* Advance `state` by one 25Hz (`40ms`) tick, exact port of `D2Common.#10603` (**0x6FD84D70**):
* - If `dir64 === targetDir64`, does nothing.
* - Otherwise sets `next = (dir64 + stepDir) & 63`, computes `rem = (next - targetDir64) & 63`,
* and clamps `dir64 = targetDir64` when `(stepDir < 0 && rem > 31) || (stepDir > 0 && rem < 31)`.
*/
export function stepUnitTurnTick(state: UnitTurnState): number {
assertDir64(state.dir64, 'state.dir64')
assertDir64(state.targetDir64, 'state.targetDir64')
if (
!Number.isInteger(state.stepDir) ||
(state.stepDir !== 0 &&
state.stepDir !== 1 &&
state.stepDir !== -1 &&
state.stepDir !== 4 &&
state.stepDir !== -4 &&
state.stepDir !== 8 &&
state.stepDir !== -8)
) {
throw new RangeError(
`state.stepDir must be a valid 1.13c turn step (0, ±1, ±4, ±8), got ${String(state.stepDir)}`,
)
}
if (!Number.isFinite(state.turnTickRemainderMs) || state.turnTickRemainderMs < 0) {
throw new RangeError(
`state.turnTickRemainderMs must be a finite non-negative number, got ${String(state.turnTickRemainderMs)}`,
)
}
if (state.dir64 === state.targetDir64) {
state.stepDir = 0
state.turnTickRemainderMs = 0
return state.dir64
}
const stepDir =
state.stepDir !== 0
? state.stepDir
: TURN_STEP_TABLE_113C[(state.targetDir64 - state.dir64) & 0x3f]!
state.stepDir = stepDir
const next = (state.dir64 + stepDir) & 0x3f
const rem = (next - state.targetDir64) & 0x3f
if ((stepDir < 0 && rem > 31) || (stepDir > 0 && rem < 31)) {
state.dir64 = state.targetDir64
} else {
state.dir64 = next
}
if (state.dir64 === state.targetDir64) {
state.stepDir = 0
state.turnTickRemainderMs = 0
}
return state.dir64
}
/**
* Advance `state` by `ticks` 25Hz game ticks (`ticks * 40ms`).
*/
export function advanceUnitTurnByTicks(state: UnitTurnState, ticks = 1): number {
assertDir64(state.dir64, 'state.dir64')
assertDir64(state.targetDir64, 'state.targetDir64')
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++) {
stepUnitTurnTick(state)
if (state.dir64 === state.targetDir64) break
}
return state.dir64
}
export const advanceUnitTurnTicks = advanceUnitTurnByTicks
/**
* Advance `state` by `dtMs` milliseconds using a 25Hz (`40ms`) tick accumulator.
*/
export function advanceUnitTurnByDtMs(state: UnitTurnState, dtMs: number): number {
assertDir64(state.dir64, 'state.dir64')
assertDir64(state.targetDir64, 'state.targetDir64')
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (!Number.isFinite(state.turnTickRemainderMs) || state.turnTickRemainderMs < 0) {
throw new RangeError(
`state.turnTickRemainderMs must be a finite non-negative number, got ${String(state.turnTickRemainderMs)}`,
)
}
if (dtMs <= 0) return state.dir64
if (state.dir64 === state.targetDir64) {
state.turnTickRemainderMs = 0
return state.dir64
}
state.turnTickRemainderMs += dtMs
while (state.turnTickRemainderMs + 1e-9 >= D2_TURN_TICK_MS && state.dir64 !== state.targetDir64) {
state.turnTickRemainderMs = Math.max(0, state.turnTickRemainderMs - D2_TURN_TICK_MS)
stepUnitTurnTick(state)
}
if (state.dir64 === state.targetDir64) {
state.turnTickRemainderMs = 0
}
return state.dir64
}

View File

@ -374,6 +374,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
it('expires castState back to mode 1 (NU) without moving caster position, and interrupts cast on UnitMove', () => { it('expires castState back to mode 1 (NU) without moving caster position, and interrupts cast on UnitMove', () => {
const world = new ClientWorld() const world = new ClientWorld()
const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0)
world.apply( world.apply(
{ {
type: 'UnitAssign', type: 'UnitAssign',
@ -385,6 +386,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
name: 'Sorc', name: 'Sorc',
x: 5100, x: 5100,
y: 5100, y: 5100,
direction: castDir,
mode: 1, mode: 1,
lifePct: 128, lifePct: 128,
}, },
@ -405,7 +407,6 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
) )
const unit = world.getUnit('player', 1)! const unit = world.getUnit('player', 1)!
const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0)
expect(unit.mode).toBe(10) expect(unit.mode).toBe(10)
expect(unit.direction).toBe(castDir) expect(unit.direction).toBe(castDir)
@ -465,6 +466,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
it('preserves local player cast facing direction on OnlineSession after cast expires into NU and clears cast on movement', () => { it('preserves local player cast facing direction on OnlineSession after cast expires into NU and clears cast on movement', () => {
const session = new OnlineSession() const session = new OnlineSession()
const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0)
session.handleServerEvent({ session.handleServerEvent({
type: 'UnitAssign', type: 'UnitAssign',
isSelf: true, isSelf: true,
@ -475,6 +477,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
name: 'Sorc', name: 'Sorc',
x: 5000, x: 5000,
y: 5000, y: 5000,
direction: expectedDir,
mode: 1, mode: 1,
lifePct: 128, lifePct: 128,
}, },
@ -488,7 +491,8 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
target: { x: 5040, y: 5020 }, target: { x: 5040, y: 5020 },
}) })
const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0) const selfUnit = session.world.getUnit('player', 1)!
expect(selfUnit.targetDirection).toBe(expectedDir)
const duringUnits = session.buildRenderableUnits() const duringUnits = session.buildRenderableUnits()
const selfDuring = duringUnits.find((u) => u.isLocalPlayer)! const selfDuring = duringUnits.find((u) => u.isLocalPlayer)!
expect(selfDuring.mode).toBe(10) expect(selfDuring.mode).toBe(10)
@ -512,6 +516,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
it('renders non-looping one-shot clip frames once (clamped at last frame) and switches back to NU clip after castState expires', () => { it('renders non-looping one-shot clip frames once (clamped at last frame) and switches back to NU clip after castState expires', () => {
clearMissingArtEntries() clearMissingArtEntries()
const world = new ClientWorld() const world = new ClientWorld()
const castDir = dir64FromPoints(5000, 5000, 5030, 5000, 0)
world.apply( world.apply(
{ {
type: 'UnitAssign', type: 'UnitAssign',
@ -523,6 +528,7 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
name: 'Sorc', name: 'Sorc',
x: 5000, x: 5000,
y: 5000, y: 5000,
direction: castDir,
mode: 1, mode: 1,
lifePct: 128, lifePct: 128,
}, },

View File

@ -1,11 +1,10 @@
/** /**
* Unit facing while moving (dir64, `src/common/units/direction.ts`). * Unit facing & 25Hz turn transition (`src/common/units/direction.ts`, `D2Common.dll` `0x6FDE41C8`).
* *
* D2 1.13c sets a unit's facing once, when it starts a segment toward its next path point * Diablo II 1.13c sets a unit's target facing (`targetDir64`) when it starts a movement segment,
* (D2Common 0x6FD5D240), and draws it through D2CMP #10020. These tests pin the client's * skill cast, or interaction (`D2Common.#10425` with `bInstant = 0`), and steps `dir64` toward
* local predictor and remote interpolator to that rule: the facing must match the movement * `targetDir64` on each 25Hz (`40ms`) tick via `TURN_STEP_TABLE_113C` (`D2Common.#10603`),
* direction, must not change in the middle of a segment, and must survive re-pathing while * drawing the current intermediate `dir64` through `D2CMP.#10020`.
* the mouse is held, idle frames and server repositioning.
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
@ -13,11 +12,21 @@ import { CommandMapper } from '../../src/client/input/command-mapper.ts'
import type { MapService } from '../../src/client/map/map-service.ts' import type { MapService } from '../../src/client/map/map-service.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts' import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts' import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts' import { D2_TICK_MS, LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts' import { OnlineSession } from '../../src/client/session/online-session.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { buildSceneDrawables } from '../../src/client/view/unit-drawables.ts'
import { ClientUnit } from '../../src/client/world/client-unit.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts' import { ClientWorld } from '../../src/client/world/client-world.ts'
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts' import {
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
createUnitTurnState,
dir64FromPoints,
dir64ToClipDirection,
setUnitTargetDirection,
stepUnitTurnTick,
} from '../../src/common/units/direction.ts'
import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts' import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts'
import { import {
computeMonsterMoveSpeedSubTilesPerSec, computeMonsterMoveSpeedSubTilesPerSec,
@ -37,6 +46,17 @@ function headingVector(k: number): readonly [number, number] {
return [Math.cos(radians), Math.sin(radians)] return [Math.cos(radians), Math.sin(radians)]
} }
/** Expected 25Hz turn trajectory from `startDir64` to `targetDir64` using `D2Common.#10603`. */
function expectedTurnSequence(startDir64: number, targetDir64: number): number[] {
const state = createUnitTurnState(startDir64)
setUnitTargetDirection(state, targetDir64, false)
const seq: number[] = [state.dir64]
while (state.dir64 !== targetDir64) {
seq.push(stepUnitTurnTick(state))
}
return seq
}
/** A walkable world sub-tile grid with its origin at (`originX`, `originY`). */ /** A walkable world sub-tile grid with its origin at (`originX`, `originY`). */
function makeGrid( function makeGrid(
originX: number, originX: number,
@ -71,20 +91,35 @@ function makeMapper(grid: SubTileWalkabilityGrid, predictor: LocalMovementPredic
}) })
} }
describe('local player facing (LocalMovementPredictor)', () => { describe('local player facing & 25Hz turn transition (LocalMovementPredictor)', () => {
it('faces each of the 16 screen headings and draws the matching clip direction', () => { it('sets targetDir64 for each of the 16 screen headings and steps dir64 at 25Hz to the matching clip direction', () => {
for (let k = 0; k < 16; k++) { for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 }) const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
const [dx, dy] = headingVector(k) const [dx, dy] = headingVector(k)
predictor.setPath([{ x: 1000 + 20 * dx, y: 1000 + 20 * dy }], true) predictor.setPath([{ x: 1000 + 20 * dx, y: 1000 + 20 * dy }], true)
const { dir64 } = predictor.getSnapshot()
expect(dir64, `heading ${k}`).toBe(HEADING_DIR64[k]) const targetDir = HEADING_DIR64[k]!
expect(dir64ToClipDirection(dir64, 16), `heading ${k}`).toBe(k) const expectedSeq = expectedTurnSequence(0, targetDir)
if (k % 2 === 0) expect(dir64ToClipDirection(dir64, 8), `heading ${k}`).toBe(k / 2)
const initialSnap = predictor.getSnapshot()
expect(initialSnap.targetDir64, `heading ${k} targetDir64`).toBe(targetDir)
expect(initialSnap.dir64, `heading ${k} initial dir64`).toBe(0)
const actualSeq: number[] = [initialSnap.dir64]
for (let tick = 1; tick < expectedSeq.length; tick++) {
const snap = predictor.update(D2_TICK_MS)
actualSeq.push(snap.dir64)
}
expect(actualSeq, `heading ${k} turn sequence`).toEqual(expectedSeq)
const finalSnap = predictor.getSnapshot()
expect(finalSnap.dir64, `heading ${k}`).toBe(targetDir)
expect(dir64ToClipDirection(finalSnap.dir64, 16), `heading ${k}`).toBe(k)
if (k % 2 === 0) expect(dir64ToClipDirection(finalSnap.dir64, 8), `heading ${k}`).toBe(k / 2)
} }
}) })
it('keeps one facing and always moves forward while the mouse is held (re-path every frame)', () => { it('preserves turn momentum and converges smoothly while the mouse is held (60Hz re-pathing)', () => {
const grid = makeGrid(4900, 4900, 200) const grid = makeGrid(4900, 4900, 200)
for (let k = 0; k < 16; k++) { for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 }) const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
@ -93,29 +128,42 @@ describe('local player facing (LocalMovementPredictor)', () => {
const goalX = 5000 + Math.round(30 * dx) const goalX = 5000 + Math.round(30 * dx)
const goalY = 5000 + Math.round(30 * dy) const goalY = 5000 + Math.round(30 * dy)
const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0) const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0)
const allowedDirs = new Set(expectedTurnSequence(0, expectedDir64))
let previousDistance = Number.POSITIVE_INFINITY let previousDistance = Number.POSITIVE_INFINITY
let frames = 0 let frames = 0
let convergedFrame = -1
for (; frames < 600; frames++) { for (; frames < 600; frames++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, frames * FRAME_MS) mapper.triggerPrimaryWorldAction(goalX, goalY, null, frames * FRAME_MS)
const snap = predictor.update(FRAME_MS) const snap = predictor.update(FRAME_MS)
expect(snap.dir64, `heading ${k} frame ${frames}`).toBe(expectedDir64) expect(snap.targetDir64, `heading ${k} frame ${frames} targetDir64`).toBe(expectedDir64)
expect(allowedDirs.has(snap.dir64), `heading ${k} frame ${frames} dir64=${snap.dir64}`).toBe(true)
if (snap.dir64 === expectedDir64 && convergedFrame === -1) {
convergedFrame = frames
}
if (convergedFrame !== -1) {
expect(snap.dir64, `heading ${k} frame ${frames} post-convergence`).toBe(expectedDir64)
}
if (!snap.moving) break if (!snap.moving) break
const distance = Math.hypot(goalX - snap.logicalX, goalY - snap.logicalY) const distance = Math.hypot(goalX - snap.logicalX, goalY - snap.logicalY)
expect(distance, `heading ${k} frame ${frames}`).toBeLessThan(previousDistance) expect(distance, `heading ${k} frame ${frames}`).toBeLessThan(previousDistance)
previousDistance = distance previousDistance = distance
} }
expect(frames, `heading ${k}`).toBeLessThan(600) expect(frames, `heading ${k}`).toBeLessThan(600)
// The player stops inside the goal cell: 1.13c path points never include the unit's cell. // Max turn takes 8 ticks = 320ms ≈ 20 frames at 60Hz
expect(convergedFrame, `heading ${k} converged frame`).toBeGreaterThanOrEqual(0)
expect(convergedFrame, `heading ${k} converged frame`).toBeLessThanOrEqual(21)
const stopped = predictor.getSnapshot() const stopped = predictor.getSnapshot()
expect(Math.round(stopped.logicalX)).toBe(goalX) expect(Math.round(stopped.logicalX)).toBe(goalX)
expect(Math.round(stopped.logicalY)).toBe(goalY) expect(Math.round(stopped.logicalY)).toBe(goalY)
expect(stopped.dir64).toBe(expectedDir64)
// Holding the button at the goal changes nothing. // Holding the button at the goal changes nothing.
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, (frames + i) * FRAME_MS) mapper.triggerPrimaryWorldAction(goalX, goalY, null, (frames + i) * FRAME_MS)
const idle = predictor.update(FRAME_MS) const idle = predictor.update(FRAME_MS)
expect(idle).toMatchObject({ moving: false, dir64: expectedDir64 }) expect(idle).toMatchObject({ moving: false, dir64: expectedDir64, targetDir64: expectedDir64 })
expect(idle.logicalX).toBe(stopped.logicalX) expect(idle.logicalX).toBe(stopped.logicalX)
expect(idle.logicalY).toBe(stopped.logicalY) expect(idle.logicalY).toBe(stopped.logicalY)
} }
@ -140,9 +188,14 @@ describe('local player facing (LocalMovementPredictor)', () => {
mapper.triggerPrimaryWorldAction(clickAt.x, clickAt.y, null, 1000) mapper.triggerPrimaryWorldAction(clickAt.x, clickAt.y, null, 1000)
const turned = predictor.getSnapshot() const turned = predictor.getSnapshot()
expect(turned.dir64).toBe( const expectedTargetDir = dir64FromPoints(
dir64FromPoints(before.logicalX, before.logicalY, clickAt.x, clickAt.y, 0), before.logicalX,
before.logicalY,
clickAt.x,
clickAt.y,
before.dir64,
) )
expect(turned.targetDir64).toBe(expectedTargetDir)
const after = predictor.update(FRAME_MS) const after = predictor.update(FRAME_MS)
const toGoalX = clickAt.x - before.logicalX const toGoalX = clickAt.x - before.logicalX
const toGoalY = clickAt.y - before.logicalY const toGoalY = clickAt.y - before.logicalY
@ -151,11 +204,15 @@ describe('local player facing (LocalMovementPredictor)', () => {
// The first step heads straight for the new goal, not back to the cell the path started in. // The first step heads straight for the new goal, not back to the cell the path started in.
const cosine = (stepX * toGoalX + stepY * toGoalY) / (Math.hypot(stepX, stepY) * Math.hypot(toGoalX, toGoalY)) const cosine = (stepX * toGoalX + stepY * toGoalY) / (Math.hypot(stepX, stepY) * Math.hypot(toGoalX, toGoalY))
expect(cosine).toBeCloseTo(1, 9) expect(cosine).toBeCloseTo(1, 9)
// Stepping 8 ticks converges dir64 to expectedTargetDir
predictor.stepTicks(8)
expect(predictor.getSnapshot().dir64).toBe(expectedTargetDir)
} }
}) })
it('sets the facing only when a segment starts', () => { it('updates targetDir64 when each segment starts and transitions dir64 across segments at 25Hz', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 }) const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100, initialDir64: 56 })
predictor.setPath( predictor.setPath(
[ [
{ x: 110, y: 100 }, { x: 110, y: 100 },
@ -163,37 +220,81 @@ describe('local player facing (LocalMovementPredictor)', () => {
], ],
true, true,
) )
expect(predictor.getSnapshot().dir64).toBe(56) // world +X is screen south-east expect(predictor.getSnapshot()).toMatchObject({ dir64: 56, targetDir64: 56 })
const seen: Array<{ waypointX: number; waypointY: number; dir64: number }> = []
for (let frame = 0; frame < 200; frame++) { const secondSegSeq = expectedTurnSequence(56, 7)
const snap = predictor.update(FRAME_MS) const secondSegDirs: number[] = []
for (let tick = 0; tick < 100; tick++) {
const snap = predictor.update(D2_TICK_MS)
if (snap.currentWaypoint && snap.currentWaypoint.y === 110) {
expect(snap.targetDir64).toBe(7)
secondSegDirs.push(snap.dir64)
}
if (!snap.moving) break if (!snap.moving) break
seen.push({ waypointX: snap.currentWaypoint!.x, waypointY: snap.currentWaypoint!.y, dir64: snap.dir64 })
} }
expect(seen.length).toBeGreaterThan(10) expect(secondSegDirs.length).toBeGreaterThan(3)
for (const step of seen) { // On the tick that reaches waypoint 1 and starts segment 2, targetDir64 becomes 7 while dir64 is 56,
expect(step.dir64).toBe(step.waypointY === 100 ? 56 : 7) // world +Y is screen south-west // and subsequent ticks step through secondSegSeq (56 -> 60 -> 62 -> 0 -> 2 -> 4 -> 5 -> 6 -> 7).
} expect(secondSegDirs[0]).toBe(secondSegSeq[0])
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 110, logicalY: 110, dir64: 7 }) expect(secondSegDirs[1]).toBe(secondSegSeq[1])
expect(predictor.getSnapshot()).toMatchObject({
moving: false,
logicalX: 110,
logicalY: 110,
dir64: 7,
targetDir64: 7,
})
}) })
it('keeps the facing while idle, on an empty path and on a 0x15 reposition', () => { it('continues turning at 25Hz while standing after a short step finishes', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100, initialDir64: 0 })
// 1-subtile move to (99, 100) (world -X -> screen NW, targetDir64 = 23, stepDir = 3 -> 8 ticks = 320ms,
// whereas running 1 sub-tile at 13.5 sub-tiles/s finishes in 2 ticks = 80ms)
predictor.setPath([{ x: 99, y: 100 }], true)
const expectedSeq = expectedTurnSequence(0, 23)
expect(predictor.getSnapshot()).toMatchObject({ dir64: 0, targetDir64: 23, moving: true })
// Tick 1 (40ms): moving = true, dir64 = 3
const after1 = predictor.update(D2_TICK_MS)
expect(after1.moving).toBe(true)
expect(after1.dir64).toBe(expectedSeq[1])
// Tick 2 (80ms): reaches (99, 100) so moving = false, dir64 = 6 (still turning while standing!)
const after2 = predictor.update(D2_TICK_MS)
expect(after2.moving).toBe(false)
expect(after2.dir64).toBe(expectedSeq[2])
for (let tick = 3; tick < expectedSeq.length; tick++) {
const snap = predictor.update(D2_TICK_MS)
expect(snap.moving).toBe(false)
expect(snap.dir64).toBe(expectedSeq[tick])
}
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23, targetDir64: 23 })
})
it('keeps the facing while idle, on an empty path and on a 0x15 reposition, and snaps on setDirection', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 }) const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
predictor.setPath([{ x: 90, y: 100 }], false) predictor.setPath([{ x: 90, y: 100 }], false)
expect(predictor.getSnapshot().dir64).toBe(23) // world −X is screen north-west expect(predictor.getSnapshot().targetDir64).toBe(23) // world −X is screen north-west
predictor.update(5000) predictor.update(5000)
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23 }) expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23, targetDir64: 23 })
predictor.update(1000) predictor.update(1000)
predictor.setPath([], true) predictor.setPath([], true)
predictor.setPath([{ x: 90, y: 100 }], true) // only the player's own cell predictor.setPath([{ x: 90, y: 100 }], true) // only the player's own cell
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 90, dir64: 23 }) expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 90, dir64: 23, targetDir64: 23 })
predictor.setPosition(300, 400) predictor.setPosition(300, 400)
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 300, logicalY: 400, moving: false, dir64: 23 }) expect(predictor.getSnapshot()).toMatchObject({
logicalX: 300,
logicalY: 400,
moving: false,
dir64: 23,
targetDir64: 23,
})
predictor.setDirection(40) predictor.setDirection(40)
expect(predictor.getSnapshot().dir64).toBe(40) expect(predictor.getSnapshot()).toMatchObject({ dir64: 40, targetDir64: 40 })
expect(() => predictor.setDirection(64)).toThrow(RangeError) expect(() => predictor.setDirection(64)).toThrow(RangeError)
expect(() => predictor.setDirection(-1)).toThrow(RangeError) expect(() => predictor.setDirection(-1)).toThrow(RangeError)
}) })
@ -204,6 +305,7 @@ describe('local player facing (LocalMovementPredictor)', () => {
predictor.setPath([{ x: 5030, y: 5000 }], true) predictor.setPath([{ x: 5030, y: 5000 }], true)
const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000) const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000)
expect(at.logicalX).toBeCloseTo(5004.7, 6) expect(at.logicalX).toBeCloseTo(5004.7, 6)
expect(at.dir64).toBe(56)
// findPathWorld floors its start (cell 5004); the predictor must path from cell 5005. // findPathWorld floors its start (cell 5004); the predictor must path from cell 5005.
const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000) const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000)
@ -214,8 +316,8 @@ describe('local player facing (LocalMovementPredictor)', () => {
}) })
}) })
describe('remote unit facing (RemoteEntityInterpolator)', () => { describe('remote unit facing & 25Hz turn transition (RemoteEntityInterpolator)', () => {
it('faces the 16 screen headings when a move starts and keeps the facing to the end', () => { it('sets targetDir64 for the 16 screen headings when a move starts and steps dir64 at 25Hz to the end', () => {
const interp = new RemoteEntityInterpolator() const interp = new RemoteEntityInterpolator()
for (let k = 0; k < 16; k++) { for (let k = 0; k < 16; k++) {
interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 }) interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 })
@ -229,8 +331,20 @@ describe('remote unit facing (RemoteEntityInterpolator)', () => {
targetY: 5000 + 10 * dy, targetY: 5000 + 10 * dy,
running: true, running: true,
}) })
expect(started.dir64, `heading ${k}`).toBe(HEADING_DIR64[k]) expect(started.targetDir64, `heading ${k} targetDir64`).toBe(HEADING_DIR64[k])
expect(started.dir64, `heading ${k} initial dir64`).toBe(0)
} }
const expectedSeqs = HEADING_DIR64.map(target => expectedTurnSequence(0, target))
for (let tick = 1; tick <= 8; tick++) {
interp.update(D2_TICK_MS)
for (let k = 0; k < 16; k++) {
const seq = expectedSeqs[k]!
const expectedDir = seq[Math.min(tick, seq.length - 1)]!
expect(interp.getEntityState(1, k)!.dir64, `heading ${k} tick ${tick}`).toBe(expectedDir)
}
}
for (let frame = 0; frame < 120; frame++) { for (let frame = 0; frame < 120; frame++) {
interp.update(FRAME_MS) interp.update(FRAME_MS)
for (let k = 0; k < 16; k++) { for (let k = 0; k < 16; k++) {
@ -240,46 +354,573 @@ describe('remote unit facing (RemoteEntityInterpolator)', () => {
expect(interp.getAllStates().every(s => !s.moving)).toBe(true) expect(interp.getAllStates().every(s => !s.moving)).toBe(true)
}) })
it('keeps the facing on a reassign without a direction and starts new units facing dir64 0', () => { it('keeps the facing on a reassign without a direction and snaps when dir64 is explicitly provided', () => {
const interp = new RemoteEntityInterpolator() const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 100, y: 100, dir64: 40 }) interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 100, y: 100, dir64: 40 })
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130 }).dir64).toBe(40) expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130 })).toMatchObject({
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130, dir64: 12 }).dir64).toBe(12) dir64: 40,
expect(interp.spawnOrSnap({ unitType: 0, unitId: 8, x: 100, y: 100 }).dir64).toBe(0) targetDir64: 40,
})
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130, dir64: 12 })).toMatchObject({
dir64: 12,
targetDir64: 12,
})
expect(interp.spawnOrSnap({ unitType: 0, unitId: 8, x: 100, y: 100 })).toMatchObject({
dir64: 0,
targetDir64: 0,
})
expect(() => interp.spawnOrSnap({ unitType: 0, unitId: 9, x: 1, y: 1, dir64: 64 })).toThrow(RangeError) expect(() => interp.spawnOrSnap({ unitType: 0, unitId: 9, x: 1, y: 1, dir64: 64 })).toThrow(RangeError)
}) })
it('faces the stop point while gliding to it and keeps the facing on a hard stop', () => { it('turns toward the stop point while gliding to it and keeps the facing on a hard stop', () => {
const interp = new RemoteEntityInterpolator() const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 1, unitId: 1, x: 5000, y: 5000 }) interp.spawnOrSnap({ unitType: 1, unitId: 1, x: 5000, y: 5000 })
expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).dir64).toBe(56) expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).targetDir64).toBe(56)
interp.update(200) // 8 ticks = 320ms completes the turn from 0 -> 56 (diff = 56, stepDir = -1)
interp.update(320)
const mid = interp.getEntityState(1, 1)! const mid = interp.getEntityState(1, 1)!
expect(mid.dir64).toBe(56) expect(mid.dir64).toBe(56)
const glide = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003, y: 5002 }) const glide = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003, y: 5002 })
const expectedGlideTargetDir = dir64FromPoints(mid.x, mid.y, 5003, 5002, 56)
expect(glide.moving).toBe(true) expect(glide.moving).toBe(true)
expect(glide.dir64).toBe(dir64FromPoints(mid.x, mid.y, 5003, 5002, 56)) expect(glide.targetDir64).toBe(expectedGlideTargetDir)
interp.update(1000) interp.update(1000)
expect(interp.getEntityState(1, 1)).toMatchObject({ x: 5003, y: 5002, moving: false, dir64: glide.dir64 }) expect(interp.getEntityState(1, 1)).toMatchObject({
x: 5003,
y: 5002,
moving: false,
dir64: expectedGlideTargetDir,
targetDir64: expectedGlideTargetDir,
})
// Within 0.15 sub-tiles the unit snaps and keeps its facing. // Within 0.15 sub-tiles the unit snaps and keeps its facing.
const snapped = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003.1, y: 5002 }) const snapped = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003.1, y: 5002 })
expect(snapped).toMatchObject({ x: 5003.1, moving: false, dir64: glide.dir64 }) expect(snapped).toMatchObject({ x: 5003.1, moving: false, dir64: expectedGlideTargetDir })
}) })
}) })
describe('skill-cast facing (ClientWorld)', () => { describe('skill-cast & interaction 25Hz facing (ClientWorld & buildSceneDrawables)', () => {
it('turns the caster toward the target as a dir64 and keeps it for a target underfoot', () => { it('sets targetDirection on SkillCast / CastAtCoord / Interact and steps direction at 25Hz without instant snap', () => {
const world = new ClientWorld() const world = new ClientWorld()
world.apply({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5000, y: 5000 } }) world.apply({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5000, y: 5000 } })
const castAt = (x: number, y: number): number => { const monster = world.getUnit('monster', 40)!
world.apply({ type: 'SkillCast', caster: { unitType: 1, id: 40 }, skill: 0, level: 1, target: { x, y } }) expect(monster.direction).toBe(0)
return world.getUnit('monster', 40)!.direction expect(monster.targetDirection).toBe(0)
// Cast toward world +X (screen south-east: targetDir64 = 56, diff = 56, stepDir = -1 -> 8 ticks)
world.apply({
type: 'SkillCast',
caster: { unitType: 1, id: 40 },
skill: 0,
level: 1,
target: { x: 5010, y: 5000 },
})
expect(monster.targetDirection).toBe(56)
expect(monster.direction).toBe(0) // Not instantly snapped before tick
// Verify intermediate directions over 8 ticks (40ms each)
const expectedTo56 = expectedTurnSequence(0, 56)
for (let tick = 1; tick < expectedTo56.length; tick++) {
world.tick(D2_TICK_MS)
expect(monster.direction).toBe(expectedTo56[tick])
// Position must not drift while casting in place
expect(monster.x).toBe(5000)
expect(monster.y).toBe(5000)
} }
expect(castAt(5010, 5000)).toBe(56) // world +X: screen south-east expect(monster.direction).toBe(56)
expect(castAt(5000, 5000)).toBe(56)
expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north // Target underfoot keeps facing
world.apply({
type: 'SkillCast',
caster: { unitType: 1, id: 40 },
skill: 0,
level: 1,
target: { x: 5000, y: 5000 },
})
expect(monster.direction).toBe(56)
expect(monster.targetDirection).toBe(56)
// Cast toward world -X-Y (screen north: targetDir64 = 32, diff = (32 - 56) & 63 = 40, stepDir = -3 -> 8 ticks)
world.apply({
type: 'CastAtCoord',
caster: { unitType: 1, id: 40 },
skill: 36,
targetX: 4990,
targetY: 4990,
})
expect(monster.targetDirection).toBe(32)
expect(monster.direction).toBe(56)
const expectedTo32 = expectedTurnSequence(56, 32)
for (let tick = 1; tick < expectedTo32.length; tick++) {
world.tick(D2_TICK_MS)
expect(monster.direction).toBe(expectedTo32[tick])
}
expect(monster.direction).toBe(32)
// Interact turns gradually toward target unit
world.apply({ type: 'UnitAssign', unit: { unitType: 2, id: 99, classId: 1, x: 5000, y: 5010 } })
world.apply({
type: 'Interact',
actor: { unitType: 1, id: 40 },
target: { unitType: 2, id: 99 },
})
expect(monster.targetDirection).toBe(7) // world +Y: screen south-west
expect(monster.direction).toBe(32)
for (let tick = 0; tick < 8; tick++) {
world.tick(D2_TICK_MS)
}
expect(monster.direction).toBe(7)
})
it('preserves intermediate unit.direction in buildSceneDrawables even when targetX/targetY are present', () => {
// Unit is at (5000, 5000) moving/casting toward (5010, 5000) (targetDir64 = 56),
// currently at intermediate facing dir64 = 61 during its 25Hz turn transition.
const result = buildSceneDrawables([
{
id: 1,
kind: 'player',
classId: 1,
x: 5000,
y: 5000,
targetX: 5010,
targetY: 5000,
direction: 61,
mode: 2,
},
{
id: 2,
kind: 'monster',
classId: 5,
x: 5000,
y: 5000,
// Omit direction: fallback to dir64FromPoints(5000, 5000, 5010, 5000, 0) = 56
targetX: 5010,
targetY: 5000,
mode: 2,
},
])
const playerDrawable = result.unitDrawables.find(d => d.id === 1)!
const monsterDrawable = result.unitDrawables.find(d => d.id === 2)!
expect(playerDrawable.direction).toBe(61)
expect(monsterDrawable.direction).toBe(56)
expect(TURN_STEP_TABLE_113C[56]).toBe(-1)
})
it('handles speed <= 0 without teleporting to waypoints, still advancing turn state at 25Hz', () => {
const predictor = new LocalMovementPredictor({
initialX: 100,
initialY: 100,
initialDir64: 0,
walkSpeedSubTilesPerSec: 0,
runSpeedSubTilesPerSec: 0,
})
predictor.setRunning(false)
predictor.setPath([{ x: 110, y: 100 }, { x: 120, y: 100 }], false)
expect(predictor.getSnapshot()).toMatchObject({
logicalX: 100,
logicalY: 100,
moving: true,
dir64: 0,
targetDir64: 56, // world +X -> screen SE (dir64 = 56)
})
// Advance 320ms (8 ticks) with speed = 0: must NOT teleport, position stays at (100, 100)
const snap = predictor.update(320)
expect(snap.logicalX).toBe(100)
expect(snap.logicalY).toBe(100)
expect(snap.moving).toBe(true)
expect(snap.dir64).toBe(56) // Turn still converged smoothly at 25Hz!
expect(snap.targetDir64).toBe(56)
})
it('fails fast on non-finite dtMs in LocalMovementPredictor.update', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => predictor.update(bad)).toThrow(RangeError)
}
})
it('halts movement and turns toward cast target when local player casts a skill while moving', () => {
const grid = makeGrid(4900, 4900, 200)
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000, initialDir64: 0 })
const mapper = makeMapper(grid, predictor)
// Start moving south-east toward (5050, 5000) (targetDir64 = 56)
mapper.triggerPrimaryWorldAction(5050, 5000, null, 0)
const movingSnap = predictor.update(40)
expect(movingSnap.moving).toBe(true)
expect(movingSnap.targetDir64).toBe(56)
// Mid-motion, player right-clicks secondary skill toward north (targetDir64 = 32)
const [nx, ny] = headingVector(8)
const goalX = movingSnap.logicalX + 30 * nx
const goalY = movingSnap.logicalY + 30 * ny
mapper.triggerSecondaryWorldAction(goalX, goalY, null, 50)
const castSnap = predictor.getSnapshot()
expect(castSnap.moving).toBe(false) // Predictor movement halted for stationary cast
expect(castSnap.targetDir64).toBe(32) // Retargeted to cast angle
// Turn steps toward 32 from current facing over subsequent ticks
const initialFacing = castSnap.dir64
const expectedSeq = expectedTurnSequence(initialFacing, 32)
for (let i = 1; i < expectedSeq.length; i++) {
const snap = predictor.update(D2_TICK_MS)
expect(snap.dir64).toBe(expectedSeq[i])
expect(snap.moving).toBe(false)
}
expect(predictor.getSnapshot().dir64).toBe(32)
})
it('handles rapid mid-turn retargeting with fractional accumulator remainder without oscillation', () => {
const state = createUnitTurnState(0)
// Target 32 (North): stepDir = -8
setUnitTargetDirection(state, 32, false)
expect(state.stepDir).toBe(-8)
// Accumulate 20ms (< 40ms): no tick stepped yet
advanceUnitTurnByDtMs(state, 20)
expect(state.dir64).toBe(0)
expect(state.turnTickRemainderMs).toBe(20)
// Rapidly change target to 48 (East, diff = 48, stepDir = -4): remainder is preserved
setUnitTargetDirection(state, 48, false)
expect(state.targetDir64).toBe(48)
expect(state.stepDir).toBe(-4)
expect(state.turnTickRemainderMs).toBe(20)
// Adding 20ms triggers a tick at the 40ms boundary: 0 + (-4) = 60
advanceUnitTurnByDtMs(state, 20)
expect(state.dir64).toBe(60)
expect(state.turnTickRemainderMs).toBe(0)
// Advance 3 more ticks (120ms): 60 -> 56 -> 52 -> 48
advanceUnitTurnByDtMs(state, 120)
expect(state.dir64).toBe(48)
expect(state.stepDir).toBe(0)
})
})
describe('adversarial reviewer r2 regressions & edge cases', () => {
it('synchronizes ClientUnit.direction and targetDirection setters with internal turn state', () => {
const unit = new ClientUnit({ unitType: 0, id: 1, classId: 0, x: 100, y: 100, direction: 0 })
expect(unit.direction).toBe(0)
expect(unit.targetDirection).toBe(0)
// Direct property assignment snaps direction and targetDirection
unit.direction = 25
expect(unit.direction).toBe(25)
expect(unit.targetDirection).toBe(25)
expect(unit.turnStepDir).toBe(0)
// Subsequent advanceTurn does not reset to old direction or step
unit.advanceTurn(40)
expect(unit.direction).toBe(25)
expect(unit.targetDirection).toBe(25)
// Setting targetDirection initiates smooth stepping toward new target
unit.targetDirection = 35
expect(unit.targetDirection).toBe(35)
expect(unit.direction).toBe(25)
expect(unit.turnStepDir).toBe(4) // diff = 10 -> TURN_STEP_TABLE[10] = +4
// Advance 40ms: 25 + 4 = 29
unit.advanceTurn(40)
expect(unit.direction).toBe(29)
// Advance 40ms: 29 + 4 = 33
unit.advanceTurn(40)
expect(unit.direction).toBe(33)
// Advance 40ms: clamps to 35, stepDir clears to 0
unit.advanceTurn(40)
expect(unit.direction).toBe(35)
expect(unit.turnStepDir).toBe(0)
// dir alias behaves identically
unit.dir = 10
expect(unit.direction).toBe(10)
expect(unit.targetDirection).toBe(10)
// Invalid directions fail fast
expect(() => {
unit.direction = 64
}).toThrow(RangeError)
expect(() => {
unit.direction = -1
}).toThrow(RangeError)
expect(() => {
unit.targetDirection = 64
}).toThrow(RangeError)
expect(() => {
unit.targetDirection = -1
}).toThrow(RangeError)
})
it('validates stepDir in ClientUnit.syncTurnState', () => {
const unit = new ClientUnit({ unitType: 0, id: 1, classId: 0, x: 100, y: 100 })
expect(() => unit.syncTurnState(0, 10, 1.5)).toThrow(RangeError)
expect(() => unit.syncTurnState(0, 10, Number.NaN)).toThrow(RangeError)
unit.syncTurnState(0, 10, 4)
expect(unit.direction).toBe(0)
expect(unit.targetDirection).toBe(10)
expect(unit.turnStepDir).toBe(4)
})
it('fails fast on non-finite dtMs even when entity/unit containers are empty', () => {
const interp = new RemoteEntityInterpolator()
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => interp.update(bad)).toThrow(RangeError)
}
const world = new ClientWorld()
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => world.stepUnitTurns(bad)).toThrow(RangeError)
expect(() => world.tick(bad)).toThrow(RangeError)
}
})
it('fails fast on corrupted turnTickRemainderMs in direction.ts', () => {
const state = createUnitTurnState(0)
state.turnTickRemainderMs = Number.NaN
expect(() => advanceUnitTurnByDtMs(state, 40)).toThrow(RangeError)
expect(() => setUnitTargetDirection(state, 10)).toThrow(RangeError)
state.turnTickRemainderMs = -5
expect(() => advanceUnitTurnByDtMs(state, 40)).toThrow(RangeError)
expect(() => setUnitTargetDirection(state, 10)).toThrow(RangeError)
})
it('unconditionally stops movement predictor on stationary actions even if target unit is missing', () => {
const grid = makeGrid(4900, 4900, 200)
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
const mapper = makeMapper(grid, predictor)
// Start moving
predictor.setPath([{ x: 5020, y: 5000 }], true)
expect(predictor.getSnapshot().moving).toBe(true)
// Dispatch stationary action with non-existent target unit
mapper.dispatchRateLimited(
{
type: 'CastLeftSkillOnTarget',
unitKind: 1, // monster
unitId: 99999, // non-existent
},
100,
)
expect(predictor.getSnapshot().moving).toBe(false)
// Again with SkillOn (after skill cooldown: 100 + 200 = 300ms)
predictor.setPath([{ x: 5020, y: 5000 }], true)
expect(predictor.getSnapshot().moving).toBe(true)
mapper.dispatchRateLimited(
{
type: 'SkillOn',
hand: 'right',
target: { unitType: 'monster', id: 99999 },
},
300,
)
expect(predictor.getSnapshot().moving).toBe(false)
// Again with OperateObject (after cooldown: 300 + 200 = 500ms)
predictor.setPath([{ x: 5020, y: 5000 }], true)
expect(predictor.getSnapshot().moving).toBe(true)
mapper.dispatchRateLimited(
{
type: 'OperateObject',
objectId: 88888,
},
500,
)
expect(predictor.getSnapshot().moving).toBe(false)
})
})
describe('adversarial reviewer r3 deep regression & online session integration', () => {
it('validates ticks in LocalMovementPredictor.stepTicks', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
for (const badTicks of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => predictor.stepTicks(badTicks)).toThrow(RangeError)
}
expect(predictor.stepTicks(0)).toMatchObject({ logicalX: 100, logicalY: 100 })
expect(predictor.stepTicks(1)).toMatchObject({ logicalX: 100, logicalY: 100 })
})
it('exposes stepDir on PredictorSnapshot and RemoteEntityMotionState', () => {
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000, initialDir64: 0 })
expect(predictor.getSnapshot().stepDir).toBe(0)
predictor.setTargetDirection(32, false)
expect(predictor.getSnapshot().stepDir).toBe(-8)
predictor.stepTicks(1)
expect(predictor.getSnapshot().dir64).toBe(56)
expect(predictor.getSnapshot().stepDir).toBe(-8)
const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 1, unitId: 5, x: 200, y: 200, dir64: 0 })
expect(interp.getEntityState(1, 5)!.stepDir).toBe(0)
interp.setEntityTargetDirection(1, 5, 20, false)
expect(interp.getEntityState(1, 5)!.stepDir).toBe(8)
})
it('validates directions and coordinates in RemoteEntityInterpolator even for non-existent entities', () => {
const interp = new RemoteEntityInterpolator()
for (const badDir of [-1, 64, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => interp.setEntityTargetDirection(1, 99999, badDir)).toThrow(RangeError)
}
for (const badCoord of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => interp.faceEntityToward(1, 99999, badCoord, 100)).toThrow(RangeError)
expect(() => interp.faceEntityToward(1, 99999, 100, badCoord)).toThrow(RangeError)
}
expect(interp.setEntityTargetDirection(1, 99999, 32)).toBeNull()
expect(interp.faceEntityToward(1, 99999, 100, 100)).toBeNull()
})
it('protects ClientUnit.turnState from external mutation via defensive copy', () => {
const unit = new ClientUnit({ unitType: 0, id: 1, classId: 0, x: 100, y: 100, direction: 0 })
const state = unit.turnState as any
state.dir64 = 45
state.targetDir64 = 55
state.stepDir = 99
state.turnTickRemainderMs = -500
expect(unit.direction).toBe(0)
expect(unit.targetDirection).toBe(0)
expect(unit.turnStepDir).toBe(0)
expect(unit.turnState.turnTickRemainderMs).toBe(0)
})
it('provides single-tick stepTurnTick() on ClientUnit', () => {
const unit = new ClientUnit({ unitType: 0, id: 1, classId: 0, x: 100, y: 100, direction: 0 })
unit.targetDirection = 16
expect(unit.turnStepDir).toBe(4)
expect(unit.stepTurnTick()).toBe(4)
expect(unit.direction).toBe(4)
expect(unit.stepTurnTick()).toBe(8)
expect(unit.direction).toBe(8)
})
it('validates 1.13c turn step domain in ClientUnit.syncTurnState', () => {
const unit = new ClientUnit({ unitType: 0, id: 1, classId: 0, x: 100, y: 100 })
for (const badStep of [2, 3, 5, 99, -2, -3, 1.5, Number.NaN]) {
expect(() => unit.syncTurnState(0, 16, badStep)).toThrow(RangeError)
}
for (const validStep of [0, 1, -1, 4, -4, 8, -8]) {
expect(() => unit.syncTurnState(0, 16, validStep)).not.toThrow()
}
})
it('integrates 25Hz turn transitions into OnlineSession, synchronizing world, predictor, interpolator and drawables', () => {
const clientWorld = new ClientWorld()
const session = new OnlineSession({ world: clientWorld })
// Non-finite dtMs or nowMs fail fast with RangeError
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => session.tick(bad)).toThrow(RangeError)
expect(() => session.tick(40, bad)).toThrow(RangeError)
}
// Assign self player at (5000, 5000) with dir64 = 0
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 0,
id: 1,
classId: 1, // Sorceress
x: 5000,
y: 5000,
direction: 0,
},
})
expect(clientWorld.selfId).toBe(1)
const selfUnit = clientWorld.getUnit('player', 1)!
expect(selfUnit.direction).toBe(0)
expect(selfUnit.targetDirection).toBe(0)
// Player casts skill toward North (4970, 4970) -> targetDir64 = 32
session.dispatchCommand(
{
type: 'SkillAt',
hand: 'right',
at: { x: 4970, y: 4970 },
},
1000,
)
expect(selfUnit.targetDirection).toBe(32)
expect(selfUnit.direction).toBe(0)
expect(selfUnit.turnStepDir).toBe(-8)
// Step session at 25Hz (40ms ticks): 0 -> 56 -> 48 -> 40 -> 32
session.tick(40, 1040)
expect(selfUnit.direction).toBe(56)
let drawables = session.buildRenderableUnits()
let playerDrawable = drawables.find(d => d.id === 1)!
expect(playerDrawable.direction).toBe(56)
session.tick(40, 1080)
expect(selfUnit.direction).toBe(48)
drawables = session.buildRenderableUnits()
playerDrawable = drawables.find(d => d.id === 1)!
expect(playerDrawable.direction).toBe(48)
session.tick(80, 1160)
expect(selfUnit.direction).toBe(32)
expect(selfUnit.turnStepDir).toBe(0)
drawables = session.buildRenderableUnits()
playerDrawable = drawables.find(d => d.id === 1)!
expect(playerDrawable.direction).toBe(32)
// Assign remote monster at (5020, 5000) facing 0
session.handleServerEvent({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 50,
classId: 5,
x: 5020,
y: 5000,
direction: 0,
},
})
const monster = clientWorld.getUnit('monster', 50)!
expect(monster.direction).toBe(0)
// Remote monster moves toward (5030, 5000) (targetDir64 = 56, diff = 56, stepDir = -1)
session.handleServerEvent({
type: 'UnitMove',
unitType: 1,
unitId: 50,
mode: 'walk',
from: { x: 5020, y: 5000 },
to: { x: 5030, y: 5000 },
} as any)
expect(monster.targetDirection).toBe(56)
expect(monster.direction).toBe(0)
// 5 ticks (200ms) steps monster: 0 -> 63 -> 62 -> 61 -> 60 -> 59
session.tick(200, 1360)
expect(monster.direction).toBe(59)
drawables = session.buildRenderableUnits()
const monsterDrawable = drawables.find(d => d.id === 50)!
expect(monsterDrawable.direction).toBe(59)
// Complete turn: +120ms (3 ticks) -> 56
session.tick(120, 1480)
expect(monster.direction).toBe(56)
// Remote monster interaction event triggers immediate targetDirection update
session.handleServerEvent({
type: 'Interact',
actor: { unitType: 1, id: 50 },
target: { unitType: 0, id: 1 }, // Local player at (5000, 5000): screen NW (targetDir64 = 23)
} as any)
expect(monster.targetDirection).toBe(23)
expect(monster.direction).toBe(56)
}) })
}) })
@ -421,4 +1062,3 @@ describe('1.13c monster movement velocity & anti-rubber-banding (MonStats.txt +
session.destroy() session.destroy()
}) })
}) })

View File

@ -725,8 +725,11 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
interp.update(200) interp.update(200)
const mover = interp.getEntityState(0, 2)! const mover = interp.getEntityState(0, 2)!
expect(mover.x).toBeGreaterThan(5200) expect(mover.x).toBeGreaterThan(5200)
// World +X is screen south-east: dir64 56 (D2Common 0x6FD5CF40). // World +X is screen south-east: targetDir64 56 (D2Common 0x6FD5CF40); 5 ticks (200ms) from dir64=0 steps -1/tick to 59, completing 56 at 320ms.
expect(mover.dir64).toBe(56) expect(mover.targetDir64).toBe(56)
expect(mover.dir64).toBe(59)
interp.update(120)
expect(interp.getEntityState(0, 2)!.dir64).toBe(56)
} }
}) })

View File

@ -1600,7 +1600,8 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
) )
expect(predictor.getSnapshot().moving).toBe(true) expect(predictor.getSnapshot().moving).toBe(true)
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2) expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
expect(predictor.getSnapshot().dir64).toBe(56) expect(predictor.getSnapshot().targetDir64).toBe(56)
expect(predictor.getSnapshot().dir64).toBe(0)
const lifeMana = (x: number, y: number, vx = 0, vy = 0, rttMs = 0) => const lifeMana = (x: number, y: number, vx = 0, vy = 0, rttMs = 0) =>
predictor.verifyServerPosition({ x, y, source: { kind: 'life-mana', vx, vy }, rttMs, inTown: false }) predictor.verifyServerPosition({ x, y, source: { kind: 'life-mana', vx, vy }, rttMs, inTown: false })
@ -1639,10 +1640,18 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
expect(() => lifeMana(5000, 5000, 0.5, 0)).toThrow(RangeError) expect(() => lifeMana(5000, 5000, 0.5, 0)).toThrow(RangeError)
expect(() => lifeMana(5000, 5000, 0, 0, -1)).toThrow(RangeError) expect(() => lifeMana(5000, 5000, 0, 0, -1)).toThrow(RangeError)
// 8. `0x15 ReassignPlayer` repositions directly, clears the path and keeps the facing. // 8. `0x15 ReassignPlayer` repositions directly, clears the path and keeps the facing/turn target.
predictor.setPosition(5005, 5000) predictor.setPosition(5005, 5000)
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 5005, logicalY: 5000, moving: false, dir64: 56 }) expect(predictor.getSnapshot()).toMatchObject({
logicalX: 5005,
logicalY: 5000,
moving: false,
dir64: 0,
targetDir64: 56,
})
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0) expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
predictor.stepTicks(8)
expect(predictor.getSnapshot().dir64).toBe(56)
// 9. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw)) // 9. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw))
// At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec // At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec

View File

@ -10,10 +10,20 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs' import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
D2_TURN_TICK_MS,
DIR64_COUNT, DIR64_COUNT,
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
advanceUnitTurnByTicks,
assertDir64, assertDir64,
computeTurnStepDir64,
createUnitTurnState,
dir64FromPoints, dir64FromPoints,
dir64ToClipDirection, dir64ToClipDirection,
setUnitTargetDirection,
snapUnitTurnDirection,
stepUnitTurnTick,
targetUnitTurnTowardPoint,
} from '../src/common/units/direction.ts' } from '../src/common/units/direction.ts'
const D2COMMON_DLL = 'samples/d2/D2Common.dll' const D2COMMON_DLL = 'samples/d2/D2Common.dll'
@ -44,6 +54,11 @@ function angularDistance(a: number, b: number): number {
return Math.abs(((((a - b) % 360) + 540) % 360) - 180) return Math.abs(((((a - b) % 360) + 540) % 360) - 180)
} }
function circularDir64Distance(a: number, b: number): number {
const diff = (b - a) & 63
return diff <= 32 ? diff : 64 - diff
}
/** /**
* The largest shift of a path direction table boundary from the uniform 5.625° grid: * The largest shift of a path direction table boundary from the uniform 5.625° grid:
* the table steps at tangent indices 13, 26, 39, 53, 68, 85 and 105, and * the table steps at tangent indices 13, 26, 39, 53, 68, 85 and 105, and
@ -204,6 +219,195 @@ describe('dir64ToClipDirection (D2CMP 1.13c #10020)', () => {
}) })
}) })
describe('unit turn step table & 25Hz state machine (D2Common 1.13c 0x6FDE41C8, #10425, #10739, #10603)', () => {
it('converges monotonically in at most 8 ticks (320ms) for all 64x64 (dir64, targetDir64) pairs', () => {
expect(TURN_STEP_TABLE_113C.length).toBe(64)
for (let startDir = 0; startDir < DIR64_COUNT; startDir++) {
for (let targetDir = 0; targetDir < DIR64_COUNT; targetDir++) {
const expectedStep = TURN_STEP_TABLE_113C[(targetDir - startDir) & 63]!
expect(computeTurnStepDir64(startDir, targetDir)).toBe(expectedStep)
const state = createUnitTurnState(startDir)
setUnitTargetDirection(state, targetDir, false)
expect(state.targetDir64).toBe(targetDir)
expect(state.stepDir).toBe(expectedStep)
if (startDir === targetDir) {
expect(state.dir64).toBe(targetDir)
expect(state.stepDir).toBe(0)
expect(stepUnitTurnTick(state)).toBe(targetDir)
continue
}
// Mid-turn starts keep dir64 at startDir until the first 25Hz tick
expect(state.dir64).toBe(startDir)
let prevDist = circularDir64Distance(state.dir64, targetDir)
let ticks = 0
while (state.dir64 !== targetDir && ticks < 16) {
// Repeatedly calling setUnitTargetDirection with the same targetDir64 mid-turn
// must preserve stepDir without resetting turn velocity (#10739)
const savedStepDir = state.stepDir
setUnitTargetDirection(state, targetDir, false)
expect(state.stepDir).toBe(savedStepDir)
stepUnitTurnTick(state)
ticks++
const nextDist = circularDir64Distance(state.dir64, targetDir)
expect(nextDist).toBeLessThan(prevDist)
prevDist = nextDist
}
expect(state.dir64, `pair (${startDir} -> ${targetDir})`).toBe(targetDir)
expect(state.stepDir, `pair (${startDir} -> ${targetDir})`).toBe(0)
expect(ticks, `pair (${startDir} -> ${targetDir})`).toBeGreaterThanOrEqual(1)
expect(ticks, `pair (${startDir} -> ${targetDir})`).toBeLessThanOrEqual(8)
// Subsequent ticks once converged are no-ops
expect(stepUnitTurnTick(state)).toBe(targetDir)
expect(state.stepDir).toBe(0)
}
}
})
it('steps diff = 32 by -8 (0 -> 56 -> 48 -> 40 -> 32 in 4 ticks) and clamps non-multiple diffs cleanly (0 -> 8 -> 16 -> 20 in 3 ticks)', () => {
const opposite = createUnitTurnState(0)
setUnitTargetDirection(opposite, 32, false)
expect(opposite.stepDir).toBe(-8)
const oppositeSteps = [
opposite.dir64,
stepUnitTurnTick(opposite),
stepUnitTurnTick(opposite),
stepUnitTurnTick(opposite),
stepUnitTurnTick(opposite),
]
expect(oppositeSteps).toEqual([0, 56, 48, 40, 32])
expect(opposite.stepDir).toBe(0)
const nonMultiplePos = createUnitTurnState(0)
setUnitTargetDirection(nonMultiplePos, 20, false)
expect(nonMultiplePos.stepDir).toBe(8)
const posSteps = [
nonMultiplePos.dir64,
stepUnitTurnTick(nonMultiplePos),
stepUnitTurnTick(nonMultiplePos),
stepUnitTurnTick(nonMultiplePos),
]
expect(posSteps).toEqual([0, 8, 16, 20])
expect(nonMultiplePos.stepDir).toBe(0)
const nonMultipleNeg = createUnitTurnState(20)
setUnitTargetDirection(nonMultipleNeg, 0, false)
expect(nonMultipleNeg.stepDir).toBe(-8)
const negSteps = [
nonMultipleNeg.dir64,
stepUnitTurnTick(nonMultipleNeg),
stepUnitTurnTick(nonMultipleNeg),
stepUnitTurnTick(nonMultipleNeg),
]
expect(negSteps).toEqual([20, 12, 4, 0])
expect(nonMultipleNeg.stepDir).toBe(0)
})
it('snaps immediately in 0 ticks when bInstant = 1 (snapUnitTurnDirection / setUnitTargetDirection(..., true))', () => {
for (let startDir = 0; startDir < DIR64_COUNT; startDir += 7) {
for (let targetDir = 0; targetDir < DIR64_COUNT; targetDir += 5) {
const s1 = createUnitTurnState(startDir)
setUnitTargetDirection(s1, (startDir + 16) & 63, false)
snapUnitTurnDirection(s1, targetDir)
expect(s1).toEqual({
dir64: targetDir,
targetDir64: targetDir,
stepDir: 0,
turnTickRemainderMs: 0,
})
const s2 = createUnitTurnState(startDir)
setUnitTargetDirection(s2, targetDir, true)
expect(s2).toEqual({
dir64: targetDir,
targetDir64: targetDir,
stepDir: 0,
turnTickRemainderMs: 0,
})
}
}
})
it('accumulates sub-tick dtMs and steps once per 40ms (25Hz) in advanceUnitTurnByDtMs', () => {
const state = createUnitTurnState(0)
// Target opposite direction (32 = North): TURN_STEP_TABLE_113C[32] = -8, requires 4 ticks = 160ms
setUnitTargetDirection(state, 32, false)
expect(state.stepDir).toBe(-8)
// 16ms + 16ms = 32ms (< 40ms): 0 ticks elapsed, dir64 still 0
expect(advanceUnitTurnByDtMs(state, 16)).toBe(0)
expect(advanceUnitTurnByDtMs(state, 16)).toBe(0)
expect(state.turnTickRemainderMs).toBe(32)
// +16ms = 48ms: 1 tick elapsed (dir64 = (0 - 8) & 63 = 56), remainder = 8ms
expect(advanceUnitTurnByDtMs(state, 16)).toBe(56)
expect(state.turnTickRemainderMs).toBe(8)
// +112ms + 8ms remainder = 120ms = 3 ticks: reaches targetDir64 = 32 (56 -> 48 -> 40 -> 32)
expect(advanceUnitTurnByDtMs(state, 3 * D2_TURN_TICK_MS - 8)).toBe(32)
expect(state.dir64).toBe(32)
expect(state.stepDir).toBe(0)
// Cancelling a turn mid-subtick by retargeting back to state.dir64 clears turnTickRemainderMs
setUnitTargetDirection(state, 0, false)
expect(advanceUnitTurnByDtMs(state, 25)).toBe(32)
expect(state.turnTickRemainderMs).toBe(25)
setUnitTargetDirection(state, 32, false)
expect(state.turnTickRemainderMs).toBe(0)
setUnitTargetDirection(state, 48, false)
expect(advanceUnitTurnByDtMs(state, 20)).toBe(32)
})
it('fails fast with RangeError on invalid directions, ticks, or dtMs', () => {
for (const bad of [-1, 64, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => createUnitTurnState(bad)).toThrow(RangeError)
expect(() => computeTurnStepDir64(bad, 0)).toThrow(RangeError)
expect(() => computeTurnStepDir64(0, bad)).toThrow(RangeError)
const s = createUnitTurnState(0)
expect(() => setUnitTargetDirection(s, bad)).toThrow(RangeError)
expect(() => snapUnitTurnDirection(s, bad)).toThrow(RangeError)
expect(() => stepUnitTurnTick({ dir64: bad, targetDir64: 0, stepDir: 0, turnTickRemainderMs: 0 })).toThrow(
RangeError,
)
expect(() => stepUnitTurnTick({ dir64: 0, targetDir64: bad, stepDir: 0, turnTickRemainderMs: 0 })).toThrow(
RangeError,
)
}
for (const badStep of [2, 3, 5, 99, -2, -3, 1.5, Number.NaN]) {
expect(() => stepUnitTurnTick({ dir64: 0, targetDir64: 16, stepDir: badStep, turnTickRemainderMs: 0 })).toThrow(
RangeError,
)
const badState = createUnitTurnState(0)
badState.stepDir = badStep
expect(() => setUnitTargetDirection(badState, 16)).toThrow(RangeError)
}
for (const badRem of [-1, -0.1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => stepUnitTurnTick({ dir64: 0, targetDir64: 16, stepDir: 4, turnTickRemainderMs: badRem })).toThrow(
RangeError,
)
expect(() => advanceUnitTurnByTicks({ dir64: 0, targetDir64: 16, stepDir: 4, turnTickRemainderMs: badRem }, 1)).toThrow(
RangeError,
)
}
const s = createUnitTurnState(0)
for (const badDt of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => advanceUnitTurnByDtMs(s, badDt)).toThrow(RangeError)
expect(() => targetUnitTurnTowardPoint(s, badDt, 0, 1, 1)).toThrow(RangeError)
}
for (const badTicks of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => advanceUnitTurnByTicks(s, badTicks)).toThrow(RangeError)
}
})
})
describe.skipIf(!hasDlls)('binary parity with the 1.13c DLLs (samples/d2)', () => { describe.skipIf(!hasDlls)('binary parity with the 1.13c DLLs (samples/d2)', () => {
it('follows the angle column of the D2Common path direction table at 0x6FDDD470', () => { it('follows the angle column of the D2Common path direction table at 0x6FDDD470', () => {
// 128 entries of { int32 x, int32 y, int32 angle }, indexed by trunc(127 · minor / major). // 128 entries of { int32 x, int32 y, int32 angle }, indexed by trunc(127 · minor / major).
@ -217,6 +421,12 @@ describe.skipIf(!hasDlls)('binary parity with the 1.13c DLLs (samples/d2)', () =
} }
}) })
it('equals the D2Common 64-entry (256-byte) turn step table at VA 0x6FDE41C8 (file offset 0x925C8 / .rdata 0x941C8)', () => {
const dllTable = readPeInt32s(D2COMMON_DLL, 0x6fde41c8, DIR64_COUNT)
expect(dllTable.length * 4).toBe(256)
expect([...TURN_STEP_TABLE_113C]).toEqual(dllTable)
})
it('equals the D2CMP direction rows at 0x6FE2F4D8 + (bsf(directions) + 1) · 256', () => { it('equals the D2CMP direction rows at 0x6FE2F4D8 + (bsf(directions) + 1) · 256', () => {
for (const directions of CLIP_DIRECTION_COUNTS) { for (const directions of CLIP_DIRECTION_COUNTS) {
const row = readPeInt32s(D2CMP_DLL, 0x6fe2f4d8 + (Math.log2(directions) + 1) * 256, DIR64_COUNT) const row = readPeInt32s(D2CMP_DLL, 0x6fe2f4d8 + (Math.log2(directions) + 1) * 256, DIR64_COUNT)