feat(animation): 452 named constants, 30-phase character animation state machine

Add animation_ids.hpp/cpp with all 452 WoW animation ID constants (anim::STAND,
anim::RUN, anim::FIRE_BOW, ... anim::FLY_BACKWARDS, etc.), nameFromId() O(1)
lookup, and flyVariant() compact 218-element ground→FLY_* resolver.

Expand AnimationController into a full state machine with 20+ named states:
spell cast (directed→omni→cast fallback chain, instant one-shot release),
hit reactions (WOUND/CRIT/DODGE/BLOCK/SHIELD_BLOCK), stun, wounded idle,
stealth animation substitution, loot, fishing channel, sit/sleep/kneel
down→loop→up transitions, sheathe/unsheathe combat enter/exit, ranged weapons
(BOW/GUN/CROSSBOW/THROWN with reload states), game object OPEN/CLOSE/DESTROY,
vehicle enter/exit, mount flight directionals (FLY_LEFT/RIGHT/UP/DOWN/BACKWARDS),
emote state variants, off-hand/pierce/dual-wield alternation, NPC
birth/spawn/drown/rise, sprint aura override, totem idle, NPC greeting/farewell.

Add spell_defines.hpp with SpellEffect (~45 constants) and SpellMissInfo
(12 constants) namespaces; replace all magic numbers in spell_handler.cpp.

Add GAMEOBJECT_BYTES_1 to update field table (all 4 expansion JSONs) and wire
GameObjectStateCallback. Add DBC cross-validation on world entry.

Expand tools/_ANIM_NAMES from ~35 to 452 entries in m2_viewer.py and
asset_pipeline_gui.py. Add tests/test_animation_ids.cpp.

Bug fixes included:
- Stand state 1 was animating READY_2H(27) — fixed to SITTING(97)
- Spell casts ended freeze-frame — add one-shot release animation
- NPC 2H swing probe chain missing ATTACK_2H_LOOSE (polearm/staff)
- Chair sits (states 2/4/5/6) incorrectly played floor-sit transition
- STOP(3) used for all spell casts — replaced with model-aware chain
This commit is contained in:
Paul 2026-04-04 23:02:53 +03:00
parent d54e262048
commit e58f9b4b40
59 changed files with 3903 additions and 483 deletions

View file

@ -34,19 +34,19 @@ static float mergeCooldownSeconds(float current, float incoming) {
static CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) {
switch (missInfo) {
case 0: return CombatTextEntry::MISS;
case 1: return CombatTextEntry::DODGE;
case 2: return CombatTextEntry::PARRY;
case 3: return CombatTextEntry::BLOCK;
case 4: return CombatTextEntry::EVADE;
case 5: return CombatTextEntry::IMMUNE;
case 6: return CombatTextEntry::DEFLECT;
case 7: return CombatTextEntry::ABSORB;
case 8: return CombatTextEntry::RESIST;
case 9:
case 10:
case SpellMissInfo::MISS: return CombatTextEntry::MISS;
case SpellMissInfo::DODGE: return CombatTextEntry::DODGE;
case SpellMissInfo::PARRY: return CombatTextEntry::PARRY;
case SpellMissInfo::BLOCK: return CombatTextEntry::BLOCK;
case SpellMissInfo::EVADE: return CombatTextEntry::EVADE;
case SpellMissInfo::IMMUNE: return CombatTextEntry::IMMUNE;
case SpellMissInfo::DEFLECT: return CombatTextEntry::DEFLECT;
case SpellMissInfo::ABSORB: return CombatTextEntry::ABSORB;
case SpellMissInfo::RESIST: return CombatTextEntry::RESIST;
case SpellMissInfo::IMMUNE2:
case SpellMissInfo::IMMUNE3:
return CombatTextEntry::IMMUNE;
case 11: return CombatTextEntry::REFLECT;
case SpellMissInfo::REFLECT: return CombatTextEntry::REFLECT;
default: return CombatTextEntry::MISS;
}
}
@ -939,7 +939,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
}
}
if (isMeleeAbility) {
if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_();
if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_(sid);
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* csm = ac->getCombatSoundManager()) {
csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false);
@ -951,6 +951,14 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
const bool wasInTimedCast = casting_ && (data.spellId == currentCastSpellId_);
// Instant spell cast animation — if this wasn't a timed cast and isn't a
// melee ability, play a brief spell cast animation (one-shot)
if (!wasInTimedCast && !isMeleeAbility && !owner_.isProfessionSpell(data.spellId)) {
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(owner_.playerGuid, true, false);
}
}
LOG_WARNING("[GO-DIAG] SPELL_GO: spellId=", data.spellId,
" casting=", casting_, " currentCast=", currentCastSpellId_,
" wasInTimedCast=", wasInTimedCast,
@ -991,6 +999,13 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
castSpell(nextSpell, nextTarget);
}
} else {
// For non-player casters: if no tracked cast state exists, this was an
// instant cast — play a brief one-shot spell animation before stopping
auto castIt = unitCastStates_.find(data.casterUnit);
bool wasTrackedCast = (castIt != unitCastStates_.end());
if (!wasTrackedCast && owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(data.casterUnit, true, false);
}
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(data.casterUnit, false, false);
}
@ -1181,6 +1196,26 @@ void SpellHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
}
}
}
// Sprint aura detection — check if any sprint/dash speed buff is active
if (data.guid == owner_.playerGuid && owner_.sprintAuraCallback_) {
static const uint32_t sprintSpells[] = {
2983, 8696, 11305, // Rogue Sprint (ranks 1-3)
1850, 9821, 33357, // Druid Dash (ranks 1-3)
36554, // Shadowstep (speed component)
68992, 68991, // Darkflight (worgen racial)
58984, // Aspect of the Pack speed
};
bool hasSprint = false;
for (const auto& a : playerAuras_) {
if (a.isEmpty()) continue;
for (uint32_t sid : sprintSpells) {
if (a.spellId == sid) { hasSprint = true; break; }
}
if (hasSprint) break;
}
owner_.sprintAuraCallback_(hasSprint);
}
}
}
@ -2222,7 +2257,7 @@ void SpellHandler::handleSpellLogMiss(network::Packet& packet) {
// TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count
// + count × (uint64 victim + uint8 missInfo)
// All expansions append uint32 reflectSpellId + uint8 reflectResult when
// missInfo==11 (REFLECT).
// missInfo==REFLECT (11).
const bool spellMissUsesFullGuid = isActiveExpansion("tbc");
auto readSpellMissGuid = [&]() -> uint64_t {
if (spellMissUsesFullGuid)
@ -2248,7 +2283,7 @@ void SpellHandler::handleSpellLogMiss(network::Packet& packet) {
struct SpellMissLogEntry {
uint64_t victimGuid = 0;
uint8_t missInfo = 0;
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
uint32_t reflectSpellId = 0; // Only valid when missInfo==REFLECT
};
std::vector<SpellMissLogEntry> parsedMisses;
parsedMisses.reserve(storedLimit);
@ -2266,9 +2301,9 @@ void SpellHandler::handleSpellLogMiss(network::Packet& packet) {
return;
}
const uint8_t missInfo = packet.readUInt8();
// REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult
// REFLECT: extra uint32 reflectSpellId + uint8 reflectResult
uint32_t reflectSpellId = 0;
if (missInfo == 11) {
if (missInfo == SpellMissInfo::REFLECT) {
if (packet.hasRemaining(5)) {
reflectSpellId = packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
@ -2912,7 +2947,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
uint8_t effectType = packet.readUInt8();
uint32_t effectLogCount = packet.readUInt32();
effectLogCount = std::min(effectLogCount, 64u); // sanity
if (effectType == 10) {
if (effectType == SpellEffect::POWER_DRAIN) {
// SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
@ -2950,7 +2985,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
" power=", drainPower, " amount=", drainAmount,
" multiplier=", drainMult);
}
} else if (effectType == 11) {
} else if (effectType == SpellEffect::HEALTH_LEECH) {
// SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
@ -2983,7 +3018,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId,
" amount=", leechAmount, " multiplier=", leechMult);
}
} else if (effectType == 24 || effectType == 114) {
} else if (effectType == SpellEffect::CREATE_ITEM || effectType == SpellEffect::CREATE_ITEM2) {
// SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(4)) break;
@ -3012,7 +3047,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
}
}
}
} else if (effectType == 26) {
} else if (effectType == SpellEffect::INTERRUPT_CAST) {
// SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
@ -3033,7 +3068,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId,
" interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec);
}
} else if (effectType == 49) {
} else if (effectType == SpellEffect::FEED_PET) {
// SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(4)) break;
@ -3182,6 +3217,12 @@ void SpellHandler::handleChannelStart(network::Packet& packet) {
}
LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec,
" spell=", chanSpellId, " total=", chanTotalMs, "ms");
// Play channeling animation (looping)
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(chanCaster, true, true);
}
// Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons
if (owner_.addonEventCallback_) {
auto unitId = owner_.guidToUnitId(chanCaster);
@ -3217,6 +3258,10 @@ void SpellHandler::handleChannelUpdate(network::Packet& packet) {
" remaining=", chanRemainMs, "ms");
// Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends
if (chanRemainMs == 0) {
// Stop channeling animation — return to idle
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(chanCaster2, false, true);
}
auto unitId = owner_.guidToUnitId(chanCaster2);
if (!unitId.empty())
owner_.fireAddonEvent("UNIT_SPELLCAST_CHANNEL_STOP", {unitId});