422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
|
import {
|
|
clearMissingArtEntries,
|
|
computeAnimDataFrameIndex,
|
|
getMissingArtEntries,
|
|
renderSceneSourceFrame,
|
|
} from '../../src/client/scene/world-renderer.ts'
|
|
import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts'
|
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
|
import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/client/view/scene-source.ts'
|
|
import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts'
|
|
import { PLR_MODES } from '../../src/common/units/plr-mode.ts'
|
|
|
|
function makeTaggedCharArt(): LoadedCharacter {
|
|
const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [
|
|
{ key: 'dt', group: 0, frames: 4, speed: 256, loop: false },
|
|
{ key: 'nu', group: 1, frames: 8, speed: 128, loop: true },
|
|
{ key: 'wl', group: 2, frames: 8, speed: 256, loop: true },
|
|
{ key: 'rn', group: 3, frames: 8, speed: 256, loop: true },
|
|
{ key: 'gh', group: 4, frames: 4, speed: 256, loop: false },
|
|
{ key: 'tn', group: 5, frames: 16, speed: 80, loop: true },
|
|
{ key: 'tw', group: 6, frames: 8, speed: 256, loop: true },
|
|
{ key: 'a1', group: 7, frames: 8, speed: 256, loop: false },
|
|
{ key: 'a2', group: 8, frames: 8, speed: 256, loop: false },
|
|
{ key: 'sc', group: 9, frames: 8, speed: 256, loop: false },
|
|
{ key: 'dd', group: 10, frames: 1, speed: 256, loop: false },
|
|
]
|
|
const groups = modes.map((m) =>
|
|
Array.from({ length: m.frames }, (_, fIdx) => ({
|
|
x: m.group * 100 + fIdx,
|
|
y: 0,
|
|
width: 32,
|
|
height: 64,
|
|
anchorX: -16,
|
|
anchorY: -56,
|
|
tag: `${m.key}_${fIdx}`,
|
|
})),
|
|
)
|
|
const clips = Object.fromEntries(
|
|
modes.map((m) => [
|
|
m.key,
|
|
{
|
|
group: m.group,
|
|
directions: 1,
|
|
frames: m.frames,
|
|
speed: m.speed,
|
|
loop: m.loop,
|
|
events: [],
|
|
},
|
|
]),
|
|
)
|
|
return {
|
|
handle: { index: 1, width: 512, height: 512 } as never,
|
|
groups: groups as never,
|
|
directions: 1,
|
|
walk: 2,
|
|
stand: 1,
|
|
members: 1,
|
|
clips,
|
|
}
|
|
}
|
|
|
|
describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isolation & AnimData.d2 Speed', () => {
|
|
it('1. isolates local player movement from remote standing players in the same scene', () => {
|
|
const session = new OnlineSession()
|
|
session.handleServerEvent({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 0x11223344,
|
|
areaId: 1, // Rogue Encampment (Town)
|
|
})
|
|
session.handleServerEvent({
|
|
type: 'UnitAssign',
|
|
isSelf: true,
|
|
unit: {
|
|
unitType: 0,
|
|
id: 1,
|
|
classId: 1, // Sorceress ('so')
|
|
name: 'LocalSorc',
|
|
x: 5100,
|
|
y: 5050,
|
|
},
|
|
})
|
|
session.handleServerEvent({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 0,
|
|
id: 2,
|
|
classId: 1, // Remote Sorceress ('so')
|
|
name: 'RemoteSorc',
|
|
x: 5104,
|
|
y: 5050,
|
|
},
|
|
})
|
|
|
|
// Start local player walking in town (mode = 6 TW)
|
|
session.predictor.setPath([{ x: 5120, y: 5050 }], false)
|
|
session.tick(40, 100)
|
|
|
|
const renderables = session.buildRenderableUnits()
|
|
const localPlayer = renderables.find((u) => u.id === 1)!
|
|
const remotePlayer = renderables.find((u) => u.id === 2)!
|
|
expect(localPlayer.mode).toBe(6) // PLRMODE_TOWNWALK (TW)
|
|
expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN)
|
|
|
|
// Render scene frame and inspect drawn sprite tags for both players
|
|
const automap = new AutomapView({ initialMode: 'level' })
|
|
const levelView = automap.setContext(0x11223344, 0, 1)
|
|
const baseX = levelView.subOriginX + 50
|
|
const baseY = levelView.subOriginY + 50
|
|
|
|
const drawnTags: string[] = []
|
|
const fakeRenderer = {
|
|
begin() {},
|
|
draw(frame: { tag?: string }) {
|
|
if (frame.tag) drawnTags.push(frame.tag)
|
|
},
|
|
setLighting() {},
|
|
setPalette() {},
|
|
flush() {},
|
|
}
|
|
const fakeTileGpuCache = {
|
|
loadedPageCount: 1,
|
|
ensureActPalette() {},
|
|
getPageHandle() {
|
|
return undefined
|
|
},
|
|
}
|
|
|
|
const source = new ClientWorldSceneSource(
|
|
(): ClientSceneWorldAdapter => ({
|
|
tick: 1,
|
|
actId: 1,
|
|
levelId: 1,
|
|
playerSubX: baseX,
|
|
playerSubY: baseY,
|
|
units: [
|
|
{
|
|
kind: 'player',
|
|
id: 1,
|
|
classId: 1,
|
|
x: baseX,
|
|
y: baseY,
|
|
mode: localPlayer.mode,
|
|
direction: 0,
|
|
isLocalPlayer: true,
|
|
},
|
|
{
|
|
kind: 'player',
|
|
id: 2,
|
|
classId: 1,
|
|
x: baseX + 4,
|
|
y: baseY,
|
|
mode: remotePlayer.mode,
|
|
direction: 0,
|
|
isLocalPlayer: false,
|
|
},
|
|
],
|
|
levelView,
|
|
}),
|
|
'800x600',
|
|
)
|
|
|
|
const charArt = makeTaggedCharArt()
|
|
renderSceneSourceFrame({
|
|
source,
|
|
lightingPreset: 'fullbright',
|
|
renderer: fakeRenderer as never,
|
|
tileGpuCache: fakeTileGpuCache as never,
|
|
characterArtByToken: new Map([['so', charArt]]),
|
|
nowMs: 80,
|
|
})
|
|
|
|
// Local player drew TW frame (`tw_2`), while remote standing player drew TN frame (`tn_0`)
|
|
expect(drawnTags).toContain('tw_2')
|
|
expect(drawnTags).toContain('tn_0')
|
|
expect(drawnTags.some((t) => t.startsWith('wl_'))).toBe(false)
|
|
})
|
|
|
|
it('2. transitions remote player mode on 0x0F PlayerMove, 0x10 PlayerMoveToTarget, 0x0D PlayerStop, and segment completion', () => {
|
|
const session = new OnlineSession()
|
|
session.handleServerEvent({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 0x11223344,
|
|
areaId: 1, // Town
|
|
})
|
|
session.handleServerEvent({
|
|
type: 'UnitAssign',
|
|
isSelf: true,
|
|
unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 },
|
|
})
|
|
session.handleServerEvent({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: { unitType: 0, id: 2, classId: 3, name: 'RemotePal', x: 5105, y: 5050 },
|
|
})
|
|
|
|
// Initially standing in town -> mode 5 (TN)
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(5)
|
|
|
|
// 0x0F PlayerMove (walk: moveType = 0x01) from (5105, 5050) to (5115, 5050)
|
|
const pkt0x0FWalk = new Uint8Array(16)
|
|
const dvWalk = new DataView(pkt0x0FWalk.buffer)
|
|
dvWalk.setUint8(0, 0x0f)
|
|
dvWalk.setUint8(1, 0) // unitType = player
|
|
dvWalk.setUint32(2, 2, true) // unitId = 2
|
|
dvWalk.setUint8(6, 0x01) // moveType = walk
|
|
dvWalk.setUint16(7, 5115, true) // targetX
|
|
dvWalk.setUint16(9, 5050, true) // targetY
|
|
dvWalk.setUint8(11, 0)
|
|
dvWalk.setUint16(12, 5105, true) // currentX
|
|
dvWalk.setUint16(14, 5050, true) // currentY
|
|
session.handleServerEvent(decode0x0FPlayerMove(pkt0x0FWalk))
|
|
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(6) // TW in town
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(6)
|
|
|
|
// 0x10 PlayerMoveToTarget (run: moveType = 0x18) targeting local player id = 1 at (5100, 5050)
|
|
const pkt0x10Run = new Uint8Array(16)
|
|
const dvRun = new DataView(pkt0x10Run.buffer)
|
|
dvRun.setUint8(0, 0x10)
|
|
dvRun.setUint8(1, 0) // unitType = player
|
|
dvRun.setUint32(2, 2, true) // unitId = 2
|
|
dvRun.setUint8(6, 0x18) // moveType = 0x18 (run to target in 1.13c D2Client 0x6fac9830)
|
|
dvRun.setUint8(7, 0) // targetType = player
|
|
dvRun.setUint32(8, 1, true) // targetId = 1
|
|
dvRun.setUint16(12, 5110, true) // currentX
|
|
dvRun.setUint16(14, 5050, true) // currentY
|
|
session.handleServerEvent(decode0x10PlayerMoveToTarget(pkt0x10Run))
|
|
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(3) // RN (even in town)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(3)
|
|
|
|
// Advance interpolation until remote player reaches target (5100, 5050) -> returns to TN (5)
|
|
for (let i = 0; i < 30; i++) {
|
|
session.tick(100, i * 100)
|
|
}
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(5)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5)
|
|
|
|
// Start another run, then stop immediately via 0x0D PlayerStop
|
|
session.handleServerEvent({
|
|
type: 'UnitMove',
|
|
unitType: 0,
|
|
id: 2,
|
|
mode: 'run',
|
|
from: { x: 5100, y: 5050 },
|
|
to: { x: 5120, y: 5050 },
|
|
})
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(3)
|
|
|
|
const pkt0x0DStop = new Uint8Array(13)
|
|
const dvStop = new DataView(pkt0x0DStop.buffer)
|
|
dvStop.setUint8(0, 0x0d)
|
|
dvStop.setUint8(1, 0)
|
|
dvStop.setUint32(2, 2, true)
|
|
dvStop.setUint8(6, 0x07) // actionCode = neutral
|
|
dvStop.setUint16(7, 5102, true)
|
|
dvStop.setUint16(9, 5050, true)
|
|
dvStop.setUint8(11, 0)
|
|
dvStop.setUint8(12, 128)
|
|
session.handleServerEvent(decode0x0DPlayerStop(pkt0x0DStop))
|
|
|
|
expect(session.world.getUnit('player', 2)?.mode).toBe(5) // Back to TN in town
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5)
|
|
})
|
|
|
|
it('3. switches local player between town (TN=5, TW=6, RN=3) and wilderness (NU=1, WL=2, RN=3) and uses DD=17 (never KK=12) on death', () => {
|
|
const session = new OnlineSession()
|
|
session.handleServerEvent({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 0x11223344,
|
|
areaId: 1, // Rogue Encampment (Town)
|
|
})
|
|
session.handleServerEvent({
|
|
type: 'UnitAssign',
|
|
isSelf: true,
|
|
unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 },
|
|
})
|
|
|
|
// Town Idle -> TN (5)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(5)
|
|
expect(PLR_MODES[5]?.token).toBe('TN')
|
|
|
|
// Town Walk -> TW (6)
|
|
session.predictor.setPath([{ x: 5120, y: 5050 }], false)
|
|
session.tick(40, 40)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(6)
|
|
expect(PLR_MODES[6]?.token).toBe('TW')
|
|
|
|
// Town Run -> RN (3) (1.13c D2Game sub_6FC7F600: running in town still uses PLRMODE_RUN=3)
|
|
session.predictor.setPath([{ x: 5140, y: 5050 }], true)
|
|
session.tick(40, 80)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3)
|
|
expect(PLR_MODES[3]?.token).toBe('RN')
|
|
|
|
// Enter Blood Moor (areaId = 2, non-town) while running -> stays RN (3)
|
|
session.handleServerEvent({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 0x11223344,
|
|
areaId: 2,
|
|
})
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3)
|
|
|
|
// Switch to walking in Blood Moor -> WL (2)
|
|
session.predictor.setPath([{ x: 5160, y: 5050 }], false)
|
|
session.tick(40, 120)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(2)
|
|
expect(PLR_MODES[2]?.token).toBe('WL')
|
|
|
|
// Stop in Blood Moor -> NU (1)
|
|
session.predictor.setPosition(5160, 5050)
|
|
expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(1)
|
|
expect(PLR_MODES[1]?.token).toBe('NU')
|
|
|
|
// Local player dies -> mode must be 17 (DD = Dead/Corpse), never 12 (KK = Kick)
|
|
expect(PLR_MODES[12]?.token).toBe('KK')
|
|
expect(PLR_MODES[17]?.token).toBe('DD')
|
|
session.handleServerEvent({
|
|
type: 'LifeMana',
|
|
life: 0,
|
|
mana: 0,
|
|
stamina: 0,
|
|
at: { x: 5160, y: 5050 },
|
|
vx: 0,
|
|
vy: 0,
|
|
})
|
|
const deadSelf = session.buildRenderableUnits().find((u) => u.id === 1)!
|
|
expect(deadSelf.isCorpse).toBe(true)
|
|
expect(deadSelf.mode).toBe(17)
|
|
expect(deadSelf.mode).not.toBe(12)
|
|
})
|
|
|
|
it('4. advances frames at 1.13c 25Hz (40ms tick) 8.8 fixed-point AnimData.d2 speed and reports missing clip speed', () => {
|
|
// speed = 256 -> 1 frame per 40ms tick (25 fps)
|
|
expect(computeAnimDataFrameIndex(0, 256, 8)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(39, 256, 8)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(40, 256, 8)).toBe(1)
|
|
expect(computeAnimDataFrameIndex(80, 256, 8)).toBe(2)
|
|
expect(computeAnimDataFrameIndex(320, 256, 8)).toBe(0) // loops at 8 frames
|
|
|
|
// speed = 128 (SONUHTH) -> 1 frame per 80ms (12.5 fps)
|
|
expect(computeAnimDataFrameIndex(0, 128, 8)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(40, 128, 8)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(79, 128, 8)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(80, 128, 8)).toBe(1)
|
|
expect(computeAnimDataFrameIndex(160, 128, 8)).toBe(2)
|
|
|
|
// speed = 80 (SOTNHTH) -> 1 frame per 128ms (7.8125 fps)
|
|
expect(computeAnimDataFrameIndex(0, 80, 16)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(127, 80, 16)).toBe(0)
|
|
expect(computeAnimDataFrameIndex(128, 80, 16)).toBe(1)
|
|
expect(computeAnimDataFrameIndex(256, 80, 16)).toBe(2)
|
|
|
|
// Non-looping death animation (DT, loop = false) clamps to frameCount - 1
|
|
expect(computeAnimDataFrameIndex(10000, 256, 4, false)).toBe(3)
|
|
|
|
// Fail-fast diagnostic when clip speed metadata is missing
|
|
clearMissingArtEntries()
|
|
const automap = new AutomapView({ initialMode: 'level' })
|
|
const levelView = automap.setContext(0x11223344, 0, 1)
|
|
const baseX = levelView.subOriginX + 50
|
|
const baseY = levelView.subOriginY + 50
|
|
const source = new ClientWorldSceneSource(
|
|
(): ClientSceneWorldAdapter => ({
|
|
tick: 1,
|
|
actId: 1,
|
|
levelId: 1,
|
|
playerSubX: baseX,
|
|
playerSubY: baseY,
|
|
units: [
|
|
{
|
|
kind: 'player',
|
|
id: 1,
|
|
classId: 1,
|
|
x: baseX,
|
|
y: baseY,
|
|
mode: 5, // TN
|
|
direction: 0,
|
|
isLocalPlayer: true,
|
|
},
|
|
],
|
|
levelView,
|
|
}),
|
|
'800x600',
|
|
)
|
|
const brokenCharArt: LoadedCharacter = {
|
|
handle: { index: 1, width: 64, height: 64 } as never,
|
|
groups: [[{ x: 0, y: 0, width: 32, height: 64 }]],
|
|
directions: 1,
|
|
walk: 0,
|
|
stand: 0,
|
|
members: 1,
|
|
// Missing 'tn' clip!
|
|
clips: {},
|
|
}
|
|
const fakeRenderer = {
|
|
begin() {},
|
|
draw() {},
|
|
setLighting() {},
|
|
setPalette() {},
|
|
flush() {},
|
|
}
|
|
renderSceneSourceFrame({
|
|
source,
|
|
lightingPreset: 'fullbright',
|
|
renderer: fakeRenderer as never,
|
|
characterArtByToken: new Map([['so', brokenCharArt]]),
|
|
nowMs: 0,
|
|
})
|
|
expect(
|
|
getMissingArtEntries().some((e) => e.category === 'player-clip' && e.id.toLowerCase() === 'so:tn'),
|
|
).toBe(true)
|
|
clearMissingArtEntries()
|
|
})
|
|
})
|