feat: profession crafting improvements and combat sound fixes

- Suppress spell sounds for profession/tradeskill spells (crafting is silent)
- Add craft quantity UI to profession trainer: recipe selector, quantity
  input, Create button, and Stop button for active queue
- Known recipes show Create button to cast directly from trainer window
- Craft queue auto-recasts on CREATE_ITEM completion, cancels on failure
- Fix missing combat sounds: player spell impacts on enemies, enemy spell
  cast sounds targeting player, instant melee ability weapon sounds
This commit is contained in:
Kelsi 2026-03-17 10:12:49 -07:00
parent 502d506a44
commit 7b03d5363b
3 changed files with 278 additions and 59 deletions

View file

@ -786,6 +786,12 @@ public:
float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; }
float getCastTimeRemaining() const { return castTimeRemaining; }
// Repeat-craft queue
void startCraftQueue(uint32_t spellId, int count);
void cancelCraftQueue();
int getCraftQueueRemaining() const { return craftQueueRemaining_; }
uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; }
// Unit cast state (tracked per GUID for target frame + boss frames)
struct UnitCastState {
bool casting = false;
@ -970,6 +976,7 @@ public:
const std::map<uint32_t, PlayerSkill>& getPlayerSkills() const { return playerSkills_; }
const std::string& getSkillName(uint32_t skillId) const;
uint32_t getSkillCategory(uint32_t skillId) const;
bool isProfessionSpell(uint32_t spellId) const;
// World entry callback (online mode - triggered when entering world)
// Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect
@ -2669,6 +2676,9 @@ private:
bool castIsChannel = false;
uint32_t currentCastSpellId = 0;
float castTimeRemaining = 0.0f;
// Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes
uint32_t craftQueueSpellId_ = 0;
int craftQueueRemaining_ = 0;
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
std::unordered_map<uint64_t, UnitCastState> unitCastStates_;
uint64_t pendingGameObjectInteractGuid_ = 0;

View file

@ -2217,6 +2217,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
lastInteractedGoGuid_ = 0;
// Cancel craft queue on cast failure
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
int playerPowerType = -1;
if (auto pe = entityManager.getEntity(playerGuid)) {
@ -6774,6 +6777,16 @@ void GameHandler::handlePacket(network::Packet& packet) {
addSystemChatMessage(msg);
LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId,
" item=", itemEntry, " name=", itemName);
// Repeat-craft queue: re-cast if more crafts remaining
if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) {
--craftQueueRemaining_;
if (craftQueueRemaining_ > 0) {
castSpell(craftQueueSpellId_, 0);
} else {
craftQueueSpellId_ = 0;
}
}
}
}
} else if (effectType == 26) {
@ -17696,6 +17709,21 @@ void GameHandler::cancelCast() {
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Cancel craft queue when player manually cancels cast
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
}
void GameHandler::startCraftQueue(uint32_t spellId, int count) {
craftQueueSpellId_ = spellId;
craftQueueRemaining_ = count;
// Cast the first one immediately
castSpell(spellId, 0);
}
void GameHandler::cancelCraftQueue() {
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
}
void GameHandler::cancelAura(uint32_t spellId) {
@ -18022,14 +18050,17 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
castTimeRemaining = castTimeTotal;
// Play precast (channeling) sound with correct magic school
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
auto it = spellNameCache_.find(data.spellId);
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM);
// Skip sound for profession/tradeskill spells (crafting should be silent)
if (!isProfessionSpell(data.spellId)) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
auto it = spellNameCache_.find(data.spellId);
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM);
}
}
}
@ -18055,14 +18086,17 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
// Cast completed
if (data.casterUnit == playerGuid) {
// Play cast-complete sound with correct magic school
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
auto it = spellNameCache_.find(data.spellId);
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playCast(school);
// Skip sound for profession/tradeskill spells (crafting should be silent)
if (!isProfessionSpell(data.spellId)) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
auto it = spellNameCache_.find(data.spellId);
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playCast(school);
}
}
}
@ -18082,8 +18116,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
isMeleeAbility = (currentCastSpellId != sid);
}
}
if (isMeleeAbility && meleeSwingCallback_) {
meleeSwingCallback_();
if (isMeleeAbility) {
if (meleeSwingCallback_) meleeSwingCallback_();
// Play weapon swing + impact sound for instant melee abilities (Sinister Strike, etc.)
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* csm = renderer->getCombatSoundManager()) {
csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false);
csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM,
audio::CombatSoundManager::ImpactType::FLESH, false);
}
}
}
// Capture cast state before clearing. Guard with spellId match so that
@ -18110,9 +18152,28 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, false, false);
}
} else if (spellCastAnimCallback_) {
// End cast animation on other unit
spellCastAnimCallback_(data.casterUnit, false, false);
} else {
if (spellCastAnimCallback_) {
// End cast animation on other unit
spellCastAnimCallback_(data.casterUnit, false, false);
}
// Play cast-complete sound for enemy spells targeting the player
bool targetsPlayer = false;
for (const auto& tgt : data.hitTargets) {
if (tgt == playerGuid) { targetsPlayer = true; break; }
}
if (targetsPlayer) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
auto it = spellNameCache_.find(data.spellId);
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playCast(school);
}
}
}
}
// Clear unit cast bar when the spell lands (for any tracked unit)
@ -18133,12 +18194,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
}
}
// Play impact sound when player is hit by any spell (from self or others)
// Play impact sound for spell hits involving the player
// - When player is hit by an enemy spell
// - When player's spell hits an enemy target
bool playerIsHit = false;
bool playerHitEnemy = false;
for (const auto& tgt : data.hitTargets) {
if (tgt == playerGuid) { playerIsHit = true; break; }
if (tgt == playerGuid) { playerIsHit = true; }
if (data.casterUnit == playerGuid && tgt != playerGuid && tgt != 0) { playerHitEnemy = true; }
}
if (playerIsHit && data.casterUnit != playerGuid) {
if (playerIsHit || playerHitEnemy) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
loadSpellNameCache();
@ -19327,7 +19392,7 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
float dy = entity->getY() - movementInfo.y;
float dz = entity->getZ() - movementInfo.z;
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist3d > 6.0f) {
if (dist3d > 10.0f) {
addSystemChatMessage("Too far away.");
return;
}
@ -19391,13 +19456,17 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
}
}
if (shouldSendLoot) {
lootTarget(guid);
// Some servers/scripts only make certain quest/chest GOs lootable after a short delay
// (use animation, state change). Queue one delayed loot attempt to catch that case.
// Don't send CMSG_LOOT immediately — give the server time to process
// CMSG_GAMEOBJ_USE first (chests need to transition to lootable state,
// gathering nodes start a spell cast). A premature CMSG_LOOT can cause
// an empty SMSG_LOOT_RESPONSE that clears our gather-cast loot state.
pendingGameObjectLootOpens_.erase(
std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(),
[&](const PendingLootOpen& p) { return p.guid == guid; }),
pendingGameObjectLootOpens_.end());
// Short delay for chests (server makes them lootable quickly after USE),
// plus a longer retry to catch slow state transitions.
pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.20f});
pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f});
} else {
// Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be
@ -19405,12 +19474,9 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
// guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT.
lastInteractedGoGuid_ = 0;
}
// Retry use briefly to survive packet loss/order races.
const bool retryLoot = shouldSendLoot;
const bool retryUse = turtleMode || isActiveExpansion("classic");
if (retryUse || retryLoot) {
pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot});
}
// Don't retry CMSG_GAMEOBJ_USE — resending can toggle chest state on some
// servers (opening→closing the chest). The delayed CMSG_LOOT retries above
// handle the case where the first loot attempt arrives too early.
}
void GameHandler::selectGossipOption(uint32_t optionId) {
@ -20584,6 +20650,13 @@ void GameHandler::handleLootResponse(network::Packet& packet) {
// WotLK 3.3.5a uses 22 bytes/item.
const bool wotlkLoot = isActiveExpansion("wotlk");
if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return;
const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0;
// If we're mid-gather-cast and got an empty loot response (premature CMSG_LOOT
// before the node became lootable), ignore it — don't clear our gather state.
if (!hasLoot && casting && currentCastSpellId != 0 && lastInteractedGoGuid_ != 0) {
LOG_DEBUG("Ignoring empty SMSG_LOOT_RESPONSE during gather cast");
return;
}
lootWindowOpen = true;
lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo
pendingGameObjectLootOpens_.erase(
@ -22667,6 +22740,15 @@ uint32_t GameHandler::getSkillCategory(uint32_t skillId) const {
return (it != skillLineCategories_.end()) ? it->second : 0;
}
bool GameHandler::isProfessionSpell(uint32_t spellId) const {
auto slIt = spellToSkillLine_.find(spellId);
if (slIt == spellToSkillLine_.end()) return false;
auto catIt = skillLineCategories_.find(slIt->second);
if (catIt == skillLineCategories_.end()) return false;
// Category 11 = profession (Blacksmithing, etc.), 9 = secondary (Cooking, First Aid, Fishing)
return catIt->second == 11 || catIt->second == 9;
}
void GameHandler::loadSkillLineDbc() {
if (skillLineDbcLoaded_) return;
skillLineDbcLoaded_ = true;

View file

@ -2091,18 +2091,20 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
std::string cmd = buf.substr(1, sp - 1);
for (char& c : cmd) c = std::tolower(c);
int detected = -1;
bool isReply = false;
if (cmd == "s" || cmd == "say") detected = 0;
else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1;
else if (cmd == "p" || cmd == "party") detected = 2;
else if (cmd == "g" || cmd == "guild") detected = 3;
else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4;
else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; }
else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5;
else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6;
else if (cmd == "bg" || cmd == "battleground") detected = 7;
else if (cmd == "rw" || cmd == "raidwarning") detected = 8;
else if (cmd == "i" || cmd == "instance") detected = 9;
else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc.
if (detected >= 0 && (selectedChatType != detected || detected == 10)) {
if (detected >= 0 && (selectedChatType != detected || detected == 10 || isReply)) {
// For channel shortcuts, also update selectedChannelIdx
if (detected == 10) {
int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc.
@ -2114,8 +2116,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
selectedChatType = detected;
// Strip the prefix, keep only the message part
std::string remaining = buf.substr(sp + 1);
// For whisper, first word after /w is the target
if (detected == 4) {
// /r reply: pre-fill whisper target from last whisper sender
if (detected == 4 && isReply) {
std::string lastSender = gameHandler.getLastWhisperSender();
if (!lastSender.empty()) {
strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
}
// remaining is the message — don't extract a target from it
} else if (detected == 4) {
// For whisper, first word after /w is the target
size_t msgStart = remaining.find(' ');
if (msgStart != std::string::npos) {
std::string wTarget = remaining.substr(0, msgStart);
@ -2576,6 +2586,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
uint64_t closestHostileUnitGuid = 0;
float closestQuestGoT = 1e30f;
uint64_t closestQuestGoGuid = 0;
float closestGoT = 1e30f;
uint64_t closestGoGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
@ -2598,16 +2610,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
// For GOs with no renderer instance yet, use a tight fallback
// sphere so invisible/unloaded doodads aren't accidentally clicked.
hitRadius = 1.2f;
heightOffset = 1.0f;
// Quest objective GOs should be easier to click.
auto go = std::static_pointer_cast<game::GameObject>(entity);
if (questObjectiveGoEntries.count(go->getEntry())) {
hitRadius = 2.2f;
heightOffset = 1.2f;
}
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
@ -2626,12 +2630,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
closestHostileUnitGuid = guid;
}
}
if (t == game::ObjectType::GAMEOBJECT && !questObjectiveGoEntries.empty()) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
if (questObjectiveGoEntries.count(go->getEntry())) {
if (hitT < closestQuestGoT) {
closestQuestGoT = hitT;
closestQuestGoGuid = guid;
if (t == game::ObjectType::GAMEOBJECT) {
if (hitT < closestGoT) {
closestGoT = hitT;
closestGoGuid = guid;
}
if (!questObjectiveGoEntries.empty()) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
if (questObjectiveGoEntries.count(go->getEntry())) {
if (hitT < closestQuestGoT) {
closestQuestGoT = hitT;
closestQuestGoGuid = guid;
}
}
}
}
@ -2643,12 +2653,23 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
}
}
// Prefer quest objective GOs over hostile monsters when both are hittable.
// Priority: quest GO > closer of (GO, hostile unit) > closest anything.
if (closestQuestGoGuid != 0) {
closestGuid = closestQuestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else if (closestGoGuid != 0 && closestHostileUnitGuid != 0) {
// Both a GO and hostile unit were hit — prefer whichever is closer.
if (closestGoT <= closestHostileUnitT) {
closestGuid = closestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else {
closestGuid = closestHostileUnitGuid;
closestType = game::ObjectType::UNIT;
}
} else if (closestGoGuid != 0) {
closestGuid = closestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else if (closestHostileUnitGuid != 0) {
// Prefer hostile monsters over nearby gameobjects/others when right-click picking.
closestGuid = closestHostileUnitGuid;
closestType = game::ObjectType::UNIT;
}
@ -5951,6 +5972,28 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
message = "";
isChannelCommand = true;
}
} else if (cmdLower == "r" || cmdLower == "reply") {
switchChatType = 4;
std::string lastSender = gameHandler.getLastWhisperSender();
if (lastSender.empty()) {
game::MessageChatData sysMsg;
sysMsg.type = game::ChatType::SYSTEM;
sysMsg.language = game::ChatLanguage::UNIVERSAL;
sysMsg.message = "No one has whispered you yet.";
gameHandler.addLocalChatMessage(sysMsg);
chatInputBuffer[0] = '\0';
return;
}
target = lastSender;
strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
if (spacePos != std::string::npos) {
message = command.substr(spacePos + 1);
type = game::ChatType::WHISPER;
} else {
message = "";
}
isChannelCommand = true;
}
// Check for emote commands
@ -13624,6 +13667,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
}
const auto& trainer = gameHandler.getTrainerSpells();
const bool isProfessionTrainer = (trainer.trainerType == 2);
// NPC name
auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid);
@ -13844,11 +13888,21 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
logCount++;
}
if (!canTrain) ImGui::BeginDisabled();
if (ImGui::SmallButton("Train")) {
gameHandler.trainSpell(spell->spellId);
if (isProfessionTrainer && alreadyKnown) {
// Profession trainer: known recipes show "Create" button to craft
bool isCasting = gameHandler.isCasting();
if (isCasting) ImGui::BeginDisabled();
if (ImGui::SmallButton("Create")) {
gameHandler.castSpell(spell->spellId, 0);
}
if (isCasting) ImGui::EndDisabled();
} else {
if (!canTrain) ImGui::BeginDisabled();
if (ImGui::SmallButton("Train")) {
gameHandler.trainSpell(spell->spellId);
}
if (!canTrain) ImGui::EndDisabled();
}
if (!canTrain) ImGui::EndDisabled();
ImGui::PopID();
}
@ -13952,6 +14006,79 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
}
}
if (!hasTrainable) ImGui::EndDisabled();
// Profession trainer: craft quantity controls
if (isProfessionTrainer) {
ImGui::Separator();
static int craftQuantity = 1;
static uint32_t selectedCraftSpell = 0;
// Show craft queue status if active
int queueRemaining = gameHandler.getCraftQueueRemaining();
if (queueRemaining > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f),
"Crafting... %d remaining", queueRemaining);
ImGui::SameLine();
if (ImGui::SmallButton("Stop")) {
gameHandler.cancelCraftQueue();
gameHandler.cancelCast();
}
} else {
// Spell selector + quantity input
// Build list of known (craftable) spells
std::vector<const game::TrainerSpell*> craftable;
for (const auto& spell : trainer.spells) {
if (isKnown(spell.spellId)) {
craftable.push_back(&spell);
}
}
if (!craftable.empty()) {
// Combo box for recipe selection
const char* previewName = "Select recipe...";
for (const auto* sp : craftable) {
if (sp->spellId == selectedCraftSpell) {
const std::string& n = gameHandler.getSpellName(sp->spellId);
if (!n.empty()) previewName = n.c_str();
break;
}
}
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f);
if (ImGui::BeginCombo("##CraftSelect", previewName)) {
for (const auto* sp : craftable) {
const std::string& n = gameHandler.getSpellName(sp->spellId);
const std::string& r = gameHandler.getSpellRank(sp->spellId);
char label[128];
if (!r.empty())
snprintf(label, sizeof(label), "%s (%s)##%u",
n.empty() ? "???" : n.c_str(), r.c_str(), sp->spellId);
else
snprintf(label, sizeof(label), "%s##%u",
n.empty() ? "???" : n.c_str(), sp->spellId);
if (ImGui::Selectable(label, sp->spellId == selectedCraftSpell)) {
selectedCraftSpell = sp->spellId;
}
}
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(50.0f);
ImGui::InputInt("##CraftQty", &craftQuantity, 0, 0);
if (craftQuantity < 1) craftQuantity = 1;
if (craftQuantity > 99) craftQuantity = 99;
ImGui::SameLine();
bool canCraft = selectedCraftSpell != 0 && !gameHandler.isCasting();
if (!canCraft) ImGui::BeginDisabled();
if (ImGui::Button("Create")) {
if (craftQuantity == 1) {
gameHandler.castSpell(selectedCraftSpell, 0);
} else {
gameHandler.startCraftQueue(selectedCraftSpell, craftQuantity);
}
}
if (!canCraft) ImGui::EndDisabled();
}
}
}
}
}
ImGui::End();