diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 4a32e04..d375d88 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -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 { diff --git a/src/client/main.ts b/src/client/main.ts index bfe3d9f..bf7cac8 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -939,7 +939,7 @@ async function bootPlayApp(): Promise { flowState.lastFrameTimeMs = null } - session.tick(dtMs || 40, nowMs) + session.tick(dtMs, nowMs) const currentSettings = settingsStore.get() const profile = getViewportProfile(currentSettings.viewport) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index e60f2d1..69c2fa4 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -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, diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 3f2b664..93cce5c 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -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, diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ab4ac53..7c91bb8 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -326,6 +326,7 @@ export class ClientWorld { private _lastLocalCastSkillId = -1 private _lastLocalCastAtMs = -1000 private _simTimeMs = 0 + private _tickAccumulatorMs = 0 private readonly _castSimStartMs = new Map() private _notedCommands = new WeakSet() private readonly _blockedSubTiles = new Set() @@ -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() this._blockedSubTiles.clear() diff --git a/tests/client/char-anim-speed-loop.test.ts b/tests/client/char-anim-speed-loop.test.ts index 1093195..8331a6f 100644 --- a/tests/client/char-anim-speed-loop.test.ts +++ b/tests/client/char-anim-speed-loop.test.ts @@ -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) + }) })