587 lines
21 KiB
TypeScript
587 lines
21 KiB
TypeScript
/**
|
|
* 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',
|
|
11: 'a1', // TH (Throw)
|
|
12: 'a1', // KK (Kick)
|
|
13: 'a1', // S1 (Skill 1)
|
|
14: 'sc', // S2 (Skill 2)
|
|
15: 'a1', // S3 (Skill 3)
|
|
16: 'a1', // S4 (Skill 4)
|
|
17: 'dd',
|
|
18: 'a1', // SQ (Sequence)
|
|
19: 'gh', // KB (Knock back, token GH)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|