diablo2-web/tests/client/unit-facing-motion.test.ts

425 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit facing while moving (dir64, `src/common/units/direction.ts`).
*
* 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.
*/
import { describe, expect, it } from 'vitest'
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 { HudManager } from '../../src/client/ui/hud-manager.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts'
import {
computeMonsterMoveSpeedSubTilesPerSec,
getMonsterKindByClassId,
} from '../../src/common/units/monsters-data.ts'
import { ByteWriter, decodeD2gsServerPacket } from '../../src/netproto/index.ts'
import type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
const FRAME_MS = 1000 / 60
/** dir64 for the 16 screen headings (0 = screen south, clockwise), as the engine quantises them. */
const HEADING_DIR64 = [0, 4, 7, 11, 15, 20, 23, 27, 32, 36, 40, 43, 47, 52, 56, 59] as const
/** World unit vector of screen heading `k`: screen south is world (+1, +1), 22.5° per step. */
function headingVector(k: number): readonly [number, number] {
const radians = ((45 + k * 22.5) * Math.PI) / 180
return [Math.cos(radians), Math.sin(radians)]
}
/** A walkable world sub-tile grid with its origin at (`originX`, `originY`). */
function makeGrid(
originX: number,
originY: number,
size: number,
blocked: (x: number, y: number) => boolean = () => false,
): SubTileWalkabilityGrid {
const walkable = new Uint8Array(size * size)
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
walkable[y * size + x] = blocked(originX + x, originY + y) ? 0 : 1
}
}
return { subWidth: size, subHeight: size, walkable, subOriginX: originX, subOriginY: originY }
}
/**
* The production input path: `CommandMapper` re-paths from the player's cell on every frame the
* left button is held (`triggerPrimaryWorldAction` → `startLocalPathPrediction`).
*/
function makeMapper(grid: SubTileWalkabilityGrid, predictor: LocalMovementPredictor): CommandMapper {
const hudManager = new HudManager()
hudManager.resize(800, 600)
const mapService = { getActiveLevelView: () => grid } as unknown as MapService
const sent: ClientCommand[] = []
return new CommandMapper({
world: new ClientWorld(),
hudManager,
predictor,
mapService,
sendCommand: cmd => sent.push(cmd),
})
}
describe('local player facing (LocalMovementPredictor)', () => {
it('faces each of the 16 screen headings and draws 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)
}
})
it('keeps one facing and always moves forward while the mouse is held (re-path every frame)', () => {
const grid = makeGrid(4900, 4900, 200)
for (let k = 0; k < 16; k++) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
const mapper = makeMapper(grid, predictor)
const [dx, dy] = headingVector(k)
const goalX = 5000 + Math.round(30 * dx)
const goalY = 5000 + Math.round(30 * dy)
const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0)
let previousDistance = Number.POSITIVE_INFINITY
let frames = 0
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)
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.
const stopped = predictor.getSnapshot()
expect(Math.round(stopped.logicalX)).toBe(goalX)
expect(Math.round(stopped.logicalY)).toBe(goalY)
// 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.logicalX).toBe(stopped.logicalX)
expect(idle.logicalY).toBe(stopped.logicalY)
}
}
})
it('never steps back to the cell centre when a new click arrives mid-cell', () => {
const grid = makeGrid(4900, 4900, 200)
const cases = [
{ runTo: { x: 5030, y: 5000 }, travelled: 4.3, clickAt: { x: 5020, y: 5010 } },
{ runTo: { x: 5030, y: 5000 }, travelled: 4.7, clickAt: { x: 5025, y: 4990 } },
{ runTo: { x: 4970, y: 5000 }, travelled: 4.3, clickAt: { x: 4980, y: 5010 } },
{ runTo: { x: 5000, y: 5030 }, travelled: 2.2, clickAt: { x: 4990, y: 5020 } },
]
for (const { runTo, travelled, clickAt } of cases) {
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
const mapper = makeMapper(grid, predictor)
mapper.triggerPrimaryWorldAction(runTo.x, runTo.y, null, 0)
const speed = predictor.getEffectiveSpeedSubTilesPerSec()
const before = predictor.update((travelled / speed) * 1000)
expect(Math.hypot(before.logicalX - 5000, before.logicalY - 5000)).toBeCloseTo(travelled, 6)
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 after = predictor.update(FRAME_MS)
const toGoalX = clickAt.x - before.logicalX
const toGoalY = clickAt.y - before.logicalY
const stepX = after.logicalX - before.logicalX
const stepY = after.logicalY - before.logicalY
// 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)
}
})
it('sets the facing only when a segment starts', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
predictor.setPath(
[
{ x: 110, y: 100 },
{ x: 110, y: 110 },
],
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)
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 })
})
it('keeps the facing while idle, on an empty path and on a 0x15 reposition', () => {
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
predictor.update(5000)
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 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 })
predictor.setPosition(300, 400)
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 300, logicalY: 400, moving: false, dir64: 23 })
predictor.setDirection(40)
expect(predictor.getSnapshot().dir64).toBe(40)
expect(() => predictor.setDirection(64)).toThrow(RangeError)
expect(() => predictor.setDirection(-1)).toThrow(RangeError)
})
it('paths from the player cell in moveToWorldSubTile, like CommandMapper', () => {
const grid = makeGrid(4900, 4900, 200)
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
predictor.setPath([{ x: 5030, y: 5000 }], true)
const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000)
expect(at.logicalX).toBeCloseTo(5004.7, 6)
// findPathWorld floors its start (cell 5004); the predictor must path from cell 5005.
const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000)
expect(remaining).toEqual([{ x: 5030, y: 5000 }])
const next = predictor.update(FRAME_MS)
expect(next.logicalX).toBeGreaterThan(at.logicalX)
expect(next.dir64).toBe(56)
})
})
describe('remote unit facing (RemoteEntityInterpolator)', () => {
it('faces the 16 screen headings when a move starts and keeps the facing to the end', () => {
const interp = new RemoteEntityInterpolator()
for (let k = 0; k < 16; k++) {
interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 })
const [dx, dy] = headingVector(k)
const started = interp.startMove({
unitType: 1,
unitId: k,
currentX: 5000,
currentY: 5000,
targetX: 5000 + 10 * dx,
targetY: 5000 + 10 * dy,
running: true,
})
expect(started.dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
}
for (let frame = 0; frame < 120; frame++) {
interp.update(FRAME_MS)
for (let k = 0; k < 16; k++) {
expect(interp.getEntityState(1, k)!.dir64, `heading ${k} frame ${frame}`).toBe(HEADING_DIR64[k])
}
}
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', () => {
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: 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', () => {
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)
const mid = interp.getEntityState(1, 1)!
expect(mid.dir64).toBe(56)
const glide = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003, y: 5002 })
expect(glide.moving).toBe(true)
expect(glide.dir64).toBe(dir64FromPoints(mid.x, mid.y, 5003, 5002, 56))
interp.update(1000)
expect(interp.getEntityState(1, 1)).toMatchObject({ x: 5003, y: 5002, moving: false, dir64: glide.dir64 })
// 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 })
})
})
describe('skill-cast facing (ClientWorld)', () => {
it('turns the caster toward the target as a dir64 and keeps it for a target underfoot', () => {
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
}
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
})
})
describe('1.13c monster movement velocity & anti-rubber-banding (MonStats.txt + D2Common 0x6FD5D240)', () => {
it('populates all 734 MonStats.txt [Velocity, Run] pairs and computes exact 1.13c sub-tile/sec speeds', () => {
expect(MONSTER_HCIDX_VELOCITIES.length).toBe(734)
// Zombies (zombie1..zombie5, hcIdx = 5..9): Velocity = 1 in MonStats.txt ([1,3], [1,4], [1,4], [1,5], [1,5])
const expectedZombieRun = [3, 4, 4, 5, 5] as const
for (let hcIdx = 5; hcIdx <= 9; hcIdx++) {
expect(MONSTER_HCIDX_VELOCITIES[hcIdx]).toEqual([1, expectedZombieRun[hcIdx - 5]])
const kind = getMonsterKindByClassId(hcIdx)
expect(kind).toBeDefined()
expect(kind?.velocity).toBe(1)
expect(kind?.runVelocity).toBe(expectedZombieRun[hcIdx - 5])
// dwVelocity = floor((75 * 256) / 100) = 192 => 192 * 25 / 4096 = 1.171875 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false, 75)).toBe(1.171875)
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false)).toBe(1.171875)
}
// Skeleton (skeleton1, hcIdx = 0): Velocity = 3, Run = 3
// dwVelocity = floor((75 * 768) / 100) = 576 => 576 * 25 / 4096 = 3.515625 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(0, false, 75)).toBe(3.515625)
// Fallen (fallen1, hcIdx = 19): Velocity = 5, Run = 5
// Walk (nVelocity = 75): dwVelocity = floor((75 * 1280) / 100) = 960 => 960 * 25 / 4096 = 5.859375 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(19, false, 75)).toBe(5.859375)
// With explicit run velocityPercent = 155: dwVelocity = floor((155 * 1280) / 100) = 1984 => 12.109375 sub-tiles/s
expect(computeMonsterMoveSpeedSubTilesPerSec(19, true, 155)).toBe(12.109375)
// Corrupt Rogue (corruptrogue2, hcIdx = 44): Velocity = 6, Run = 8
// Fallback run without velocityPercent: runBonus = floor(800/6) - 100 = 33 => rawPercent = 108 => dwVelocity = floor((108 * 1536) / 100) = 1658 => 1658 * 25 / 4096 = 10.11962890625
expect(computeMonsterMoveSpeedSubTilesPerSec(44, true)).toBeCloseTo((25 * 1658) / 4096, 8)
// Akara (hcIdx = 148, Velocity = 1) and Kashya (hcIdx = 150, Velocity = 3) from 04-d2gs-idle-60s.d2cap
expect(computeMonsterMoveSpeedSubTilesPerSec(148, false, 75)).toBe(1.171875)
expect(computeMonsterMoveSpeedSubTilesPerSec(150, false, 75)).toBe(3.515625)
})
it('interpolates Zombie (hcIdx=5) 0x67 NPCMove and 0x68 NPCMoveToTarget at 1.171875 sub-tiles/s without rubber-banding', () => {
const session = new OnlineSession()
// 1. Spawn local player at (5010, 5000) and Zombie (classId=5) at (5000, 5000)
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'Hero', x: 5010, y: 5000, mode: 1, lifePct: 128 },
})
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 200, classId: 5, x: 5000, y: 5000, mode: 1, lifePct: 128 },
})
// 2. Server sends 0x67 NPCMove for Zombie to walk 3 sub-tiles to (5003, 5000) with nVelocity = 75 (takes 64 ticks = 2560ms)
const w67 = new ByteWriter(16)
w67.u8(0x67)
w67.u32LE(200)
w67.u8(0x01) // walk
w67.u16LE(5003)
w67.u16LE(5000)
w67.u16LE(0)
w67.u8(2)
w67.i16LE(75)
w67.u8(0)
session.handleServerEvent(decodeD2gsServerPacket(w67.toUint8Array()))
// Immediately after packet ingestion, Zombie must still be at (5000, 5000), NOT teleported to 5003
expect(session.world.getUnit('monster', 200)?.x).toBe(5000)
expect(session.interpolator.getEntityState(1, 200)?.x).toBe(5000)
// 3. Advance 32 ticks (1280ms = half of 2560ms): Zombie must be at 5001.5 (NOT prematurely snapped to 5003 as with old 9 sub-tiles/s!)
for (let i = 0; i < 32; i++) {
session.tick(40, 1000 + (i + 1) * 40)
}
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5001.5, 5)
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5001.5, 5)
expect(session.world.getUnit('monster', 200)?.mode).toBe(2) // still walking (WL)
// 4. Advance remaining 32 ticks (total 2560ms): Zombie reaches (5003, 5000) exactly when server 0x6D NPCStop arrives
for (let i = 32; i < 64; i++) {
session.tick(40, 1000 + (i + 1) * 40)
}
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5003, 5)
// Server sends 0x6D NPCStop at (5003, 5000) -> zero rubber-band jump!
session.handleServerEvent({
type: 'UnitMove',
unitType: 'monster',
id: 200,
mode: 'stop',
to: { x: 5003, y: 5000 },
})
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5003, 5)
// 5. Server sends 0x68 NPCMoveToTarget chasing local player (id=1 at 5010, 5000) from current (5003, 5000) with nVelocity=75
const w68 = new ByteWriter(21)
w68.u8(0x68)
w68.u32LE(200)
w68.u8(0x00) // walk in 0x68
w68.u16LE(5003)
w68.u16LE(5000)
w68.u8(0) // targetType = player
w68.u32LE(1) // targetId = 1
w68.u16LE(0)
w68.u8(2)
w68.i16LE(75)
w68.u8(0)
session.handleServerEvent(decodeD2gsServerPacket(w68.toUint8Array()))
// Step 25 ticks (1000ms): Zombie advances monotonically by 1.171875 sub-tiles to 5004.171875
let prevX = session.world.getUnit('monster', 200)!.x
for (let i = 0; i < 25; i++) {
session.tick(40, 4000 + (i + 1) * 40)
const curX = session.world.getUnit('monster', 200)!.x
expect(curX).toBeGreaterThanOrEqual(prevX)
prevX = curX
}
expect(prevX).toBeCloseTo(5003 + 1.171875, 4)
// Server sends next 0x68 re-sync after ~21 ticks (from X = 5004, Y = 5000): Zombie must NOT jump backward or teleport!
const w68Next = new ByteWriter(21)
w68Next.u8(0x68)
w68Next.u32LE(200)
w68Next.u8(0x00)
w68Next.u16LE(5004)
w68Next.u16LE(5000)
w68Next.u8(0)
w68Next.u32LE(1)
w68Next.u16LE(0)
w68Next.u8(2)
w68Next.i16LE(75)
w68Next.u8(0)
const xBeforeResync = session.world.getUnit('monster', 200)!.x
session.handleServerEvent(decodeD2gsServerPacket(w68Next.toUint8Array()))
const xAfterResync = session.world.getUnit('monster', 200)!.x
// Drift is ~0.17 sub-tiles (< 1.5 soft-reconcile threshold), so visual position stays at xBeforeResync without rubber-banding!
expect(xAfterResync).toBeCloseTo(xBeforeResync, 5)
session.destroy()
})
})