fix(client/netproto): align monster movement speed with 1.13c MonStats velocity and D2GS 0x67/0x68 nVelocity
This commit is contained in:
parent
77a20c742d
commit
778bd57806
|
|
@ -71,6 +71,7 @@ import { getViewportProfile } from '../view/viewport-profile.ts'
|
|||
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
|
||||
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
|
||||
import { OutboundRateLimiter } from './rate-limiter.ts'
|
||||
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
|
||||
import { isTownLevel } from '../../common/world/scene-population.ts'
|
||||
|
||||
export type OnlineSessionPhase =
|
||||
|
|
@ -1205,6 +1206,17 @@ export class OnlineSession {
|
|||
: { x: 0, y: 0 }),
|
||||
} as ServerEvent
|
||||
}
|
||||
const preMoveUnit =
|
||||
ev.type === 'UnitMove'
|
||||
? this.world.getUnit(
|
||||
(evAny.unitType ?? ev.unitType) as UnitType,
|
||||
Number(evAny.id ?? evAny.unitId ?? 1),
|
||||
)
|
||||
: undefined
|
||||
const preMoveUnitPos =
|
||||
preMoveUnit !== undefined && (preMoveUnit.x !== 0 || preMoveUnit.y !== 0)
|
||||
? { x: preMoveUnit.x, y: preMoveUnit.y }
|
||||
: undefined
|
||||
this.world.apply(ev)
|
||||
|
||||
if (
|
||||
|
|
@ -1371,10 +1383,49 @@ export class OnlineSession {
|
|||
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
|
||||
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
|
||||
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
|
||||
const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX
|
||||
const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
|
||||
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
|
||||
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
|
||||
let toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX
|
||||
let toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
|
||||
if (
|
||||
ev.target &&
|
||||
((rawToX === 0 && rawToY === 0) ||
|
||||
(ev.from !== undefined && rawToX === ev.from.x && rawToY === ev.from.y))
|
||||
) {
|
||||
const targetTypeId = unitTypeToId(ev.target.unitType)
|
||||
const isTargetLocalPlayer =
|
||||
targetTypeId === 0 && (ev.target.id === this.world.selfId || this.world.selfId === 0)
|
||||
if (isTargetLocalPlayer) {
|
||||
const predSnap = this.predictor.getSnapshot()
|
||||
if (predSnap.x !== 0 || predSnap.y !== 0) {
|
||||
toX = predSnap.x
|
||||
toY = predSnap.y
|
||||
}
|
||||
} else {
|
||||
const targetInterp = this.interpolator.getEntityState(targetTypeId, ev.target.id)
|
||||
if (targetInterp && (targetInterp.x !== 0 || targetInterp.y !== 0)) {
|
||||
toX = targetInterp.x
|
||||
toY = targetInterp.y
|
||||
}
|
||||
}
|
||||
if (unit && (toX !== 0 || toY !== 0)) {
|
||||
unit.targetX = toX
|
||||
unit.targetY = toY
|
||||
}
|
||||
}
|
||||
const explicitFromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
|
||||
const explicitFromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
|
||||
const existingInterp = this.interpolator.getEntityState(evTypeId, evUnitId)
|
||||
const fromX =
|
||||
explicitFromX !== undefined
|
||||
? Number(explicitFromX)
|
||||
: !existingInterp && preMoveUnitPos !== undefined
|
||||
? preMoveUnitPos.x
|
||||
: undefined
|
||||
const fromY =
|
||||
explicitFromY !== undefined
|
||||
? Number(explicitFromY)
|
||||
: !existingInterp && preMoveUnitPos !== undefined
|
||||
? preMoveUnitPos.y
|
||||
: undefined
|
||||
const isLocalPlayer =
|
||||
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
|
||||
if (unit && unit.unitType === 'player') {
|
||||
|
|
@ -1396,22 +1447,41 @@ export class OnlineSession {
|
|||
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
|
||||
}
|
||||
} else if (ev.mode === 'stop') {
|
||||
this.interpolator.stopEntity({
|
||||
const motionState = this.interpolator.stopEntity({
|
||||
unitType: evTypeId,
|
||||
unitId: evUnitId,
|
||||
x: toX,
|
||||
y: toY,
|
||||
})
|
||||
if (unit) {
|
||||
unit.x = motionState.x
|
||||
unit.y = motionState.y
|
||||
unit.direction = motionState.dir64
|
||||
}
|
||||
} else {
|
||||
this.interpolator.startMove({
|
||||
const speedSubTilesPerSec =
|
||||
evTypeId === 1
|
||||
? computeMonsterMoveSpeedSubTilesPerSec(
|
||||
unit?.classId ?? 0,
|
||||
ev.mode === 'run',
|
||||
ev.velocityPercent,
|
||||
)
|
||||
: undefined
|
||||
const motionState = this.interpolator.startMove({
|
||||
unitType: evTypeId,
|
||||
unitId: evUnitId,
|
||||
...(fromX !== undefined ? { currentX: Number(fromX) } : {}),
|
||||
...(fromY !== undefined ? { currentY: Number(fromY) } : {}),
|
||||
...(fromX !== undefined ? { currentX: fromX } : {}),
|
||||
...(fromY !== undefined ? { currentY: fromY } : {}),
|
||||
targetX: toX,
|
||||
targetY: toY,
|
||||
running: ev.mode === 'run',
|
||||
...(speedSubTilesPerSec !== undefined ? { speedSubTilesPerSec } : {}),
|
||||
})
|
||||
if (unit) {
|
||||
unit.x = motionState.x
|
||||
unit.y = motionState.y
|
||||
unit.direction = motionState.dir64
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -1516,6 +1586,8 @@ export class OnlineSession {
|
|||
this.predictor.setDirection(selfUnit.direction)
|
||||
}
|
||||
|
||||
this.world.tick(dtMs, nowMs)
|
||||
|
||||
this.interpolator.update(safeDt)
|
||||
for (const state of this.interpolator.getAllStates()) {
|
||||
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
|
||||
|
|
@ -1544,19 +1616,19 @@ export class OnlineSession {
|
|||
unit.mode = unitInTown ? 5 : 1
|
||||
unit.moveMode = 'stop'
|
||||
}
|
||||
} else if (
|
||||
unit.unitType === 'monster' &&
|
||||
!state.moving &&
|
||||
unit.lifePct > 0 &&
|
||||
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
|
||||
) {
|
||||
unit.mode = 1
|
||||
unit.moveMode = 'stop'
|
||||
} else if (unit.unitType === 'monster' && unit.lifePct > 0) {
|
||||
if (state.moving && !unit.castState && unit.moveMode !== 'stop') {
|
||||
unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 9 : 2
|
||||
} else if (
|
||||
!state.moving &&
|
||||
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
|
||||
) {
|
||||
unit.mode = 1
|
||||
unit.moveMode = 'stop'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.world.tick(dtMs, nowMs)
|
||||
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export class ClientUnit {
|
|||
targetY?: number | undefined
|
||||
targetUnit?: UnitRef | undefined
|
||||
moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined
|
||||
velocityPercent?: number | undefined
|
||||
pathType?: number | undefined
|
||||
mode: number
|
||||
direction: number
|
||||
lifePct: number
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
type CharacterAnimSpeedParams,
|
||||
} from '../../common/units/char-anim-speed.ts'
|
||||
import { dir64FromPoints } from '../../common/units/direction.ts'
|
||||
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
|
||||
import { isTownLevel } from '../../common/world/scene-population.ts'
|
||||
import {
|
||||
ClientUnit,
|
||||
|
|
@ -1535,8 +1536,7 @@ export class ClientWorld {
|
|||
}
|
||||
|
||||
// Continuous unit movement interpolation across display frames
|
||||
const step = (safeDt / 1000) * 10
|
||||
if (step > 0) {
|
||||
if (safeDt > 0) {
|
||||
const inTown = isTownLevel(this.areaId)
|
||||
for (const unit of this._units.values()) {
|
||||
if (unit.dead || unit.castState || unit.unitType === 'missile') continue
|
||||
|
|
@ -1545,6 +1545,16 @@ export class ClientWorld {
|
|||
const dy = unit.targetY - unit.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist === 0) continue
|
||||
const speedSubTilesPerSec =
|
||||
unit.unitType === 'monster'
|
||||
? computeMonsterMoveSpeedSubTilesPerSec(
|
||||
unit.classId,
|
||||
unit.moveMode === 'run',
|
||||
unit.velocityPercent,
|
||||
)
|
||||
: 10
|
||||
const step = (safeDt / 1000) * speedSubTilesPerSec
|
||||
if (step <= 0) continue
|
||||
if (dist <= step) {
|
||||
unit.x = unit.targetX
|
||||
unit.y = unit.targetY
|
||||
|
|
@ -1701,6 +1711,8 @@ export class ClientWorld {
|
|||
x: Number(ev.targetX ?? ev.x ?? 0),
|
||||
y: Number(ev.targetY ?? ev.y ?? 0),
|
||||
},
|
||||
...(ev.velocityPercent !== undefined ? { velocityPercent: Number(ev.velocityPercent) } : {}),
|
||||
...(ev.pathType !== undefined ? { pathType: Number(ev.pathType) } : {}),
|
||||
}
|
||||
} else if (ev.type === 'ChatMessage') {
|
||||
ev = {
|
||||
|
|
@ -1903,6 +1915,12 @@ export class ClientWorld {
|
|||
unit.targetUnit = ev.target
|
||||
const moveMode = ev.mode ?? 'walk'
|
||||
unit.moveMode = moveMode
|
||||
if (ev.velocityPercent !== undefined) {
|
||||
unit.velocityPercent = ev.velocityPercent
|
||||
}
|
||||
if (ev.pathType !== undefined) {
|
||||
unit.pathType = ev.pathType
|
||||
}
|
||||
if (prevX !== targetX || prevY !== targetY) {
|
||||
unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1515,6 +1515,75 @@ export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
|
|||
733: "willowisp8",
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical 1.13c `MonStats.txt` `[Velocity, Run]` pairs indexed by `hcIdx` (0..733),
|
||||
* extracted directly from `Patch_D2.mpq` (`data/global/excel/MonStats.txt`).
|
||||
*/
|
||||
export const MONSTER_HCIDX_VELOCITIES: readonly (readonly [number, number])[] = [
|
||||
[3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [1, 3], [1, 4], [1, 4], [1, 5], [1, 5], [6, 6], [6, 6],
|
||||
[5, 5], [5, 5], [7, 7], [4, 4], [4, 4], [5, 5], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [8, 8],
|
||||
[4, 4], [4, 4], [5, 5], [5, 5], [3, 3], [5, 5], [5, 5], [6, 6], [6, 6], [7, 7], [0, 0], [0, 0],
|
||||
[0, 0], [0, 0], [5, 5], [6, 6], [6, 6], [7, 7], [8, 8], [5, 5], [6, 8], [6, 10], [7, 12], [8, 12],
|
||||
[7, 12], [7, 12], [7, 13], [7, 13], [7, 14], [5, 5], [6, 6], [7, 7], [9, 9], [8, 8], [4, 4], [4, 4],
|
||||
[5, 5], [5, 5], [6, 6], [3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [4, 4], [4, 4], [4, 4], [4, 4],
|
||||
[4, 4], [4, 4], [4, 10], [5, 12], [5, 12], [6, 14], [10, 10], [8, 8], [8, 8], [8, 8], [8, 8], [5, 10],
|
||||
[6, 11], [7, 12], [8, 13], [5, 5], [6, 6], [7, 7], [8, 8], [4, 4], [5, 5], [5, 5], [6, 6], [6, 6],
|
||||
[3, 3], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2], [2, 2], [3, 3], [4, 4], [5, 5], [3, 3], [3, 3],
|
||||
[3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [6, 12], [6, 12], [6, 12], [6, 12], [15, 15], [15, 15],
|
||||
[15, 15], [15, 15], [6, 6], [7, 7], [8, 8], [8, 9], [8, 11], [3, 3], [3, 3], [3, 3], [3, 3], [6, 6],
|
||||
[7, 7], [7, 7], [8, 8], [9, 9], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [6, 6], [7, 7], [8, 8],
|
||||
[9, 9], [10, 10], [2, 2], [4, 4], [1, 1], [2, 2], [3, 3], [5, 5], [8, 8], [0, 0], [4, 4], [3, 3],
|
||||
[8, 8], [7, 7], [4, 4], [7, 7], [4, 4], [5, 6], [5, 8], [6, 9], [7, 10], [4, 4], [5, 6], [5, 8],
|
||||
[6, 10], [7, 12], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2],
|
||||
[4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [5, 5], [3, 3], [4, 4], [6, 6], [8, 8], [1, 1], [1, 1],
|
||||
[1, 1], [1, 1], [1, 1], [2, 2], [2, 2], [2, 2], [4, 4], [3, 3], [2, 2], [4, 4], [3, 3], [4, 7],
|
||||
[4, 4], [4, 4], [0, 0], [0, 0], [0, 0], [0, 0], [4, 4], [10, 15], [6, 6], [7, 7], [8, 8], [9, 9],
|
||||
[10, 10], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [4, 4], [4, 4], [4, 4], [1, 1],
|
||||
[0, 0], [2, 2], [4, 4], [4, 4], [4, 4], [4, 4], [4, 4], [6, 8], [6, 9], [6, 11], [6, 6], [6, 6],
|
||||
[6, 6], [6, 6], [8, 8], [6, 6], [2, 2], [2, 2], [2, 2], [9, 9], [10, 10], [11, 11], [5, 5], [0, 0],
|
||||
[2, 2], [2, 2], [2, 2], [2, 2], [10, 16], [2, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0],
|
||||
[2, 2], [2, 2], [8, 8], [6, 8], [2, 2], [5, 5], [8, 8], [7, 11], [8, 8], [0, 0], [4, 4], [4, 4],
|
||||
[5, 5], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [1, 1], [4, 4], [4, 4], [4, 4], [4, 4],
|
||||
[4, 4], [8, 8], [9, 9], [9, 9], [10, 10], [12, 6], [3, 2], [10, 16], [3, 2], [2, 2], [7, 7], [7, 7],
|
||||
[7, 7], [8, 10], [9, 11], [10, 12], [10, 16], [10, 16], [10, 16], [6, 12], [7, 12], [8, 12], [6, 7], [7, 7],
|
||||
[8, 7], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [2, 2], [5, 5], [5, 5], [0, 0], [0, 0], [0, 0],
|
||||
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [0, 0], [8, 8], [0, 0], [0, 0],
|
||||
[0, 0], [0, 0], [7, 11], [1, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 12], [8, 12], [10, 12],
|
||||
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [9, 9], [11, 11], [8, 8], [7, 11],
|
||||
[10, 16], [10, 16], [10, 16], [13, 15], [12, 14], [3, 4], [0, 0], [0, 0], [2, 2], [0, 0], [10, 16], [0, 0],
|
||||
[0, 0], [5, 10], [5, 10], [5, 10], [5, 10], [6, 6], [6, 6], [4, 4], [4, 4], [5, 5], [5, 5], [4, 4],
|
||||
[4, 4], [5, 5], [5, 5], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [0, 0], [0, 0], [5, 10], [5, 10],
|
||||
[6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [0, 0], [7, 7], [0, 0], [0, 0], [2, 2], [0, 0], [6, 6],
|
||||
[2, 2], [8, 8], [0, 0], [0, 0], [0, 0], [10, 10], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [10, 20],
|
||||
[5, 10], [5, 10], [6, 6], [6, 6], [6, 6], [7, 7], [7, 7], [7, 7], [5, 9], [6, 6], [6, 6], [6, 6],
|
||||
[0, 0], [0, 0], [0, 0], [0, 0], [1, 5], [1, 5], [2, 5], [2, 5], [3, 5], [3, 7], [3, 8], [4, 9],
|
||||
[5, 10], [6, 11], [5, 8], [6, 6], [7, 7], [7, 7], [1, 1], [1, 1], [1, 1], [7, 10], [7, 10], [7, 10],
|
||||
[7, 10], [7, 10], [7, 10], [7, 10], [7, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10],
|
||||
[10, 10], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [4, 4],
|
||||
[4, 4], [5, 5], [5, 5], [6, 6], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0],
|
||||
[7, 7], [7, 7], [7, 7], [7, 7], [7, 7], [0, 0], [0, 0], [0, 0], [0, 0], [3, 3], [3, 3], [3, 3],
|
||||
[3, 3], [3, 3], [9, 9], [9, 9], [9, 9], [9, 9], [9, 9], [3, 3], [3, 3], [1, 1], [2, 2], [3, 3],
|
||||
[0, 0], [0, 0], [0, 0], [0, 0], [2, 2], [0, 0], [5, 14], [5, 14], [0, 0], [0, 0], [7, 7], [2, 2],
|
||||
[0, 0], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [4, 7], [5, 14], [5, 14], [5, 14], [5, 14], [5, 14],
|
||||
[10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [12, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [8, 10],
|
||||
[8, 10], [8, 10], [8, 10], [8, 10], [2, 2], [10, 12], [10, 16], [6, 6], [8, 14], [8, 14], [0, 0], [0, 0],
|
||||
[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [8, 8], [8, 8], [8, 8], [12, 12], [4, 4],
|
||||
[4, 4], [5, 5], [5, 5], [5, 5], [9, 9], [9, 9], [10, 10], [11, 11], [12, 12], [10, 10], [10, 10], [10, 10],
|
||||
[11, 11], [12, 12], [9, 9], [9, 9], [10, 10], [12, 12], [6, 10], [6, 10], [8, 12], [8, 12], [9, 14], [15, 15],
|
||||
[15, 15], [15, 15], [15, 15], [15, 15], [4, 4], [6, 6], [6, 6], [6, 6], [4, 4], [6, 6], [12, 16], [12, 16],
|
||||
[12, 16], [8, 12], [8, 12], [8, 12], [8, 12], [8, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6],
|
||||
[6, 6], [5, 5], [5, 5], [9, 12], [9, 12], [8, 8], [8, 8], [8, 8], [8, 8], [8, 8], [14, 14], [14, 14],
|
||||
[12, 12], [12, 12], [12, 12], [15, 15], [15, 15], [15, 15], [10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [6, 6],
|
||||
[5, 5], [5, 5], [12, 12], [12, 12], [9, 9], [9, 9], [6, 6], [6, 6], [10, 10], [10, 10], [10, 10], [10, 10],
|
||||
[10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [7, 14], [7, 14], [5, 5], [5, 5], [5, 5], [5, 5], [6, 11],
|
||||
[6, 11], [6, 6], [6, 6], [7, 7], [7, 7], [10, 12], [10, 12], [4, 4], [6, 6], [1, 1], [7, 10], [7, 10],
|
||||
[7, 10], [8, 11], [10, 16], [10, 16], [7, 7], [7, 7], [10, 10], [10, 10], [10, 16], [10, 16], [8, 12], [8, 8],
|
||||
[8, 8], [8, 8], [3, 5], [6, 7], [6, 7], [8, 7], [8, 7], [3, 3], [8, 8], [8, 8], [10, 16], [8, 8],
|
||||
[10, 15], [6, 6], [0, 0], [0, 0], [10, 16], [8, 7], [7, 7], [8, 8], [4, 4], [8, 11], [6, 6], [14, 14],
|
||||
[12, 12], [8, 12], [8, 12], [8, 12], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [6, 6], [5, 5], [8, 8],
|
||||
[8, 8], [15, 15],
|
||||
]
|
||||
|
||||
/**
|
||||
* Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2038,8 +2038,8 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly<
|
|||
attack1: { minDamage: 20, maxDamage: 40, toHit: 70 },
|
||||
},
|
||||
skeleton1: {
|
||||
velocity: 4,
|
||||
runVelocity: 4,
|
||||
velocity: 3,
|
||||
runVelocity: 3,
|
||||
minHp: 61,
|
||||
maxHp: 121,
|
||||
armour: 100,
|
||||
|
|
@ -2048,6 +2048,20 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly<
|
|||
},
|
||||
}
|
||||
|
||||
import { MONSTER_HCIDX_TO_ID, MONSTER_HCIDX_VELOCITIES } from './monster-mapping.ts'
|
||||
|
||||
const CANONICAL_113C_VELOCITIES_BY_ID: Readonly<Record<string, readonly [number, number]>> = (() => {
|
||||
const out: Record<string, readonly [number, number]> = {}
|
||||
for (let hcIdx = 0; hcIdx < MONSTER_HCIDX_VELOCITIES.length; hcIdx++) {
|
||||
const id = MONSTER_HCIDX_TO_ID[hcIdx]
|
||||
const pair = MONSTER_HCIDX_VELOCITIES[hcIdx]
|
||||
if (id && pair) {
|
||||
out[id] = pair
|
||||
}
|
||||
}
|
||||
return out
|
||||
})()
|
||||
|
||||
let cachedCanonicalMonsterKinds: Map<string, MonsterKind> | null = null
|
||||
|
||||
function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
|
||||
|
|
@ -2056,11 +2070,14 @@ function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
|
|||
const enriched = new Map<string, MonsterKind>()
|
||||
for (const [id, kind] of rawKinds) {
|
||||
const ov = CANONICAL_113C_MONSTER_OVERRIDES[id]
|
||||
const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()]
|
||||
const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined
|
||||
const canonicalRun = canonicalSpeed && canonicalSpeed[1] > 0 ? canonicalSpeed[1] : undefined
|
||||
const enrichedKind: MonsterKind = {
|
||||
...kind,
|
||||
enabled: kind.killable || !kind.inTown,
|
||||
velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5),
|
||||
runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
|
||||
velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5),
|
||||
runVelocity: ov?.runVelocity ?? canonicalRun ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
|
||||
minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21),
|
||||
maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61),
|
||||
armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84),
|
||||
|
|
@ -2093,7 +2110,12 @@ export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<s
|
|||
return typeof value === 'function' ? value.bind(map) : value
|
||||
}
|
||||
if (typeof prop === 'string') {
|
||||
return map.get(prop) ?? map.get(prop.toLowerCase())
|
||||
const direct = map.get(prop) ?? map.get(prop.toLowerCase())
|
||||
if (direct !== undefined) return direct
|
||||
if (/^\d+$/.test(prop)) {
|
||||
const id = MONSTER_HCIDX_TO_ID[Number(prop)]
|
||||
if (id) return map.get(id)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
|
|
@ -2104,4 +2126,62 @@ export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<s
|
|||
},
|
||||
) as unknown as ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>>
|
||||
|
||||
/**
|
||||
* Look up a canonical 1.13c `MonsterKind` by numeric `MonStats.txt` `hcIdx` (`0..733`).
|
||||
*/
|
||||
export function getMonsterKindByClassId(classId: number): MonsterKind | undefined {
|
||||
const id = MONSTER_HCIDX_TO_ID[classId]
|
||||
if (!id) return undefined
|
||||
return getCanonicalMonsterKinds().get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute D2 1.13c monster movement speed in sub-tiles per second (`D2Common.dll`
|
||||
* `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp`
|
||||
* `sub_6FDAB8A0` 0x6FDAB8A0).
|
||||
*
|
||||
* - `nBaseVelocity = pMonStatsTxt->nVelocity << 8`
|
||||
* - In `D2Game.dll` (`Monster.cpp:214`), monsters start with base `STAT_VELOCITYPERCENT = 75`.
|
||||
* When running (`MONMODE_RUN`), `D2Game.dll` (`AiThink.cpp:912`, `MonsterMode.cpp:1925`) adds
|
||||
* `100 * nRun / nVelocity - 100` to `STAT_VELOCITYPERCENT` and transmits the total in S2C
|
||||
* `0x67` / `0x68` `nVelocity`.
|
||||
* - `nVelocityPercent = Math.max(25, effectiveVelocityPercent)`
|
||||
* - `dwVelocity = Math.floor((nVelocityPercent * nBaseVelocity) / 100)`
|
||||
* - Per-tick displacement (25 ticks/s): `nVelocityMagnitude = (1024 * dwVelocity) >> 6 = 16 * dwVelocity`
|
||||
* in `1/65536` sub-tiles/tick -> `(25 * dwVelocity) / 4096` sub-tiles/s.
|
||||
*/
|
||||
export function computeMonsterMoveSpeedSubTilesPerSec(
|
||||
classId: number,
|
||||
isRun = false,
|
||||
velocityPercent?: number,
|
||||
): number {
|
||||
const pair = MONSTER_HCIDX_VELOCITIES[classId]
|
||||
const kind = getMonsterKindByClassId(classId)
|
||||
const baseVelocity =
|
||||
pair && pair[0] > 0
|
||||
? pair[0]
|
||||
: kind && kind.velocity > 0
|
||||
? kind.velocity
|
||||
: 5
|
||||
const runVelocity =
|
||||
pair && pair[1] > 0
|
||||
? pair[1]
|
||||
: kind && kind.runVelocity > 0
|
||||
? kind.runVelocity
|
||||
: baseVelocity
|
||||
|
||||
let rawPercent: number
|
||||
if (velocityPercent !== undefined && velocityPercent !== 0) {
|
||||
rawPercent = velocityPercent
|
||||
} else if (isRun && baseVelocity > 0 && runVelocity > baseVelocity) {
|
||||
const runBonus = Math.floor((100 * runVelocity) / baseVelocity) - 100
|
||||
rawPercent = 75 + Math.max(0, runBonus)
|
||||
} else {
|
||||
rawPercent = 75
|
||||
}
|
||||
|
||||
const effectivePercent = Math.max(25, rawPercent)
|
||||
const nBaseVelocity = baseVelocity << 8
|
||||
const dwVelocity = Math.floor((effectivePercent * nBaseVelocity) / 100)
|
||||
return (25 * dwVelocity) / 4096
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,12 +128,21 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent {
|
|||
const moveType = r.u8()
|
||||
const targetX = r.u16LE()
|
||||
const targetY = r.u16LE()
|
||||
let pathType = 0
|
||||
let velocityPercent = 0
|
||||
if (r.remaining >= 5) {
|
||||
r.u16LE() // unk0x0A
|
||||
pathType = r.u8()
|
||||
velocityPercent = r.i16LE()
|
||||
}
|
||||
return {
|
||||
type: 'UnitMove',
|
||||
unitType: 1,
|
||||
id,
|
||||
mode: resolveMoveMode(moveType),
|
||||
to: { x: targetX, y: targetY },
|
||||
...(pathType !== 0 ? { pathType } : {}),
|
||||
...(velocityPercent !== 0 ? { velocityPercent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +156,13 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
|
|||
const currentY = r.u16LE()
|
||||
const targetType = clampUnitType(r.u8())
|
||||
const targetId = r.u32LE()
|
||||
let pathType = 0
|
||||
let velocityPercent = 0
|
||||
if (r.remaining >= 5) {
|
||||
r.u16LE() // unk0x0F
|
||||
pathType = r.u8()
|
||||
velocityPercent = r.i16LE()
|
||||
}
|
||||
return {
|
||||
type: 'UnitMove',
|
||||
unitType: 1,
|
||||
|
|
@ -155,6 +171,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
|
|||
from: { x: currentX, y: currentY },
|
||||
to: { x: currentX, y: currentY },
|
||||
target: { unitType: targetType, id: targetId },
|
||||
...(pathType !== 0 ? { pathType } : {}),
|
||||
...(velocityPercent !== 0 ? { velocityPercent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export type ServerEvent =
|
|||
readonly from?: Pt | undefined
|
||||
readonly to: Pt
|
||||
readonly target?: UnitRef | undefined
|
||||
readonly velocityPercent?: number | undefined
|
||||
readonly pathType?: number | undefined
|
||||
}
|
||||
| {
|
||||
readonly type: 'UnitReassign'
|
||||
|
|
|
|||
|
|
@ -15,8 +15,15 @@ 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
|
||||
|
|
@ -275,3 +282,143 @@ describe('skill-cast facing (ClientWorld)', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -235,7 +235,31 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => {
|
||||
it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType, pathType, and velocityPercent (1.13c D2PacketDef.h)', () => {
|
||||
// Real 0x67 NPCMove from 04-d2gs-idle-60s.d2cap (Akara id=11 walking to (5313,6294) with pathType=2, nVelocity=75):
|
||||
// 67 0b000000 01 c114 9618 0000 02 4b00 00
|
||||
const akara67 = new Uint8Array([
|
||||
0x67,
|
||||
0x0b, 0x00, 0x00, 0x00, // id = 11
|
||||
0x01, // moveType = 0x01 (walk)
|
||||
0xc1, 0x14, // targetX = 5313
|
||||
0x96, 0x18, // targetY = 6294
|
||||
0x00, 0x00, // unk0x0A
|
||||
0x02, // pathType = 2 (PATHTYPE_TOWARD)
|
||||
0x4b, 0x00, // velocityPercent = 75 (0x004b)
|
||||
0x00, // unk0x0F
|
||||
])
|
||||
const ev67 = decodeD2gsServerPacket(akara67)
|
||||
expect(ev67).toEqual({
|
||||
type: 'UnitMove',
|
||||
unitType: 1,
|
||||
id: 11,
|
||||
mode: 'walk',
|
||||
to: { x: 5313, y: 6294 },
|
||||
pathType: 2,
|
||||
velocityPercent: 75,
|
||||
})
|
||||
|
||||
const w68Run = new ByteWriter(21)
|
||||
w68Run.u8(0x68)
|
||||
w68Run.u32LE(88)
|
||||
|
|
@ -244,13 +268,21 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
|
|||
w68Run.u16LE(5200)
|
||||
w68Run.u8(0) // targetType = player
|
||||
w68Run.u32LE(1) // targetId = 1
|
||||
w68Run.bytes(new Uint8Array(6))
|
||||
w68Run.u16LE(0) // unk0x0F
|
||||
w68Run.u8(2) // pathType = 2
|
||||
w68Run.i16LE(155) // velocityPercent = 155 (e.g. Fallen run: 75 + (100*9/5 - 100) = 155)
|
||||
w68Run.u8(0) // unk0x14
|
||||
const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array())
|
||||
expect(ev68).toMatchObject({
|
||||
type: 'UnitMove',
|
||||
unitType: 1,
|
||||
id: 88,
|
||||
mode: 'run',
|
||||
from: { x: 5100, y: 5200 },
|
||||
target: { unitType: 0, id: 1 },
|
||||
pathType: 2,
|
||||
velocityPercent: 155,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue