feat(client,common): implement 1.13c frame-by-frame 25Hz unit turn transition (closes #543)

This commit is contained in:
troytt 2026-10-02 12:29:29 +00:00
parent 8fcd2238cc
commit 94f0a705c8
12 changed files with 1825 additions and 130 deletions

View File

@ -946,10 +946,50 @@ export class CommandMapper {
command.type === 'CastRightSkillOnLocation' ||
command.type === 'CastLeftSkillOnTarget' ||
command.type === 'CastRightSkillOnTarget' ||
command.type === 'Interact' ||
command.type === 'OperateObject' ||
command.type === 'NpcInit' ||
command.type === 'Resurrect'
) {
this.world.noteOutgoingCommand(command)
}
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 {
command,

View File

@ -9,11 +9,20 @@
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
* - `0x6D NPCStop` (`{ unitId, x, y, life }`)
*
* Facings are dir64 values (`common/units/direction.ts`), set once when a segment starts
* (like D2Common 0x6FD5D240) and kept while the segment is walked and after it ends.
* Facings are 64-direction values (`common/units/direction.ts`): movement segments and skill/attack
* 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 {
D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC,
@ -33,10 +42,16 @@ export interface RemoteEntityMotionState {
readonly speedSubTilesPerSec: number
readonly moving: 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
/** 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 {
readonly unitType: number
readonly unitId: number
@ -67,13 +82,30 @@ interface MutableRemoteEntityMotion {
speedSubTilesPerSec: number
moving: boolean
running: boolean
dir64: number
turnState: UnitTurnState
}
function entityKey(unitType: number, unitId: number): string {
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 }`.
* In D2 1.13c:
@ -109,7 +141,8 @@ export class RemoteEntityInterpolator {
/**
* 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: {
readonly unitType: number
@ -121,6 +154,12 @@ export class RemoteEntityInterpolator {
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
const key = entityKey(params.unitType, params.unitId)
const previous = this.entities.get(key)
const turnState: UnitTurnState =
params.dir64 !== undefined
? createUnitTurnState(params.dir64)
: previous
? { ...previous.turnState }
: createUnitTurnState(0)
const state: MutableRemoteEntityMotion = {
unitType: params.unitType,
unitId: params.unitId >>> 0,
@ -131,10 +170,10 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
moving: false,
running: false,
dir64: params.dir64 ?? previous?.dir64 ?? 0,
turnState,
}
this.entities.set(key, state)
return { ...state }
return toState(state)
}
/**
@ -159,7 +198,7 @@ export class RemoteEntityInterpolator {
speedSubTilesPerSec,
moving: false,
running,
dir64: 0,
turnState: createUnitTurnState(0),
}
this.entities.set(key, state)
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
@ -180,7 +219,7 @@ export class RemoteEntityInterpolator {
const dist = Math.hypot(dx, dy)
if (dist > 1e-4) {
state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, state.targetX, state.targetY, state.dir64)
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 (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
@ -195,7 +234,7 @@ export class RemoteEntityInterpolator {
state.moving = false
}
return { ...state }
return toState(state)
}
/**
@ -225,15 +264,56 @@ export class RemoteEntityInterpolator {
state.targetY = cmd.y
state.moving = false
} else {
// Smoothly finish the small remaining glide to the authoritative stop point, facing it so
// the unit never slides backwards.
// Smoothly finish the small remaining glide to the authoritative stop point, turning toward
// it via D2Common.#10425 (bInstant = 0) so the unit never slides backwards.
state.targetX = cmd.x
state.targetY = cmd.y
state.moving = true
state.dir64 = dir64FromPoints(state.x, state.y, cmd.x, cmd.y, state.dir64)
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 {
@ -245,12 +325,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 {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (dtMs <= 0) return
const dtSec = dtMs / 1000
for (const state of this.entities.values()) {
advanceUnitTurnByDtMs(state.turnState, dtMs)
if (!state.moving) continue
const dx = state.targetX - state.x
const dy = state.targetY - state.y
@ -280,10 +366,10 @@ export class RemoteEntityInterpolator {
getEntityState(unitType: number, unitId: number): RemoteEntityMotionState | null {
const state = this.entities.get(entityKey(unitType, unitId))
return state ? { ...state } : null
return state ? toState(state) : null
}
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
* 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
* 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 {
findPathWorld,
type PathfindOptions,
@ -46,6 +54,7 @@ const GATE_LEAD_MAX_DIST_SQ = 100
export interface PredictorConfig {
readonly initialX?: number
readonly initialY?: number
readonly initialDir64?: number
readonly walkSpeedSubTilesPerSec?: number
readonly runSpeedSubTilesPerSec?: number
readonly fasterRunWalkPercent?: number
@ -115,8 +124,12 @@ export interface PredictorSnapshot {
readonly moving: boolean
/** True if running (false if walking). */
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
/** Target facing (`D2DynamicPathStrc` `+0x65` `nNewDirection`, 0..63). */
readonly targetDir64: number
/** Cached turn step per 25Hz tick (`D2DynamicPathStrc` `+0x66` `nStepDir`: 0, ±1, ±4, ±8). */
readonly stepDir: number
/** Current active target waypoint, or null if idle. */
readonly currentWaypoint: SubTilePoint | null
/** Remaining waypoints including current target. */
@ -141,7 +154,7 @@ export class LocalMovementPredictor {
private runSpeed: number
private fasterRunWalkPercent: number
private running = true
private dir64 = 0
private readonly turnState: UnitTurnState
private waypoints: SubTilePoint[] = []
private waypointIndex = 0
@ -151,6 +164,7 @@ export class LocalMovementPredictor {
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
this.turnState = createUnitTurnState(config.initialDir64 ?? 0)
}
/**
@ -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 {
assertDir64(dir64, 'dir64')
this.dir64 = dir64
snapUnitTurnDirection(this.turnState, dir64)
}
/**
* Set the target facing (`+0x65`) and cache `stepDir` (`+0x66`) via the 1.13c turn table
* (`D2Common.#10425` `0x6FD85270`, `bInstant = 0` by default).
*/
setTargetDirection(targetDir64: number, instant = false): void {
setUnitTargetDirection(this.turnState, targetDir64, instant)
}
/**
* Face toward world sub-tile `(targetX, targetY)` (`D2Common.#10739` `0x6FD852F0`).
*/
facePoint(targetX: number, targetY: number, instant = false): void {
targetUnitTurnTowardPoint(this.turnState, this.logicalX, this.logicalY, targetX, targetY, instant)
}
/**
@ -261,6 +290,9 @@ export class LocalMovementPredictor {
* Step the predictor by `ticks` 25Hz game ticks (`ticks * 40ms`).
*/
stepTicks(ticks = 1): PredictorSnapshot {
if (!Number.isInteger(ticks) || ticks < 0) {
throw new RangeError(`ticks must be a non-negative integer, got ${String(ticks)}`)
}
for (let i = 0; i < ticks; i++) {
this.update(D2_TICK_MS)
}
@ -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 {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (dtMs <= 0) return this.getSnapshot()
let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * (dtMs / 1000)
while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) {
const speed = this.getEffectiveSpeedSubTilesPerSec()
if (speed <= 0) {
advanceUnitTurnByDtMs(this.turnState, dtMs)
return this.getSnapshot()
}
let remainingTimeMs = dtMs
while (remainingTimeMs > 0 && this.waypointIndex < this.waypoints.length) {
const target = this.waypoints[this.waypointIndex]!
const dx = target.x - this.logicalX
const dy = target.y - this.logicalY
const segDist = Math.hypot(dx, dy)
const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0
if (remainingDist >= segDist) {
if (remainingTimeMs >= segTimeMs) {
if (segTimeMs > 0) {
advanceUnitTurnByDtMs(this.turnState, segTimeMs)
}
this.logicalX = target.x
this.logicalY = target.y
remainingDist -= segDist
remainingTimeMs -= segTimeMs
this.waypointIndex++
this.faceCurrentWaypoint()
} else {
const ratio = remainingDist / segDist
const ratio = segTimeMs > 0 ? remainingTimeMs / segTimeMs : 0
this.logicalX += dx * ratio
this.logicalY += dy * ratio
remainingDist = 0
advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
remainingTimeMs = 0
}
}
@ -299,6 +346,10 @@ export class LocalMovementPredictor {
this.waypointIndex = 0
}
if (remainingTimeMs > 0) {
advanceUnitTurnByDtMs(this.turnState, remainingTimeMs)
}
return this.getSnapshot()
}
@ -377,20 +428,30 @@ export class LocalMovementPredictor {
y: this.logicalY,
moving: currentWaypoint !== null,
running: this.running,
dir64: this.dir64,
dir64: this.turnState.dir64,
targetDir64: this.turnState.targetDir64,
stepDir: this.turnState.stepDir,
currentWaypoint,
remainingWaypoints: this.waypoints.slice(this.waypointIndex),
}
}
/**
* Face the current waypoint. The engine sets the facing whenever a unit starts a segment
* toward its next path point (D2Common 0x6FD5D240), not while walking it.
* Face the current waypoint (`bInstant = 0`). The engine sets the target facing whenever a unit
* starts a segment toward its next path point (D2Common 0x6FD5D240 at 0x6FD5D370), and steps
* `dir64` at 25Hz via #10603 (0x6FD84D70).
*/
private faceCurrentWaypoint(): void {
const target = this.waypoints[this.waypointIndex]
if (target === undefined) return
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,
createWsBridgeResolver,
decodeD2gsS2cPacket,
unitTypeFromId,
unitTypeToId,
type CharClassId,
type CharCreateSpec,
@ -973,6 +974,26 @@ export class OnlineSession {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return
}
} else {
this.world.noteOutgoingCommand(cmd)
}
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)
}
}
}
@ -1029,6 +1050,22 @@ export class OnlineSession {
? { x: Number(evAny.targetX), y: Number(evAny.targetY) }
: { x: 0, y: 0 }),
} 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 fromX = Number(evAny.from?.x ?? evAny.currentX ?? evAny.x ?? 0)
const fromY = Number(evAny.from?.y ?? evAny.currentY ?? evAny.y ?? 0)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? fromX)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? fromY)
ev = {
...evAny,
type: 'UnitMove',
unitType: uType,
id: uId,
mode: evAny.mode ?? 'walk',
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
} as ServerEvent
}
this.world.apply(ev)
@ -1041,6 +1078,32 @@ export class OnlineSession {
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) {
case 'LoadAct': {
// 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
@ -1128,6 +1191,9 @@ export class OnlineSession {
this.predictor.setPosition(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) {
this.interpolator.spawnOrSnap({
unitType: uTypeId,
@ -1185,7 +1251,7 @@ export class OnlineSession {
case 'UnitMove': {
const evAny = ev as any
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 ?? 0)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
@ -1213,6 +1279,51 @@ export class OnlineSession {
targetY: toY,
running: ev.mode === 'run',
})
const worldUnit = this.world.getUnit(ev.unitType, evUnitId)
if (worldUnit && ev.target) {
this.interpolator.setEntityTargetDirection(
evTypeId,
evUnitId,
worldUnit.targetDirection,
false,
)
}
}
break
}
case 'SkillCast': {
const raw = ev as any
const actorType: UnitType =
raw.caster?.unitType ??
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
}
@ -1285,16 +1396,54 @@ export class OnlineSession {
* Step the session by `dtMs` milliseconds:
* - Flushes rate-limited movement commands in `CommandMapper`
* - 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
*/
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)}`)
}
this.tickCounter++
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(dtMs)
if (predSnap.x !== 0 || predSnap.y !== 0) {
this.camera.update(predSnap.x, predSnap.y)
}
if (selfUnit) {
selfUnit.syncTurnState(predSnap.dir64, predSnap.targetDir64, predSnap.stepDir)
}
for (const unit of this.world.units.values()) {
if (unit.unitType === 'player' && (this.world.selfId === 0 || unit.id === (this.world.selfId || 1))) {
continue
}
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(dtMs)
}
}
this.interpolator.update(dtMs)
for (const state of this.interpolator.getAllStates()) {
@ -1302,7 +1451,7 @@ export class OnlineSession {
if (unit) {
unit.x = state.x
unit.y = state.y
unit.direction = state.dir64
unit.syncTurnState(state.dir64, state.targetDir64, state.stepDir)
}
}
}

View File

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

View File

@ -5,6 +5,17 @@
* tracked by `ClientWorld`.
*/
import {
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
advanceUnitTurnByTicks,
assertDir64,
createUnitTurnState,
setUnitTargetDirection,
snapUnitTurnDirection,
targetUnitTurnTowardPoint,
type UnitTurnState,
} from '../../common/units/direction.ts'
import {
unitTypeFromId,
type ItemData,
@ -46,7 +57,27 @@ export class ClientUnit {
targetUnit?: UnitRef | undefined
moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined
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
flags: number
warpId?: number | undefined
@ -74,7 +105,8 @@ export class ClientUnit {
this.x = snapshot.x
this.y = snapshot.y
this.mode = snapshot.mode ?? 0
this.direction = snapshot.direction ?? 0
const initialDir = snapshot.direction ?? 0
this._turnState = createUnitTurnState(initialDir)
this.lifePct = snapshot.lifePct ?? 128
this.flags = snapshot.flags ?? 0
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
@ -101,7 +133,11 @@ export class ClientUnit {
}
set dir(v: number) {
this.direction = v
this.setDirection(v)
}
get turnStepDir(): number {
return this._turnState.stepDir
}
get quality(): number | undefined {
@ -112,6 +148,88 @@ export class ClientUnit {
this.itemQuality = v
}
/**
* Snap both `direction` and `targetDirection` immediately (`bInstant = 1`).
*/
setDirection(dir64: number): void {
snapUnitTurnDirection(this._turnState, dir64)
}
/**
* Set `targetDirection` (`bInstant = 0` by default, or `bInstant = 1` when `instant = true`).
*/
setTargetDirection(targetDir64: number, instant = false): void {
setUnitTargetDirection(this._turnState, targetDir64, instant)
}
/**
* Turn toward `(targetX, targetY)` (`bInstant = 0` by default).
*/
faceToward(targetX: number, targetY: number, instant = false): number {
targetUnitTurnTowardPoint(this._turnState, this.x, this.y, targetX, targetY, instant)
return this._turnState.targetDir64
}
/**
* Advance the 25Hz (`40ms`) turn accumulator by `dtMs` milliseconds (`D2Common.#10603`).
*/
advanceTurn(dtMs: number): number {
advanceUnitTurnByDtMs(this._turnState, dtMs)
return this._turnState.dir64
}
advanceTurnByDtMs(dtMs: number): number {
return this.advanceTurn(dtMs)
}
/**
* Advance the unit turn state by one 25Hz tick (`D2Common.#10603`).
*/
stepTurnTick(): number {
return this.stepTurnTicks(1)
}
/**
* Advance the unit turn state by `ticks` 25Hz ticks (`D2Common.#10603`).
*/
stepTurnTicks(ticks = 1): number {
advanceUnitTurnByTicks(this._turnState, ticks)
return this._turnState.dir64
}
/**
* Synchronize `direction` and `targetDirection` from external motion state (`RemoteEntityInterpolator` / `LocalMovementPredictor`).
*/
syncTurnState(dir64: number, targetDir64: number, stepDir?: number): void {
assertDir64(dir64, 'dir64')
assertDir64(targetDir64, 'targetDir64')
if (
stepDir !== undefined &&
(!Number.isInteger(stepDir) ||
(stepDir !== 0 &&
stepDir !== 1 &&
stepDir !== -1 &&
stepDir !== 4 &&
stepDir !== -4 &&
stepDir !== 8 &&
stepDir !== -8))
) {
throw new RangeError(
`stepDir must be a valid 1.13c turn step (0, ±1, ±4, ±8), got ${String(stepDir)}`,
)
}
if (dir64 === targetDir64) {
this._turnState.stepDir = 0
this._turnState.turnTickRemainderMs = 0
} else if (stepDir !== undefined && stepDir !== 0) {
this._turnState.stepDir = stepDir
} else if (this._turnState.targetDir64 !== targetDir64 || this._turnState.stepDir === 0) {
this._turnState.stepDir = TURN_STEP_TABLE_113C[(targetDir64 - dir64) & 0x3f]!
}
this._turnState.dir64 = dir64
this._turnState.targetDir64 = targetDir64
}
updateFromSnapshot(snapshot: UnitSnapshot): void {
if (snapshot.classId >= 0) {
this.classId = snapshot.classId
@ -127,7 +245,7 @@ export class ClientUnit {
this.mode = snapshot.mode
}
if (snapshot.direction !== undefined) {
this.direction = snapshot.direction
this.setDirection(snapshot.direction)
}
if (snapshot.lifePct !== undefined) {
this.lifePct = snapshot.lifePct
@ -182,6 +300,11 @@ export class ClientUnit {
this.x = from.x
this.y = from.y
}
if (to.x !== 0 || to.y !== 0) {
if (mode !== 'stop' || Math.hypot(to.x - this.x, to.y - this.y) > 0.15) {
this.faceToward(to.x, to.y, false)
}
}
this.moveMode = mode
this.targetX = to.x
this.targetY = to.y

View File

@ -18,7 +18,6 @@ import {
type UnitType,
} from '../../netproto/index.ts'
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { dir64FromPoints } from '../../common/units/direction.ts'
import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts'
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
@ -219,9 +218,10 @@ export class ClientWorld {
private applySkillCastToUnit(unit: ClientUnit, skillId: number, targetX: number, targetY: number): void {
unit.targetX = targetX
unit.targetY = targetY
// `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the
// same 1.13c direction table as movement; a target on the unit's own position keeps the facing.
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
unit.moveMode = 'stop'
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
// `unit.direction` toward `unit.targetDirection`.
unit.faceToward(targetX, targetY, false)
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
unit.mode = mode
@ -249,9 +249,42 @@ export class ClientWorld {
noteOutgoingCommand(cmd: ClientCommand): void {
const cur = this._npcInteraction
switch (cmd.type) {
case 'NpcInit':
case 'NpcInit': {
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
}
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 'NpcOpenGamble': {
const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble'
@ -430,11 +463,26 @@ export class ClientWorld {
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)
}
}
tick(dtMs = 40): void {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
this.frameCount++
const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10)
for (const unit of this._units.values()) {
if (unit.dead) continue
unit.advanceTurn(dtMs)
if (unit.moveMode === 'stop') continue
if (unit.targetX === undefined || unit.targetY === undefined) continue
const dx = unit.targetX - unit.x
const dy = unit.targetY - unit.y
@ -513,6 +561,7 @@ export class ClientWorld {
name: ev.name ?? ev.code,
x: Number(ev.x ?? 0),
y: Number(ev.y ?? 0),
...(ev.direction !== undefined ? { direction: Number(ev.direction) } : {}),
lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128,
},
}
@ -522,20 +571,51 @@ export class ClientWorld {
unitType: unitTypeFromId(ev.unitType ?? 1),
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 fromX = Number(evAny.from?.x ?? evAny.currentX ?? evAny.x ?? 0)
const fromY = Number(evAny.from?.y ?? evAny.currentY ?? evAny.y ?? 0)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? fromX)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? fromY)
ev = {
type: 'UnitMove',
unitType: unitTypeFromId(ev.unitType ?? 0),
id: Number(ev.unitId ?? ev.id ?? 1),
unitType: uType,
id: uId,
mode: evAny.mode ?? 'walk',
from: {
x: Number(ev.currentX ?? ev.x ?? 0),
y: Number(ev.currentY ?? ev.y ?? 0),
x: fromX,
y: fromY,
},
to: {
x: toX,
y: toY,
},
...(evAny.target ? { target: evAny.target } : {}),
}
} 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),
y: Number(ev.targetY ?? ev.y ?? 0),
},
}
} 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') {
ev = {
type: 'Chat',
@ -680,16 +760,34 @@ export class ClientWorld {
}
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') {
this._monsters.set(unit.id, unit)
}
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
unit.x = ev.from.x
unit.y = ev.from.y
} else if (ev.to.x !== 0 || ev.to.y !== 0) {
unit.x = ev.to.x
unit.y = ev.to.y
}
unit.moveMode = ev.mode ?? 'walk'
const targetUnit = ev.target ? this.getUnit(ev.target.unitType, ev.target.id) : undefined
const faceX =
targetUnit && ev.to.x === unit.x && ev.to.y === unit.y && (targetUnit.x !== 0 || targetUnit.y !== 0)
? targetUnit.x
: ev.to.x
const faceY =
targetUnit && ev.to.x === unit.x && ev.to.y === unit.y && (targetUnit.x !== 0 || targetUnit.y !== 0)
? targetUnit.y
: ev.to.y
if (unit.moveMode !== 'stop' && (faceX !== 0 || faceY !== 0)) {
unit.faceToward(faceX, faceY, false)
}
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.y = ev.to.y
}
}
unit.targetX = ev.to.x
unit.targetY = ev.to.y
@ -760,6 +858,31 @@ export class ClientWorld {
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': {
const casterType: 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

@ -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
* (D2Common 0x6FD5D240), and draws it through D2CMP #10020. These tests pin the client's
* local predictor and remote interpolator to that rule: the facing must match the movement
* direction, must not change in the middle of a segment, and must survive re-pathing while
* the mouse is held, idle frames and server repositioning.
* Diablo II 1.13c sets a unit's target facing (`targetDir64`) when it starts a movement segment,
* skill cast, or interaction (`D2Common.#10425` with `bInstant = 0`), and steps `dir64` toward
* `targetDir64` on each 25Hz (`40ms`) tick via `TURN_STEP_TABLE_113C` (`D2Common.#10603`),
* drawing the current intermediate `dir64` through `D2CMP.#10020`.
*/
import { describe, expect, it } from 'vitest'
@ -13,10 +12,21 @@ import { CommandMapper } from '../../src/client/input/command-mapper.ts'
import type { MapService } from '../../src/client/map/map-service.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.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 { 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 { 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 type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
const FRAME_MS = 1000 / 60
@ -30,6 +40,17 @@ function headingVector(k: number): readonly [number, number] {
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`). */
function makeGrid(
originX: number,
@ -64,20 +85,35 @@ function makeMapper(grid: SubTileWalkabilityGrid, predictor: LocalMovementPredic
})
}
describe('local player facing (LocalMovementPredictor)', () => {
it('faces each of the 16 screen headings and draws the matching clip direction', () => {
describe('local player facing & 25Hz turn transition (LocalMovementPredictor)', () => {
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++) {
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
const [dx, dy] = headingVector(k)
predictor.setPath([{ x: 1000 + 20 * dx, y: 1000 + 20 * dy }], true)
const { dir64 } = predictor.getSnapshot()
expect(dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
expect(dir64ToClipDirection(dir64, 16), `heading ${k}`).toBe(k)
if (k % 2 === 0) expect(dir64ToClipDirection(dir64, 8), `heading ${k}`).toBe(k / 2)
const targetDir = HEADING_DIR64[k]!
const expectedSeq = expectedTurnSequence(0, targetDir)
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)
for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
@ -86,29 +122,42 @@ describe('local player facing (LocalMovementPredictor)', () => {
const goalX = 5000 + Math.round(30 * dx)
const goalY = 5000 + Math.round(30 * dy)
const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0)
const allowedDirs = new Set(expectedTurnSequence(0, expectedDir64))
let previousDistance = Number.POSITIVE_INFINITY
let frames = 0
let convergedFrame = -1
for (; frames < 600; frames++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, frames * 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
const distance = Math.hypot(goalX - snap.logicalX, goalY - snap.logicalY)
expect(distance, `heading ${k} frame ${frames}`).toBeLessThan(previousDistance)
previousDistance = distance
}
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()
expect(Math.round(stopped.logicalX)).toBe(goalX)
expect(Math.round(stopped.logicalY)).toBe(goalY)
expect(stopped.dir64).toBe(expectedDir64)
// Holding the button at the goal changes nothing.
for (let i = 0; i < 10; i++) {
mapper.triggerPrimaryWorldAction(goalX, goalY, null, (frames + i) * 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.logicalY).toBe(stopped.logicalY)
}
@ -133,9 +182,14 @@ describe('local player facing (LocalMovementPredictor)', () => {
mapper.triggerPrimaryWorldAction(clickAt.x, clickAt.y, null, 1000)
const turned = predictor.getSnapshot()
expect(turned.dir64).toBe(
dir64FromPoints(before.logicalX, before.logicalY, clickAt.x, clickAt.y, 0),
const expectedTargetDir = dir64FromPoints(
before.logicalX,
before.logicalY,
clickAt.x,
clickAt.y,
before.dir64,
)
expect(turned.targetDir64).toBe(expectedTargetDir)
const after = predictor.update(FRAME_MS)
const toGoalX = clickAt.x - before.logicalX
const toGoalY = clickAt.y - before.logicalY
@ -144,11 +198,15 @@ describe('local player facing (LocalMovementPredictor)', () => {
// 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))
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', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
it('updates targetDir64 when each segment starts and transitions dir64 across segments at 25Hz', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100, initialDir64: 56 })
predictor.setPath(
[
{ x: 110, y: 100 },
@ -156,37 +214,81 @@ describe('local player facing (LocalMovementPredictor)', () => {
],
true,
)
expect(predictor.getSnapshot().dir64).toBe(56) // world +X is screen south-east
const seen: Array<{ waypointX: number; waypointY: number; dir64: number }> = []
for (let frame = 0; frame < 200; frame++) {
const snap = predictor.update(FRAME_MS)
expect(predictor.getSnapshot()).toMatchObject({ dir64: 56, targetDir64: 56 })
const secondSegSeq = expectedTurnSequence(56, 7)
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
seen.push({ waypointX: snap.currentWaypoint!.x, waypointY: snap.currentWaypoint!.y, dir64: snap.dir64 })
}
expect(seen.length).toBeGreaterThan(10)
for (const step of seen) {
expect(step.dir64).toBe(step.waypointY === 100 ? 56 : 7) // world +Y is screen south-west
}
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 110, logicalY: 110, dir64: 7 })
expect(secondSegDirs.length).toBeGreaterThan(3)
// On the tick that reaches waypoint 1 and starts segment 2, targetDir64 becomes 7 while dir64 is 56,
// and subsequent ticks step through secondSegSeq (56 -> 60 -> 62 -> 0 -> 2 -> 4 -> 5 -> 6 -> 7).
expect(secondSegDirs[0]).toBe(secondSegSeq[0])
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 })
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)
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23 })
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23, targetDir64: 23 })
predictor.update(1000)
predictor.setPath([], true)
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)
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)
expect(predictor.getSnapshot().dir64).toBe(40)
expect(predictor.getSnapshot()).toMatchObject({ dir64: 40, targetDir64: 40 })
expect(() => predictor.setDirection(64)).toThrow(RangeError)
expect(() => predictor.setDirection(-1)).toThrow(RangeError)
})
@ -197,6 +299,7 @@ describe('local player facing (LocalMovementPredictor)', () => {
predictor.setPath([{ x: 5030, y: 5000 }], true)
const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000)
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.
const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000)
@ -207,8 +310,8 @@ describe('local player facing (LocalMovementPredictor)', () => {
})
})
describe('remote unit facing (RemoteEntityInterpolator)', () => {
it('faces the 16 screen headings when a move starts and keeps the facing to the end', () => {
describe('remote unit facing & 25Hz turn transition (RemoteEntityInterpolator)', () => {
it('sets targetDir64 for the 16 screen headings when a move starts and steps dir64 at 25Hz to the end', () => {
const interp = new RemoteEntityInterpolator()
for (let k = 0; k < 16; k++) {
interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 })
@ -222,8 +325,20 @@ describe('remote unit facing (RemoteEntityInterpolator)', () => {
targetY: 5000 + 10 * dy,
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++) {
interp.update(FRAME_MS)
for (let k = 0; k < 16; k++) {
@ -233,45 +348,574 @@ describe('remote unit facing (RemoteEntityInterpolator)', () => {
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()
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, dir64: 12 }).dir64).toBe(12)
expect(interp.spawnOrSnap({ unitType: 0, unitId: 8, x: 100, y: 100 }).dir64).toBe(0)
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130 })).toMatchObject({
dir64: 40,
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)
})
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()
interp.spawnOrSnap({ unitType: 1, unitId: 1, x: 5000, y: 5000 })
expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).dir64).toBe(56)
interp.update(200)
expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).targetDir64).toBe(56)
// 8 ticks = 320ms completes the turn from 0 -> 56 (diff = 56, stepDir = -1)
interp.update(320)
const mid = interp.getEntityState(1, 1)!
expect(mid.dir64).toBe(56)
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.dir64).toBe(dir64FromPoints(mid.x, mid.y, 5003, 5002, 56))
expect(glide.targetDir64).toBe(expectedGlideTargetDir)
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.
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)', () => {
it('turns the caster toward the target as a dir64 and keeps it for a target underfoot', () => {
describe('skill-cast & interaction 25Hz facing (ClientWorld & buildSceneDrawables)', () => {
it('sets targetDirection on SkillCast / CastAtCoord / Interact and steps direction at 25Hz without instant snap', () => {
const world = new ClientWorld()
world.apply({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5000, y: 5000 } })
const castAt = (x: number, y: number): number => {
world.apply({ type: 'SkillCast', caster: { unitType: 1, id: 40 }, skill: 0, level: 1, target: { x, y } })
return world.getUnit('monster', 40)!.direction
const monster = world.getUnit('monster', 40)!
expect(monster.direction).toBe(0)
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(castAt(5000, 5000)).toBe(56)
expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north
expect(monster.direction).toBe(56)
// 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)
})
})

View File

@ -725,8 +725,11 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
interp.update(200)
const mover = interp.getEntityState(0, 2)!
expect(mover.x).toBeGreaterThan(5200)
// World +X is screen south-east: dir64 56 (D2Common 0x6FD5CF40).
expect(mover.dir64).toBe(56)
// 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.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().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) =>
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, 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)
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)
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))
// 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 { describe, expect, it } from 'vitest'
import {
D2_TURN_TICK_MS,
DIR64_COUNT,
TURN_STEP_TABLE_113C,
advanceUnitTurnByDtMs,
advanceUnitTurnByTicks,
assertDir64,
computeTurnStepDir64,
createUnitTurnState,
dir64FromPoints,
dir64ToClipDirection,
setUnitTargetDirection,
snapUnitTurnDirection,
stepUnitTurnTick,
targetUnitTurnTowardPoint,
} from '../src/common/units/direction.ts'
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)
}
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 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)', () => {
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).
@ -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', () => {
for (const directions of CLIP_DIRECTION_COUNTS) {
const row = readPeInt32s(D2CMP_DLL, 0x6fe2f4d8 + (Math.log2(directions) + 1) * 256, DIR64_COUNT)