fix(client): decouple 25Hz simulation and character animations from display refresh rate
- Decouple discrete simulation stepping (missiles, overlays, frameCount) in ClientWorld to fixed 25Hz ticks (40ms) via accumulator, preventing animation speedup at high refresh rates (120Hz/144Hz/240Hz). - Continuous movement interpolation for moving units using exact (safeDt / 1000) * 10 without Math.max(1, ...). - Guard held mouse / repeat clicks from interrupting active cast animation every 100ms via CommandMapper.isPlayerBusyCasting. - Preserve locally-predicted cast timing and avoid animation restart when server 0x4C SkillCast echo arrives. - Drive fallback cast overlay in unit-drawables using elapsedTicks and animRate instead of global display tick. - Add comprehensive vitest coverage in tests/client/char-anim-speed-loop.test.ts.
This commit is contained in:
parent
b839301aae
commit
62663c4840
|
|
@ -346,6 +346,23 @@ export class CommandMapper {
|
|||
return this.hudManager.isPointInterceptedByHud(screenX, screenY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the local player is currently executing a non-expired cast
|
||||
* or attack animation window.
|
||||
*/
|
||||
isPlayerBusyCasting(nowMs = performance.now()): boolean {
|
||||
const selfUnit =
|
||||
this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined
|
||||
const castState = selfUnit?.castState ?? this.world.self.castState
|
||||
if (!castState) return false
|
||||
if (castState.untilMs !== undefined) {
|
||||
return nowMs < castState.untilMs
|
||||
}
|
||||
const elapsed = castState.elapsedMs ?? 0
|
||||
const duration = castState.durationMs ?? 0
|
||||
return duration > 0 && elapsed < duration
|
||||
}
|
||||
|
||||
// ─── Mouse / Pointer Input Handlers ──────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -378,8 +395,14 @@ export class CommandMapper {
|
|||
}
|
||||
this.lockedPointerUnit = null
|
||||
}
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return
|
||||
}
|
||||
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||
} else if (this.rightMouseDown) {
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return
|
||||
}
|
||||
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||
}
|
||||
}
|
||||
|
|
@ -421,12 +444,18 @@ export class CommandMapper {
|
|||
this.leftMouseDown = true
|
||||
this.lockedPointerUnit =
|
||||
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||
}
|
||||
|
||||
if (button === 2) {
|
||||
this.rightMouseDown = true
|
||||
this.lockedPointerUnit = null
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||
}
|
||||
|
||||
|
|
@ -461,6 +490,9 @@ export class CommandMapper {
|
|||
|
||||
// Stand-still modifier (Shift): cast left skill on location/target instead of moving
|
||||
if (this.shiftHeld) {
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
this.pendingItemPickupId = null
|
||||
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
||||
return this.dispatchRateLimited(
|
||||
|
|
@ -611,6 +643,9 @@ export class CommandMapper {
|
|||
}
|
||||
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
|
||||
if (leftSkillId > 0) {
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
return this.dispatchRateLimited(
|
||||
{
|
||||
type: 'SkillOn',
|
||||
|
|
@ -634,6 +669,9 @@ export class CommandMapper {
|
|||
}
|
||||
|
||||
// Ground walk / run
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
this.pendingItemPickupId = null
|
||||
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
||||
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
||||
|
|
@ -657,6 +695,9 @@ export class CommandMapper {
|
|||
hoveredUnit: ClientUnit | null = null,
|
||||
nowMs = performance.now(),
|
||||
): DispatchedCommandRecord | null {
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
this.pendingItemPickupId = null
|
||||
if (hoveredUnit) {
|
||||
return this.dispatchRateLimited(
|
||||
|
|
@ -982,6 +1023,9 @@ export class CommandMapper {
|
|||
if (this.lockedPointerUnit?.unitType === 'item') {
|
||||
return null
|
||||
}
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||||
const record = this.triggerPrimaryWorldAction(
|
||||
target.subtileX,
|
||||
|
|
@ -993,6 +1037,9 @@ export class CommandMapper {
|
|||
}
|
||||
|
||||
if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
||||
if (this.isPlayerBusyCasting(nowMs)) {
|
||||
return null
|
||||
}
|
||||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||||
const record = this.triggerSecondaryWorldAction(
|
||||
target.subtileX,
|
||||
|
|
@ -1078,7 +1125,7 @@ export class CommandMapper {
|
|||
command.type === 'CastRightSkillOnTarget' ||
|
||||
command.type === 'Resurrect'
|
||||
) {
|
||||
this.world.noteOutgoingCommand(command)
|
||||
this.world.noteOutgoingCommand(command, nowMs)
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -939,7 +939,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
flowState.lastFrameTimeMs = null
|
||||
}
|
||||
|
||||
session.tick(dtMs || 40, nowMs)
|
||||
session.tick(dtMs, nowMs)
|
||||
const currentSettings = settingsStore.get()
|
||||
const profile = getViewportProfile(currentSettings.viewport)
|
||||
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ export class OnlineSession {
|
|||
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||||
return {
|
||||
tick: this.tickCounter,
|
||||
tick: this.world.frameCount,
|
||||
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
||||
playerSubX,
|
||||
playerSubY,
|
||||
|
|
|
|||
|
|
@ -592,7 +592,17 @@ export function buildSceneDrawables(
|
|||
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
|
||||
if (ovTxt) {
|
||||
const totalFrames = Math.max(1, ovTxt.frames)
|
||||
const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick)))
|
||||
const animRate = Math.max(1, ovTxt.animRate || 16)
|
||||
const elapsedMs =
|
||||
activeCastState.elapsedMs ??
|
||||
(activeCastState.startedAtMs && typeof performance !== 'undefined'
|
||||
? Math.max(0, performance.now() - activeCastState.startedAtMs)
|
||||
: 0)
|
||||
const elapsedTicks = elapsedMs / 40
|
||||
const frame = Math.min(
|
||||
totalFrames - 1,
|
||||
Math.max(0, Math.floor((elapsedTicks * animRate) / 16)),
|
||||
)
|
||||
const ovDrawable: SceneOverlayDrawable = {
|
||||
drawableKind: 'overlay',
|
||||
ownerUnitId: unit.id,
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@ export class ClientWorld {
|
|||
private _lastLocalCastSkillId = -1
|
||||
private _lastLocalCastAtMs = -1000
|
||||
private _simTimeMs = 0
|
||||
private _tickAccumulatorMs = 0
|
||||
private readonly _castSimStartMs = new Map<string, number>()
|
||||
private _notedCommands = new WeakSet<object>()
|
||||
private readonly _blockedSubTiles = new Set<string>()
|
||||
|
|
@ -989,11 +990,21 @@ export class ClientWorld {
|
|||
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
||||
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
||||
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = Date.now()
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
if (this.selfId !== 0) {
|
||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
||||
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
|
||||
const isBusyActiveCast =
|
||||
selfUnit.castState &&
|
||||
selfUnit.castState.skillId === skillId &&
|
||||
now < selfUnit.castState.untilMs
|
||||
if (!isBusyActiveCast) {
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = now
|
||||
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
|
||||
}
|
||||
} else {
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = now
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -1008,12 +1019,22 @@ export class ClientWorld {
|
|||
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
||||
const targetX = targetUnit?.x ?? this._self.x
|
||||
const targetY = targetUnit?.y ?? this._self.y
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = Date.now()
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
if (this.selfId !== 0) {
|
||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
||||
selfUnit.targetUnit = targetRef
|
||||
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
|
||||
const isBusyActiveCast =
|
||||
selfUnit.castState &&
|
||||
selfUnit.castState.skillId === skillId &&
|
||||
now < selfUnit.castState.untilMs
|
||||
if (!isBusyActiveCast) {
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = now
|
||||
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
|
||||
}
|
||||
} else {
|
||||
this._lastLocalCastSkillId = skillId
|
||||
this._lastLocalCastAtMs = now
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -1485,25 +1506,65 @@ export class ClientWorld {
|
|||
}
|
||||
|
||||
tick(dtMs = 40, nowMs?: number): void {
|
||||
this.frameCount++
|
||||
const safeDt = Math.max(0, dtMs)
|
||||
const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0)
|
||||
const cappedDt = Math.min(200, safeDt)
|
||||
this._tickAccumulatorMs += cappedDt
|
||||
this._simTimeMs += safeDt
|
||||
const step = Math.max(1, (safeDt / 1000) * 10)
|
||||
const ticksToStep = Math.max(1, Math.round(safeDt / 40))
|
||||
const inTown = isTownLevel(this.areaId)
|
||||
|
||||
this.expireCastStates(nowMs, safeDt)
|
||||
|
||||
const D2_TICK_MS = 40
|
||||
const EPSILON = 1e-4
|
||||
while (this._tickAccumulatorMs >= D2_TICK_MS - EPSILON) {
|
||||
this._tickAccumulatorMs = Math.max(0, this._tickAccumulatorMs - D2_TICK_MS)
|
||||
this.stepDiscreteSimulationTick(D2_TICK_MS)
|
||||
}
|
||||
|
||||
// Continuous unit movement interpolation across display frames
|
||||
const step = (safeDt / 1000) * 10
|
||||
if (step > 0) {
|
||||
const inTown = isTownLevel(this.areaId)
|
||||
for (const unit of this._units.values()) {
|
||||
if (unit.dead || unit.castState || unit.unitType === 'missile') continue
|
||||
if (unit.targetX === undefined || unit.targetY === undefined) continue
|
||||
const dx = unit.targetX - unit.x
|
||||
const dy = unit.targetY - unit.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist === 0) continue
|
||||
if (dist <= step) {
|
||||
unit.x = unit.targetX
|
||||
unit.y = unit.targetY
|
||||
if (
|
||||
unit.unitType === 'player' &&
|
||||
(unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)
|
||||
) {
|
||||
unit.mode = inTown ? 5 : 1
|
||||
unit.moveMode = 'stop'
|
||||
} else if (
|
||||
unit.unitType === 'monster' &&
|
||||
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
|
||||
) {
|
||||
unit.mode = 1
|
||||
unit.moveMode = 'stop'
|
||||
}
|
||||
} else {
|
||||
unit.x += (dx / dist) * step
|
||||
unit.y += (dy / dist) * step
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stepDiscreteSimulationTick(_tickDtMs = 40): void {
|
||||
this.frameCount++
|
||||
|
||||
const unitsSnapshot = Array.from(this._units.values())
|
||||
for (const unit of unitsSnapshot) {
|
||||
if (!this._units.has(unit.key)) continue
|
||||
|
||||
// 1. Step missile units
|
||||
// 1. Step missile units (1 simulation tick)
|
||||
if (unit.unitType === 'missile') {
|
||||
for (let t = 0; t < ticksToStep; t++) {
|
||||
if (!this._units.has(unit.key)) break
|
||||
this.stepMissileUnit(unit)
|
||||
}
|
||||
this.stepMissileUnit(unit)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -1511,7 +1572,7 @@ export class ClientWorld {
|
|||
if (unit.overlays.length > 0) {
|
||||
const remainingOverlays: ClientUnitOverlay[] = []
|
||||
for (const ov of unit.overlays) {
|
||||
ov.ageTicks += ticksToStep
|
||||
ov.ageTicks += 1
|
||||
const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16)
|
||||
if (ov.loop) {
|
||||
ov.frame = rawFrame % Math.max(1, ov.totalFrames)
|
||||
|
|
@ -1523,28 +1584,6 @@ export class ClientWorld {
|
|||
}
|
||||
unit.overlays = remainingOverlays
|
||||
}
|
||||
|
||||
// 3. Interpolate moving non-casting units
|
||||
if (unit.dead || unit.castState) continue
|
||||
if (unit.targetX === undefined || unit.targetY === undefined) continue
|
||||
const dx = unit.targetX - unit.x
|
||||
const dy = unit.targetY - unit.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist === 0) continue
|
||||
if (dist <= step) {
|
||||
unit.x = unit.targetX
|
||||
unit.y = unit.targetY
|
||||
if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) {
|
||||
unit.mode = inTown ? 5 : 1
|
||||
unit.moveMode = 'stop'
|
||||
} else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) {
|
||||
unit.mode = 1
|
||||
unit.moveMode = 'stop'
|
||||
}
|
||||
} else {
|
||||
unit.x += (dx / dist) * step
|
||||
unit.y += (dy / dist) * step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2027,15 +2066,22 @@ export class ClientWorld {
|
|||
}
|
||||
}
|
||||
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
const timeSinceLocalCast = now - this._lastLocalCastAtMs
|
||||
const isSelfEcho =
|
||||
unit.unitType === 'player' &&
|
||||
(unit.id === this.selfId || this.selfId === 0) &&
|
||||
skillId === this._lastLocalCastSkillId &&
|
||||
Date.now() - this._lastLocalCastAtMs >= 0 &&
|
||||
Date.now() - this._lastLocalCastAtMs <= 250
|
||||
timeSinceLocalCast >= 0 &&
|
||||
timeSinceLocalCast <= 500
|
||||
if (isSelfEcho) {
|
||||
this._lastLocalCastSkillId = -1
|
||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
|
||||
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
|
||||
unit.targetX = targetX
|
||||
unit.targetY = targetY
|
||||
} else {
|
||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
|
||||
}
|
||||
} else {
|
||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs)
|
||||
}
|
||||
|
|
@ -2605,6 +2651,7 @@ export class ClientWorld {
|
|||
this._lastLocalCastSkillId = -1
|
||||
this._lastLocalCastAtMs = -1000
|
||||
this._simTimeMs = 0
|
||||
this._tickAccumulatorMs = 0
|
||||
this._castSimStartMs.clear()
|
||||
this._notedCommands = new WeakSet<object>()
|
||||
this._blockedSubTiles.clear()
|
||||
|
|
|
|||
|
|
@ -642,4 +642,171 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
|
|||
const finalFrame = drawnFrames.at(-1)!
|
||||
expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10)
|
||||
})
|
||||
|
||||
it('decouples discrete 25Hz simulation ticks from high display refresh rates (144Hz, 240Hz, and 0ms frames)', () => {
|
||||
const world = new ClientWorld()
|
||||
world.apply({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: {
|
||||
unitType: 'player',
|
||||
id: 1,
|
||||
classId: 1,
|
||||
name: 'Sorc',
|
||||
x: 5000,
|
||||
y: 5000,
|
||||
mode: 1,
|
||||
lifePct: 128,
|
||||
},
|
||||
})
|
||||
|
||||
// Spawn a missile (e.g. Fireball) and a cast overlay
|
||||
const missile = world.spawnMissileByKey('fireball', 5000, 5000, 5050, 5000)
|
||||
const overlay = world.attachUnitOverlay(world.getUnit('player', 1)!, 'fire_cast_2', true)
|
||||
expect(world.frameCount).toBe(0)
|
||||
expect(missile.ageTicks).toBe(0)
|
||||
expect(overlay.ageTicks).toBe(0)
|
||||
|
||||
// Simulate 1 second at 144Hz: 144 frames of ~6.944ms each = 1000ms
|
||||
const dt144 = 1000 / 144
|
||||
for (let frame = 1; frame <= 144; frame++) {
|
||||
world.tick(dt144, frame * dt144)
|
||||
}
|
||||
|
||||
// In 1 second (1000ms), 25Hz simulation should advance by exactly 25 ticks, NOT 144 ticks!
|
||||
expect(world.frameCount).toBe(25)
|
||||
expect(missile.ageTicks).toBe(25)
|
||||
expect(overlay.ageTicks).toBe(25)
|
||||
|
||||
// Simulate 0ms duplicate RAF callbacks
|
||||
for (let i = 0; i < 10; i++) {
|
||||
world.tick(0, 1000)
|
||||
}
|
||||
expect(world.frameCount).toBe(25)
|
||||
expect(missile.ageTicks).toBe(25)
|
||||
expect(overlay.ageTicks).toBe(25)
|
||||
|
||||
// Advance 40ms more (1 tick)
|
||||
world.tick(40, 1040)
|
||||
expect(world.frameCount).toBe(26)
|
||||
expect(missile.ageTicks).toBe(26)
|
||||
expect(overlay.ageTicks).toBe(26)
|
||||
})
|
||||
|
||||
it('prevents held mouse from interrupting active cast animation every 100ms via isPlayerBusyCasting', () => {
|
||||
const session = new OnlineSession()
|
||||
session.handleServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: {
|
||||
unitType: 'player',
|
||||
id: 1,
|
||||
classId: 1, // Sorceress
|
||||
name: 'Sorc',
|
||||
x: 5000,
|
||||
y: 5000,
|
||||
mode: 1,
|
||||
lifePct: 128,
|
||||
},
|
||||
})
|
||||
session.world.self.rightSkill = 47 // Fireball
|
||||
|
||||
// Player presses and holds right mouse button to cast at t = 1000ms
|
||||
const rec1 = session.commandMapper.handlePointerDown(400, 300, 2, 1000)
|
||||
expect(rec1).not.toBeNull()
|
||||
expect(rec1?.command.type).toBe('SkillAt')
|
||||
|
||||
// Client world prediction has set up castState with duration ~520ms (until t = 1520ms)
|
||||
const selfUnit = session.world.getUnit('player', 1)!
|
||||
expect(selfUnit.castState).toBeDefined()
|
||||
expect(selfUnit.castState?.skillId).toBe(47)
|
||||
const untilMs = selfUnit.castState!.untilMs
|
||||
expect(untilMs).toBeGreaterThan(1500)
|
||||
|
||||
// At t = 1100ms (100ms later, which is the 100ms skillMinIntervalMs rate limit cooldown),
|
||||
// player is still holding right mouse button.
|
||||
expect(session.commandMapper.isPlayerBusyCasting(1100)).toBe(true)
|
||||
const rec2 = session.commandMapper.tick(1100)
|
||||
expect(rec2).toBeNull() // Must NOT interrupt active cast!
|
||||
expect(selfUnit.castState?.startedAtMs).toBe(1000) // Cast start time intact!
|
||||
|
||||
// Repeat tick at t = 1200, 1300, 1400, 1500ms
|
||||
for (let t = 1200; t <= 1500; t += 100) {
|
||||
expect(session.commandMapper.tick(t)).toBeNull()
|
||||
expect(selfUnit.castState?.startedAtMs).toBe(1000)
|
||||
}
|
||||
|
||||
// Step world past untilMs so the cast finishes
|
||||
session.world.tick(untilMs - 1000 + 40, untilMs + 40)
|
||||
expect(selfUnit.castState).toBeUndefined()
|
||||
expect(session.commandMapper.isPlayerBusyCasting(untilMs + 40)).toBe(false)
|
||||
|
||||
// Now that previous cast completed, held mouse initiates the next cast cleanly!
|
||||
const recNext = session.commandMapper.tick(untilMs + 40)
|
||||
expect(recNext).not.toBeNull()
|
||||
expect(recNext?.type).toBe('SkillAt')
|
||||
expect(selfUnit.castState).toBeDefined()
|
||||
expect(selfUnit.castState?.startedAtMs).toBe(untilMs + 40)
|
||||
|
||||
session.destroy()
|
||||
})
|
||||
|
||||
it('preserves locally-predicted cast timing and avoids restarting animation when server SkillCast echo arrives', () => {
|
||||
const world = new ClientWorld()
|
||||
world.apply(
|
||||
{
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: {
|
||||
unitType: 'player',
|
||||
id: 1,
|
||||
classId: 1,
|
||||
name: 'Sorc',
|
||||
x: 5000,
|
||||
y: 5000,
|
||||
mode: 1,
|
||||
lifePct: 128,
|
||||
},
|
||||
},
|
||||
1000,
|
||||
)
|
||||
world.self.rightSkill = 47
|
||||
|
||||
// Local client predicts right skill cast at t = 1000ms
|
||||
world.noteOutgoingCommand(
|
||||
{
|
||||
type: 'SkillAt',
|
||||
hand: 'right',
|
||||
at: { x: 5030, y: 5000 },
|
||||
},
|
||||
1000,
|
||||
)
|
||||
|
||||
const unit = world.getUnit('player', 1)!
|
||||
expect(unit.castState).toBeDefined()
|
||||
expect(unit.castState?.startedAtMs).toBe(1000)
|
||||
const origUntilMs = unit.castState!.untilMs
|
||||
|
||||
// Advance 80ms of local playback (2 simulation frames)
|
||||
world.tick(40, 1040)
|
||||
world.tick(40, 1080)
|
||||
expect(unit.castState?.elapsedMs).toBe(80)
|
||||
|
||||
// At t = 1080ms (80ms RTT), server 0x4C SkillCast echo arrives
|
||||
world.apply(
|
||||
{
|
||||
type: 'SkillCast',
|
||||
caster: { unitType: 'player', id: 1 },
|
||||
skill: 47,
|
||||
level: 1,
|
||||
target: { x: 5030, y: 5000 },
|
||||
},
|
||||
1080,
|
||||
)
|
||||
|
||||
// The cast timing must be preserved without resetting back to frame 0 or startedAtMs = 1080
|
||||
expect(unit.castState?.startedAtMs).toBe(1000)
|
||||
expect(unit.castState?.untilMs).toBe(origUntilMs)
|
||||
expect(unit.castState?.elapsedMs).toBe(80)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue