diablo2-web/src/game/engine/anim-dispatcher.ts

311 lines
10 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c `AnimData.d2` Frame Dispatcher (`AnimDispatcher`).
*
* Implements:
* - 1.13c `EIAS` (`floor((120 * IAS) / (120 + IAS)) + skillIas - WSM`, clamped `[-85, 75]`)
* and `EFCR` (`floor((120 * FCR) / (120 + FCR))`, clamped `[-85, 75]`) diminishing returns.
* - `AnimData.d2` 8.8 fixed-point frame advancement (`speed = (animSpeed * (100 + effRate)) / 100`).
* - Action frame flag (`0x01` melee/cast release, `0x02` missile launch) dispatching.
* - `STATE_SKILLDELAY` global cooldown enforcement (`delay` ticks from `Skills.txt`).
* - Shapeshift / weapon `restrict` validation (`restrict=1` any form, `restrict=2` shapeshift only,
* `restrict=0` human form only except Armageddon `249`).
* - Mana (`manaCost256`) and ammo quantity (`decquant`) deduction on the action frame.
*/
import type { D2DataRegistry, SkillRecord } from './data-registry.ts'
import type { AnimDataRecord } from '../../formats/animdata.ts'
import { evaluateCalc, computeSkillManaCost256 } from './calc-ast.ts'
import { UnitStatList } from './stat-list.ts'
import { StateBus } from './state-bus.ts'
/**
* 1.13c Diminishing returns conversion for IAS (`EIAS`) and FCR (`EFCR`):
* `floor((120 * rate) / (120 + rate))`
*/
export function computeDiminishingRate113c(statRate: number): number {
if (statRate <= 0) return Math.max(-85, Math.trunc(statRate))
return Math.trunc((120 * statRate) / (120 + statRate))
}
export function computeEffectiveIas(params: {
itemIas: number
skillIas?: number // e.g. Fanaticism / Burst of Speed / Frenzy / Werewolf
weaponWsm?: number // e.g. -20 for Phase Blade, +10 for Maul
}): number {
const eias = computeDiminishingRate113c(params.itemIas) + (params.skillIas ?? 0) - (params.weaponWsm ?? 0)
return Math.max(-85, Math.min(75, Math.trunc(eias)))
}
export function computeEffectiveFcr(itemFcr: number): number {
const efcr = computeDiminishingRate113c(itemFcr)
return Math.max(-85, Math.min(75, Math.trunc(efcr)))
}
export interface ActiveAnimationState {
readonly skill: SkillRecord
readonly slvl: number
readonly cofName: string
readonly animRecord: AnimDataRecord
readonly framesPerDirection: number
readonly stepSpeed256: number
readonly actionFrame: number
readonly targetX: number
readonly targetY: number
frameAcc256: number
currentFrame: number
actionTriggered: boolean
completed: boolean
}
export interface CastAttemptResult {
readonly allowed: boolean
readonly reason?: 'skill_delay' | 'insufficient_mana' | 'restrict_shapeshift' | 'restrict_human' | 'passive_skill'
readonly animState?: ActiveAnimationState
}
export class AnimDispatcher {
readonly registry: D2DataRegistry
readonly statList: UnitStatList
readonly stateBus: StateBus
readonly charToken: string // 'am' | 'so' | 'ne' | 'pa' | 'bar'->'ba' | 'dz' | 'ai'
readonly weaponClass: string // 'hth' | '1hs' | 'bow' | 'jav' | 'h2h' | 'stf'
private currentAnim: ActiveAnimationState | null = null
private skillDelayExpireFrame = 0
private ammoQuantity = 250
constructor(params: {
registry: D2DataRegistry
statList: UnitStatList
stateBus: StateBus
charToken: string
weaponClass?: string
}) {
this.registry = params.registry
this.statList = params.statList
this.stateBus = params.stateBus
this.charToken = params.charToken.toUpperCase()
this.weaponClass = (params.weaponClass ?? 'HTH').toUpperCase()
}
isSkillDelayActive(currentTick: number): boolean {
return this.skillDelayExpireFrame > currentTick || this.stateBus.hasState('skilldelay')
}
getRemainingSkillDelay(currentTick: number): number {
return Math.max(0, this.skillDelayExpireFrame - currentTick)
}
getAmmoQuantity(): number {
return this.ammoQuantity
}
setAmmoQuantity(qty: number): void {
this.ammoQuantity = Math.max(0, Math.trunc(qty))
}
/**
* Validate shapeshift `restrict` rule from `Skills.txt`:
* - `restrict = 0`: usable in human form only (except `Armageddon` 249 and `Werewolf` 223 / `Werebear` 228 unshift)
* - `restrict = 1`: usable in both human and shapeshift forms
* - `restrict = 2`: usable in shapeshift (`STATE_WOLF` / `STATE_BEAR`) form only!
*/
validateRestriction(skill: SkillRecord): { ok: boolean; reason?: 'restrict_shapeshift' | 'restrict_human' } {
const inWolfOrBear =
this.stateBus.hasState('wolf') ||
this.stateBus.hasState('bear') ||
this.stateBus.hasState('wearwolf') ||
this.stateBus.hasState('wearbear')
if (skill.restrict === 2 && !inWolfOrBear) {
return { ok: false, reason: 'restrict_shapeshift' }
}
if (
skill.restrict === 0 &&
inWolfOrBear &&
skill.id !== 249 && // Armageddon usable in shapeshift
skill.id !== 223 && // Werewolf toggle
skill.id !== 228 && // Werebear toggle
skill.id !== 0 // Attack usable in shapeshift
) {
return { ok: false, reason: 'restrict_human' }
}
return { ok: true }
}
/**
* Initiate a skill animation (`srvstfunc` phase).
*/
startSkillAnimation(params: {
skill: SkillRecord
slvl: number
currentTick: number
targetX?: number
targetY?: number
onActionFrame?: (anim: ActiveAnimationState, tick: number) => void
}): CastAttemptResult {
const { skill, slvl, currentTick } = params
if (skill.passive) {
return { allowed: false, reason: 'passive_skill' }
}
// Check `STATE_SKILLDELAY` cooldown if the skill itself has a `delay` (`cd` skills)
const delayTicks = skill.delay
? evaluateCalc(skill.delay, {
registry: this.registry,
activeSkill: skill,
slvl,
blvl: this.statList.getBaseSkillLevel(skill.id),
})
: 0
if (delayTicks > 0 && this.isSkillDelayActive(currentTick)) {
return { allowed: false, reason: 'skill_delay' }
}
// Check shapeshift `restrict`
const restrictCheck = this.validateRestriction(skill)
if (!restrictCheck.ok) {
return { allowed: false, reason: restrictCheck.reason ?? 'restrict_shapeshift' }
}
// Check mana cost before starting animation
const { manaCost256 } = computeSkillManaCost256(skill, slvl)
if (this.statList.getMana256() < manaCost256 && !skill.nocostinstate) {
return { allowed: false, reason: 'insufficient_mana' }
}
// Resolve `AnimData.d2` COF record (`<TOKEN><MODE><WCLASS>`)
const rawMode = (skill.anim || 'SC').toUpperCase()
const mode = rawMode === 'SQ' ? (skill.seqTrans || 'A1').toUpperCase() : rawMode
const cofCandidates = [
`${this.charToken}${mode}${this.weaponClass}`,
`${this.charToken}${mode}HTH`,
`${this.charToken}${mode}1HS`,
`${this.charToken}SCHTH`,
`${this.charToken}A1HTH`,
]
let animRecord: AnimDataRecord | undefined
let chosenCof = cofCandidates[0]!
for (const cand of cofCandidates) {
const rec = this.registry.getAnimDataRecord(cand)
if (rec && rec.hasSpeed) {
animRecord = rec
chosenCof = cand
break
}
}
if (!animRecord) {
animRecord = {
name: chosenCof,
framesPerDirection: 14,
animationSpeed: 256,
hasSpeed: true,
events: [{ frame: 6, code: 1 }],
block: 0,
}
}
const isCastMode = mode === 'SC'
const effRate = isCastMode
? computeEffectiveFcr(this.statList.getAccruedStat('item_fastercastrate'))
: computeEffectiveIas({
itemIas: this.statList.getAccruedStat('item_fasterattackrate'),
skillIas: this.statList.getModifierBonus('skill_ias'),
weaponWsm: this.statList.getAccruedStat('weapon_wsm'),
})
const stepSpeed256 = Math.max(32, Math.trunc((animRecord.animationSpeed * (100 + effRate)) / 100))
const actionEvt = animRecord.events.find(e => (e.code & (0x01 | 0x02)) !== 0)
const actionFrame = actionEvt
? actionEvt.frame
: Math.max(1, Math.trunc(animRecord.framesPerDirection / 2))
this.currentAnim = {
skill,
slvl,
cofName: chosenCof,
animRecord,
framesPerDirection: animRecord.framesPerDirection,
stepSpeed256,
actionFrame,
targetX: params.targetX ?? 0,
targetY: params.targetY ?? 0,
frameAcc256: 0,
currentFrame: 0,
actionTriggered: false,
completed: false,
}
return {
allowed: true,
animState: this.currentAnim,
}
}
/**
* Advance the active animation by 1 tick (`25 Hz`), firing the action frame (`0x01`/`0x02`)
* when `currentFrame >= actionFrame` and deducting mana / ammo / setting `STATE_SKILLDELAY`.
*/
tick(
currentTick: number,
onActionFrame: (anim: ActiveAnimationState, tick: number) => void,
): { actionFired: boolean; anim: ActiveAnimationState | null } {
if (!this.currentAnim || this.currentAnim.completed) {
return { actionFired: false, anim: this.currentAnim }
}
const anim = this.currentAnim
anim.frameAcc256 += anim.stepSpeed256
anim.currentFrame = Math.trunc(anim.frameAcc256 / 256)
let actionFired = false
if (!anim.actionTriggered && anim.currentFrame >= anim.actionFrame) {
anim.actionTriggered = true
actionFired = true
// Deduct mana cost (`manaCost256`) on the action frame
const { manaCost256 } = computeSkillManaCost256(anim.skill, anim.slvl)
if (!anim.skill.nocostinstate) {
this.statList.setMana256(this.statList.getMana256() - manaCost256)
}
// Deduct ammo quantity (`decquant`) if applicable
if (anim.skill.decquant && this.ammoQuantity > 0) {
this.ammoQuantity -= 1
}
// Apply `STATE_SKILLDELAY` (`delay` ticks in `Skills.txt`, e.g. Hydra 40f, Frozen Orb 25f)
const delayTicks = anim.skill.delay
? evaluateCalc(anim.skill.delay, {
registry: this.registry,
activeSkill: anim.skill,
slvl: anim.slvl,
blvl: this.statList.getBaseSkillLevel(anim.skill.id),
})
: 0
if (delayTicks > 0) {
this.skillDelayExpireFrame = currentTick + delayTicks
this.stateBus.applyState({
stateNameOrId: 'skilldelay',
slvl: 1,
durationFrames: delayTicks,
currentFrame: currentTick,
})
}
onActionFrame(anim, currentTick)
}
if (anim.currentFrame >= anim.framesPerDirection) {
anim.completed = true
}
return { actionFired, anim }
}
getCurrentAnimation(): ActiveAnimationState | null {
return this.currentAnim
}
}