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