From 7bd10903f63dc87c1449f89deb272af4dd3898da Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 09:13:09 +0000 Subject: [PATCH] fix(client): drive 1.13c character animation speed from FCR/IAS/WSM and stop one-shot cast/attack looping (#552) --- src/client/scene/world-renderer.ts | 165 +++++- src/client/session/online-session.ts | 53 +- src/client/view/unit-drawables.ts | 31 +- src/client/world/client-unit.ts | 16 +- src/client/world/client-world.ts | 348 ++++++++++-- src/client/world/inventory.ts | 22 +- src/common/units/char-anim-speed.ts | 578 +++++++++++++++++++ tests/client/char-anim-speed-loop.test.ts | 645 ++++++++++++++++++++++ 8 files changed, 1775 insertions(+), 83 deletions(-) create mode 100644 src/common/units/char-anim-speed.ts create mode 100644 tests/client/char-anim-speed-loop.test.ts diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index d16d5b2..3709d94 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,4 +1,9 @@ import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts' +import { + computeAnimFrameIndex, + computeStepSpeed256, + getCharAnimClip113c, +} from '../../common/units/char-anim-speed.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts' import { PLR_MODES } from '../../common/units/plr-mode.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' @@ -45,7 +50,12 @@ import { type LightingPassTargets, } from './lighting-presets.ts' -export { layoutGroundItemLabels } +export { + computeAnimFrameIndex, + computeStepSpeed256, + getCharAnimClip113c, + layoutGroundItemLabels, +} /** * Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile @@ -64,9 +74,47 @@ declare global { } } +interface TrackedOneShotState { + readonly clipKey: string + readonly rawStartedAtMs: number | undefined + readonly effectiveStartMs: number +} + const missingArtRegistry = new Map() const missingArtList: MissingArtEntry[] = [] const dropStartMsByItemId = new Map() +const oneShotStartMsByUnitKey = new Map() + +function resolveOneShotStartMs( + unitKey: string, + clipKey: string, + rawStartedAtMs: number | undefined, + nowMs: number, +): number { + if (rawStartedAtMs !== undefined && Number.isFinite(rawStartedAtMs) && rawStartedAtMs <= nowMs) { + oneShotStartMsByUnitKey.set(unitKey, { + clipKey, + rawStartedAtMs, + effectiveStartMs: rawStartedAtMs, + }) + return rawStartedAtMs + } + const prev = oneShotStartMsByUnitKey.get(unitKey) + if ( + prev !== undefined && + prev.clipKey === clipKey && + prev.rawStartedAtMs === rawStartedAtMs && + nowMs >= prev.effectiveStartMs + ) { + return prev.effectiveStartMs + } + oneShotStartMsByUnitKey.set(unitKey, { + clipKey, + rawStartedAtMs, + effectiveStartMs: nowMs, + }) + return nowMs +} if (typeof window !== 'undefined' && !window.__d2MissingArt) { window.__d2MissingArt = missingArtList @@ -104,6 +152,7 @@ export function clearMissingArtEntries(): void { missingArtRegistry.clear() missingArtList.length = 0 dropStartMsByItemId.clear() + oneShotStartMsByUnitKey.clear() if (typeof window !== 'undefined') { window.__d2MissingArt = missingArtList } @@ -430,6 +479,7 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { + const isMoving = unit.mode === 2 || unit.mode === 3 || unit.mode === 6 const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase() const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken if (!requestedClipKey) { @@ -443,8 +493,12 @@ function drawSceneUnitDrawable( const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] const clip = charArt.clips?.[requestedClipKey] ?? - (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) - if (!clip || !(clip.speed > 0)) { + (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) ?? + (isMoving ? charArt.clips?.wl : charArt.clips?.nu) + const canonicalClip = getCharAnimClip113c(unit.classId, requestedClipKey) + const baseSpeed = + (clip?.speed && clip.speed > 0 ? clip.speed : canonicalClip?.speed) ?? 0 + if (!clip || !(baseSpeed > 0)) { reportMissingArt( 'player-clip', `${unit.token}:${requestedClipKey}`, @@ -457,15 +511,52 @@ function drawSceneUnitDrawable( const groupIndex = clip.group + direction const group = charArt.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) - const shouldLoop = - !unit.isCorpse && - requestedClipKey !== 'dd' && - requestedClipKey !== 'dt' && - clip.loop !== false - const frameIndex = - unit.isCorpse && requestedClipKey !== 'dt' - ? frameCount - 1 - : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) + const unitKey = `player:${unit.unitId}` + let frameIndex = 0 + if ((unit.isCorpse && requestedClipKey !== 'dt') || requestedClipKey === 'dd') { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = frameCount - 1 + } else { + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + (clip.loop !== undefined + ? clip.loop + : (canonicalClip?.loop ?? + (requestedClipKey === 'nu' || + requestedClipKey === 'tn' || + requestedClipKey === 'wl' || + requestedClipKey === 'tw' || + requestedClipKey === 'rn'))) + const stepSpeed256 = + unit.animStepSpeed256 ?? + unit.castState?.stepSpeed256 ?? + computeStepSpeed256(baseSpeed, unit.animEffRate ?? unit.castState?.effRate ?? 0) + if (shouldLoop) { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: true, + nowMs, + }) + } else { + const startedAtMs = resolveOneShotStartMs( + unitKey, + requestedClipKey, + unit.animStartedAtMs ?? unit.castState?.startedAtMs, + nowMs, + ) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: false, + nowMs, + startedAtMs, + }) + } + } const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -534,15 +625,47 @@ function drawSceneUnitDrawable( const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) - const shouldLoop = - !unit.isCorpse && - requestedClipKey !== 'dd' && - requestedClipKey !== 'dt' && - clip.loop !== false - const frameIndex = - unit.isCorpse && requestedClipKey !== 'dt' - ? frameCount - 1 - : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) + const unitKey = `monster:${unit.unitId}` + let frameIndex = 0 + if ((unit.isCorpse && requestedClipKey !== 'dt') || requestedClipKey === 'dd') { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = frameCount - 1 + } else { + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + (clip.loop !== undefined + ? clip.loop + : requestedClipKey === 'nu' || requestedClipKey === 'wl' || requestedClipKey === 'rn') + const stepSpeed256 = + unit.animStepSpeed256 ?? + unit.castState?.stepSpeed256 ?? + clip.speed + if (shouldLoop) { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: true, + nowMs, + }) + } else { + const startedAtMs = resolveOneShotStartMs( + unitKey, + requestedClipKey, + unit.animStartedAtMs ?? unit.castState?.startedAtMs, + nowMs, + ) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: false, + nowMs, + startedAtMs, + }) + } + } const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index b82d6e2..3af1e8c 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1139,6 +1139,12 @@ export class OnlineSession { try { this.gameAdapter.send(cmd) this.world.noteOutgoingCommand(cmd) + if (this.world.selfId !== 0) { + const selfUnit = this.world.getUnit('player', this.world.selfId) + if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { + this.predictor.setDirection(selfUnit.direction) + } + } } catch (err) { const msg = err instanceof Error ? err.message : String(err) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true) @@ -1410,6 +1416,16 @@ export class OnlineSession { break } + case 'SkillCast': { + if (this.world.selfId !== 0) { + const selfUnit = this.world.getUnit('player', this.world.selfId) + if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { + this.predictor.setDirection(selfUnit.direction) + } + } + break + } + case 'UnitRemove': { this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id) break @@ -1482,17 +1498,25 @@ export class OnlineSession { * - Flushes rate-limited movement commands in `CommandMapper` * - Steps `LocalMovementPredictor` and `Camera` * - Steps `RemoteEntityInterpolator` and syncs interpolated positions to `ClientWorld` units + * - Expires finished one-shot cast/attack animations in `ClientWorld` */ tick(dtMs = 40, nowMs = performance.now()): void { + const safeDt = Math.max(0, dtMs) this.tickCounter++ this.commandMapper.tick(nowMs) - const predSnap = this.predictor.update(dtMs) + const predSnap = this.predictor.update(safeDt) if (predSnap.x !== 0 || predSnap.y !== 0) { this.camera.update(predSnap.x, predSnap.y) } - this.interpolator.update(dtMs) + const selfUnit = + this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined + if (!predSnap.moving && selfUnit?.castState) { + this.predictor.setDirection(selfUnit.direction) + } + + this.interpolator.update(safeDt) for (const state of this.interpolator.getAllStates()) { const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) if (unit) { @@ -1539,7 +1563,10 @@ export class OnlineSession { /** * Build `RenderableUnitInput[]` from `ClientWorld` + `LocalMovementPredictor` + `RemoteEntityInterpolator`. */ - buildRenderableUnits(): RenderableUnitInput[] { + buildRenderableUnits(nowMs?: number): RenderableUnitInput[] { + if (nowMs !== undefined) { + this.world.expireCastStates(nowMs) + } const out: RenderableUnitInput[] = [] const predSnap = this.predictor.getSnapshot() const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x @@ -1550,6 +1577,9 @@ export class OnlineSession { if (this.world.selfId !== 0 && selfClass !== null) { const selfUnit = this.world.getUnit('player', this.world.selfId) const isSelfDead = this.world.isSelfDead || this.world.isDead + if (!predSnap.moving && selfUnit?.castState) { + this.predictor.setDirection(selfUnit.direction) + } const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined const selfInTown = this.isSubTileInTown(selfX, selfY) const neutralMode = selfInTown ? 5 : 1 @@ -1578,6 +1608,9 @@ export class OnlineSession { } const selfDirection = selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 + const selfAnimStartedAtMs = selfCastState?.startedAtMs ?? selfUnit?.modeStartedAtMs + const selfAnimStepSpeed256 = selfCastState?.stepSpeed256 ?? selfUnit?.animStepSpeed256 + const selfAnimEffRate = selfCastState?.effRate ?? selfUnit?.animEffRate out.push({ id: this.world.selfId, kind: 'player', @@ -1597,6 +1630,9 @@ export class OnlineSession { isCorpse: isSelfDead, ...(selfCastState !== undefined ? { castState: selfCastState } : {}), ...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}), + ...(selfAnimStartedAtMs !== undefined ? { animStartedAtMs: selfAnimStartedAtMs } : {}), + ...(selfAnimStepSpeed256 !== undefined ? { animStepSpeed256: selfAnimStepSpeed256 } : {}), + ...(selfAnimEffRate !== undefined ? { animEffRate: selfAnimEffRate } : {}), }) } @@ -1701,6 +1737,9 @@ export class OnlineSession { const isCorpse = (u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) || (u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0)) + if (interp?.moving && u.castState) { + this.world.clearUnitCastState(u) + } const castState = !isCorpse ? u.castState : undefined let mode = castState ? castState.mode || u.mode : u.mode if (u.unitType === 'player' && !isCorpse && !castState) { @@ -1824,6 +1863,9 @@ export class OnlineSession { (partyMember.partyState === 1 || (partyMember.partyId !== undefined && partyMember.partyId !== 0xffff)), ) + const unitAnimStartedAtMs = castState?.startedAtMs ?? u.modeStartedAtMs + const unitAnimStepSpeed256 = castState?.stepSpeed256 ?? u.animStepSpeed256 + const unitAnimEffRate = castState?.effRate ?? u.animEffRate out.push({ id: u.id, @@ -1844,7 +1886,10 @@ export class OnlineSession { states: Array.from(u.states.list()), ...(castState !== undefined ? { castState } : {}), ...(u.overlays.length > 0 ? { overlays: u.overlays } : {}), - ...(u.unitType === 'player' ? { isPartyMember } : {}), + ...(unitAnimStartedAtMs !== undefined ? { animStartedAtMs: unitAnimStartedAtMs } : {}), + ...(unitAnimStepSpeed256 !== undefined ? { animStepSpeed256: unitAnimStepSpeed256 } : {}), + ...(unitAnimEffRate !== undefined ? { animEffRate: unitAnimEffRate } : {}), + ...(u.unitType === 'player' ? { isPartyMember, isLocalPlayer: false } : {}), }) } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 60f7eda..fa4709d 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -82,6 +82,9 @@ export interface RenderableUnitInput { readonly itemCode?: string | undefined readonly castState?: UnitCastState | undefined readonly animMode?: string | undefined + readonly animStartedAtMs?: number | undefined + readonly animStepSpeed256?: number | undefined + readonly animEffRate?: number | undefined readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined @@ -123,6 +126,10 @@ export interface SceneUnitDrawable { readonly itemCode?: string | undefined readonly castState?: UnitCastState | undefined readonly animMode?: string | undefined + readonly animStartedAtMs?: number | undefined + readonly animStepSpeed256?: number | undefined + readonly animEffRate?: number | undefined + readonly isLocalPlayer?: boolean | undefined readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined @@ -553,9 +560,23 @@ export function buildSceneDrawables( unit.animMode ?? (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) ? 'A1' - : !isCorpse && effectiveMode === 10 - ? 'SC' - : undefined) + : !isCorpse && effectiveMode === 8 + ? 'A2' + : !isCorpse && effectiveMode === 10 + ? 'SC' + : undefined) + const animStartedAtMs = + unit.animStartedAtMs ?? + activeCastState?.startedAtMs ?? + (rawAny.modeStartedAtMs as number | undefined) + const animStepSpeed256 = + unit.animStepSpeed256 ?? + activeCastState?.stepSpeed256 ?? + (rawAny.animStepSpeed256 as number | undefined) + const animEffRate = + unit.animEffRate ?? + activeCastState?.effRate ?? + (rawAny.animEffRate as number | undefined) if (activeCastState) { const castOverlayKey = @@ -659,6 +680,10 @@ export function buildSceneDrawables( : {}), ...(activeCastState !== undefined ? { castState: activeCastState } : {}), ...(animMode !== undefined ? { animMode } : {}), + ...(animStartedAtMs !== undefined ? { animStartedAtMs } : {}), + ...(animStepSpeed256 !== undefined ? { animStepSpeed256 } : {}), + ...(animEffRate !== undefined ? { animEffRate } : {}), + ...(unit.isLocalPlayer !== undefined ? { isLocalPlayer: unit.isLocalPlayer } : {}), ...(unit.portalOwnerName !== undefined ? { portalOwnerName: unit.portalOwnerName } : {}), ...(unit.portalAreaId !== undefined ? { portalAreaId: unit.portalAreaId } : {}), ...(unit.isPartyMember !== undefined ? { isPartyMember: unit.isPartyMember } : {}), diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 7163d47..0f95e80 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -38,9 +38,15 @@ export interface UnitCastState { readonly targetX: number readonly targetY: number readonly mode: number - readonly startedAtMs: number - readonly untilMs: number + startedAtMs: number + untilMs: number readonly castOverlayToken?: string | undefined + readonly durationMs?: number | undefined + elapsedMs?: number | undefined + readonly stepSpeed256?: number | undefined + readonly frames?: number | undefined + readonly baseSpeed?: number | undefined + readonly effRate?: number | undefined } export function makeUnitKey(unitType: UnitType, id: number): string { @@ -108,6 +114,12 @@ export class ClientUnit { startedAtTick?: number | undefined sourceSkillId?: number | undefined skillLevel?: number | undefined + modeStartedAtMs?: number | undefined + modeUntilMs?: number | undefined + modeDurationMs?: number | undefined + modeElapsedMs?: number | undefined + animStepSpeed256?: number | undefined + animEffRate?: number | undefined readonly stats = new StatList() readonly states = new UnitStateSet() diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 6c73350..ef343f8 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -26,6 +26,28 @@ import { getOverlayTxtData, getSkillCastOverlay, } from '../../common/skills/missiles-data.ts' +import { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveCharacterAnimSpeedParams, + resolveCharacterStatTotal, + resolveEquippedWeaponWsm, + resolveWeaponWsm, + STAT_FASTERATTACKRATE, + STAT_FASTERBLOCKRATE, + STAT_FASTERCASTRATE, + STAT_FASTERHITRECOVERY, + STAT_SKILL_IAS, + type CharacterAnimSpeedParams, +} from '../../common/units/char-anim-speed.ts' import { dir64FromPoints } from '../../common/units/direction.ts' import { isTownLevel } from '../../common/world/scene-population.ts' import { @@ -37,6 +59,29 @@ import { import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' +export { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveCharacterAnimSpeedParams, + resolveCharacterStatTotal, + resolveEquippedWeaponWsm, + resolveWeaponWsm, + STAT_FASTERATTACKRATE, + STAT_FASTERBLOCKRATE, + STAT_FASTERCASTRATE, + STAT_FASTERHITRECOVERY, + STAT_SKILL_IAS, + type CharacterAnimSpeedParams, +} + export interface PartyMemberState { readonly unitId: number charName?: string | undefined @@ -272,6 +317,8 @@ export class ClientWorld { } private _lastNpcTransaction: NpcTransactionRecord | null = null private _npcTransactionSeq = 0 + private _clockMs: number | null = null + private _clockExplicit = false private readonly _changeListeners = new Set<(ev: ServerEvent) => void>() private _nextMissileId = 0x70000000 @@ -305,6 +352,53 @@ export class ClientWorld { return this._mercHireList } + private resolveEventNowMs(nowMs?: number): number { + if (nowMs !== undefined && Number.isFinite(nowMs)) { + this._clockMs = nowMs + this._clockExplicit = true + return nowMs + } + if (this._clockExplicit && this._clockMs !== null) { + return this._clockMs + } + const autoNow = typeof performance !== 'undefined' ? performance.now() : Date.now() + this._clockMs = autoNow + return autoNow + } + + /** + * Resolves 1.13c character animation speed parameters (`frames`, `baseSpeed`, `effRate`, + * `stepSpeed256`, `durationMs`) for `modeOrClipKey` from the unit's stats and equipped items. + */ + resolvePlayerAnimSpeed( + modeOrClipKey: number | string, + unitId = this.selfId, + ): CharacterAnimSpeedParams { + const unit = unitId !== 0 ? this.getUnit('player', unitId) : undefined + const isSelf = unitId === this.selfId || this.selfId === 0 + const classId = + unit && unit.classId >= 0 && unit.classId <= 6 ? unit.classId : (this._self.charClass ?? 1) + return resolveCharacterAnimSpeedParams(classId, modeOrClipKey, { + unitStats: unit?.stats, + selfStats: isSelf ? this._self.stats : undefined, + equipment: isSelf ? Array.from(this._containers.equipment.values()) : undefined, + sockets: isSelf ? this._containers.sockets : undefined, + }) + } + + clearUnitCastState(unit: ClientUnit): void { + unit.castState = undefined + unit.modeUntilMs = undefined + unit.modeDurationMs = undefined + unit.modeElapsedMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + this._castSimStartMs.delete(unit.key) + if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { + this._self.castState = undefined + } + } + /** * Registers an optional world sub-tile collision callback (`true` = blocked wall/obstacle). */ @@ -723,6 +817,7 @@ export class ClientWorld { targetY: number, skillLevel = 1, spawnVisuals = true, + nowMs?: number, ): void { unit.targetX = targetX unit.targetY = targetY @@ -731,22 +826,50 @@ export class ClientWorld { // same 1.13c direction table as movement; a target on the unit's own position keeps the facing. unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) + const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10 unit.mode = mode const castOverlayToken = getSkillCastOverlay(skillId) - const now = Date.now() + const now = this.resolveEventNowMs(nowMs) + let frames = 12 + let baseSpeed = 256 + let effRate = 0 + let stepSpeed256 = 256 + let durationMs = computeOneShotDurationMs(frames, stepSpeed256) + + if (unit.unitType === 'player') { + const animParams = this.resolvePlayerAnimSpeed(mode, unit.id) + frames = animParams.frames + baseSpeed = animParams.baseSpeed + effRate = animParams.effRate + stepSpeed256 = animParams.stepSpeed256 + durationMs = animParams.durationMs + } + const castState: UnitCastState = { skillId, targetX, targetY, mode, startedAtMs: now, - untilMs: now + 400, + untilMs: now + durationMs, castOverlayToken, + durationMs, + elapsedMs: 0, + stepSpeed256, + frames, + baseSpeed, + effRate, } unit.castState = castState this._castSimStartMs.set(unit.key, this._simTimeMs) - if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { + unit.modeStartedAtMs = now + unit.modeUntilMs = now + durationMs + unit.modeDurationMs = durationMs + unit.modeElapsedMs = 0 + unit.animStepSpeed256 = stepSpeed256 + unit.animEffRate = effRate + if (isSelf) { this._self.castState = castState } @@ -768,7 +891,7 @@ export class ClientWorld { * refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is * sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08). */ - noteOutgoingCommand(cmd: ClientCommand): void { + noteOutgoingCommand(cmd: ClientCommand, nowMs?: number): void { if (cmd && typeof cmd === 'object') { if (this._notedCommands.has(cmd)) return this._notedCommands.add(cmd) @@ -834,6 +957,30 @@ export class ClientWorld { this._isSelfDead = false this._self.isDead = false break + case 'Move': + case 'WalkToLocation': + case 'RunToLocation': + case 'MoveToUnit': + case 'WalkToTarget': + case 'RunToTarget': { + if (this.selfId !== 0) { + const selfUnit = this.getUnit('player', this.selfId) + if (selfUnit) { + this.clearUnitCastState(selfUnit) + const isWalk = + cmd.type === 'WalkToLocation' || + cmd.type === 'WalkToTarget' || + (cmd.type === 'Move' && cmd.run === false) || + (cmd.type === 'MoveToUnit' && cmd.run === false) + selfUnit.moveMode = isWalk ? 'walk' : 'run' + if (!selfUnit.dead) { + selfUnit.mode = isWalk ? (isTownLevel(this.areaId) ? 6 : 2) : 3 + } + } + } + this._self.castState = undefined + break + } case 'SkillAt': case 'CastLeftSkillOnLocation': case 'CastRightSkillOnLocation': { @@ -846,7 +993,7 @@ export class ClientWorld { this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) } break } @@ -866,7 +1013,7 @@ export class ClientWorld { if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) selfUnit.targetUnit = targetRef - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) } break } @@ -1230,6 +1377,113 @@ export class ClientWorld { } } + /** + * Expires any completed one-shot `castState` or `GH` recovery windows on units, + * returning `unit.mode` to `1` (`NU` neutral) and clearing `castState`. + */ + expireCastStates(nowMs?: number, dtMs = 0): void { + const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0) + if (nowMs !== undefined && Number.isFinite(nowMs)) { + if (!this._clockExplicit) { + for (const unit of this._units.values()) { + if (unit.castState && nowMs < unit.castState.startedAtMs) { + const elapsed = unit.castState.elapsedMs ?? 0 + const duration = + unit.castState.durationMs ?? + Math.max(40, unit.castState.untilMs - unit.castState.startedAtMs) + unit.castState.startedAtMs = nowMs - elapsed + unit.castState.untilMs = unit.castState.startedAtMs + duration + } + if (unit.modeStartedAtMs !== undefined && nowMs < unit.modeStartedAtMs) { + const elapsed = unit.modeElapsedMs ?? 0 + const duration = + unit.modeDurationMs ?? + (unit.modeUntilMs !== undefined ? Math.max(40, unit.modeUntilMs - unit.modeStartedAtMs) : 0) + unit.modeStartedAtMs = nowMs - elapsed + if (unit.modeUntilMs !== undefined) { + unit.modeUntilMs = unit.modeStartedAtMs + duration + } + } + } + } + this._clockMs = nowMs + this._clockExplicit = true + } else if (safeDt > 0 && this._clockMs !== null) { + this._clockMs += safeDt + } + + const effNow = nowMs !== undefined && Number.isFinite(nowMs) ? nowMs : this._clockMs + + for (const unit of this._units.values()) { + if (unit.castState) { + if (safeDt > 0) { + unit.castState.elapsedMs = (unit.castState.elapsedMs ?? 0) + safeDt + } + const durationMs = + unit.castState.durationMs ?? + Math.max(40, unit.castState.untilMs - unit.castState.startedAtMs) + const expiredByClock = + effNow !== null && + effNow >= unit.castState.untilMs && + effNow >= unit.castState.startedAtMs + const expiredByElapsed = (unit.castState.elapsedMs ?? 0) >= durationMs + + if (expiredByClock || expiredByElapsed) { + const castMode = unit.castState.mode + this.clearUnitCastState(unit) + if ( + !unit.dead && + (unit.mode === castMode || + unit.mode === 7 || + unit.mode === 8 || + unit.mode === 10 || + (unit.unitType === 'monster' && (unit.mode === 4 || unit.mode === 5 || unit.mode === 7))) + ) { + unit.mode = unit.unitType === 'player' && isTownLevel(this.areaId) ? 5 : 1 + if (!unit.moveMode || unit.moveMode === 'stop') { + unit.targetX = undefined + unit.targetY = undefined + } + } + } + } else if (unit.modeUntilMs !== undefined) { + if (safeDt > 0) { + unit.modeElapsedMs = (unit.modeElapsedMs ?? 0) + safeDt + } + const durationMs = + unit.modeDurationMs ?? + Math.max(40, unit.modeUntilMs - (unit.modeStartedAtMs ?? 0)) + const expiredByClock = + effNow !== null && + effNow >= unit.modeUntilMs && + effNow >= (unit.modeStartedAtMs ?? 0) + const expiredByElapsed = (unit.modeElapsedMs ?? 0) >= durationMs + + if (expiredByClock || expiredByElapsed) { + unit.modeUntilMs = undefined + unit.modeDurationMs = undefined + unit.modeElapsedMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + if ( + !unit.dead && + ((unit.unitType === 'player' && unit.mode === 4) || + (unit.unitType === 'monster' && unit.mode === 3)) + ) { + unit.mode = unit.unitType === 'player' && isTownLevel(this.areaId) ? 5 : 1 + } + } + } + } + + if (this._self.castState) { + const selfUnit = this.selfId !== 0 ? this.getUnit('player', this.selfId) : undefined + if (!selfUnit || !selfUnit.castState) { + this._self.castState = undefined + } + } + } + tick(dtMs = 40, nowMs?: number): void { this.frameCount++ const safeDt = Math.max(0, dtMs) @@ -1238,6 +1492,8 @@ export class ClientWorld { const ticksToStep = Math.max(1, Math.round(safeDt / 40)) const inTown = isTownLevel(this.areaId) + this.expireCastStates(nowMs, safeDt) + const unitsSnapshot = Array.from(this._units.values()) for (const unit of unitsSnapshot) { if (!this._units.has(unit.key)) continue @@ -1268,28 +1524,8 @@ export class ClientWorld { unit.overlays = remainingOverlays } - // 3. Expire castState when cast window completes - if (unit.castState) { - const simStart = this._castSimStartMs.get(unit.key) ?? this._simTimeMs - const simElapsed = this._simTimeMs - simStart - const wallExpired = nowMs !== undefined && nowMs >= unit.castState.untilMs - if (simElapsed >= 400 || wallExpired) { - unit.castState = undefined - this._castSimStartMs.delete(unit.key) - if (!unit.dead) { - unit.mode = unit.unitType === 'player' && inTown ? 5 : 1 // return to TN or NU (neutral) - unit.targetX = unit.x - unit.targetY = unit.y - } - if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { - this._self.castState = undefined - } - } - continue - } - - // 4. Interpolate moving non-casting units - if (unit.dead) continue + // 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 @@ -1345,15 +1581,15 @@ export class ClientWorld { return unit } - applyEvent(ev: ServerEvent | Record): void { - this.apply(ev as ServerEvent) + applyEvent(ev: ServerEvent | Record, nowMs?: number): void { + this.apply(ev as ServerEvent, nowMs) } - applyServerEvent(ev: ServerEvent | Record): void { - this.apply(ev as ServerEvent) + applyServerEvent(ev: ServerEvent | Record, nowMs?: number): void { + this.apply(ev as ServerEvent, nowMs) } - apply(rawEv: ServerEvent | Record): void { + apply(rawEv: ServerEvent | Record, nowMs?: number): void { if (!rawEv || typeof rawEv !== 'object') return let ev: any = rawEv if (ev.type === 'ActDataLoaded') { @@ -1571,6 +1807,9 @@ export class ClientWorld { if (unit.unitType === 'monster') { this._monsters.set(unit.id, unit) } + this.clearUnitCastState(unit) + const prevX = ev.from && (ev.from.x !== 0 || ev.from.y !== 0) ? ev.from.x : unit.x + const prevY = ev.from && (ev.from.x !== 0 || ev.from.y !== 0) ? ev.from.y : unit.y if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { unit.x = ev.from.x unit.y = ev.from.y @@ -1603,8 +1842,8 @@ export class ClientWorld { unit.targetUnit = ev.target const moveMode = ev.mode ?? 'walk' unit.moveMode = moveMode - if (targetX !== unit.x || targetY !== unit.y) { - unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) + if (prevX !== targetX || prevY !== targetY) { + unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) } if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { const inTown = isTownLevel(this.areaId) @@ -1652,6 +1891,7 @@ export class ClientWorld { case 'UnitReassign': { const unit = this.ensureUnit(ev.unitType, ev.id) + this.clearUnitCastState(unit) unit.x = ev.at.x unit.y = ev.at.y unit.targetX = ev.at.x @@ -1674,16 +1914,27 @@ export class ClientWorld { case 'UnitMode': { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) + this.clearUnitCastState(unit) + const now = this.resolveEventNowMs(nowMs) unit.mode = ev.mode - if ( - ev.mode === 0 || - ev.mode === 1 || - ev.mode === 3 || - ev.mode === 12 || - ev.mode === 16 || - ev.mode === 17 - ) { - unit.castState = undefined + unit.modeStartedAtMs = now + unit.modeElapsedMs = 0 + if (unit.unitType === 'player' && ev.mode === 4) { + const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id) + unit.modeDurationMs = speedInfo.durationMs + unit.modeUntilMs = now + speedInfo.durationMs + unit.animStepSpeed256 = speedInfo.stepSpeed256 + unit.animEffRate = speedInfo.effRate + } else if (unit.unitType === 'monster' && ev.mode === 3) { + unit.modeDurationMs = 240 + unit.modeUntilMs = now + 240 + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + } else { + unit.modeDurationMs = undefined + unit.modeUntilMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x @@ -1784,9 +2035,9 @@ export class ClientWorld { Date.now() - this._lastLocalCastAtMs <= 250 if (isSelfEcho) { this._lastLocalCastSkillId = -1 - this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false) + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) } else { - this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true) + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs) } break } @@ -1865,7 +2116,8 @@ export class ClientWorld { } case 'Item': - case 'ItemEvent': { + case 'ItemEvent': + case 'ItemAction': { // 0x17 WeaponSwitch if (ev.action === 0x17) { this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0 @@ -2349,6 +2601,8 @@ export class ClientWorld { this._castSimStartMs.clear() this._notedCommands = new WeakSet() this._blockedSubTiles.clear() + this._clockMs = null + this._clockExplicit = false } clear(): void { diff --git a/src/client/world/inventory.ts b/src/client/world/inventory.ts index 8859b6c..e66841a 100644 --- a/src/client/world/inventory.ts +++ b/src/client/world/inventory.ts @@ -82,7 +82,7 @@ export class ClientInventoryManager implements ClientItemContainers { if (this.equipment.has(itemId)) { const eq = this.equipment.get(itemId)! this.equipment.delete(itemId) - const slot = eq.bodyLoc || eq.equippedSlot || 0 + const slot = eq.bodyLoc || eq.equippedSlot || (eq as { equipSlot?: number }).equipSlot || 0 if (this.equipmentBySlot.get(slot)?.id === itemId) { this.equipmentBySlot.delete(slot) } @@ -111,7 +111,7 @@ export class ClientInventoryManager implements ClientItemContainers { const m = this.merc.get(itemId) this.merc.delete(itemId) if (m) { - const slot = m.bodyLoc || m.equippedSlot || 0 + const slot = m.bodyLoc || m.equippedSlot || (m as { equipSlot?: number }).equipSlot || 0 if (this.mercBySlot.get(slot)?.id === itemId) { this.mercBySlot.delete(slot) } @@ -205,11 +205,16 @@ export class ClientInventoryManager implements ClientItemContainers { this.cube.set(stored.id, stored) break case 'equipment': { - this.equipment.set(stored.id, stored) - const slot = stored.bodyLoc || stored.equippedSlot || 0 + const slot = stored.bodyLoc || stored.equippedSlot || (stored as { equipSlot?: number }).equipSlot || 0 if (slot > 0) { + const prevInSlot = this.equipmentBySlot.get(slot) + if (prevInSlot && prevInSlot.id !== stored.id) { + this.equipment.delete(prevInSlot.id) + this.allById.delete(prevInSlot.id) + } this.equipmentBySlot.set(slot, stored) } + this.equipment.set(stored.id, stored) break } case 'belt': { @@ -224,11 +229,16 @@ export class ClientInventoryManager implements ClientItemContainers { break } case 'merc': { - this.merc.set(stored.id, stored) - const mSlot = stored.bodyLoc || stored.equippedSlot || 0 + const mSlot = stored.bodyLoc || stored.equippedSlot || (stored as { equipSlot?: number }).equipSlot || 0 if (mSlot > 0) { + const prevInSlot = this.mercBySlot.get(mSlot) + if (prevInSlot && prevInSlot.id !== stored.id) { + this.merc.delete(prevInSlot.id) + this.allById.delete(prevInSlot.id) + } this.mercBySlot.set(mSlot, stored) } + this.merc.set(stored.id, stored) break } case 'shop': diff --git a/src/common/units/char-anim-speed.ts b/src/common/units/char-anim-speed.ts new file mode 100644 index 0000000..d500341 --- /dev/null +++ b/src/common/units/char-anim-speed.ts @@ -0,0 +1,578 @@ +/** + * Diablo II v1.13c Character Animation Speed & One-Shot Lifecycle Math. + * + * Ground Truth (`D2Common.dll` / `D2Client.dll` + `data/global/AnimData.d2` + `Weapons.txt`): + * - Engine simulation rate is 25Hz (40ms per tick). + * - `AnimData.d2` stores per-clip `frames` (frames per direction) and `speed` in 8.8 fixed-point + * where `256` = 1.0 frame per 40ms tick. + * - Diminishing returns on item FCR / IAS / FHR: + * `diminished = floor(120 * rate / (120 + rate))` (for `rate > 0`; clamped to `[-85, 75]`). + * - Spell casting (`SC`, mode 10): + * `EFCR = clamp(diminished(fcr), -85, 75)` + * - Weapon attacks (`A1` / `A2`, modes 7 / 8): + * `EIAS = clamp(diminished(itemIas) + skillIas - weaponWsm, -85, 75)` + * where `weaponWsm` is the equipped weapon's `speed` column from 1.13c `Weapons.txt`. + * - Get Hit recovery (`GH`, mode 4): + * `EFHR = clamp(diminished(fhr), -85, 75)` + * - Effective 8.8 fixed-point step speed: + * `stepSpeed256 = max(32, floor(baseSpeed * (100 + effRate) / 100))` + * - One-shot animation duration: + * `ticks = ceil(frames * 256 / stepSpeed256)` + * `durationMs = ticks * 40` + */ + +import { RAW_WEAPONS } from '../data/canonical-drop-data.ts' +import { parseWeaponsTable, type WeaponBase, type WeaponTable } from '../items/weapons.ts' + +/** 1.13c simulation tick duration in milliseconds (25Hz). */ +export const D2_TICK_MS = 40 + +/** 1.13c `ItemStatCost.txt` stat IDs for animation rate modifiers. */ +export const STAT_SKILL_IAS = 68 +export const STAT_FASTERATTACKRATE = 93 +export const STAT_FASTERMOVEVELOCITY = 96 +export const STAT_FASTERHITRECOVERY = 99 +export const STAT_FASTERBLOCKRATE = 102 +export const STAT_FASTERCASTRATE = 105 + +export type CharAnimClipKey = + | 'wl' + | 'nu' + | 'rn' + | 'a1' + | 'a2' + | 'sc' + | 'gh' + | 'dt' + | 'dd' + | 'tn' + | 'tw' + +export interface CharAnimClip113c { + readonly frames: number + readonly speed: number + readonly loop: boolean + readonly events: readonly { readonly f: number; readonly c: number }[] +} + +/** + * Canonical 1.13c `AnimData.d2` clip metadata for all 7 playable classes (`0..6`: + * `am, so, ne, pa, ba, dz, ai`), matching `public/assets/characters/char-*.json`. + */ +export const CHAR_ANIM_CLIPS_113C: Readonly< + Record>> +> = { + // 0: Amazon (am) + 0: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 13, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + a2: { frames: 13, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + sc: { frames: 20, speed: 256, loop: false, events: [{ f: 13, c: 1 }] }, + gh: { frames: 6, speed: 256, loop: false, events: [] }, + dt: { frames: 23, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 1: Sorceress (so) + 1: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 16, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + a2: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 14, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + gh: { frames: 8, speed: 256, loop: false, events: [] }, + dt: { frames: 24, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 2: Necromancer (ne) + 2: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 15, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + a2: { frames: 15, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + sc: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + gh: { frames: 7, speed: 256, loop: false, events: [] }, + dt: { frames: 27, speed: 176, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 3: Paladin (pa) + 3: { + wl: { frames: 10, speed: 288, loop: true, events: [] }, + nu: { frames: 8, speed: 184, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 14, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + a2: { frames: 14, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 16, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 28, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 128, loop: true, events: [] }, + tw: { frames: 10, speed: 288, loop: true, events: [] }, + }, + // 4: Barbarian (ba) + 4: { + wl: { frames: 8, speed: 168, loop: true, events: [] }, + nu: { frames: 8, speed: 96, loop: true, events: [] }, + rn: { frames: 8, speed: 216, loop: true, events: [] }, + a1: { frames: 12, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + a2: { frames: 12, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + sc: { frames: 14, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 27, speed: 240, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 5: Druid (dz) + 5: { + wl: { frames: 8, speed: 136, loop: true, events: [] }, + nu: { frames: 6, speed: 120, loop: true, events: [] }, + rn: { frames: 8, speed: 168, loop: true, events: [] }, + a1: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + a2: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 15, speed: 208, loop: false, events: [{ f: 8, c: 1 }] }, + gh: { frames: 7, speed: 256, loop: false, events: [] }, + dt: { frames: 25, speed: 176, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 96, loop: true, events: [] }, + tw: { frames: 8, speed: 128, loop: true, events: [] }, + }, + // 6: Assassin (ai) + 6: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 11, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + a2: { frames: 12, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + sc: { frames: 17, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 20, speed: 208, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 12, speed: 64, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, +} + +const TOKEN_TO_CLASS_ID: Readonly> = { + am: 0, + ama: 0, + amazon: 0, + so: 1, + sor: 1, + sorceress: 1, + ne: 2, + nec: 2, + necromancer: 2, + pa: 3, + pal: 3, + paladin: 3, + ba: 4, + bar: 4, + barbarian: 4, + dz: 5, + dru: 5, + druid: 5, + ai: 6, + ass: 6, + asn: 6, + assassin: 6, +} + +const PLAYER_MODE_NUM_TO_CLIP: Readonly> = { + 0: 'dt', + 1: 'nu', + 2: 'wl', + 3: 'rn', + 4: 'gh', + 5: 'tn', + 6: 'tw', + 7: 'a1', + 8: 'a2', + 9: 'a1', + 10: 'sc', + 17: 'dd', +} + +/** + * Resolves the canonical 1.13c `AnimData.d2` clip metadata for a given character class + * (`0..6` or token `'am'|'so'|'ne'|'pa'|'ba'|'dz'|'ai'`) and mode (`0..17` or clip key). + */ +export function getCharAnimClip113c( + classIdOrToken: number | string, + modeOrClipKey: number | string, +): CharAnimClip113c { + const classId = + typeof classIdOrToken === 'number' + ? classIdOrToken >= 0 && classIdOrToken <= 6 + ? Math.trunc(classIdOrToken) + : 1 + : (TOKEN_TO_CLASS_ID[classIdOrToken.trim().toLowerCase()] ?? 1) + const classClips = CHAR_ANIM_CLIPS_113C[classId] ?? CHAR_ANIM_CLIPS_113C[1]! + + const clipKey: CharAnimClipKey = + typeof modeOrClipKey === 'number' + ? (PLAYER_MODE_NUM_TO_CLIP[Math.trunc(modeOrClipKey)] ?? 'nu') + : ((modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) in classClips + ? (modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) + : 'nu') + + return classClips[clipKey] ?? classClips.nu +} + +/** + * 1.13c diminishing returns formula (`D2Common.dll`): + * `floor(120 * rate / (120 + rate))` for `rate > 0`. + */ +export function computeDiminishingRate113c(rate: number): number { + if (!Number.isFinite(rate)) return 0 + if (rate <= 0) return Math.max(-85, Math.trunc(rate)) + return Math.trunc((120 * rate) / (120 + rate)) +} + +/** + * Effective Faster Cast Rate (`EFCR = clamp(diminished(fcr), -85, 75)`). + */ +export function computeEffectiveFcr(fcr: number): number { + return Math.max(-85, Math.min(75, computeDiminishingRate113c(fcr))) +} + +/** + * Effective Increased Attack Speed (`EIAS = clamp(diminished(itemIas) + skillIas - weaponWsm, -85, 75)`). + * Accepts either an options object `{ itemIas, skillIas, weaponWsm }` or positional `(itemIas, skillIas, weaponWsm)`. + */ +export function computeEffectiveIas( + input: + | { + readonly itemIas: number + readonly skillIas?: number | undefined + readonly weaponWsm?: number | undefined + } + | number, + skillIasArg = 0, + weaponWsmArg = 0, +): number { + const itemIas = typeof input === 'number' ? input : input.itemIas + const skillIas = typeof input === 'number' ? skillIasArg : (input.skillIas ?? 0) + const weaponWsm = typeof input === 'number' ? weaponWsmArg : (input.weaponWsm ?? 0) + const dimIas = computeDiminishingRate113c(itemIas) + const safeSkillIas = Number.isFinite(skillIas) ? Math.trunc(skillIas) : 0 + const safeWsm = Number.isFinite(weaponWsm) ? Math.trunc(weaponWsm) : 0 + return Math.max(-85, Math.min(75, dimIas + safeSkillIas - safeWsm)) +} + +/** + * Effective Faster Hit Recovery (`EFHR = clamp(diminished(fhr), -85, 75)`). + */ +export function computeEffectiveFhr(fhr: number): number { + return Math.max(-85, Math.min(75, computeDiminishingRate113c(fhr))) +} + +/** + * Effective 8.8 fixed-point animation step speed: + * `stepSpeed256 = max(32, floor(baseSpeed * (100 + effRate) / 100))`. + */ +export function computeStepSpeed256(baseSpeed: number, effRate = 0): number { + const safeBase = Number.isFinite(baseSpeed) && baseSpeed > 0 ? baseSpeed : 256 + const safeRate = Number.isFinite(effRate) ? effRate : 0 + return Math.max(32, Math.floor((safeBase * (100 + safeRate)) / 100)) +} + +/** + * Total 25Hz simulation ticks for a one-shot animation: + * `ceil(frames * 256 / stepSpeed256)`. + */ +export function computeOneShotTicks(frames: number, stepSpeed256: number): number { + const safeFrames = Math.max(1, Math.trunc(Number.isFinite(frames) ? frames : 1)) + const safeSpeed = Math.max(32, Math.trunc(Number.isFinite(stepSpeed256) ? stepSpeed256 : 256)) + return Math.max(1, Math.ceil((safeFrames * 256) / safeSpeed)) +} + +/** + * Total duration in milliseconds for a one-shot animation at 25Hz (40ms per tick): + * `ceil(frames * 256 / stepSpeed256) * 40`. + */ +export function computeOneShotDurationMs(frames: number, stepSpeed256: number): number { + return computeOneShotTicks(frames, stepSpeed256) * D2_TICK_MS +} + +let cachedWeaponsTable: WeaponTable | null = null + +/** + * Lazily parses and memoizes the canonical 1.13c `Weapons.txt` table (`RAW_WEAPONS`). + */ +export function getCanonicalWeaponsTable(): WeaponTable { + if (!cachedWeaponsTable) { + cachedWeaponsTable = parseWeaponsTable(RAW_WEAPONS) + } + return cachedWeaponsTable +} + +/** + * Resolves a weapon record from canonical 1.13c `Weapons.txt` by item code. + */ +export function resolveWeaponBase113c(code: string | undefined | null): WeaponBase | undefined { + if (!code) return undefined + const trimmed = code.trim() + if (!trimmed) return undefined + return getCanonicalWeaponsTable().get(trimmed) +} + +/** + * Resolves the 1.13c Weapon Speed Modifier (`WSM`, `speed` column in `Weapons.txt`) for an item code. + * Unarmed (`hth`) or non-weapon codes return `0`. + */ +export function resolveWeaponWsm(code: string | undefined | null): number { + const weapon = resolveWeaponBase113c(code) + return weapon ? weapon.speed : 0 +} + +export interface EquippedItemStatLike { + readonly statId?: number | undefined + readonly id?: number | undefined + readonly param?: number | undefined + readonly value: number +} + +export interface EquippedItemLike { + readonly id?: number | undefined + readonly code?: string | undefined + readonly equippedSlot?: number | undefined + readonly equipSlot?: number | undefined + readonly bodyLoc?: number | undefined + readonly broken?: boolean | undefined + readonly inSocket?: boolean | undefined + readonly stats?: readonly EquippedItemStatLike[] | undefined + readonly runewordStats?: readonly EquippedItemStatLike[] | undefined + readonly setBonusStats?: readonly (readonly EquippedItemStatLike[])[] | undefined +} + +export interface StatListReaderLike { + get(statId: number, param?: number): number + has?(statId: number, param?: number): boolean +} + +/** + * Resolves the effective Weapon Speed Modifier (`WSM`) across equipped items. + * Prefers primary/secondary weapon arm slots (4 = right arm, 5 = left arm); if dual-wielding two + * weapons in slots 4 and 5, averages their WSM (`trunc((wsm4 + wsm5) / 2)`). + */ +export function resolveEquippedWeaponWsm( + equipment?: Iterable | null, +): number { + if (!equipment) return 0 + let slot4Wsm: number | undefined + let slot5Wsm: number | undefined + let fallbackWsm: number | undefined + + for (const item of equipment) { + if (!item || item.broken || item.inSocket) continue + const weapon = resolveWeaponBase113c(item.code) + if (!weapon) continue + const slot = item.equippedSlot ?? item.equipSlot ?? item.bodyLoc ?? 0 + if (slot === 4 && slot4Wsm === undefined) { + slot4Wsm = weapon.speed + } else if (slot === 5 && slot5Wsm === undefined) { + slot5Wsm = weapon.speed + } else if (fallbackWsm === undefined) { + fallbackWsm = weapon.speed + } + } + + if (slot4Wsm !== undefined && slot5Wsm !== undefined) { + return Math.trunc((slot4Wsm + slot5Wsm) / 2) + } + if (slot4Wsm !== undefined) return slot4Wsm + if (slot5Wsm !== undefined) return slot5Wsm + return fallbackWsm ?? 0 +} + +function sumItemStatList( + stats: readonly EquippedItemStatLike[] | undefined, + targetStatId: number, +): number { + if (!stats || stats.length === 0) return 0 + let total = 0 + for (const s of stats) { + const id = s.statId ?? s.id + if (id === targetStatId && Number.isFinite(s.value)) { + total += s.value + } + } + return total +} + +/** + * Resolves the total value of `statId` for a character from both `StatList` (`unitStats` / `selfStats`) + * and equipped items (`equipment` + socketed children in `sockets`). + */ +export function resolveCharacterStatTotal( + statId: number, + sources: { + readonly unitStats?: StatListReaderLike | null | undefined + readonly selfStats?: StatListReaderLike | null | undefined + readonly equipment?: Iterable | null | undefined + readonly sockets?: ReadonlyMap | null | undefined + }, +): number { + const unitVal = sources.unitStats ? sources.unitStats.get(statId) : 0 + const selfVal = sources.selfStats ? sources.selfStats.get(statId) : 0 + // `StatSet` writes the same wire value to both `self.stats` and `selfUnit.stats`. + // Use `unitVal` if non-zero, otherwise `selfVal` (e.g., if `StatSet` arrived before `UnitAssign`). + const baseStatVal = unitVal !== 0 ? unitVal : selfVal + + let equipTotal = 0 + const visitedIds = new Set() + + const addFromItem = (item: EquippedItemLike, includeRunewordAndSet: boolean): void => { + if (!item || item.broken) return + if (item.id !== undefined) { + if (visitedIds.has(item.id)) return + visitedIds.add(item.id) + } + equipTotal += sumItemStatList(item.stats, statId) + if (includeRunewordAndSet) { + equipTotal += sumItemStatList(item.runewordStats, statId) + if (item.setBonusStats) { + for (const group of item.setBonusStats) { + equipTotal += sumItemStatList(group, statId) + } + } + } + } + + if (sources.equipment) { + for (const item of sources.equipment) { + if (!item || item.broken || item.inSocket) continue + addFromItem(item, true) + if (item.id !== undefined && sources.sockets) { + const children = sources.sockets.get(item.id) + if (children) { + for (const child of children) { + addFromItem(child, false) + } + } + } + } + } + + return baseStatVal + equipTotal +} + +export interface CharacterAnimSpeedParams { + readonly clipKey: CharAnimClipKey + readonly frames: number + readonly baseSpeed: number + readonly loop: boolean + readonly effRate: number + readonly stepSpeed256: number + readonly durationMs: number + readonly fcr: number + readonly itemIas: number + readonly skillIas: number + readonly weaponWsm: number + readonly fhr: number +} + +/** + * Resolves full 1.13c animation speed parameters (`frames`, `baseSpeed`, `effRate`, `stepSpeed256`, + * `durationMs`) for a character class and animation mode given their stats and equipped items. + */ +export function resolveCharacterAnimSpeedParams( + classIdOrToken: number | string, + modeOrClipKey: number | string, + sources: { + readonly unitStats?: StatListReaderLike | null | undefined + readonly selfStats?: StatListReaderLike | null | undefined + readonly equipment?: Iterable | null | undefined + readonly sockets?: ReadonlyMap | null | undefined + readonly weaponCode?: string | null | undefined + readonly weaponWsmOverride?: number | null | undefined + } = {}, +): CharacterAnimSpeedParams { + const clip = getCharAnimClip113c(classIdOrToken, modeOrClipKey) + const clipKey: CharAnimClipKey = + typeof modeOrClipKey === 'number' + ? (PLAYER_MODE_NUM_TO_CLIP[Math.trunc(modeOrClipKey)] ?? 'nu') + : ((modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) || 'nu') + + const equipmentList = sources.equipment ? Array.from(sources.equipment) : undefined + const normalizedSources = { + ...sources, + equipment: equipmentList, + } + + const fcr = resolveCharacterStatTotal(STAT_FASTERCASTRATE, normalizedSources) + const itemIas = resolveCharacterStatTotal(STAT_FASTERATTACKRATE, normalizedSources) + const skillIas = resolveCharacterStatTotal(STAT_SKILL_IAS, normalizedSources) + const fhr = resolveCharacterStatTotal(STAT_FASTERHITRECOVERY, normalizedSources) + const weaponWsm = + sources.weaponWsmOverride !== undefined && sources.weaponWsmOverride !== null + ? sources.weaponWsmOverride + : sources.weaponCode + ? resolveWeaponWsm(sources.weaponCode) + : resolveEquippedWeaponWsm(equipmentList) + + let effRate = 0 + if (clipKey === 'sc') { + effRate = computeEffectiveFcr(fcr) + } else if (clipKey === 'a1' || clipKey === 'a2') { + effRate = computeEffectiveIas({ itemIas, skillIas, weaponWsm }) + } else if (clipKey === 'gh') { + effRate = computeEffectiveFhr(fhr) + } + + const stepSpeed256 = computeStepSpeed256(clip.speed, effRate) + const durationMs = computeOneShotDurationMs(clip.frames, stepSpeed256) + + return { + clipKey, + frames: clip.frames, + baseSpeed: clip.speed, + loop: clip.loop, + effRate, + stepSpeed256, + durationMs, + fcr, + itemIas, + skillIas, + weaponWsm, + fhr, + } +} + +/** + * Computes the 0-based animation frame index at `nowMs` according to 1.13c 25Hz (40ms) fixed-point + * progression (`stepSpeed256` where `256` = 1 frame per 40ms tick): + * - For non-looping clips (`loop === false`, e.g. `sc`, `a1`, `a2`, `gh`, `dt`, `dd`), starts at + * frame `0` at `startedAtMs` and clamps at `frameCount - 1` (never wraps via `% frameCount`). + * - For looping clips (`loop === true`, e.g. `nu`, `wl`, `rn`, `tn`, `tw`), wraps cleanly via `% frameCount`. + */ +export function computeAnimFrameIndex(params: { + readonly frameCount: number + readonly stepSpeed256: number + readonly loop: boolean + readonly nowMs: number + readonly startedAtMs?: number | undefined +}): number { + const frameCount = Math.max(1, Math.trunc(params.frameCount || 1)) + if (frameCount <= 1) return 0 + const stepSpeed256 = Math.max(32, Math.trunc(params.stepSpeed256 || 256)) + const elapsedMs = + params.startedAtMs !== undefined && Number.isFinite(params.startedAtMs) + ? Math.max(0, params.nowMs - params.startedAtMs) + : Math.max(0, params.nowMs) + const rawFrame = Math.floor(((elapsedMs / D2_TICK_MS) * stepSpeed256) / 256) + if (!params.loop) { + return Math.max(0, Math.min(frameCount - 1, rawFrame)) + } + return ((rawFrame % frameCount) + frameCount) % frameCount +} diff --git a/tests/client/char-anim-speed-loop.test.ts b/tests/client/char-anim-speed-loop.test.ts new file mode 100644 index 0000000..1093195 --- /dev/null +++ b/tests/client/char-anim-speed-loop.test.ts @@ -0,0 +1,645 @@ +import { describe, expect, it } from 'vitest' +import { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveWeaponWsm, +} from '../../src/common/units/char-anim-speed.ts' +import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts' +import type { AnimClipMeta } from '../../src/client/render/actor-animator.ts' +import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts' +import { + clearMissingArtEntries, + renderSceneSourceFrame, +} from '../../src/client/scene/world-renderer.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import type { SpriteRenderer } from '../../src/client/render/renderer.ts' +import type { SceneFrameSnapshot, SceneSource } from '../../src/client/view/scene-source.ts' +import { buildSceneDrawables, type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' +import { getViewportProfile } from '../../src/client/view/viewport-profile.ts' +import type { ClientUnit } from '../../src/client/world/client-unit.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' + +type MockFrame = LoadedCharacter['groups'][number][number] + +function toRenderableUnit(unit: ClientUnit): RenderableUnitInput { + return { + id: unit.id, + kind: unit.unitType, + classId: unit.classId, + x: unit.x, + y: unit.y, + ...(unit.targetX !== undefined ? { targetX: unit.targetX } : {}), + ...(unit.targetY !== undefined ? { targetY: unit.targetY } : {}), + mode: unit.mode, + direction: unit.direction, + lifePct: unit.lifePct, + ...(unit.castState !== undefined ? { castState: unit.castState } : {}), + ...(unit.modeStartedAtMs !== undefined ? { animStartedAtMs: unit.modeStartedAtMs } : {}), + ...(unit.animStepSpeed256 !== undefined ? { animStepSpeed256: unit.animStepSpeed256 } : {}), + ...(unit.animEffRate !== undefined ? { animEffRate: unit.animEffRate } : {}), + states: Array.from(unit.states.list()), + } +} + +function createMockCharacterArt(classId = 1): LoadedCharacter { + const canonical = CHAR_ANIM_CLIPS_113C[classId] ?? CHAR_ANIM_CLIPS_113C[1]! + const groups: MockFrame[][] = [] + const clips: Record = {} + const numDirs = 16 + + let groupCursor = 0 + for (const [clipKey, spec] of Object.entries(canonical)) { + clips[clipKey] = { + group: groupCursor, + frames: spec.frames, + directions: numDirs, + speed: spec.speed, + loop: spec.loop, + events: [], + } + for (let dir = 0; dir < numDirs; dir++) { + const dirFrames: MockFrame[] = [] + for (let f = 0; f < spec.frames; f++) { + dirFrames.push({ + x: (groupCursor + dir) * 1000 + f, + y: f, + width: 48, + height: 72, + anchorX: -24, + anchorY: -64, + }) + } + groups.push(dirFrames) + } + groupCursor += numDirs + } + + return { + handle: 1 as unknown as LoadedCharacter['handle'], + stand: clips.nu!.group, + walk: clips.wl!.group, + directions: numDirs, + members: 1, + clips, + groups, + } +} + +function createSpyRenderer(): { + readonly renderer: SpriteRenderer + readonly drawnFrames: MockFrame[] +} { + const drawnFrames: MockFrame[] = [] + const renderer = { + begin: () => {}, + flush: () => {}, + setPalette: () => {}, + setLighting: () => {}, + addIndexedAtlas: () => 1 as any, + draw: (frame: MockFrame) => { + drawnFrames.push(frame) + }, + } as unknown as SpriteRenderer + return { renderer, drawnFrames } +} + +describe('1.13c character animation speed & one-shot lifecycle (#552)', () => { + it('computes 1.13c AnimData.d2 diminishing returns, EFCR/EIAS/EFHR, stepSpeed256, and durationMs', () => { + expect(computeDiminishingRate113c(0)).toBe(0) + expect(computeDiminishingRate113c(20)).toBe(17) + expect(computeDiminishingRate113c(40)).toBe(30) + expect(computeDiminishingRate113c(63)).toBe(41) + expect(computeDiminishingRate113c(105)).toBe(56) + expect(computeDiminishingRate113c(120)).toBe(60) + expect(computeDiminishingRate113c(200)).toBe(75) + expect(computeDiminishingRate113c(-30)).toBe(-30) + + expect(computeEffectiveFcr(0)).toBe(0) + expect(computeEffectiveFcr(105)).toBe(56) + expect(computeEffectiveFcr(500)).toBe(75) + + // Phase Blade (WSM -30) + 40% item IAS (diminished = 30) + 20% Fanaticism -> min(75, 30 + 20 - (-30)) = 75 + expect(computeEffectiveIas(40, 20, -30)).toBe(75) + // Great Maul (WSM +20) + 0% IAS -> -20 + expect(computeEffectiveIas(0, 0, 20)).toBe(-20) + + expect(computeEffectiveFhr(0)).toBe(0) + expect(computeEffectiveFhr(86)).toBe(50) + + // Sorceress SC: 14 frames, baseSpeed = 256 + const sorcSc = getCharAnimClip113c(1, 'sc')! + expect(sorcSc.frames).toBe(14) + expect(sorcSc.speed).toBe(256) + expect(sorcSc.loop).toBe(false) + + const step0 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(0)) + expect(step0).toBe(256) + expect(computeOneShotTicks(sorcSc.frames, step0)).toBe(14) + expect(computeOneShotDurationMs(sorcSc.frames, step0)).toBe(560) + + const step105 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(105)) + expect(step105).toBe(399) + expect(computeOneShotTicks(sorcSc.frames, step105)).toBe(9) + expect(computeOneShotDurationMs(sorcSc.frames, step105)).toBe(360) + }) + + it('resolves 1.13c weapon WSM from Weapons.txt codes', () => { + expect(resolveWeaponWsm('7cr')).toBe(-30) // Phase Blade + expect(resolveWeaponWsm('hax')).toBe(0) // Hand Axe + expect(resolveWeaponWsm('axe')).toBe(10) // Axe + expect(resolveWeaponWsm('mau')).toBe(10) // Maul + expect(resolveWeaponWsm('gma')).toBe(20) // Great Maul + }) + + it('drives spell cast speed and castState duration from FCR in StatList and equipped items', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, // Sorceress + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // Cast Fireball (skillId = 47) at 0% FCR + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5020, y: 5000 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + expect(unit.mode).toBe(10) + expect(unit.castState).toBeDefined() + expect(unit.castState!.stepSpeed256).toBe(256) + expect(unit.castState!.durationMs).toBe(560) + expect(unit.castState!.untilMs).toBe(1560) + + // Equip a weapon + shield totaling 105% FCR (statId = 105) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 101, + code: 'obf', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [{ id: 105, value: 40 }], + runewordStats: [{ id: 105, value: 35 }], + } as any, + }) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 102, + code: 'uit', + container: 'equipment', + equipSlot: 5, + x: 0, + y: 0, + stats: [{ id: 105, value: 30 }], + } as any, + }) + + // Cast Fireball at 105% FCR + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5020, y: 5000 }, + }, + 2000, + ) + + expect(unit.castState).toBeDefined() + expect(unit.castState!.effRate).toBe(56) + expect(unit.castState!.stepSpeed256).toBe(399) + expect(unit.castState!.durationMs).toBe(360) + expect(unit.castState!.untilMs).toBe(2360) + }) + + it('drives melee attack speed and duration from IAS, skill IAS, and equipped weapon WSM', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 3, // Paladin (A1: 14 frames, baseSpeed = 256) + name: 'Pala', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // 1) Equip slow Great Maul ('gma', WSM = +20) with 0% IAS + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 201, + code: 'gma', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [], + } as any, + }) + + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 0, // Normal Attack -> mode 7 (A1) + level: 1, + target: { x: 5005, y: 5000 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + expect(unit.mode).toBe(7) + expect(unit.castState!.effRate).toBe(-20) + expect(unit.castState!.stepSpeed256).toBe(204) + expect(unit.castState!.durationMs).toBe(720) + + // 2) Replace with Phase Blade ('7cr', WSM = -30, +40% IAS) + 20% skill IAS (statId 68) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 202, + code: '7cr', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [{ id: 93, value: 40 }], + } as any, + }) + world.apply({ type: 'StatSet', stat: 68, value: 20, param: 0 }, 2000) + + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 0, + level: 1, + target: { x: 5005, y: 5000 }, + }, + 2000, + ) + + expect(unit.castState!.effRate).toBe(75) + expect(unit.castState!.stepSpeed256).toBe(448) + expect(unit.castState!.durationMs).toBe(320) + expect(unit.castState!.untilMs).toBe(2320) + }) + + it('clamps non-looping one-shot clips at frameCount - 1 without modulo wrapping and wraps looping clips', () => { + // Non-looping 14-frame clip at stepSpeed256 = 256 (1 frame per 40ms) + const framesVisited: number[] = [] + for (let t = 0; t <= 1200; t += 40) { + framesVisited.push( + computeAnimFrameIndex({ + frameCount: 14, + stepSpeed256: 256, + loop: false, + nowMs: 1000 + t, + startedAtMs: 1000, + }), + ) + } + expect(framesVisited[0]).toBe(0) + expect(framesVisited[1]).toBe(1) + expect(framesVisited[13]).toBe(13) + // After 13 * 40 = 520ms, all subsequent samples stay clamped at 13 (never wrap to 0) + for (let i = 13; i < framesVisited.length; i++) { + expect(framesVisited[i]).toBe(13) + } + + // Looping 8-frame clip wraps around + expect( + computeAnimFrameIndex({ + frameCount: 8, + stepSpeed256: 256, + loop: true, + nowMs: 8 * 40, + }), + ).toBe(0) + expect( + computeAnimFrameIndex({ + frameCount: 8, + stepSpeed256: 256, + loop: true, + nowMs: 9 * 40, + }), + ).toBe(1) + }) + + it('expires castState back to mode 1 (NU) without moving caster position, and interrupts cast on UnitMove', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5100, + y: 5100, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // Cast ranged spell toward (5200, 5100) + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5200, y: 5100 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0) + expect(unit.mode).toBe(10) + expect(unit.direction).toBe(castDir) + + // Mid-cast tick at t = 1280ms (280ms elapsed < 560ms duration) + world.tick(280, 1280) + expect(unit.x).toBe(5100) + expect(unit.y).toBe(5100) + expect(unit.castState).toBeDefined() + expect(unit.mode).toBe(10) + + const midDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables + expect(midDrawables[0]!.mode).toBe(10) + expect(midDrawables[0]!.animMode).toBe('SC') + expect(midDrawables[0]!.direction).toBe(castDir) + + // Tick past cast completion at t = 1560ms (560ms elapsed) + world.tick(280, 1560) + expect(unit.castState).toBeUndefined() + expect(world.self.castState).toBeUndefined() + expect(unit.mode).toBe(1) + expect(unit.direction).toBe(castDir) + + const afterDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables + expect(afterDrawables[0]!.mode).toBe(1) + expect(afterDrawables[0]!.animMode).toBeUndefined() + expect(afterDrawables[0]!.direction).toBe(castDir) + + // Start another cast at t = 2000ms and interrupt it with UnitMove at t = 2100ms + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5200, y: 5100 }, + }, + 2000, + ) + expect(unit.castState).toBeDefined() + expect(unit.mode).toBe(10) + + world.apply( + { + type: 'UnitMove', + unitType: 'player', + id: 1, + mode: 'run', + from: { x: 5100, y: 5100 }, + to: { x: 5120, y: 5120 }, + }, + 2100, + ) + expect(unit.castState).toBeUndefined() + expect(world.self.castState).toBeUndefined() + expect(unit.mode).toBe(3) + }) + + it('preserves local player cast facing direction on OnlineSession after cast expires into NU and clears cast on movement', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }) + + session.handleServerEvent({ + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5040, y: 5020 }, + }) + + const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0) + const duringUnits = session.buildRenderableUnits() + const selfDuring = duringUnits.find((u) => u.isLocalPlayer)! + expect(selfDuring.mode).toBe(10) + expect(selfDuring.direction).toBe(expectedDir) + expect(selfDuring.castState).toBeDefined() + + // Advance 600ms via session.tick so the 560ms Sorceress cast finishes + for (let i = 1; i <= 15; i++) { + session.tick(40, i * 40) + } + + const afterUnits = session.buildRenderableUnits() + const selfAfter = afterUnits.find((u) => u.isLocalPlayer)! + expect(selfAfter.mode).toBe(1) // Returned to NU (idle) + expect(selfAfter.castState).toBeUndefined() + expect(selfAfter.direction).toBe(expectedDir) // Still facing cast target! + + session.destroy() + }) + + it('renders non-looping one-shot clip frames once (clamped at last frame) and switches back to NU clip after castState expires', () => { + clearMissingArtEntries() + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, // Sorceress + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5030, y: 5000 }, + }, + 1000, + ) + + const charArt = createMockCharacterArt(1) + const characterArtByToken = new Map([['so', charArt]]) + const { renderer, drawnFrames } = createSpyRenderer() + const vp = getViewportProfile('800x600') + + const makeSource = (): SceneSource => ({ + isWorldReady: () => true, + getLevelView: () => null, + getViewportProfile: () => vp, + setViewportProfile: () => vp, + buildLightingInputs: () => ({}) as any, + buildFrameSnapshot: (): SceneFrameSnapshot => { + const unit = world.getUnit('player', 1)! + const entities = buildSceneDrawables([ + { + id: unit.id, + kind: 'player', + classId: unit.classId, + x: unit.x, + y: unit.y, + mode: unit.mode, + direction: unit.direction, + castState: unit.castState, + animStartedAtMs: unit.castState?.startedAtMs ?? unit.modeStartedAtMs, + animStepSpeed256: unit.castState?.stepSpeed256 ?? unit.animStepSpeed256, + animEffRate: unit.castState?.effRate ?? unit.animEffRate, + isLocalPlayer: true, + }, + ]) + return { + tick: 0, + worldReady: true, + actId: 1, + levelId: 1, + viewportProfile: vp, + playerSubTile: { x: 5000, y: 5000 }, + playerLocalSubTile: { x: 5000, y: 5000 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities, + } + }, + }) as unknown as SceneSource + + const source = makeSource() + const scClip = charArt.clips!.sc! + const nuClip = charArt.clips!.nu! + const unit = world.getUnit('player', 1)! + const clipDir = dir64ToClipDirection(unit.direction, scClip.directions ?? 16) + const expectedScGroup = scClip.group + clipDir + const expectedNuGroup = nuClip.group + clipDir + + // At t = 1000ms (cast start), renders SC frame 0 + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1000, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 0) + expect(drawnFrames.at(-1)!.y).toBe(0) + + // At t = 1200ms (5 ticks into cast), renders SC frame 5 + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1200, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 5) + expect(drawnFrames.at(-1)!.y).toBe(5) + + // Even if world.tick hasn't been called yet at t = 1800ms, the one-shot SC clip clamps at frame 13 (never wraps to 0) + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1800, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 13) + expect(drawnFrames.at(-1)!.y).toBe(13) + + // Once world.tick(600, 1600) expires castState, unit returns to NU clip while preserving clipDir + world.tick(600, 1600) + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1600, + }) + const finalFrame = drawnFrames.at(-1)! + expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10) + }) +})