#include "StarCraftingInterface.hpp" #include "StarJsonExtra.hpp" #include "StarGuiReader.hpp" #include "StarLexicalCast.hpp" #include "StarRoot.hpp" #include "StarItemTooltip.hpp" #include "StarPlayer.hpp" #include "StarContainerEntity.hpp" #include "StarWorldClient.hpp" #include "StarPlayerBlueprints.hpp" #include "StarButtonWidget.hpp" #include "StarPaneManager.hpp" #include "StarPortraitWidget.hpp" #include "StarLabelWidget.hpp" #include "StarTextBoxWidget.hpp" #include "StarImageWidget.hpp" #include "StarListWidget.hpp" #include "StarImageStretchWidget.hpp" #include "StarItemSlotWidget.hpp" #include "StarConfiguration.hpp" #include "StarObjectItem.hpp" #include "StarAssets.hpp" #include "StarItemDatabase.hpp" #include "StarObjectDatabase.hpp" #include "StarPlayerInventory.hpp" #include "StarPlayerLog.hpp" #include "StarMixer.hpp" namespace Star { CraftingPane::CraftingPane(WorldClientPtr worldClient, PlayerPtr player, Json const& settings, EntityId sourceEntityId) { m_worldClient = std::move(worldClient); m_player = std::move(player); m_blueprints = m_player->blueprints(); m_recipeAutorefreshCooldown = 0; m_sourceEntityId = sourceEntityId; auto assets = Root::singleton().assets(); // get the config data for this crafting pane, default to "bare hands" crafting auto baseConfig = settings.get("config", "/interface/windowconfig/crafting.config"); m_settings = jsonMerge(assets->json("/interface/windowconfig/crafting.config:default"), jsonMerge(assets->fetchJson(baseConfig), settings)); m_filter = StringSet::from(jsonToStringList(m_settings.get("filter", JsonArray()))); GuiReader reader; reader.registerCallback("spinCount.up", [=](Widget*) { if (m_count < maxCraft()) m_count++; else m_count = 1; countChanged(); }); reader.registerCallback("spinCount.down", [=](Widget*) { if (m_count > 1) m_count--; else m_count = std::max(maxCraft(), 1); countChanged(); }); reader.registerCallback("tbSpinCount", [=](Widget*) { countTextChanged(); }); reader.registerCallback("close", [=](Widget*) { dismiss(); }); reader.registerCallback("btnCraft", [=](Widget*) { toggleCraft(); }); reader.registerCallback("btnStopCraft", [=](Widget*) { toggleCraft(); }); reader.registerCallback("btnFilterHaveMaterials", [=](Widget*) { Root::singleton().configuration()->setPath("crafting.filterHaveMaterials", m_filterHaveMaterials->isChecked()); updateAvailableRecipes(); }); reader.registerCallback("filter", [=](Widget*) { updateAvailableRecipes(); }); reader.registerCallback("categories", [=](Widget*) { updateAvailableRecipes(); }); reader.registerCallback("rarities", [=](Widget*) { updateAvailableRecipes(); }); reader.registerCallback("btnUpgrade", [=](Widget*) { upgradeTable(); }); // this is where the GUI gets built and the buttons begin to have existence. // all possible callbacks must exist by this point Json paneLayout = m_settings.get("paneLayout"); paneLayout = jsonMerge(paneLayout, m_settings.get("paneLayoutOverride", {})); reader.construct(paneLayout, this); if (auto upgradeButton = fetchChild("btnUpgrade")) { upgradeButton->disable(); Maybe recipeData = m_settings.optArray("upgradeMaterials"); // create a recipe out of the listed upgrade materials. // for ease of creating a tooltip later. if (recipeData) { m_upgradeRecipe = ItemRecipe(); for (auto ingredient : *recipeData) m_upgradeRecipe->inputs.append(ItemDescriptor(ingredient.getString("item"), ingredient.getUInt("count"), {})); upgradeButton->setVisibility(true); } else { upgradeButton->setVisibility(false); } } m_guiList = fetchChild("scrollArea.itemList"); m_textBox = fetchChild("tbSpinCount"); m_filterHaveMaterials = fetchChild("btnFilterHaveMaterials"); if (m_filterHaveMaterials) m_filterHaveMaterials->setChecked(Root::singleton().configuration()->getPath("crafting.filterHaveMaterials").toBool()); fetchChild("btnCraft")->disable(); if (auto spinCountUp = fetchChild("spinCount.up")) spinCountUp->disable(); if (auto spinCountDown = fetchChild("spinCount.down")) spinCountDown->disable(); m_displayedRecipe = NPos; updateAvailableRecipes(); m_crafting = false; m_count = 1; countChanged(); if (m_settings.getBool("titleFromEntity", false) && sourceEntityId != NullEntityId) { auto entity = m_worldClient->entity(sourceEntityId); if (auto container = as(entity)) { if (container->iconItem()) { auto itemDatabase = Root::singleton().itemDatabase(); auto iconItem = itemDatabase->itemShared(container->iconItem()); auto icon = make_shared(iconItem, "/interface/inventory/portrait.png"); String title = this->title(); if (title.empty()) title = container->containerDescription(); String subTitle = this->subTitle(); if (subTitle.empty()) subTitle = container->containerSubTitle(); icon->showRarity(false); setTitle(icon, title, subTitle); } } if (auto portaitEntity = as(entity)) { auto portrait = make_shared(portaitEntity, PortraitMode::Bust); portrait->setIconMode(); String title = this->title(); if (title.empty()) title = portaitEntity->name(); String subTitle = this->subTitle(); setTitle(portrait, title, subTitle); } } } void CraftingPane::displayed() { Pane::displayed(); if (auto filterWidget = fetchChild("filter")) { filterWidget->setText(""); filterWidget->blur(); } updateAvailableRecipes(); // unlock any recipes specified if (auto recipeUnlocks = m_settings.opt("initialRecipeUnlocks")) { for (String itemName : jsonToStringList(*recipeUnlocks)) m_player->addBlueprint(ItemDescriptor(itemName)); } } void CraftingPane::dismissed() { stopCrafting(); Pane::dismissed(); m_itemCache.clear(); } PanePtr CraftingPane::createTooltip(Vec2I const& screenPosition) { for (size_t i = 0; i < m_guiList->numChildren(); ++i) { auto entry = m_guiList->itemAt(i); if (entry->getChildAt(screenPosition)) { auto& recipe = m_recipesWidgetMap.getLeft(entry); return setupTooltip(recipe); } } if (WidgetPtr child = getChildAt(screenPosition)) { if (child->name() == "btnUpgrade") { if (m_upgradeRecipe) return setupTooltip(*m_upgradeRecipe); } } return {}; } EntityId CraftingPane::sourceEntityId() const { return m_sourceEntityId; } void CraftingPane::upgradeTable() { if (m_sourceEntityId != NullEntityId) { // Checks that the upgrade path exists if (m_upgradeRecipe) { if (m_player->isAdmin() || ItemDatabase::canMakeRecipe(*m_upgradeRecipe, m_player->inventory()->availableItems(), m_player->inventory()->availableCurrencies())) { if (!m_player->isAdmin()) consumeIngredients(*m_upgradeRecipe, 1); // upgrade the old table m_worldClient->sendEntityMessage(m_sourceEntityId, "requestUpgrade"); // unlock any recipes specified if (auto recipeUnlocks = m_settings.opt("upgradeRecipeUnlocks")) { for (String itemName : jsonToStringList(*recipeUnlocks)) m_player->addBlueprint(ItemDescriptor(itemName)); } // this closes the interface window dismiss(); } } } } size_t CraftingPane::itemCount(List const& store, ItemDescriptor const& item) { auto itemDb = Root::singleton().itemDatabase(); return itemDb->getCountOfItem(store, item); } void CraftingPane::update(float dt) { // shut down if we can't reach the table anymore. if (m_sourceEntityId != NullEntityId) { auto sourceEntity = as(m_worldClient->entity(m_sourceEntityId)); if (!sourceEntity || !m_worldClient->playerCanReachEntity(m_sourceEntityId) || !sourceEntity->isInteractive()) { dismiss(); return; } } // similarly if the player is dead if (m_player->isDead()) { dismiss(); return; } // has the selected recipe changed ? bool changedHighlight = (m_displayedRecipe != m_guiList->selectedItem()); if (changedHighlight) { stopCrafting(); // TODO: allow viewing other recipes without interrupting crafting m_displayedRecipe = m_guiList->selectedItem(); countTextChanged(); auto recipe = recipeFromSelectedWidget(); if (recipe.isNull()) { fetchChild("description")->removeAllChildren(); } else { auto description = fetchChild("description"); description->removeAllChildren(); auto item = Root::singleton().itemDatabase()->itemShared(recipe.output); ItemTooltipBuilder::buildItemDescription(description, item); } } // crafters gonna craft while (m_crafting && m_craftTimer.wrapTick()) { craft(min(m_count, (int)m_settings.getInt("craftCount", 1))); } // update crafting icon, progress and buttons if (auto currentRecipeIcon = fetchChild("currentRecipeIcon")) { auto recipe = recipeFromSelectedWidget(); if (recipe.isNull()) { currentRecipeIcon->setItem(nullptr); } else { auto single = recipe.output.singular(); ItemPtr item = m_itemCache[single]; currentRecipeIcon->setItem(item); if (m_crafting) currentRecipeIcon->setProgress(1.0f - m_craftTimer.percent()); else currentRecipeIcon->setProgress(1.0f); } } --m_recipeAutorefreshCooldown; // changed recipe or auto update time if (changedHighlight || (m_recipeAutorefreshCooldown <= 0)) { updateAvailableRecipes(); updateCraftButtons(); } setLabel("lblPlayerMoney", toString((int)m_player->currency("money"))); Pane::update(dt); } void CraftingPane::updateCraftButtons() { auto normalizedBag = m_player->inventory()->availableItems(); auto availableCurrencies = m_player->inventory()->availableCurrencies(); auto recipe = recipeFromSelectedWidget(); bool recipeAvailable = !recipe.isNull() && (m_player->isAdmin() || ItemDatabase::canMakeRecipe(recipe, normalizedBag, availableCurrencies)); fetchChild("btnCraft")->setEnabled(recipeAvailable); if (auto spinCountUp = fetchChild("spinCount.up")) spinCountUp->setEnabled(recipeAvailable); if (auto spinCountDown = fetchChild("spinCount.down")) spinCountDown->setEnabled(recipeAvailable); if (auto stopCraftButton = fetchChild("btnStopCraft")) { stopCraftButton->setVisibility(m_crafting); fetchChild("btnCraft")->setVisibility(!m_crafting); } if (auto upgradeButton = fetchChild("btnUpgrade")) { bool canUpgrade = (m_upgradeRecipe && (m_player->isAdmin() || ItemDatabase::canMakeRecipe(*m_upgradeRecipe, normalizedBag, availableCurrencies))); upgradeButton->setEnabled(canUpgrade); } } void CraftingPane::updateAvailableRecipes() { m_recipeAutorefreshCooldown = 30; StringSet categoryFilter; if (auto categoriesGroup = fetchChild("categories")) { if (auto selectedCategories = categoriesGroup->checkedButton()) { for (auto group : selectedCategories->data().getArray("filter")) categoryFilter.add(group.toString()); } } HashSet rarityFilter; if (auto raritiesGroup = fetchChild("rarities")) { if (auto selectedRarities = raritiesGroup->checkedButton()) { for (auto entry : jsonToStringSet(selectedRarities->data().getArray("rarity"))) rarityFilter.add(RarityNames.getLeft(entry)); } } String filterText; if (auto filterWidget = fetchChild("filter")) filterText = filterWidget->getText(); m_recipes = determineRecipes(); size_t currentOffset = 0; ItemRecipe selectedRecipe; if (m_guiList->selectedWidget()) selectedRecipe = m_recipesWidgetMap.getLeft(m_guiList->selectedWidget()); HashMap normalizedBag = m_player->inventory()->availableItems(); m_guiList->clear(); for (auto const& recipe : m_recipes) { auto widget = m_recipesWidgetMap.valueRight(recipe); if (widget) { m_guiList->addItem(widget); } else { widget = m_guiList->addItem(); m_recipesWidgetMap.add(recipe, widget); } setupWidget(widget, recipe, normalizedBag); if (selectedRecipe == recipe) { m_guiList->setSelected(currentOffset); } currentOffset++; } } void CraftingPane::setupWidget(WidgetPtr const& widget, ItemRecipe const& recipe, HashMap const& normalizedBag) { auto& root = Root::singleton(); auto single = recipe.output.singular(); ItemPtr item = m_itemCache[single]; if (!item) { item = root.itemDatabase()->itemShared(single); m_itemCache[single] = item; } bool unavailable = false; size_t price = recipe.currencyInputs.value("money", 0); if (!m_player->isAdmin()) { for (auto const& p : recipe.currencyInputs) { if (m_player->currency(p.first) < p.second) unavailable = true; } auto itemDb = Root::singleton().itemDatabase(); for (auto const& input : recipe.inputs) { if (itemDb->getCountOfItem(normalizedBag, input, recipe.matchInputParameters) < input.count()) unavailable = true; } } String name = item->friendlyName(); if (recipe.output.count() > 1) name = strf("{} (x{})", name, recipe.output.count()); auto itemName = widget->fetchChild("itemName"); auto notcraftableoverlay = widget->fetchChild("notcraftableoverlay"); itemName->setText(name); if (unavailable) { itemName->setColor(Color::Gray); notcraftableoverlay->show(); } else { itemName->setColor(Color::White); notcraftableoverlay->hide(); } if (price > 0) { widget->setLabel("priceLabel", toString(price)); if (auto icon = widget->fetchChild("moneyIcon")) icon->setVisibility(true); } else { widget->setLabel("priceLabel", ""); if (auto icon = widget->fetchChild("moneyIcon")) icon->setVisibility(false); } if (auto newIndicator = widget->fetchChild("newIcon")) { if (m_blueprints->isNew(recipe.output.singular())) { newIndicator->show(); widget->setLabel("priceLabel", ""); if (auto icon = widget->fetchChild("moneyIcon")) icon->setVisibility(false); } else { newIndicator->hide(); } } widget->fetchChild("itemIcon")->setItem(item); widget->show(); } PanePtr CraftingPane::setupTooltip(ItemRecipe const& recipe) { auto& root = Root::singleton(); auto tooltip = make_shared(); GuiReader reader; reader.construct(root.assets()->json("/interface/craftingtooltip/craftingtooltip.config"), tooltip.get()); auto guiList = tooltip->fetchChild("itemList"); guiList->clear(); auto normalizedBag = m_player->inventory()->availableItems(); auto itemDb = root.itemDatabase(); auto addIngredient = [guiList](ItemPtr const& item, size_t availableCount, size_t requiredCount) { auto widget = guiList->addItem(); widget->fetchChild("itemName")->setText(item->friendlyName()); auto countWidget = widget->fetchChild("count"); countWidget->setText(strf("{}/{}", availableCount, requiredCount)); if (availableCount < requiredCount) countWidget->setColor(Color::Red); else countWidget->setColor(Color::Green); widget->fetchChild("itemIcon")->setItem(item); widget->show(); }; auto currenciesConfig = root.assets()->json("/currencies.config"); for (auto const& p : recipe.currencyInputs) { if (p.second > 0) { auto currencyItem = root.itemDatabase()->itemShared(ItemDescriptor(currenciesConfig.get(p.first).getString("representativeItem"))); addIngredient(currencyItem, m_player->currency(p.first), p.second); } } for (auto const& input : recipe.inputs) { auto item = root.itemDatabase()->itemShared(input.singular()); size_t itemCount = itemDb->getCountOfItem(normalizedBag, input, recipe.matchInputParameters); addIngredient(item, itemCount, input.count()); } auto background = tooltip->fetchChild("background"); background->setSize(background->size() + Vec2I(0, guiList->size()[1])); auto title = tooltip->fetchChild("title"); title->setPosition(title->position() + Vec2I(0, guiList->size()[1])); tooltip->setSize(background->size()); return tooltip; } bool CraftingPane::consumeIngredients(ItemRecipe& recipe, int count) { auto itemDb = Root::singleton().itemDatabase(); auto normalizedBag = m_player->inventory()->availableItems(); auto availableCurrencies = m_player->inventory()->availableCurrencies(); // make sure we still have the currencies and items avaialable for (auto const& p : recipe.currencyInputs) { uint64_t countRequired = p.second * count; if (availableCurrencies.value(p.first) < countRequired) { updateAvailableRecipes(); return false; } } for (auto input : recipe.inputs) { size_t countRequired = input.count() * count; if (itemDb->getCountOfItem(normalizedBag, input, recipe.matchInputParameters) < countRequired) { updateAvailableRecipes(); return false; } } // actually consume the currencies and items for (auto const& p : recipe.currencyInputs) { if (p.second > 0) m_player->inventory()->consumeCurrency(p.first, p.second * count); } for (auto input : recipe.inputs) { if (count > 0) m_player->inventory()->consumeItems(ItemDescriptor(input.name(), input.count() * count, input.parameters()), recipe.matchInputParameters); } return true; } void CraftingPane::stopCrafting() { if (m_craftingSound) m_craftingSound->stop(); m_crafting = false; } void CraftingPane::toggleCraft() { if (m_crafting) { stopCrafting(); } else { auto recipe = recipeFromSelectedWidget(); if (recipe.duration > 0 && !m_settings.getBool("disableTimer", false)) { m_crafting = true; m_craftTimer = GameTimer(recipe.duration); if (auto craftingSound = m_settings.optString("craftingSound")) { auto assets = Root::singleton().assets(); m_craftingSound = make_shared(*assets->audio(*craftingSound)); m_craftingSound->setLoops(-1); GuiContext::singleton().playAudio(m_craftingSound); } } else { craft(m_count); } } } void CraftingPane::craft(int count) { auto& root = Root::singleton(); if (m_guiList->selectedItem() != NPos) { auto recipe = recipeFromSelectedWidget(); if (!m_player->isAdmin() && !consumeIngredients(recipe, count)) { stopCrafting(); return; } ItemDescriptor itemDescriptor = recipe.output; int remainingItemCount = itemDescriptor.count() * count; while (remainingItemCount > 0) { auto craftedItem = root.itemDatabase()->item(itemDescriptor.singular().multiply(remainingItemCount)); remainingItemCount -= craftedItem->count(); m_player->giveItem(craftedItem); for (auto& collectable : recipe.collectables) m_player->addCollectable(collectable.first, collectable.second); } m_blueprints->markAsRead(recipe.output.singular()); } updateAvailableRecipes(); m_count -= count; if (m_count <= 0) { m_count = 1; stopCrafting(); } countChanged(); updateCraftButtons(); } void CraftingPane::countTextChanged() { if (m_textBox) { int appropriateDefaultCount = 1; try { if (!m_textBox->getText().replace("x", "").size()) { m_count = appropriateDefaultCount; } else { m_count = clamp(lexicalCast(m_textBox->getText().replace("x", "")), appropriateDefaultCount, maxCraft()); countChanged(); } } catch (BadLexicalCast const&) { m_count = appropriateDefaultCount; countChanged(); } } else { m_count = 1; } } void CraftingPane::countChanged() { if (m_textBox) m_textBox->setText(strf("x{}", m_count), false); } List CraftingPane::determineRecipes() { HashSet recipes; auto itemDb = Root::singleton().itemDatabase(); StringSet categoryFilter; if (auto categoriesGroup = fetchChild("categories")) { if (auto selectedCategories = categoriesGroup->checkedButton()) { for (auto group : selectedCategories->data().getArray("filter")) categoryFilter.add(group.toString()); } } HashSet rarityFilter; if (auto raritiesGroup = fetchChild("rarities")) { if (auto selectedRarities = raritiesGroup->checkedButton()) { for (auto entry : jsonToStringSet(selectedRarities->data().getArray("rarity"))) rarityFilter.add(RarityNames.getLeft(entry)); } } String filterText; if (auto filterWidget = fetchChild("filter")) filterText = filterWidget->getText(); bool filterHaveMaterials = false; if (m_filterHaveMaterials) filterHaveMaterials = m_filterHaveMaterials->isChecked(); if (m_settings.getBool("printer", false)) { auto objectDatabase = Root::singleton().objectDatabase(); StringList itemList; if (m_player->isAdmin()) itemList = objectDatabase->allObjects(); else itemList = StringList::from(m_player->log()->scannedObjects()); filter(itemList, [objectDatabase, itemDb](String const& itemName) { if (objectDatabase->isObject(itemName)) { if (auto objectConfig = objectDatabase->getConfig(itemName)) return objectConfig->printable && itemDb->hasItem(itemName); } return false; }); float printTime = m_settings.getFloat("printTime", 0); float printFactor = m_settings.getFloat("printCostFactor", 1.0); for (auto& itemName : itemList) { ItemRecipe recipe; recipe.output = ItemDescriptor(itemName, 1); auto recipeItem = itemDb->itemShared(recipe.output); int itemPrice = int(recipeItem->price() * printFactor); recipe.currencyInputs["money"] = itemPrice; recipe.outputRarity = recipeItem->rarity(); recipe.duration = printTime; recipe.guiFilterString = ItemDatabase::guiFilterString(recipeItem); recipe.groups = StringSet{objectDatabase->getConfig(itemName)->category}; recipes.add(recipe); } } else if (m_settings.contains("recipes")) { for (auto& entry : m_settings.getArray("recipes")) { if (entry.type() == Json::Type::String) recipes.addAll(itemDb->recipesForOutputItem(entry.toString())); else recipes.add(itemDb->parseRecipe(entry)); } if (filterHaveMaterials) recipes.addAll(itemDb->recipesFromSubset(m_player->inventory()->availableItems(), m_player->inventory()->availableCurrencies(), take(recipes), m_filter)); } else { if (filterHaveMaterials) recipes.addAll(itemDb->recipesFromBagContents(m_player->inventory()->availableItems(), m_player->inventory()->availableCurrencies(), m_filter)); else recipes.addAll(itemDb->allRecipes(m_filter)); } if (!m_player->isAdmin() && m_settings.getBool("requiresBlueprint", true)) { auto tempRecipes = take(recipes); for (auto const& recipe : tempRecipes) { if (m_blueprints->isKnown(recipe.output)) recipes.add(recipe); } } if (!categoryFilter.empty()) { auto temprecipes = take(recipes); for (auto const& recipe : temprecipes) { if (recipe.groups.hasIntersection(categoryFilter)) recipes.add(recipe); } } if (!rarityFilter.empty()) { auto temprecipes = take(recipes); for (auto const& recipe : temprecipes) { if (recipe.output) { if (rarityFilter.contains(recipe.outputRarity)) recipes.add(recipe); } } } if (!filterText.empty()) { auto bits = filterText.toLower().splitAny(" ,.?*\\+/|\t"); auto temprecipes = take(recipes); for (auto const& recipe : temprecipes) { if (recipe.output) { bool match = true; auto guiFilterString = recipe.guiFilterString; for (auto const& bit : bits) { match &= guiFilterString.contains(bit); if (!match) break; } if (match) recipes.add(recipe); } } } List sortedRecipes = recipes.values(); auto itemDatabase = Root::singleton().itemDatabase(); sortByComputedValue(sortedRecipes, [itemDatabase](ItemRecipe const& recipe) { return make_tuple(itemDatabase->itemFriendlyName(recipe.output.name()).trim().toLower(), recipe.output.name()); }); return sortedRecipes; } int CraftingPane::maxCraft() { if (m_player->isAdmin()) return 1000; auto itemDb = Root::singleton().itemDatabase(); int res = 0; if (m_guiList->selectedItem() != NPos && m_guiList->selectedItem() < m_recipes.size()) { HashMap normalizedBag = m_player->inventory()->availableItems(); auto selectedRecipe = recipeFromSelectedWidget(); res = itemDb->maxCraftableInBag(normalizedBag, m_player->inventory()->availableCurrencies(), selectedRecipe); res = std::min(res, 1000); } return res; } ItemRecipe CraftingPane::recipeFromSelectedWidget() const { auto pane = m_guiList->selectedWidget(); if (pane && m_recipesWidgetMap.hasRightValue(pane)) { return m_recipesWidgetMap.getLeft(pane); } return ItemRecipe(); } }