show recipes visually instead of describing them in text

This commit is contained in:
2026-08-11 21:40:36 +02:00
parent 3274df91d4
commit f255224ccd
38 changed files with 1023 additions and 452 deletions

View File

@@ -2,60 +2,46 @@
#include <cstddef>
#include <QGridLayout>
#include <QIcon>
#include <QPixmap>
#include <QFont>
#include <QFrame>
#include <QLabel>
#include <QPushButton>
#include <QSize>
#include <QStringList>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingType.h"
#include "DisplayName.h"
#include "GameConfig.h"
#include "ItemIconCache.h"
#include "OptionButton.h"
#include "RecipesConfig.h"
#include "RecipeTooltip.h"
#include "ShipsConfig.h"
#include "Simulation.h"
namespace
{
QString itemLine(const std::string& itemId, int amount)
std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients)
{
return QStringLiteral(" ")
+ QString::fromStdString(toDisplayName(itemId))
+ QStringLiteral(" x ") + QString::number(amount);
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(ingredients.size());
for (const RecipeIngredient& ingredient : ingredients)
{
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
}
return amounts;
}
QString shipTooltip(const ShipDef& def)
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
const QString name = QString::fromStdString(toDisplayName(def.id));
QStringList lines;
lines << QObject::tr("Ship: %1").arg(name);
if (def.schematic.materials.empty())
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
lines << QObject::tr("Materials: none");
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
else
{
lines << QObject::tr("Materials:");
for (const RecipeIngredient& material : def.schematic.materials)
{
lines << itemLine(material.item, material.amount);
}
lines << QObject::tr(" + installed modules");
}
lines << QObject::tr("Completion time: %1 s")
.arg(def.schematic.productionTimeSeconds);
lines << QObject::tr("Produces: 1 %1").arg(name);
return lines.join('\n');
return amounts;
}
} // namespace
@@ -64,16 +50,24 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
BuildingType type, Simulation& sim, const GameConfig& config)
{
std::vector<RecipeSelectionOption> options;
options.push_back({std::string(), QObject::tr("(None)"), QString()});
options.push_back({std::string(), QObject::tr("(None)"), RecipeLineRow::Spec()});
if (type == BuildingType::Shipyard)
{
for (const ShipDef& def : config.ships.ships)
{
if (!sim.isSchematicUnlocked(def.id)) { continue; }
// The base materials and base production time, excluding the modules the
// player has not placed yet (REQ-UI-SELECT-OPTIONS). No output item: a ship
// has no icon, so the caption is what the option produces.
RecipeLineRow::Spec line;
line.inputs = toAmounts(def.schematic.materials);
line.durationSeconds = def.schematic.productionTimeSeconds;
options.push_back({def.id,
QString::fromStdString(toDisplayName(def.id)),
shipTooltip(def)});
line});
}
}
else
@@ -87,16 +81,15 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
{
continue;
}
// Icon shown on the option button (REQ-UI-RECIPE-ICON): the recipe's
// configured icon item, else its first output item.
const std::string iconItemId = recipe.icon
? *recipe.icon
: (recipe.outputs.empty() ? std::string()
: recipe.outputs.front().item);
RecipeLineRow::Spec line;
line.inputs = toAmounts(recipe.inputs);
line.outputs = toAmounts(recipe.outputs);
line.durationSeconds = recipe.durationSeconds;
options.push_back({recipe.id,
QString::fromStdString(toDisplayName(recipe.id)),
buildRecipeTooltip(recipe),
iconItemId});
line});
}
}
@@ -105,51 +98,77 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
namespace
{
// On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON).
const QSize kOptionIconSize(32, 32);
// Height the option list is allowed to take, as the dialog's parent window's height
// less what the window margins and the dialog's own frame need. Fully unlocked, an
// Assembler offers more options than any window can hold, and the modal is never
// resized to fit (REQ-UI-PANEL-MODAL) -- so the list scrolls instead.
const int kListHeightReservePx = 80;
// Fallback cap for a dialog built without a parent window to measure.
const int kFallbackListHeightPx = 600;
}
RecipeSelectionDialog::RecipeSelectionDialog(
const std::vector<RecipeSelectionOption>& options,
const QString& title, ItemIconCache* itemIcons, QWidget* parent)
const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
QWidget* parent)
: QDialog(parent)
{
setWindowTitle(title);
setModal(true);
QVBoxLayout* mainLayout = new QVBoxLayout(this);
QGridLayout* grid = new QGridLayout();
mainLayout->addLayout(grid);
const int columnCount = 2;
// One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS).
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents);
scrollArea->setMaximumHeight(parent != nullptr
? qMax(kListHeightReservePx, parent->height() - kListHeightReservePx)
: kFallbackListHeightPx);
mainLayout->addWidget(scrollArea);
QWidget* list = new QWidget(scrollArea);
QVBoxLayout* listLayout = new QVBoxLayout(list);
listLayout->setContentsMargins(0, 0, 0, 0);
listLayout->setSpacing(4);
for (int i = 0; i < static_cast<int>(options.size()); ++i)
{
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)];
QPushButton* button = new QPushButton(this);
// Icon-only for a recipe option: the produced item's icon on its colored square,
// or the square alone when the item has no icon file (REQ-UI-RECIPE-ICON). The
// name stays reachable via the tooltip. Options carrying no item at all -- the
// "(None)" entry and the Shipyard's schematics -- keep their text caption, as
// does an item with neither square nor icon to show.
const QPixmap icon = option.iconItemId.empty()
? QPixmap()
: itemIcons->getSquarePixmap(option.iconItemId, kOptionIconSize.width());
if (!icon.isNull())
// An OptionButton so the face of widgets below is what sizes the button
// (REQ-UI-SELECT-OPTIONS).
OptionButton* button = new OptionButton(list);
if (option.line.isEmpty())
{
button->setIcon(QIcon(icon));
button->setIconSize(kOptionIconSize);
// The "(None)" option makes nothing and is a caption alone
// (REQ-UI-SELECT-OPTIONS).
button->setText(option.caption);
}
else
{
button->setText(option.caption);
// The name over the recipe it stands for. Both are transparent to the mouse
// so a click anywhere on the face still reaches the button beneath them.
QVBoxLayout* face = new QVBoxLayout(button);
face->setContentsMargins(8, 6, 8, 6);
face->setSpacing(2);
QLabel* nameLabel = new QLabel(option.caption, button);
QFont nameFont = nameLabel->font();
nameFont.setBold(true);
nameLabel->setFont(nameFont);
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(nameLabel);
RecipeLineRow* line = new RecipeLineRow(itemIcons, buildingIcons, button);
line->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(line);
line->setLine(option.line);
}
if (!option.tooltip.isEmpty())
{
button->setToolTip(option.tooltip);
}
button->setMinimumHeight(40);
grid->addWidget(button, i / columnCount, i % columnCount);
listLayout->addWidget(button);
m_optionIds.push_back(option.id);
const int index = i;
@@ -158,6 +177,9 @@ RecipeSelectionDialog::RecipeSelectionDialog(
onOptionClicked(index);
});
}
listLayout->addStretch(1);
scrollArea->setWidget(list);
}
std::optional<std::string> RecipeSelectionDialog::getChosenId() const