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)
|
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 ──────────────────────────
|
// ─── Mouse / Pointer Input Handlers ──────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -378,8 +395,14 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
this.lockedPointerUnit = null
|
this.lockedPointerUnit = null
|
||||||
}
|
}
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
} else if (this.rightMouseDown) {
|
} else if (this.rightMouseDown) {
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -421,12 +444,18 @@ export class CommandMapper {
|
||||||
this.leftMouseDown = true
|
this.leftMouseDown = true
|
||||||
this.lockedPointerUnit =
|
this.lockedPointerUnit =
|
||||||
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
|
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (button === 2) {
|
if (button === 2) {
|
||||||
this.rightMouseDown = true
|
this.rightMouseDown = true
|
||||||
this.lockedPointerUnit = null
|
this.lockedPointerUnit = null
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
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
|
// Stand-still modifier (Shift): cast left skill on location/target instead of moving
|
||||||
if (this.shiftHeld) {
|
if (this.shiftHeld) {
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
this.pendingItemPickupId = null
|
this.pendingItemPickupId = null
|
||||||
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
|
|
@ -611,6 +643,9 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
|
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
|
||||||
if (leftSkillId > 0) {
|
if (leftSkillId > 0) {
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
type: 'SkillOn',
|
type: 'SkillOn',
|
||||||
|
|
@ -634,6 +669,9 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ground walk / run
|
// Ground walk / run
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
this.pendingItemPickupId = null
|
this.pendingItemPickupId = null
|
||||||
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
||||||
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
||||||
|
|
@ -657,6 +695,9 @@ export class CommandMapper {
|
||||||
hoveredUnit: ClientUnit | null = null,
|
hoveredUnit: ClientUnit | null = null,
|
||||||
nowMs = performance.now(),
|
nowMs = performance.now(),
|
||||||
): DispatchedCommandRecord | null {
|
): DispatchedCommandRecord | null {
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
this.pendingItemPickupId = null
|
this.pendingItemPickupId = null
|
||||||
if (hoveredUnit) {
|
if (hoveredUnit) {
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
|
|
@ -982,6 +1023,9 @@ export class CommandMapper {
|
||||||
if (this.lockedPointerUnit?.unitType === 'item') {
|
if (this.lockedPointerUnit?.unitType === 'item') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||||||
const record = this.triggerPrimaryWorldAction(
|
const record = this.triggerPrimaryWorldAction(
|
||||||
target.subtileX,
|
target.subtileX,
|
||||||
|
|
@ -993,6 +1037,9 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
||||||
|
if (this.isPlayerBusyCasting(nowMs)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||||||
const record = this.triggerSecondaryWorldAction(
|
const record = this.triggerSecondaryWorldAction(
|
||||||
target.subtileX,
|
target.subtileX,
|
||||||
|
|
@ -1078,7 +1125,7 @@ export class CommandMapper {
|
||||||
command.type === 'CastRightSkillOnTarget' ||
|
command.type === 'CastRightSkillOnTarget' ||
|
||||||
command.type === 'Resurrect'
|
command.type === 'Resurrect'
|
||||||
) {
|
) {
|
||||||
this.world.noteOutgoingCommand(command)
|
this.world.noteOutgoingCommand(command, nowMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -939,7 +939,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
flowState.lastFrameTimeMs = null
|
flowState.lastFrameTimeMs = null
|
||||||
}
|
}
|
||||||
|
|
||||||
session.tick(dtMs || 40, nowMs)
|
session.tick(dtMs, nowMs)
|
||||||
const currentSettings = settingsStore.get()
|
const currentSettings = settingsStore.get()
|
||||||
const profile = getViewportProfile(currentSettings.viewport)
|
const profile = getViewportProfile(currentSettings.viewport)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -415,7 +415,7 @@ export class OnlineSession {
|
||||||
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||||
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||||||
return {
|
return {
|
||||||
tick: this.tickCounter,
|
tick: this.world.frameCount,
|
||||||
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
||||||
playerSubX,
|
playerSubX,
|
||||||
playerSubY,
|
playerSubY,
|
||||||
|
|
|
||||||
|
|
@ -592,7 +592,17 @@ export function buildSceneDrawables(
|
||||||
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
|
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
|
||||||
if (ovTxt) {
|
if (ovTxt) {
|
||||||
const totalFrames = Math.max(1, ovTxt.frames)
|
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 = {
|
const ovDrawable: SceneOverlayDrawable = {
|
||||||
drawableKind: 'overlay',
|
drawableKind: 'overlay',
|
||||||
ownerUnitId: unit.id,
|
ownerUnitId: unit.id,
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,7 @@ export class ClientWorld {
|
||||||
private _lastLocalCastSkillId = -1
|
private _lastLocalCastSkillId = -1
|
||||||
private _lastLocalCastAtMs = -1000
|
private _lastLocalCastAtMs = -1000
|
||||||
private _simTimeMs = 0
|
private _simTimeMs = 0
|
||||||
|
private _tickAccumulatorMs = 0
|
||||||
private readonly _castSimStartMs = new Map<string, number>()
|
private readonly _castSimStartMs = new Map<string, number>()
|
||||||
private _notedCommands = new WeakSet<object>()
|
private _notedCommands = new WeakSet<object>()
|
||||||
private readonly _blockedSubTiles = new Set<string>()
|
private readonly _blockedSubTiles = new Set<string>()
|
||||||
|
|
@ -989,11 +990,21 @@ export class ClientWorld {
|
||||||
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
||||||
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
||||||
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
||||||
this._lastLocalCastSkillId = skillId
|
const now = this.resolveEventNowMs(nowMs)
|
||||||
this._lastLocalCastAtMs = Date.now()
|
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -1008,12 +1019,22 @@ export class ClientWorld {
|
||||||
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
||||||
const targetX = targetUnit?.x ?? this._self.x
|
const targetX = targetUnit?.x ?? this._self.x
|
||||||
const targetY = targetUnit?.y ?? this._self.y
|
const targetY = targetUnit?.y ?? this._self.y
|
||||||
this._lastLocalCastSkillId = skillId
|
const now = this.resolveEventNowMs(nowMs)
|
||||||
this._lastLocalCastAtMs = Date.now()
|
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
const selfUnit = this.ensureUnit('player', this.selfId)
|
||||||
selfUnit.targetUnit = targetRef
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -1485,25 +1506,65 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(dtMs = 40, nowMs?: number): void {
|
tick(dtMs = 40, nowMs?: number): void {
|
||||||
this.frameCount++
|
const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0)
|
||||||
const safeDt = Math.max(0, dtMs)
|
const cappedDt = Math.min(200, safeDt)
|
||||||
|
this._tickAccumulatorMs += cappedDt
|
||||||
this._simTimeMs += safeDt
|
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)
|
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())
|
const unitsSnapshot = Array.from(this._units.values())
|
||||||
for (const unit of unitsSnapshot) {
|
for (const unit of unitsSnapshot) {
|
||||||
if (!this._units.has(unit.key)) continue
|
if (!this._units.has(unit.key)) continue
|
||||||
|
|
||||||
// 1. Step missile units
|
// 1. Step missile units (1 simulation tick)
|
||||||
if (unit.unitType === 'missile') {
|
if (unit.unitType === 'missile') {
|
||||||
for (let t = 0; t < ticksToStep; t++) {
|
this.stepMissileUnit(unit)
|
||||||
if (!this._units.has(unit.key)) break
|
|
||||||
this.stepMissileUnit(unit)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1511,7 +1572,7 @@ export class ClientWorld {
|
||||||
if (unit.overlays.length > 0) {
|
if (unit.overlays.length > 0) {
|
||||||
const remainingOverlays: ClientUnitOverlay[] = []
|
const remainingOverlays: ClientUnitOverlay[] = []
|
||||||
for (const ov of unit.overlays) {
|
for (const ov of unit.overlays) {
|
||||||
ov.ageTicks += ticksToStep
|
ov.ageTicks += 1
|
||||||
const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16)
|
const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16)
|
||||||
if (ov.loop) {
|
if (ov.loop) {
|
||||||
ov.frame = rawFrame % Math.max(1, ov.totalFrames)
|
ov.frame = rawFrame % Math.max(1, ov.totalFrames)
|
||||||
|
|
@ -1523,28 +1584,6 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
unit.overlays = remainingOverlays
|
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 =
|
const isSelfEcho =
|
||||||
unit.unitType === 'player' &&
|
unit.unitType === 'player' &&
|
||||||
(unit.id === this.selfId || this.selfId === 0) &&
|
(unit.id === this.selfId || this.selfId === 0) &&
|
||||||
skillId === this._lastLocalCastSkillId &&
|
skillId === this._lastLocalCastSkillId &&
|
||||||
Date.now() - this._lastLocalCastAtMs >= 0 &&
|
timeSinceLocalCast >= 0 &&
|
||||||
Date.now() - this._lastLocalCastAtMs <= 250
|
timeSinceLocalCast <= 500
|
||||||
if (isSelfEcho) {
|
if (isSelfEcho) {
|
||||||
this._lastLocalCastSkillId = -1
|
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 {
|
} else {
|
||||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs)
|
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs)
|
||||||
}
|
}
|
||||||
|
|
@ -2605,6 +2651,7 @@ export class ClientWorld {
|
||||||
this._lastLocalCastSkillId = -1
|
this._lastLocalCastSkillId = -1
|
||||||
this._lastLocalCastAtMs = -1000
|
this._lastLocalCastAtMs = -1000
|
||||||
this._simTimeMs = 0
|
this._simTimeMs = 0
|
||||||
|
this._tickAccumulatorMs = 0
|
||||||
this._castSimStartMs.clear()
|
this._castSimStartMs.clear()
|
||||||
this._notedCommands = new WeakSet<object>()
|
this._notedCommands = new WeakSet<object>()
|
||||||
this._blockedSubTiles.clear()
|
this._blockedSubTiles.clear()
|
||||||
|
|
|
||||||
|
|
@ -642,4 +642,171 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
|
||||||
const finalFrame = drawnFrames.at(-1)!
|
const finalFrame = drawnFrames.at(-1)!
|
||||||
expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10)
|
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