Implement REQ-BLD-COPY-CONFIG-FEEDBACK: copy-settings visual feedback

Add on-screen feedback for the Shift copy-settings gesture, driven by a
single configurable color (visuals.toml [overlays].copy_config):
- eligible-target tint over every same-type building/site while a config
  is cached (source included), shown only while a paste is possible;
- a brief selection-style outline flash on copy (source) and paste (target).

Flashes use a wall-clock countdown (frame delta), so they play for a fixed
0.3 s regardless of game speed and while paused, and fade independently per
building. Extract the footprint-rect resolution shared by the selection
highlight and the new feedback into footprintWidgetRect(). Pure rendering;
no simulation or selection state touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNNLeFWhVzvxkK9qVXP2K
This commit is contained in:
2026-07-09 21:03:38 +02:00
parent 56e963679c
commit f8e9bfb845
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)
tile_highlight = "#ffffff22" # tile under cursor
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)

View File

@@ -271,6 +271,20 @@ void GameWorldView::onFrame()
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
{
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
@@ -384,6 +398,7 @@ void GameWorldView::paintGL()
drawTiles(painter);
drawBuildings(painter);
drawCopyConfigFeedback(painter);
drawStations(painter);
drawBeltItems(painter);
drawScrap(painter);
@@ -998,6 +1013,29 @@ void GameWorldView::drawBuildings(QPainter& 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)
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
@@ -1005,26 +1043,47 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter)
for (BuildingId selId : m_selectedBuildingIds)
{
std::optional<QPoint> anchor;
std::optional<QSize> footprint;
const std::optional<QRectF> rect = footprintWidgetRect(selId);
if (!rect.has_value()) { continue; }
// Outline sits 1px outside the footprint (into adjacent tiles).
painter.drawRect(rect->adjusted(-1, -1, 1, 1));
}
}
if (const Building* b = m_sim->buildings().findBuilding(selId))
{
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; }
void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
{
const QColor color = m_visuals->overlays.copyConfig;
const QPointF tl = tileToWidget(*anchor);
const QRectF bboxRect(tl.x(), tl.y(),
footprint->width() * static_cast<qreal>(tilePx()),
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; }
m_copiedConfig = config;
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
}
void GameWorldView::pasteConfigTo(BuildingId id)
@@ -1914,6 +1974,9 @@ void GameWorldView::pasteConfigTo(BuildingId id)
const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id);
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;
// 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));
m_selectedBuildingIds.clear();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false;
m_scrollXTiles = 0.0f;
m_scrollLeft = false;

View File

@@ -117,6 +117,7 @@ private:
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter);
void drawScrap(QPainter& painter);
@@ -138,6 +139,10 @@ private:
QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) 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 enemyStationRightEdge() const;
@@ -226,6 +231,18 @@ private:
// only while Shift is down and cleared on Shift release.
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;
BuildingId m_demolishHoverBuildingId;
bool m_debugDraw;

View File

@@ -48,6 +48,7 @@ struct OverlayVisuals
QColor selectionRect;
QColor tileHighlight;
QColor selectedOutline;
QColor copyConfig;
};
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.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
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