278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
/**
|
||
* 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 { ClientWorld } from '../../src/client/world/client-world.ts'
|
||
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.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
|
||
})
|
||
})
|