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

@@ -94,14 +94,6 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
// Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output
// in the UI when unset. Not validated against known items here — a missing
// icon is not an error (REQ-UI-ITEM-ICON).
if (mt.contains("icon"))
{
def.icon = utility::requireString(mt["icon"], file, elemPath + ".icon");
}
cfg.recipes.push_back(std::move(def));
}

View File

@@ -32,10 +32,6 @@ struct RecipeDef
std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs;
double durationSeconds;
// Optional id of the item whose icon represents this recipe in the recipe-
// selection dialog (REQ-UI-RECIPE-ICON). When unset, the first output item is
// used. A missing icon file for that item is not an error (REQ-UI-ITEM-ICON).
std::optional<std::string> icon;
// Assembler only. When true, this recipe is available from game start
// regardless of the implicit item graph — used for base recipes that no
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.

View File

@@ -81,6 +81,33 @@ computeShipyardRequiredMaterials(const GameConfig& config,
}
return requiredMaterials;
}
double computeShipyardProductionTimeSeconds(
const GameConfig& config, const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout)
{
const ShipDef* shipDef = config.ships.findShipDef(recipeId);
if (!shipDef)
{
return 0.0;
}
double seconds = shipDef->schematic.productionTimeSeconds;
if (shipLayout.has_value())
{
for (const PlacedModule& pm : shipLayout->placedModules)
{
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
seconds += modDef->productionTimeSeconds;
}
}
return seconds;
}
bool hasInputsToStart(const GameConfig& config, const Building& b)
{
if (b.type == BuildingType::Shipyard)

View File

@@ -44,6 +44,12 @@ std::map<std::string, int> computeShipyardRequiredMaterials(
const GameConfig& config, const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
// The ship's base production time plus that of every module in the configured layout
// (REQ-MOD-PRODUCTION-TIME), over a stored configuration as above.
double computeShipyardProductionTimeSeconds(
const GameConfig& config, const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
// True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b);

View File

@@ -390,39 +390,6 @@ duration_seconds = 1.0
std::runtime_error);
}
TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "recipes.toml", R"(
[[recipe]]
id = "with_icon"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 1.0
icon = "hardened_steel"
[[recipe]]
id = "without_icon"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 1}]
duration_seconds = 1.0
)");
const RecipesConfig cfg =
ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string());
const RecipeDef& withIcon = cfg.recipes.at(0);
REQUIRE(withIcon.id == "with_icon");
REQUIRE(withIcon.icon.has_value());
REQUIRE(*withIcon.icon == "hardened_steel");
const RecipeDef& withoutIcon = cfg.recipes.at(1);
REQUIRE(withoutIcon.id == "without_icon");
REQUIRE_FALSE(withoutIcon.icon.has_value());
}
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
namespace

View File

@@ -25,7 +25,9 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
@@ -59,7 +61,9 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp

View File

@@ -114,7 +114,7 @@ QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string&
if (withSquare)
{
// Null unless the item has something to draw -- a square, an icon, or both --
// so a caller with nothing to show can fall back to text (REQ-UI-RECIPE-ICON).
// so a caller with nothing to show can fall back to text (REQ-UI-ITEM-ICON).
const bool hasSquare = m_visuals != nullptr
&& m_visuals->items.find(itemId) != m_visuals->items.end();
if (hasSquare || hasIcon(itemId))

View File

@@ -18,9 +18,11 @@ struct VisualsConfig;
//
// The square is the item's `fill` and `outline` from visuals.toml with the icon inset
// within it, and it backs the icon both in the game world and wherever the UI displays
// an item as an item: the recipe-selection dialog's option buttons (REQ-UI-RECIPE-ICON),
// the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), and the
// recipe summary (REQ-UI-RECIPE-SUMMARY). Defined here once rather than per widget, in
// an item as an item: the selection panel's item chips (REQ-UI-SINGLE-SELECTION,
// REQ-UI-HQ-PANEL), and every recipe drawn as a line -- the recipe summary, the
// selection dialog's option buttons, the item production tooltip
// (REQ-UI-RECIPE-SUMMARY, REQ-UI-SELECT-OPTIONS, REQ-UI-ITEM-TOOLTIP). Defined here
// once rather than per widget, in
// two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for
// widgets that want a ready-made pixmap.
//

60
src/ui/ItemProducers.cpp Normal file
View File

@@ -0,0 +1,60 @@
#include "ItemProducers.h"
#include "GameConfig.h"
#include "RecipesConfig.h"
#include "Simulation.h"
namespace
{
bool producesItem(const RecipeDef& recipe, const std::string& itemId)
{
for (const RecipeOutput& output : recipe.outputs)
{
if (output.item == itemId)
{
return true;
}
}
return false;
}
bool isAvailable(const RecipeDef& recipe, const Simulation& sim)
{
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
return sim.isRecipeUnlocked(recipe.id);
}
return sim.isBuildingUnlocked(recipe.building);
}
} // namespace
ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim,
const GameConfig& config)
{
ItemProduction result;
bool anyProducer = false;
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (!producesItem(recipe, itemId)) { continue; }
anyProducer = true;
if (isAvailable(recipe, sim))
{
result.recipes.push_back(&recipe);
}
}
if (!result.recipes.empty())
{
result.origin = ItemOrigin::Produced;
}
else
{
result.origin = anyProducer ? ItemOrigin::Undiscovered : ItemOrigin::Salvaged;
}
return result;
}

35
src/ui/ItemProducers.h Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <vector>
struct GameConfig;
struct RecipeDef;
class Simulation;
// Where an item comes from, as far as the player has discovered (REQ-UI-ITEM-TOOLTIP).
enum class ItemOrigin
{
Produced, // at least one recipe the player can run makes it
Undiscovered, // recipes make it, but none of them is available yet
Salvaged, // no recipe makes it at all -- scrap, which comes from debris
};
struct ItemProduction
{
ItemOrigin origin = ItemOrigin::Salvaged;
// The available producers, in config order. Empty unless origin is Produced.
std::vector<const RecipeDef*> recipes;
};
// Every way the player can currently produce the given item, for the tooltip that
// explains an item chip (REQ-UI-ITEM-TOOLTIP).
//
// What counts as available differs by building type, because only Miner and Assembler
// recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the
// unlock state, while a Smelter's or Reprocessing Plant's implicit recipes
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is
// unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a
// plant the player cannot place.
ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim,
const GameConfig& config);

View File

@@ -224,7 +224,8 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
m_itemIcons.get(), m_buildingIcons.get(), this);
dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command =
@@ -317,7 +318,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
m_layoutBlueprints,
std::move(unlockedModuleIds),
m_gameWorldView->isDebugDrawEnabled(),
this);
m_itemIcons.get(), this);
// Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after
// a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way.
placeOnSelectionPanel(dialog);
@@ -427,7 +428,8 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
bool autoOpenLayout = false;
std::string chosenSchematic;
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this);
RecipeSelectionDialog dialog(options, title, m_itemIcons.get(),
m_buildingIcons.get(), this);
placeOnSelectionPanel(dialog);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{

35
src/ui/OptionButton.cpp Normal file
View File

@@ -0,0 +1,35 @@
#include "OptionButton.h"
#include <QLayout>
#include <QStyle>
#include <QStyleOptionButton>
OptionButton::OptionButton(QWidget* parent)
: QPushButton(parent)
{
}
QSize OptionButton::sizeHint() const
{
if (layout() == nullptr)
{
return QPushButton::sizeHint();
}
return sizeForFace(layout()->sizeHint());
}
QSize OptionButton::minimumSizeHint() const
{
if (layout() == nullptr)
{
return QPushButton::minimumSizeHint();
}
return sizeForFace(layout()->minimumSize());
}
QSize OptionButton::sizeForFace(const QSize& faceSize) const
{
QStyleOptionButton option;
initStyleOption(&option);
return style()->sizeFromContents(QStyle::CT_PushButton, &option, faceSize, this);
}

28
src/ui/OptionButton.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
#include <QPushButton>
#include <QSize>
// A push button whose face is built of widgets rather than of a caption: an option's
// name over the recipe line stating what it makes (REQ-UI-SELECT-OPTIONS), or a module's
// name over what it costs (REQ-MOD-UI-DIALOG).
//
// It exists only to be measured correctly. QPushButton computes its size hint from its
// text and icon and ignores a layout set on it, so a button carrying a face of widgets
// would be sized as if it were empty and clip everything in it. This asks the layout
// instead, and then lets the style add what the button's own frame needs.
class OptionButton : public QPushButton
{
Q_OBJECT
public:
explicit OptionButton(QWidget* parent = nullptr);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
// The style's button size for the given face size, or the plain QPushButton hint
// when there is no face to measure.
QSize sizeForFace(const QSize& faceSize) const;
};

140
src/ui/RecipeLineRow.cpp Normal file
View File

@@ -0,0 +1,140 @@
#include "RecipeLineRow.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLayoutItem>
#include <QPixmap>
#include "BuildingIconCache.h"
#include "ItemIconCache.h"
namespace
{
// Size the items' colored squares and the building's chip are drawn at on a line, in
// device-independent pixels. Larger than the artwork they carry, which the square
// insets (REQ-UI-ITEM-ICON).
const int kIconSizePx = 18;
// Adds a freshly built label to the line and shows it.
//
// The show is what makes it count: a widget created under an already-visible parent
// starts hidden, and a layout treats a hidden item as empty, so an unshown label would
// add nothing to the size hint the surrounding widget measures itself against as soon
// as this returns.
void addAndShow(QHBoxLayout* layout, QLabel* label)
{
layout->addWidget(label);
label->show();
}
} // namespace
RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
QWidget* parent)
: QWidget(parent)
, m_itemIcons(itemIcons)
, m_buildingIcons(buildingIcons)
{
m_layout = new QHBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
hide();
}
void RecipeLineRow::setLine(const Spec& spec)
{
if (spec.isEmpty())
{
// Forgotten as well as hidden, so re-selecting the same recipe later is seen as
// a change and shows the row again.
m_spec = Spec();
hide();
return;
}
if (spec == m_spec)
{
return;
}
m_spec = spec;
rebuild(spec);
show();
}
void RecipeLineRow::rebuild(const Spec& spec)
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget())
{
item->widget()->deleteLater();
}
delete item;
}
// The building the recipe runs in, where the line has to distinguish one producer
// from another (REQ-UI-ITEM-TOOLTIP). A building with no chip file leaves the icon
// off, as everywhere else (REQ-UI-BUILD-ICON).
if (spec.building.has_value() && m_buildingIcons != nullptr)
{
const QPixmap chip =
m_buildingIcons->getChip(buildingTypeId(*spec.building), kIconSizePx);
if (!chip.isNull())
{
QLabel* chipLabel = new QLabel(this);
chipLabel->setPixmap(chip);
addAndShow(m_layout, chipLabel);
}
}
if (!spec.name.isEmpty())
{
addAndShow(m_layout, new QLabel(spec.name, this));
}
addAmounts(spec.inputs);
if (!spec.inputs.empty() && !spec.outputs.empty())
{
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
addAndShow(m_layout, new QLabel(QString(rightArrow), this));
}
addAmounts(spec.outputs);
if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0)
{
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
const QString time = spec.durationIsAddition
? tr("+%1 s").arg(*spec.durationSeconds, 0, 'f', 1)
: tr("%1 s").arg(*spec.durationSeconds, 0, 'f', 1);
addAndShow(m_layout, new QLabel(
QStringLiteral("%1 %2").arg(middleDot).arg(time), this));
}
m_layout->addStretch(1);
}
void RecipeLineRow::addAmounts(const std::vector<Amount>& amounts)
{
for (const Amount& entry : amounts)
{
// The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file
// is not an error: the square stands alone then, and only an item with no square
// either falls back to its id in text.
const QPixmap icon = (m_itemIcons != nullptr)
? m_itemIcons->getSquarePixmap(entry.itemId, kIconSizePx)
: QPixmap();
if (!icon.isNull())
{
QLabel* iconLabel = new QLabel(this);
iconLabel->setPixmap(icon);
addAndShow(m_layout, iconLabel);
}
else
{
addAndShow(m_layout,
new QLabel(QString::fromStdString(entry.itemId), this));
}
addAndShow(m_layout, new QLabel(QString::number(entry.amount), this));
}
}

98
src/ui/RecipeLineRow.h Normal file
View File

@@ -0,0 +1,98 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include <QString>
#include <QWidget>
#include "BuildingType.h"
class BuildingIconCache;
class ItemIconCache;
class QHBoxLayout;
// One recipe drawn on a line: what it consumes, an arrow, what it produces, and how
// long a cycle takes (REQ-UI-RECIPE-SUMMARY). Every place the UI states what something
// makes draws this same line, so a recipe reads alike wherever it is shown:
//
// * beneath the selection button, for the recipe a building is running
// (REQ-UI-RECIPE-SUMMARY);
// * on a selection dialog's option buttons, under the option's name
// (REQ-UI-SELECT-OPTIONS);
// * in an item's production tooltip and in the unlock-choice dialog, there led by the
// icon of the building that runs the recipe and its name
// (REQ-UI-ITEM-TOOLTIP, REQ-DEF-SCHEMATIC-DROP);
// * on a module button and in the layout dialog's build cost, where there is a price
// and a time but nothing produced (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL).
class RecipeLineRow : public QWidget
{
Q_OBJECT
public:
// One item and how many of it the line states.
struct Amount
{
std::string itemId;
int amount = 0;
bool operator==(const Amount& other) const
{
return itemId == other.itemId && amount == other.amount;
}
};
// What the line says. Everything but the inputs is optional, which is what lets the
// one widget serve the summary, the option buttons, the tooltip lines and the cost
// lines.
struct Spec
{
// Drawn as a leading chip when the line has to say which building runs the
// recipe -- an item may have several producers (REQ-UI-ITEM-TOOLTIP). Unset
// wherever the surrounding widget already establishes the building.
std::optional<BuildingType> building;
// Leading recipe name, for the same reason. Empty where the name is the
// caption above the line instead.
QString name;
std::vector<Amount> inputs;
// Empty for a line that produces no item of its own: a ship schematic, or a
// module's price. No arrow is drawn then.
std::vector<Amount> outputs;
std::optional<double> durationSeconds;
// True where the time is added to something else rather than being a cycle of
// its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG).
bool durationIsAddition = false;
bool operator==(const Spec& other) const
{
return building == other.building && name == other.name
&& inputs == other.inputs && outputs == other.outputs
&& durationSeconds == other.durationSeconds
&& durationIsAddition == other.durationIsAddition;
}
bool isEmpty() const { return inputs.empty() && outputs.empty(); }
};
// Both caches may be null, which leaves the icons off: an item with no square and
// no icon falls back to its id, and a building with no chip to its name alone.
// Neither is owned.
RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
QWidget* parent = nullptr);
// Hides the row when the spec has nothing to state, which is how a building with no
// recipe shows no summary at all (REQ-UI-RECIPE-SUMMARY).
void setLine(const Spec& spec);
private:
void rebuild(const Spec& spec);
void addAmounts(const std::vector<Amount>& amounts);
ItemIconCache* m_itemIcons;
BuildingIconCache* m_buildingIcons;
QHBoxLayout* m_layout;
// What the row currently shows, so a refresh at tick rate rebuilds it only when the
// recipe actually changed.
Spec m_spec;
};

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

View File

@@ -8,11 +8,13 @@
#include <QString>
#include "BuildingType.h"
#include "RecipeLineRow.h"
struct GameConfig;
class BuildingIconCache;
class ItemIconCache;
class Simulation;
class QPushButton;
class ItemIconCache;
// One selectable entry in the recipe/schematic selection dialog
// (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id.
@@ -20,23 +22,23 @@ struct RecipeSelectionOption
{
std::string id;
QString caption;
QString tooltip;
// Id of the item whose icon is shown on the option button (REQ-UI-RECIPE-ICON);
// empty for the "(None)" option and for Shipyard schematics, which keep their
// caption. When set but no icon file exists, the button falls back to the caption.
std::string iconItemId;
// What the option makes, drawn beneath its caption on the button
// (REQ-UI-SELECT-OPTIONS). Empty for the "(None)" option, which is a caption alone.
// A ship schematic fills the inputs and the time and leaves the outputs empty: a
// ship is not an item and has no icon, so the caption is what it produces.
RecipeLineRow::Spec line;
};
// Builds the lock-aware list of selectable options for a production building
// of the given type, with display captions and info tooltips
// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-TOOLTIP). The first entry is always a
// of the given type, with display captions and recipe lines
// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-OPTIONS). The first entry is always a
// "(None)" option with an empty id. Shared by the selection dialog and the
// panel selection button so both render identical captions and tooltips, and
// panel selection button so both render identical captions, and
// usable for construction sites which have no Building yet (REQ-BLD-SITE-CONFIG).
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
BuildingType type, Simulation& sim, const GameConfig& config);
// Modal dialog showing a grid of option buttons (REQ-UI-SELECT-BUTTON). The
// Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The
// game is paused by the caller while it is open. Clicking an option selects it
// and closes the dialog; dismissing it (close/Esc) leaves no choice.
class RecipeSelectionDialog : public QDialog
@@ -44,12 +46,10 @@ class RecipeSelectionDialog : public QDialog
Q_OBJECT
public:
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to
// render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose item has
// no icon file fall back to their caption text. Not owned.
// The icon caches draw each option's recipe line (REQ-UI-ITEM-ICON). Not owned.
RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
const QString& title, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
BuildingIconCache* buildingIcons, QWidget* parent = nullptr);
std::optional<std::string> getChosenId() const;

View File

@@ -1,52 +0,0 @@
#include "RecipeTooltip.h"
#include <QObject>
#include <QStringList>
#include "DisplayName.h"
#include "RecipesConfig.h"
namespace
{
QString itemLine(const std::string& itemId, int amount)
{
return QStringLiteral(" ")
+ QString::fromStdString(toDisplayName(itemId))
+ QStringLiteral(" x ") + QString::number(amount);
}
} // namespace
QString buildRecipeTooltip(const RecipeDef& recipe)
{
QStringList lines;
lines << QObject::tr("Recipe: %1")
.arg(QString::fromStdString(toDisplayName(recipe.id)));
if (recipe.inputs.empty())
{
lines << QObject::tr("Inputs: none");
}
else
{
lines << QObject::tr("Inputs:");
for (const RecipeIngredient& ingredient : recipe.inputs)
{
lines << itemLine(ingredient.item, ingredient.amount);
}
}
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
if (!recipe.outputs.empty())
{
lines << QObject::tr("Produces:");
for (const RecipeOutput& output : recipe.outputs)
{
lines << itemLine(output.item, output.amount);
}
}
return lines.join('\n');
}

View File

@@ -1,11 +0,0 @@
#pragma once
#include <QString>
struct RecipeDef;
// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name,
// each input item name and quantity, the completion time, and the produced
// output item name and quantity. Shared by the recipe-selection dialog and the
// schematic choice dialog's "Unlocks recipes:" list so both render identically.
QString buildRecipeTooltip(const RecipeDef& recipe);

View File

@@ -6,12 +6,35 @@
#include <QVBoxLayout>
#include "DisplayName.h"
#include "RecipeTooltip.h"
#include "RecipeLineRow.h"
#include "RecipesConfig.h"
namespace
{
std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients)
{
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;
}
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
return amounts;
}
QString grantKindLabel(SchematicType type)
{
switch (type)
@@ -28,7 +51,8 @@ QString grantKindLabel(SchematicType type)
SchematicChoiceDialog::SchematicChoiceDialog(
const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
const RecipesConfig& recipes, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons,
QWidget* parent)
: QDialog(parent)
, m_chosenIndex(0)
@@ -108,14 +132,23 @@ SchematicChoiceDialog::SchematicChoiceDialog(
{
for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
{
QLabel* recipeLabel = new QLabel(
QString::fromStdString(toDisplayName(recipeId)), card);
recipeLabel->setAlignment(Qt::AlignCenter);
if (const RecipeDef* def = recipes.findRecipeDef(recipeId))
{
recipeLabel->setToolTip(buildRecipeTooltip(*def));
}
cardLayout->addWidget(recipeLabel);
const RecipeDef* def = recipes.findRecipeDef(recipeId);
if (def == nullptr) { continue; }
// The recipe drawn as it is everywhere else, led by the building
// that runs it and its name, so the line says everything there is
// to say about it and carries no tooltip (REQ-DEF-SCHEMATIC-DROP).
RecipeLineRow::Spec spec;
spec.building = def->building;
spec.name = QString::fromStdString(toDisplayName(def->id));
spec.inputs = toAmounts(def->inputs);
spec.outputs = toAmounts(def->outputs);
spec.durationSeconds = def->durationSeconds;
RecipeLineRow* line =
new RecipeLineRow(itemIcons, buildingIcons, card);
cardLayout->addWidget(line);
line->setLine(spec);
}
}
}

View File

@@ -7,14 +7,19 @@
#include "SchematicChoiceOption.h"
struct RecipesConfig;
class BuildingIconCache;
class ItemIconCache;
class SchematicChoiceDialog : public QDialog
{
Q_OBJECT
public:
// The icon caches draw the recipe lines of the "Unlocks recipes" list
// (REQ-DEF-SCHEMATIC-DROP, REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON). Not owned.
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
const RecipesConfig& recipes, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons,
QWidget* parent = nullptr);
int getChosenIndex() const;

View File

@@ -5,11 +5,16 @@
#include <functional>
#include "DisplayName.h"
#include "OptionButton.h"
#include "ProductionRules.h"
#include "RecipeLineRow.h"
#include "SectionBox.h"
#include <QGridLayout>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
@@ -22,6 +27,18 @@
namespace
{
std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients)
{
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;
}
const int kCellSize = 32;
// Empty ship tiles pulse between this grey and white at 1 Hz while a module is
@@ -401,9 +418,11 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
bool debugDraw,
ItemIconCache* itemIcons,
QWidget* parent)
: QDialog(parent)
, m_config(config)
, m_itemIcons(itemIcons)
, m_shipId(shipId)
, m_unlockedModuleIds(std::move(unlockedModuleIds))
, m_rows(0)
@@ -463,10 +482,22 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
// Middle: three-column area (stats | module buttons | blueprints).
QHBoxLayout* columnsLayout = new QHBoxLayout();
// Left column: ship stats panel.
// Left column: ship stats panel, and beneath it what the layout being configured
// would cost to build (REQ-MOD-UI-DIALOG). The cost sits outside the stats panel
// because that widget is shared with the live ship card and the balancing tool,
// neither of which costs anything -- and the balancing tool is deliberately built
// without the item icons this line draws with.
QVBoxLayout* leftLayout = new QVBoxLayout();
m_statsPanel = new ShipStatsPanel(config, this);
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
columnsLayout->addWidget(m_statsPanel);
leftLayout->addWidget(m_statsPanel);
m_buildCostSection = new SectionBox(tr("Build cost"), this);
m_buildCostLine = new RecipeLineRow(m_itemIcons, nullptr, m_buildCostSection);
m_buildCostSection->getContentLayout()->addWidget(m_buildCostLine);
leftLayout->addWidget(m_buildCostSection);
leftLayout->addStretch(1);
columnsLayout->addLayout(leftLayout);
// Center column: module selection buttons.
QVBoxLayout* centerLayout = new QVBoxLayout();
@@ -487,11 +518,34 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
m_moduleButtons.push_back(nullptr);
continue;
}
const QString label = QString::fromStdString(toDisplayName(def.id))
+ "\n" + QString::fromStdString(def.glyph);
QPushButton* btn = new QPushButton(label, this);
// The module's name over what it costs -- its materials and the production time
// it adds (REQ-MOD-UI-DIALOG). The glyph is how a module is identified on the
// layout grid, not here. Both children are transparent to the mouse so a click
// anywhere on the face still reaches the button.
OptionButton* btn = new OptionButton(this);
btn->setCheckable(true);
btn->setFixedHeight(48);
QVBoxLayout* face = new QVBoxLayout(btn);
face->setContentsMargins(8, 4, 8, 4);
face->setSpacing(1);
QLabel* nameLabel =
new QLabel(QString::fromStdString(toDisplayName(def.id)), btn);
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(nameLabel);
RecipeLineRow::Spec cost;
cost.inputs = toAmounts(def.materials);
cost.durationSeconds = def.productionTimeSeconds;
cost.durationIsAddition = true;
RecipeLineRow* costLine = new RecipeLineRow(m_itemIcons, nullptr, btn);
costLine->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(costLine);
costLine->setLine(cost);
// The config tooltip stays: it says what the module does, which the cost line
// does not (REQ-MOD-UI-MODULE-TOOLTIP).
if (def.tooltip)
{
btn->setToolTip(QString::fromStdString(*def.tooltip));
@@ -731,6 +785,29 @@ void ShipLayoutDialog::updateGridWidget()
void ShipLayoutDialog::updateStats()
{
m_statsPanel->refresh(m_shipId, m_placedModules);
// What the layout as it currently stands would cost to build, so the price follows
// every placement and removal (REQ-MOD-UI-DIALOG). The sums come from the
// simulation's own rules rather than being added up a second time here
// (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME).
ShipLayoutConfig layout;
layout.placedModules = m_placedModules;
const std::map<std::string, int> materials =
computeShipyardRequiredMaterials(*m_config, m_shipId, layout);
RecipeLineRow::Spec cost;
cost.inputs.reserve(materials.size());
for (const std::pair<const std::string, int>& entry : materials)
{
cost.inputs.push_back(RecipeLineRow::Amount{ entry.first, entry.second });
}
cost.durationSeconds =
computeShipyardProductionTimeSeconds(*m_config, m_shipId, layout);
m_buildCostLine->setLine(cost);
// A schematic costing no materials at all has no line to draw, and the section then
// stays out of the way.
m_buildCostSection->setVisible(!cost.inputs.empty());
}
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,

View File

@@ -14,7 +14,10 @@
#include "ShipLayout.h"
#include "ShipLayoutBlueprint.h"
class ItemIconCache;
class QPushButton;
class RecipeLineRow;
class SectionBox;
class ShipStatsPanel;
class ShipLayoutDialog : public QDialog
@@ -22,12 +25,15 @@ class ShipLayoutDialog : public QDialog
Q_OBJECT
public:
// itemIcons draws what each module costs and what the layout costs in total
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). Not owned.
ShipLayoutDialog(const GameConfig* config,
const std::string& shipId,
const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
bool debugDraw,
ItemIconCache* itemIcons,
QWidget* parent = nullptr);
std::optional<ShipLayoutConfig> getResult() const;
@@ -60,6 +66,7 @@ private:
void loadLayoutBlueprint(const std::vector<PlacedModule>& modules);
const GameConfig* m_config;
ItemIconCache* m_itemIcons;
std::string m_shipId;
std::set<std::string> m_unlockedModuleIds;
std::vector<std::string> m_shipLayout;
@@ -81,6 +88,8 @@ private:
QPushButton* m_removeButton;
QWidget* m_gridWidget;
ShipStatsPanel* m_statsPanel;
SectionBox* m_buildCostSection;
RecipeLineRow* m_buildCostLine;
bool m_debugDraw;
std::optional<ShipLayoutConfig> m_result;

View File

@@ -10,7 +10,7 @@
#include "DisplayName.h"
#include "FactoryQueries.h"
#include "ProductionSection.h"
#include "RecipeSummaryRow.h"
#include "RecipeLineRow.h"
#include "SectionBox.h"
#include "SelectionNames.h"
#include "Simulation.h"
@@ -18,13 +18,13 @@
namespace
{
std::vector<RecipeSummaryRow::Amount> toAmounts(const std::map<std::string, int>& map)
std::vector<RecipeLineRow::Amount> toAmounts(const std::map<std::string, int>& map)
{
std::vector<RecipeSummaryRow::Amount> amounts;
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(map.size());
for (const std::pair<const std::string, int>& entry : map)
{
amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second });
amounts.push_back(RecipeLineRow::Amount{ entry.first, entry.second });
}
return amounts;
}
@@ -66,17 +66,17 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context
{
// The summary is configuration -- what the building will do -- so it sits with the
// selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY).
m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this);
m_recipeSummary = new RecipeLineRow(context.itemIcons, context.buildingIcons, this);
getConfigurationLayout()->addWidget(m_recipeSummary);
m_inputSection = new SectionBox(tr("Input buffers"), this);
m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection);
m_inputChips = new ItemChipRow(context, m_inputSection);
m_inputSection->getContentLayout()->addWidget(m_inputChips);
m_production = new ProductionSection(this);
m_outputSection = new SectionBox(tr("Output buffer"), this);
m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection);
m_outputChips = new ItemChipRow(context, m_outputSection);
m_outputSection->getContentLayout()->addWidget(m_outputChips);
// In the direction the materials flow: what goes in, what is being made of it, what
@@ -98,10 +98,14 @@ void BufferedBuildingContent::refreshConfiguration()
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
// The card already names the building in its header, so the summary states the cycle
// alone -- no building chip, no recipe name (REQ-UI-RECIPE-SUMMARY).
const CycleInfo cycle = getCycleInfo(target);
m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs),
toAmounts(cycle.perCycleOutputs),
cycle.durationSeconds);
RecipeLineRow::Spec summary;
summary.inputs = toAmounts(cycle.perCycleInputs);
summary.outputs = toAmounts(cycle.perCycleOutputs);
summary.durationSeconds = cycle.durationSeconds;
m_recipeSummary->setLine(summary);
refreshControls(target);
}

View File

@@ -11,7 +11,7 @@
struct Building;
struct BuildingTarget;
class ProductionSection;
class RecipeSummaryRow;
class RecipeLineRow;
class SectionBox;
// Shared body of the four cards that show one building with buffers -- the Miner and
@@ -74,7 +74,7 @@ private:
BuildingId m_id;
RecipeSummaryRow* m_recipeSummary;
RecipeLineRow* m_recipeSummary;
// Input buffers, production progress, output buffer -- in that order, so the card
// reads the way the materials flow (REQ-UI-SINGLE-SELECTION).

View File

@@ -14,7 +14,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
@@ -49,7 +49,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp

View File

@@ -23,7 +23,7 @@ HqContent::HqContent(const SelectionContext& context, const SelectionRequest& re
m_hpBar = new BarRow(tr("HP"), this);
m_stockSection = new SectionBox(tr("Building blocks"), this);
m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection);
m_stockChips = new ItemChipRow(context, m_stockSection);
m_stockSection->getContentLayout()->addWidget(m_stockChips);
// HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL).

View File

@@ -1,11 +1,15 @@
#include "ItemChip.h"
#include <QCursor>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QTimer>
#include <QVBoxLayout>
#include "ItemTooltip.h"
namespace
{
@@ -13,11 +17,18 @@ namespace
const int kCountSizeRisePt = 2;
const int kSubLineSizeDropPt = 1;
// How long the cursor rests on a chip before its tooltip appears, in milliseconds.
// Matches the feel of an ordinary tooltip, which this one replaces (REQ-UI-ITEM-TOOLTIP).
const int kHoverDelayMs = 500;
} // namespace
ItemChip::ItemChip(const QPixmap& icon, QWidget* parent)
ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId,
const QPixmap& icon, QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_itemId(itemId)
{
// Its own boxed chrome, drawn with palette colors like the rest of the panel's
// furniture rather than from visuals.toml, which is for world rendering.
@@ -58,6 +69,11 @@ ItemChip::ItemChip(const QPixmap& icon, QWidget* parent)
layout->addWidget(m_iconLabel);
layout->addWidget(text);
m_hoverTimer = new QTimer(this);
m_hoverTimer->setSingleShot(true);
m_hoverTimer->setInterval(kHoverDelayMs);
connect(m_hoverTimer, &QTimer::timeout, this, &ItemChip::showTooltip);
}
void ItemChip::setCount(const QString& count)
@@ -70,3 +86,28 @@ void ItemChip::setSubLine(const QString& subLine)
m_subLineLabel->setText(subLine);
m_subLineLabel->setVisible(!subLine.isEmpty());
}
void ItemChip::enterEvent(QEvent* event)
{
m_hoverTimer->start();
QWidget::enterEvent(event);
}
void ItemChip::leaveEvent(QEvent* event)
{
m_hoverTimer->stop();
if (m_tooltip != nullptr)
{
m_tooltip->hide();
}
QWidget::leaveEvent(event);
}
void ItemChip::showTooltip()
{
if (m_tooltip == nullptr)
{
m_tooltip = new ItemTooltip(m_context, m_itemId, this);
}
m_tooltip->showAt(QCursor::pos());
}

View File

@@ -1,28 +1,51 @@
#pragma once
#include <string>
#include <QPixmap>
#include <QString>
#include <QWidget>
#include "SelectionContext.h"
class ItemTooltip;
class QEvent;
class QLabel;
class QTimer;
// One buffered item: its icon, its count in a larger type, and a sub-line beneath the
// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities
// rather than as a run of text.
//
// Hovering it explains where its item comes from (REQ-UI-ITEM-TOOLTIP).
class ItemChip : public QWidget
{
Q_OBJECT
public:
// An empty icon leaves the icon off and the chip laid out around the text alone.
ItemChip(const QPixmap& icon, QWidget* parent = nullptr);
ItemChip(const SelectionContext& context, const std::string& itemId,
const QPixmap& icon, QWidget* parent = nullptr);
void setCount(const QString& count);
// Left empty for an item with nothing to say beneath its count.
void setSubLine(const QString& subLine);
protected:
void enterEvent(QEvent* event) override;
void leaveEvent(QEvent* event) override;
private:
QLabel* m_iconLabel;
QLabel* m_countLabel;
QLabel* m_subLineLabel;
void showTooltip();
SelectionContext m_context;
std::string m_itemId;
QLabel* m_iconLabel;
QLabel* m_countLabel;
QLabel* m_subLineLabel;
// Held back so a cursor crossing the chip on its way elsewhere does not flash the
// tooltip, the way an ordinary tooltip is.
QTimer* m_hoverTimer;
// Built on the first hover, because most chips are never hovered at all.
ItemTooltip* m_tooltip = nullptr;
};

View File

@@ -19,9 +19,9 @@ const int kChipsPerRow = 2;
} // namespace
ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent)
ItemChipRow::ItemChipRow(const SelectionContext& context, QWidget* parent)
: QWidget(parent)
, m_itemIcons(itemIcons)
, m_context(context)
{
m_layout = new QGridLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
@@ -68,9 +68,10 @@ void ItemChipRow::rebuildChips(const std::vector<Entry>& entries)
// The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file
// is not an error: the chip then carries the square alone, and only an item with
// no square either leaves the chip laid out around its count alone.
const QPixmap icon = m_itemIcons->getSquarePixmap(itemId, kChipIconSizePx);
const QPixmap icon =
m_context.itemIcons->getSquarePixmap(itemId, kChipIconSizePx);
ItemChip* chip = new ItemChip(icon, this);
ItemChip* chip = new ItemChip(m_context, itemId, icon, this);
m_layout->addWidget(chip, static_cast<int>(index) / kChipsPerRow,
static_cast<int>(index) % kChipsPerRow);
// Shown right away, because the panel measures itself as soon as this returns. A

View File

@@ -6,8 +6,9 @@
#include <QString>
#include <QWidget>
#include "SelectionContext.h"
class ItemChip;
class ItemIconCache;
class QGridLayout;
// The buffered items of one building, each as a chip carrying the item's icon, its
@@ -28,16 +29,18 @@ public:
QString subLine;
};
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
// no icon file simply shows no icon, which is not an error. Not owned.
explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
// The context supplies the icon cache each chip draws its item with
// (REQ-UI-ITEM-ICON) -- an item with no icon file simply shows no icon, which is not
// an error -- and what a chip's tooltip reads to explain its item
// (REQ-UI-ITEM-TOOLTIP).
explicit ItemChipRow(const SelectionContext& context, QWidget* parent = nullptr);
void setEntries(const std::vector<Entry>& entries);
private:
void rebuildChips(const std::vector<Entry>& entries);
ItemIconCache* m_itemIcons;
SelectionContext m_context;
QGridLayout* m_layout;
std::vector<std::string> m_itemIds; // what the chips currently stand for
std::vector<ItemChip*> m_chips;

View File

@@ -0,0 +1,163 @@
#include "ItemTooltip.h"
#include <QFont>
#include <QGuiApplication>
#include <QLabel>
#include <QLayoutItem>
#include <QPalette>
#include <QRect>
#include <QScreen>
#include <QVBoxLayout>
#include "DisplayName.h"
#include "GameConfig.h"
#include "ItemProducers.h"
#include "RecipeLineRow.h"
#include "RecipesConfig.h"
#include "Simulation.h"
namespace
{
// Gap between the cursor and the tooltip's corner, in device-independent pixels. Wide
// enough that the cursor never covers the heading.
const int kCursorOffsetPx = 16;
std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients)
{
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;
}
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(outputs.size());
for (const RecipeOutput& output : outputs)
{
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
}
return amounts;
}
// The screen the cursor is on, falling back to the primary screen when the position is
// on none of them (a cursor between two screens of different heights).
QRect availableScreenRect(const QPoint& globalPos)
{
const QScreen* screen = QGuiApplication::screenAt(globalPos);
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
return (screen != nullptr) ? screen->availableGeometry() : QRect();
}
} // namespace
ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId,
QWidget* parent)
: QFrame(parent, Qt::ToolTip)
, m_context(context)
, m_itemId(itemId)
{
// The palette's tooltip colors behind a themed border, so it reads as a tooltip
// rather than as a stray window.
setFrameShape(QFrame::StyledPanel);
setAutoFillBackground(true);
QPalette tooltipPalette = palette();
tooltipPalette.setColor(QPalette::Window,
tooltipPalette.color(QPalette::ToolTipBase));
tooltipPalette.setColor(QPalette::WindowText,
tooltipPalette.color(QPalette::ToolTipText));
setPalette(tooltipPalette);
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(6, 4, 6, 4);
m_layout->setSpacing(2);
}
void ItemTooltip::showAt(const QPoint& globalPos)
{
rebuild();
adjustSize();
// Kept on the screen the cursor is on: a tooltip for a chip near the right or
// bottom edge would otherwise be cut off.
const QRect screen = availableScreenRect(globalPos);
QPoint topLeft(globalPos.x() + kCursorOffsetPx, globalPos.y() + kCursorOffsetPx);
if (screen.isValid())
{
topLeft.setX(qMax(screen.left(),
qMin(topLeft.x(), screen.right() - width() + 1)));
topLeft.setY(qMax(screen.top(),
qMin(topLeft.y(), screen.bottom() - height() + 1)));
}
move(topLeft);
show();
}
void ItemTooltip::rebuild()
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget())
{
item->widget()->deleteLater();
}
delete item;
}
// The heading names the item. For an input chip this is the only place it is named
// at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
QLabel* heading =
new QLabel(QString::fromStdString(toDisplayName(m_itemId)), this);
QFont headingFont = heading->font();
headingFont.setBold(true);
heading->setFont(headingFont);
m_layout->addWidget(heading);
heading->show();
const ItemProduction production =
findItemProduction(m_itemId, *m_context.sim, *m_context.config);
// Scrap is salvaged from debris rather than crafted (REQ-RES-DEBRIS-DROP), so it
// says where it comes from in place of the caption and lists nothing.
QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged
? tr("Salvaged from debris")
: tr("Produced by"), this);
m_layout->addWidget(caption);
caption->show();
if (production.origin == ItemOrigin::Undiscovered)
{
// Made somehow, but by nothing the player has unlocked -- named without being
// shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP).
QLabel* undiscovered = new QLabel(tr("Undiscovered"), this);
m_layout->addWidget(undiscovered);
undiscovered->show();
return;
}
for (const RecipeDef* recipe : production.recipes)
{
RecipeLineRow::Spec spec;
spec.building = recipe->building;
spec.name = QString::fromStdString(toDisplayName(recipe->id));
spec.inputs = toAmounts(recipe->inputs);
spec.outputs = toAmounts(recipe->outputs);
spec.durationSeconds = recipe->durationSeconds;
RecipeLineRow* line =
new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this);
m_layout->addWidget(line);
line->setLine(spec);
}
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <QFrame>
#include "SelectionContext.h"
class QVBoxLayout;
// The tooltip an item chip shows on hover: the item's name, and every way the player
// can currently produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP).
//
// A window of its own rather than a Qt tooltip, because Qt's are text and this one is
// drawn -- item squares, building chips and all. It is a Qt::ToolTip window, so it
// floats above everything and takes no input.
class ItemTooltip : public QFrame
{
Q_OBJECT
public:
ItemTooltip(const SelectionContext& context, const std::string& itemId,
QWidget* parent = nullptr);
// Rebuilds the content and shows the tooltip near the given screen position, kept
// inside the screen. Rebuilt on every show because what produces an item changes as
// the player unlocks recipes (REQ-LOCK-UI-RECIPE).
void showAt(const QPoint& globalPos);
private:
void rebuild();
SelectionContext m_context;
std::string m_itemId;
QVBoxLayout* m_layout;
};

View File

@@ -45,13 +45,14 @@ void RecipeSelectionControl::setRecipeId(const std::string& recipeId)
if (option.id == recipeId && !option.id.empty())
{
m_button->setText(option.caption);
m_button->setToolTip(option.tooltip);
return;
}
}
// No tooltip of its own: what the building has selected is drawn beneath the button
// as the recipe summary, and where its items come from is reachable by hovering the
// card's item chips (REQ-UI-SELECT-BUTTON, REQ-UI-ITEM-TOOLTIP).
m_button->setText(m_type == BuildingType::Shipyard
? tr("Select schematic")
: tr("Select recipe"));
m_button->setToolTip(QString());
}

View File

@@ -1,123 +0,0 @@
#include "RecipeSummaryRow.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLayoutItem>
#include <QPixmap>
#include "ItemIconCache.h"
namespace
{
// Size the items' colored squares are drawn at on the summary line, in device-independent
// pixels. Larger than the artwork they carry, which the square insets (REQ-UI-ITEM-ICON).
const int kSummaryIconSizePx = 18;
// Adds a freshly built label to the summary and shows it.
//
// The show is what makes it count: a widget created under an already-visible parent
// starts hidden, and a layout treats a hidden item as empty, so an unshown label would
// add nothing to the size hint the panel measures itself against as soon as this
// returns.
void addAndShow(QHBoxLayout* layout, QLabel* label)
{
layout->addWidget(label);
label->show();
}
} // namespace
RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_itemIcons(itemIcons)
{
m_layout = new QHBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
hide();
}
void RecipeSummaryRow::setSummary(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs,
double durationSeconds)
{
if (outputs.empty() && inputs.empty())
{
// Forgotten as well as hidden, so re-selecting the same recipe later is seen as
// a change and shows the row again.
m_inputs.clear();
m_outputs.clear();
m_durationSeconds = -1.0;
hide();
return;
}
if (inputs == m_inputs && outputs == m_outputs
&& durationSeconds == m_durationSeconds)
{
return;
}
m_inputs = inputs;
m_outputs = outputs;
m_durationSeconds = durationSeconds;
rebuild(inputs, outputs, durationSeconds);
show();
}
void RecipeSummaryRow::rebuild(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs,
double durationSeconds)
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget())
{
item->widget()->deleteLater();
}
delete item;
}
addAmounts(inputs);
if (!inputs.empty())
{
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
addAndShow(m_layout, new QLabel(QString(rightArrow), this));
}
addAmounts(outputs);
if (durationSeconds > 0.0)
{
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
addAndShow(m_layout, new QLabel(
QStringLiteral("%1 %2").arg(middleDot).arg(
tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this));
}
m_layout->addStretch(1);
}
void RecipeSummaryRow::addAmounts(const std::vector<Amount>& amounts)
{
for (const Amount& entry : amounts)
{
// The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file
// is not an error: the square stands alone then, and only an item with no square
// either falls back to its id in text.
const QPixmap icon =
m_itemIcons->getSquarePixmap(entry.itemId, kSummaryIconSizePx);
if (!icon.isNull())
{
QLabel* iconLabel = new QLabel(this);
iconLabel->setPixmap(icon);
addAndShow(m_layout, iconLabel);
}
else
{
addAndShow(m_layout,
new QLabel(QString::fromStdString(entry.itemId), this));
}
addAndShow(m_layout, new QLabel(QString::number(entry.amount), this));
}
}

View File

@@ -1,52 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <QWidget>
class ItemIconCache;
class QHBoxLayout;
// What one production cycle does, on a line: each input item with its per-cycle amount,
// an arrow, each output with its amount, and the cycle time
// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to
// open the selection dialog, and it is the panel's only display of the cycle time.
class RecipeSummaryRow : public QWidget
{
Q_OBJECT
public:
struct Amount
{
std::string itemId;
int amount = 0;
bool operator==(const Amount& other) const
{
return itemId == other.itemId && amount == other.amount;
}
};
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
// no icon file falls back to its id. Not owned.
explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
// Hides the row when there is nothing selected to produce, which is how a building
// with no recipe shows no summary at all.
void setSummary(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs, double durationSeconds);
private:
void rebuild(const std::vector<Amount>& inputs, const std::vector<Amount>& outputs,
double durationSeconds);
void addAmounts(const std::vector<Amount>& amounts);
ItemIconCache* m_itemIcons;
QHBoxLayout* m_layout;
// What the row currently shows, so a refresh at tick rate rebuilds it only when the
// recipe actually changed.
std::vector<Amount> m_inputs;
std::vector<Amount> m_outputs;
double m_durationSeconds = -1.0;
};

View File

@@ -83,20 +83,11 @@ BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo(
*getContext().config, target.recipeId, target.shipLayout);
// A shipyard's output is the ship itself, which never lands in an item buffer -- so
// the summary shows one ship rather than an item id.
info.durationSeconds = shipDef->schematic.productionTimeSeconds;
if (target.shipLayout.has_value())
{
for (const PlacedModule& placed : target.shipLayout->placedModules)
{
const ModuleDef* moduleDef =
getContext().config->modules.findModuleDef(placed.moduleId);
if (moduleDef)
{
info.durationSeconds += moduleDef->productionTimeSeconds;
}
}
}
// the summary shows no output item at all. The time is the schematic's plus its
// modules', from the same place the layout dialog's build cost reads it
// (REQ-MOD-PRODUCTION-TIME).
info.durationSeconds = computeShipyardProductionTimeSeconds(
*getContext().config, target.recipeId, target.shipLayout);
info.runsProduction = true;
return info;
}