feat: color cast bars green/red by spell interruptibility from Spell.dbc

Load AttributesEx from Spell.dbc for all expansions (Classic/TBC/WotLK/
Turtle). Check SPELL_ATTR_EX_NOT_INTERRUPTIBLE (bit 4 = 0x10) to classify
each cast as interruptible or not when SMSG_SPELL_START arrives.

Target frame and nameplate cast bars now use:
- Green: spell can be interrupted by Kick/Counterspell/Pummel etc.
- Red: spell is immune to interrupt (boss abilities, instant-cast effects)
Both colors pulse faster at >80% completion to signal the closing window.

Adds GameHandler::isSpellInterruptible() and UnitCastState::interruptible.
This commit is contained in:
Kelsi 2026-03-17 19:43:19 -07:00
parent b8712f380d
commit 279b4de09a
7 changed files with 55 additions and 17 deletions

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 124,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 124,
"Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215,
"CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40,
"DispelType": 3

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 4, "IconID": 133,
"ID": 0, "Attributes": 4, "AttributesEx": 5, "IconID": 133,
"Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225,
"PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49,
"DispelType": 2

View file

@ -798,6 +798,7 @@ public:
uint32_t spellId = 0;
float timeRemaining = 0.0f;
float timeTotal = 0.0f;
bool interruptible = true; ///< false when SPELL_ATTR_EX_NOT_INTERRUPTIBLE is set
};
// Returns cast state for any unit by GUID (empty/non-casting if not found)
const UnitCastState* getUnitCastState(uint64_t guid) const {
@ -819,6 +820,10 @@ public:
auto* s = getUnitCastState(targetGuid);
return s ? s->timeRemaining : 0.0f;
}
bool isTargetCastInterruptible() const {
auto* s = getUnitCastState(targetGuid);
return s ? s->interruptible : true;
}
// Talents
uint8_t getActiveTalentSpec() const { return activeTalentSpec_; }
@ -2056,6 +2061,9 @@ public:
const std::string& getSkillLineName(uint32_t spellId) const;
/// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other)
uint8_t getSpellDispelType(uint32_t spellId) const;
/// Returns true if the spell can be interrupted by abilities like Kick/Counterspell.
/// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10).
bool isSpellInterruptible(uint32_t spellId) const;
struct TrainerTab {
std::string name;
@ -3059,7 +3067,7 @@ private:
// Trainer
bool trainerWindowOpen_ = false;
TrainerListData currentTrainerList_;
struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; };
struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; };
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;

View file

@ -18279,10 +18279,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
// Track cast bar for any non-player caster (target frame + boss frames)
if (data.casterUnit != playerGuid && data.castTime > 0) {
auto& s = unitCastStates_[data.casterUnit];
s.casting = true;
s.spellId = data.spellId;
s.timeTotal = data.castTime / 1000.0f;
s.timeRemaining = s.timeTotal;
s.casting = true;
s.spellId = data.spellId;
s.timeTotal = data.castTime / 1000.0f;
s.timeRemaining = s.timeTotal;
s.interruptible = isSpellInterruptible(data.spellId);
// Trigger cast animation on the casting unit
if (spellCastAnimCallback_) {
spellCastAnimCallback_(data.casterUnit, true, false);
@ -21320,6 +21321,14 @@ void GameHandler::loadSpellNameCache() {
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; }
}
// AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE)
uint32_t attrExField = 0xFFFFFFFF;
bool hasAttrExField = false;
if (spellL) {
uint32_t f = spellL->field("AttributesEx");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; }
}
// Tooltip/description field
uint32_t tooltipField = 0xFFFFFFFF;
if (spellL) {
@ -21334,7 +21343,7 @@ void GameHandler::loadSpellNameCache() {
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
if (!name.empty()) {
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0};
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0};
if (tooltipField != 0xFFFFFFFF) {
entry.description = dbc->getString(i, tooltipField);
}
@ -21349,6 +21358,9 @@ void GameHandler::loadSpellNameCache() {
if (hasDispelField) {
entry.dispelType = static_cast<uint8_t>(dbc->getUInt32(i, dispelField));
}
if (hasAttrExField) {
entry.attrEx = dbc->getUInt32(i, attrExField);
}
spellNameCache_[id] = std::move(entry);
}
}
@ -21554,6 +21566,15 @@ uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const {
return (it != spellNameCache_.end()) ? it->second.dispelType : 0;
}
bool GameHandler::isSpellInterruptible(uint32_t spellId) const {
if (spellId == 0) return true;
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
if (it == spellNameCache_.end()) return true; // assume interruptible if unknown
// SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010)
return (it->second.attrEx & 0x00000010u) == 0;
}
const std::string& GameHandler::getSkillLineName(uint32_t spellId) const {
auto slIt = spellToSkillLine_.find(spellId);
if (slIt == spellToSkillLine_.end()) return EMPTY_STRING;

View file

@ -4288,14 +4288,19 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
float castPct = gameHandler.getTargetCastProgress();
float castLeft = gameHandler.getTargetCastTimeRemaining();
uint32_t tspell = gameHandler.getTargetCastSpellId();
bool interruptible = gameHandler.isTargetCastInterruptible();
const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : "";
// Pulse bright orange when cast is > 80% complete — interrupt window closing
// Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80%
ImVec4 castBarColor;
if (castPct > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f);
if (interruptible)
castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse
else
castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse
} else {
castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f);
castBarColor = interruptible ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) // green = can interrupt
: ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor);
char castLabel[72];
@ -9702,14 +9707,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
castBarBaseY += snSz.y + 2.0f;
}
// Cast bar background + fill (pulse orange when >80% = interrupt window closing)
ImU32 cbBg = IM_COL32(40, 30, 60, A(180));
// Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete
ImU32 cbBg = IM_COL32(30, 25, 40, A(180));
ImU32 cbFill;
if (castPct > 0.8f && unit->isHostile()) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
cbFill = IM_COL32(static_cast<int>(255 * pulse), static_cast<int>(130 * pulse), 0, A(220));
cbFill = cs->interruptible
? IM_COL32(static_cast<int>(40 * pulse), static_cast<int>(220 * pulse), static_cast<int>(40 * pulse), A(220)) // green pulse
: IM_COL32(static_cast<int>(255 * pulse), static_cast<int>(30 * pulse), static_cast<int>(30 * pulse), A(220)); // red pulse
} else {
cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar
cbFill = cs->interruptible
? IM_COL32(50, 190, 50, A(200)) // green = interruptible
: IM_COL32(190, 40, 40, A(200)); // red = uninterruptible
}
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f);