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

@ -542,6 +542,7 @@ EntityController::UnitFieldIndices EntityController::UnitFieldIndices::resolve()
fieldIndex(UF::UNIT_FIELD_DISPLAYID),
fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID),
fieldIndex(UF::UNIT_NPC_FLAGS),
fieldIndex(UF::UNIT_NPC_EMOTESTATE),
fieldIndex(UF::UNIT_FIELD_BYTES_0),
fieldIndex(UF::UNIT_FIELD_BYTES_1)
};
@ -697,6 +698,7 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block,
}
}
else if (key == ufi.npcFlags) { unit->setNpcFlags(val); }
else if (key == ufi.npcEmoteState) { unit->setNpcEmoteState(val); }
else if (key == ufi.dynFlags) {
unit->setDynamicFlags(val);
if (block.objectType == ObjectType::UNIT &&
@ -795,7 +797,28 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat
if (!uid.empty())
pendingEvents_.emit("UNIT_DISPLAYPOWER", {uid});
}
} else if (key == ufi.flags) { unit->setUnitFlags(val); }
} else if (key == ufi.flags) {
uint32_t oldFlags = unit->getUnitFlags();
unit->setUnitFlags(val);
// Detect stun state change on local player
constexpr uint32_t UNIT_FLAG_STUNNED = 0x00040000;
if (block.guid == owner_.playerGuid && owner_.stunStateCallback_) {
bool wasStunned = (oldFlags & UNIT_FLAG_STUNNED) != 0;
bool nowStunned = (val & UNIT_FLAG_STUNNED) != 0;
if (wasStunned != nowStunned) {
owner_.stunStateCallback_(nowStunned);
}
}
// Detect stealth state change on local player
constexpr uint32_t UNIT_FLAG_SNEAKING = 0x02000000;
if (block.guid == owner_.playerGuid && owner_.stealthStateCallback_) {
bool wasStealth = (oldFlags & UNIT_FLAG_SNEAKING) != 0;
bool nowStealth = (val & UNIT_FLAG_SNEAKING) != 0;
if (wasStealth != nowStealth) {
owner_.stealthStateCallback_(nowStealth);
}
}
}
else if (ufi.bytes1 != 0xFFFF && key == ufi.bytes1 && block.guid == owner_.playerGuid) {
uint8_t newForm = static_cast<uint8_t>((val >> 24) & 0xFF);
if (newForm != owner_.shapeshiftFormId_) {
@ -863,6 +886,14 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat
}
unit->setMountDisplayId(val);
} else if (key == ufi.npcFlags) { unit->setNpcFlags(val); }
else if (key == ufi.npcEmoteState) {
uint32_t oldEmote = unit->getNpcEmoteState();
unit->setNpcEmoteState(val);
// Fire emote animation callback so entity_spawner can update the NPC's idle anim
if (val != oldEmote && owner_.emoteAnimCallback_) {
owner_.emoteAnimCallback_(block.guid, val);
}
}
// Power/maxpower range checks AFTER all specific fields
else if (key >= ufi.powerBase && key < ufi.powerBase + 7) {
unit->setPowerByType(static_cast<uint8_t>(key - ufi.powerBase), val);
@ -889,6 +920,11 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat
}
}
// Fire player health callback for wounded-idle animation
if (result.healthChanged && block.guid == owner_.playerGuid && owner_.playerHealthCallback_) {
owner_.playerHealthCallback_(unit->getHealth(), unit->getMaxHealth());
}
return result;
}
@ -1632,6 +1668,17 @@ void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::s
entity->getZ(), entity->getOrientation());
}
}
// Detect GO state changes from GAMEOBJECT_BYTES_1 (packed: byte0=state, byte1=type, byte2=artKit, byte3=animProgress)
const uint16_t ufGoBytes1 = fieldIndex(UF::GAMEOBJECT_BYTES_1);
if (ufGoBytes1 != 0xFFFF) {
auto itB = block.fields.find(ufGoBytes1);
if (itB != block.fields.end()) {
uint8_t goState = static_cast<uint8_t>(itB->second & 0xFF);
if (owner_.gameObjectStateCallback_)
owner_.gameObjectStateCallback_(block.guid, goState);
}
}
}
// ============================================================