From 94f0a705c804ff7a00deaa76d33e34e7b7f06637 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 12:29:29 +0000 Subject: [PATCH] feat(client,common): implement 1.13c frame-by-frame 25Hz unit turn transition (closes #543) --- src/client/input/command-mapper.ts | 40 + src/client/motion/interpolate.ts | 122 ++- src/client/motion/predictor.ts | 99 ++- src/client/session/online-session.ts | 155 +++- src/client/view/unit-drawables.ts | 19 +- src/client/world/client-unit.ts | 131 ++- src/client/world/client-world.ts | 151 +++- src/common/units/direction.ts | 244 ++++++ tests/client/unit-facing-motion.test.ts | 762 ++++++++++++++++-- .../e2e-netproto/tier3-cross-feature.test.ts | 7 +- .../tier5-adversarial-coverage.test.ts | 15 +- tests/unit-direction.test.ts | 210 +++++ 12 files changed, 1825 insertions(+), 130 deletions(-) diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 8ef5dda..fd63660 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -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, diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 614d085..2181a71 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -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)) } } diff --git a/src/client/motion/predictor.ts b/src/client/motion/predictor.ts index 8f7c2d5..fda57fe 100644 --- a/src/client/motion/predictor.ts +++ b/src/client/motion/predictor.ts @@ -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, + ) } /** diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..202a32f 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -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) } } } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..2742eb0 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -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 { diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..917c50a 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -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 { + 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 diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..990c4a0 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -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 ?? diff --git a/src/common/units/direction.ts b/src/common/units/direction.ts index 4405fe2..3473469 100644 --- a/src/common/units/direction.ts +++ b/src/common/units/direction.ts @@ -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 +} + diff --git a/tests/client/unit-facing-motion.test.ts b/tests/client/unit-facing-motion.test.ts index 3480762..837e760 100644 --- a/tests/client/unit-facing-motion.test.ts +++ b/tests/client/unit-facing-motion.test.ts @@ -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) + }) +}) + + diff --git a/tests/e2e-netproto/tier3-cross-feature.test.ts b/tests/e2e-netproto/tier3-cross-feature.test.ts index 25872d0..36cbdf0 100644 --- a/tests/e2e-netproto/tier3-cross-feature.test.ts +++ b/tests/e2e-netproto/tier3-cross-feature.test.ts @@ -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) } }) diff --git a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts index 7c48424..34b4f40 100644 --- a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts @@ -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 diff --git a/tests/unit-direction.test.ts b/tests/unit-direction.test.ts index 178d566..74532e9 100644 --- a/tests/unit-direction.test.ts +++ b/tests/unit-direction.test.ts @@ -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)