implement visual feedback for copy building settings with shift + click gesture

This commit is contained in:
2026-07-09 21:33:24 +02:00
parent cd966daba9
commit 5f6ecbf6c8
5 changed files with 102 additions and 18 deletions

View File

@@ -348,6 +348,7 @@ demolish_tint = "#ff000033" # demolish-mode hover tint
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT) selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
tile_highlight = "#ffffff22" # tile under cursor tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s) selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST) # Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)

View File

@@ -271,6 +271,20 @@ void GameWorldView::onFrame()
m_activeBeams = std::move(live); m_activeBeams = std::move(live);
} }
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
// flash plays for a fixed real duration regardless of game speed, including
// while the game is paused (REQ-BLD-COPY-CONFIG-FEEDBACK).
if (!m_copyConfigFlashes.empty())
{
std::vector<CopyConfigFlash> live;
for (CopyConfigFlash flash : m_copyConfigFlashes)
{
flash.remainingMs -= elapsed;
if (flash.remainingMs > 0) { live.push_back(flash); }
}
m_copyConfigFlashes = std::move(live);
}
// Apply held scroll // Apply held scroll
{ {
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED). // Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
@@ -384,6 +398,7 @@ void GameWorldView::paintGL()
drawTiles(painter); drawTiles(painter);
drawBuildings(painter); drawBuildings(painter);
drawCopyConfigFeedback(painter);
drawStations(painter); drawStations(painter);
drawBeltItems(painter); drawBeltItems(painter);
drawScrap(painter); drawScrap(painter);
@@ -998,6 +1013,29 @@ void GameWorldView::drawBuildings(QPainter& painter)
drawSelectionHighlights(painter); drawSelectionHighlights(painter);
} }
std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
{
std::optional<QPoint> anchor;
std::optional<QSize> footprint;
if (const Building* b = m_sim->buildings().findBuilding(id))
{
anchor = b->anchor;
footprint = b->footprint;
}
else if (const ConstructionSite* s = m_sim->buildings().findSite(id))
{
anchor = s->anchor;
footprint = s->footprint;
}
if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }
const QPointF tl = tileToWidget(*anchor);
return QRectF(tl.x(), tl.y(),
footprint->width() * static_cast<qreal>(tilePx()),
footprint->height() * static_cast<qreal>(tilePx()));
}
void GameWorldView::drawSelectionHighlights(QPainter& painter) void GameWorldView::drawSelectionHighlights(QPainter& painter)
{ {
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
@@ -1005,26 +1043,47 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter)
for (BuildingId selId : m_selectedBuildingIds) for (BuildingId selId : m_selectedBuildingIds)
{ {
std::optional<QPoint> anchor; const std::optional<QRectF> rect = footprintWidgetRect(selId);
std::optional<QSize> footprint; if (!rect.has_value()) { continue; }
// Outline sits 1px outside the footprint (into adjacent tiles).
if (const Building* b = m_sim->buildings().findBuilding(selId)) painter.drawRect(rect->adjusted(-1, -1, 1, 1));
{
anchor = b->anchor;
footprint = b->footprint;
} }
else if (const ConstructionSite* s = m_sim->buildings().findSite(selId)) }
{
anchor = s->anchor;
footprint = s->footprint;
}
if (!anchor.has_value() || !footprint.has_value()) { continue; }
const QPointF tl = tileToWidget(*anchor); void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
const QRectF bboxRect(tl.x(), tl.y(), {
footprint->width() * static_cast<qreal>(tilePx()), const QColor color = m_visuals->overlays.copyConfig;
footprint->height() * static_cast<qreal>(tilePx()));
painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1)); // Eligible-target tint: while a configuration is cached, every same-type
// building and site (the source included) is a valid paste target and is
// washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK).
if (m_copiedConfig.has_value())
{
painter.setPen(Qt::NoPen);
painter.setBrush(color);
const BuildingType type = m_copiedConfig->type;
for (const Building& b : m_sim->buildings().allBuildings())
{
if (b.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(b.id);
if (rect.has_value()) { painter.drawRect(*rect); }
}
for (const ConstructionSite& s : m_sim->buildings().allSites())
{
if (s.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(s.id);
if (rect.has_value()) { painter.drawRect(*rect); }
}
}
// Copy / paste flashes: a brief outline in the same color, drawn like the
// selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK).
painter.setPen(QPen(color, 2));
painter.setBrush(Qt::NoBrush);
for (const CopyConfigFlash& flash : m_copyConfigFlashes)
{
const std::optional<QRectF> rect = footprintWidgetRect(flash.id);
if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); }
} }
} }
@@ -1905,6 +1964,7 @@ void GameWorldView::copyConfigFrom(BuildingId id)
if (!config->recipeId.has_value() && !config->isSplitter) { return; } if (!config->recipeId.has_value() && !config->isSplitter) { return; }
m_copiedConfig = config; m_copiedConfig = config;
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
} }
void GameWorldView::pasteConfigTo(BuildingId id) void GameWorldView::pasteConfigTo(BuildingId id)
@@ -1914,6 +1974,9 @@ void GameWorldView::pasteConfigTo(BuildingId id)
const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id); const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id);
if (!target.has_value() || target->type != m_copiedConfig->type) { return; } if (!target.has_value() || target->type != m_copiedConfig->type) { return; }
// The paste applies below; flash the target to confirm (REQ-BLD-COPY-CONFIG-FEEDBACK).
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
const BuildingConfig& source = *m_copiedConfig; const BuildingConfig& source = *m_copiedConfig;
// The cached settings were valid on a same-type source building, so they are // The cached settings were valid on a same-type source building, so they are
@@ -2041,6 +2104,7 @@ void GameWorldView::resetForNewGame()
std::make_shared<DemolishModeChangedEvent>(false)); std::make_shared<DemolishModeChangedEvent>(false));
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
m_copiedConfig = std::nullopt; m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false; m_boxSelecting = false;
m_scrollXTiles = 0.0f; m_scrollXTiles = 0.0f;
m_scrollLeft = false; m_scrollLeft = false;

View File

@@ -117,6 +117,7 @@ private:
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter); void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter); void drawBeltItems(QPainter& painter);
void drawScrap(QPainter& painter); void drawScrap(QPainter& painter);
@@ -138,6 +139,10 @@ private:
QPoint widgetToTile(QPoint widgetPt) const; QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const; QRectF tileRect(QPoint tile) const;
QRect viewportRect() const; QRect viewportRect() const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
float asteroidLeftEdge() const; float asteroidLeftEdge() const;
float enemyStationRightEdge() const; float enemyStationRightEdge() const;
@@ -226,6 +231,18 @@ private:
// only while Shift is down and cleared on Shift release. // only while Shift is down and cleared on Shift release.
std::optional<BuildingConfig> m_copiedConfig; std::optional<BuildingConfig> m_copiedConfig;
// Brief outline flash shown on a building when settings are copied from it or
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
std::vector<CopyConfigFlash> m_copyConfigFlashes;
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_demolishMode; bool m_demolishMode;
BuildingId m_demolishHoverBuildingId; BuildingId m_demolishHoverBuildingId;
bool m_debugDraw; bool m_debugDraw;

View File

@@ -48,6 +48,7 @@ struct OverlayVisuals
QColor selectionRect; QColor selectionRect;
QColor tileHighlight; QColor tileHighlight;
QColor selectedOutline; QColor selectedOutline;
QColor copyConfig;
}; };
struct ToastVisuals struct ToastVisuals

View File

@@ -224,6 +224,7 @@ VisualsConfig VisualsLoader::load(const std::string& path)
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect"); cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight"); cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config");
} }
// Toast // Toast