fix(client): drive 1.13c character animation speed from FCR/IAS/WSM and stop one-shot cast/attack looping (#552)
This commit is contained in:
parent
4a36b692c0
commit
7bd10903f6
|
|
@ -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<string, MissingArtEntry>()
|
||||
const missingArtList: MissingArtEntry[] = []
|
||||
const dropStartMsByItemId = new Map<number, number>()
|
||||
const oneShotStartMsByUnitKey = new Map<string, TrackedOneShotState>()
|
||||
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>): void {
|
||||
this.apply(ev as ServerEvent)
|
||||
applyEvent(ev: ServerEvent | Record<string, any>, nowMs?: number): void {
|
||||
this.apply(ev as ServerEvent, nowMs)
|
||||
}
|
||||
|
||||
applyServerEvent(ev: ServerEvent | Record<string, any>): void {
|
||||
this.apply(ev as ServerEvent)
|
||||
applyServerEvent(ev: ServerEvent | Record<string, any>, nowMs?: number): void {
|
||||
this.apply(ev as ServerEvent, nowMs)
|
||||
}
|
||||
|
||||
apply(rawEv: ServerEvent | Record<string, any>): void {
|
||||
apply(rawEv: ServerEvent | Record<string, any>, 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<object>()
|
||||
this._blockedSubTiles.clear()
|
||||
this._clockMs = null
|
||||
this._clockExplicit = false
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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<number, Readonly<Record<CharAnimClipKey, CharAnimClip113c>>>
|
||||
> = {
|
||||
// 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<Record<string, number>> = {
|
||||
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<Record<number, CharAnimClipKey>> = {
|
||||
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<EquippedItemLike> | 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<EquippedItemLike> | null | undefined
|
||||
readonly sockets?: ReadonlyMap<number, readonly EquippedItemLike[]> | 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<number>()
|
||||
|
||||
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<EquippedItemLike> | null | undefined
|
||||
readonly sockets?: ReadonlyMap<number, readonly EquippedItemLike[]> | 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
|
||||
}
|
||||
|
|
@ -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<string, AnimClipMeta> = {}
|
||||
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<string, LoadedCharacter>([['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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue