Files
dota_factory/src/ui/GameWorldView.cpp
Malte Langkabel 57ab63202f float the build buttons as a bar over the game world
Implements the REQ-UI-BUILD-BAR rewrite: the build buttons leave the side
panel column for a widget floating at the bottom center of the game world
view, as one non-wrapping row. BuildButtonGrid is renamed BuildButtonBar and
its QGridLayout becomes a QHBoxLayout; the side panel column is left with two
equal-height panels.

Buttons become icon-only. Each face is composed into a single pixmap per
QIcon mode - hotkey badge in the top-left corner, chip icon centered, cost
and block icon below - because a QPushButton holds only one icon. That
replaces the custom-painted BuildButton and lets Qt grey an unaffordable
button by swapping the pixmap. The building name moves into the tooltip,
which now always leads with it, and a missing chip SVG falls back to the name
in the chip's place.

The badge labels come from InputMapper, searched out of the same table the
key handler uses so a badge cannot claim a key that does nothing. The
Deconstruct button has no cost, so it shows its name there instead, and sits
last behind a gap.

Parenting the bar to MainWindow makes creation order the stacking order: it
lands above the world view and its vignettes and below the modal dim, and Qt
routes mouse events to it rather than the world, which is the whole input
clause of REQ-UI-BUILD-BAR at no cost.

Requirements are amended for the two things the mockup added: the hotkey
badge and the Deconstruct caption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-06 08:12:38 +02:00

1656 lines
58 KiB
C++

#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include <algorithm>
#include <cctype>
#include <climits>
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <QColor>
#include <QCoreApplication>
#include <QCursor>
#include <QDir>
#include <QFile>
#include <QFocusEvent>
#include <QFont>
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QRadialGradient>
#include <QFontMetrics>
#include <QStringList>
#include <QTimer>
#include "Building.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "DeconstructModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EventManager.h"
#include "FactionComponent.h"
#include "GameOverEvent.h"
#include "HealthComponent.h"
#include "ItemIconCache.h"
#include "PositionComponent.h"
#include "DebrisSystem.h"
#include "SelectionChangedEvent.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "DebrisComponent.h"
#include "SurfaceMask.h"
#include "Tick.h"
#include "TunnelCompletion.h"
#include "EscapeMenuRequestedEvent.h"
#include "TracePrintRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "BossWaveUpdatedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "UnlockedBuildingsChangedEvent.h"
#include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
namespace
{
// Keep only the filter entries whose item type is currently unlocked
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
const Simulation& sim)
{
std::vector<ItemType> result;
for (const ItemType& item : filter)
{
if (sim.isItemUnlocked(item.id)) { result.push_back(item); }
}
return result;
}
Rotation rotateClockwise(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
Rotation rotateCounterClockwise(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
} // namespace
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir,
ItemIconCache* itemIcons, const ParsedReplay* replay,
QWidget* parent)
: QOpenGLWidget(parent)
, m_sim(sim)
, m_config(config)
, m_visuals(visuals)
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0)
, m_camera(config->world.scroll, config->world.regions)
, m_debugDraw(false)
, m_rng(std::random_device{}())
, m_boxSelecting(false)
, m_gameOverShown(false)
, m_schematicChoiceShown(false)
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
m_renderer = std::make_unique<WorldRenderer>(*sim, *visuals, itemIcons, configDir);
m_renderTimer = new QTimer(this);
m_renderTimer->setInterval(16);
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
m_renderTimer->start();
m_frameTimer.start();
registerForEvents();
if (replay)
{
// View-only playback: ignore live input and drive ticks from the recorded
// stream. No recorder (we are not creating a new run).
m_commandManager.setReplayMode(true);
m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
m_replayPlayer->start(); // process tick-0 entries before the first tick
}
else
{
// Record every run to disk, into a "replays" folder next to the executable.
// Uses the exe's real directory rather than traversing up from the config
// dir: with a symlinked build layout, relative traversal would resolve into
// the wrong tree. Attaching the recorder opens the first file and writes the
// header for the initial run.
const QString replayDir =
QDir(QCoreApplication::applicationDirPath()).filePath("replays");
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
configDir, replayDir.toStdString()));
}
}
GameWorldView::~GameWorldView()
{
unregisterForEvents();
}
void GameWorldView::initializeGL()
{
// QPainter handles all rendering; no custom GL state needed.
}
void GameWorldView::onFrame()
{
const qint64 elapsed = m_frameTimer.restart();
if (m_replayPlayer)
{
// Playback: apply recorded commands at their ticks and verify checksums.
// Manual speed/pause still works; playback only moves forward.
const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i)
{
// Stop at the recorded stream's end, or at the run's terminal state:
// the real game froze when it was won or lost, so the replay does too
// (the recording may run a few ticks past that point).
if (m_replayPlayer->isFinished()
|| m_sim->isWon() || m_sim->isGameOver()) { break; }
m_sim->tick();
m_replayPlayer->advanceTo(m_sim->getCurrentTick());
}
}
else
{
// Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md).
const bool commandsApplied = m_commandManager.hasPending();
m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match.
if (m_viewResetPending)
{
m_viewResetPending = false;
resetForNewGame();
// The reset command may have drained after a long-open modal (e.g. the
// Game Over dialog), so `elapsed` above still holds the whole time that
// dialog was open. Feeding it into the tick driver would fast-forward the
// brand-new run by that duration. resetForNewGame() has rebased the time
// source; skip this frame's tick advance so the stale delta is discarded.
return;
}
// Notify presentation widgets that queued commands were applied, so a
// paused player still sees the effect (e.g. a shipyard's layout preview
// after picking a schematic) even though no tick advances. UI-only: this
// does not touch the command queue or simulation, so replay recording
// and determinism are unaffected.
if (commandsApplied)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PlayerCommandsAppliedEvent>());
}
const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i)
{
m_sim->tick();
// Periodic checksum (every 30 ticks) for replay desync detection.
m_commandManager.recordTickCheckpoint();
}
}
// Emit fire events via EventManager
{
const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
for (const BeamFiredEvent& fe : fires)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BeamFiredEvent>(fe));
}
}
// Expire old beams. Lifetime is measured in game ticks so beams stay
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
{
const Tick now = m_sim->getCurrentTick();
std::vector<ActiveBeam> live;
for (const ActiveBeam& b : m_activeBeams)
{
if (now - b.event.emittedAt < kBeamLifetimeTicks)
{
live.push_back(b);
}
}
m_activeBeams = std::move(live);
}
// Drop selected debris that were collected or despawned this frame, so the
// panel stops counting them and the selection empties out (REQ-UI-DEBRIS-CLICK-SELECT).
pruneDespawnedDebris();
pruneDespawnedActors();
// 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
{
const bool viewMoved =
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
// While the view scrolls, the tile under a stationary cursor changes,
// so refresh the box-select rectangle even though no mouse move fires.
if (m_boxSelecting && viewMoved)
{
m_boxCurrentTile =
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
}
}
// Fire events for any state that changed since the last frame
{
const Tick newTick = m_sim->getCurrentTick();
const int newBlocks = m_sim->getBuildingBlocksStock();
const int newExpCost = m_sim->getCurrentExpansionCost();
const int newBoss = m_sim->getBossWaveCounter();
const Tick newCountdown = m_sim->getBossCountdownTicks();
if (newTick != m_lastTick)
{
m_lastTick = newTick;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TickAdvancedEvent>());
}
if (newBlocks != m_lastBlocks)
{
m_lastBlocks = newBlocks;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingBlocksChangedEvent>());
}
if (newExpCost != m_lastExpansionCost)
{
m_lastExpansionCost = newExpCost;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExpansionCostChangedEvent>());
}
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
{
m_lastBossCounter = newBoss;
m_lastBossCountdown = newCountdown;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BossWaveUpdatedEvent>());
}
// Unlocked building set changes only after a drop is applied or on Restart
// (REQ-LOCK-BUILDING); the build bar rebuilds its visible buttons.
int newUnlockedBuildingCount = 0;
for (const BuildingDef& def : m_sim->getConfig().buildings.buildings)
{
if (m_sim->isBuildingUnlocked(def.type)) { ++newUnlockedBuildingCount; }
}
if (newUnlockedBuildingCount != m_lastUnlockedBuildingCount)
{
m_lastUnlockedBuildingCount = newUnlockedBuildingCount;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<UnlockedBuildingsChangedEvent>());
}
}
// Sim-state polls are input sources for the live game; in replay these are
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
// and game-over becomes the passive "replay ended" state below).
if (!m_replayPlayer)
{
// Schematic choice available
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
{
m_schematicChoiceShown = true;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
}
if (!m_sim->hasSchematicChoicesPending())
{
m_schematicChoiceShown = false;
}
// Win check
if (m_sim->isWon() && !m_winShown)
{
m_winShown = true;
m_gameSpeedMultiplier = 0.0;
EventManager::getInstance()->sendEventImmediately(std::make_shared<WinEvent>());
}
// Game over check
if (m_sim->isGameOver() && !m_gameOverShown)
{
m_gameOverShown = true;
m_gameSpeedMultiplier = 0.0;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameOverEvent>());
}
}
// Artifact count is a passive reflection of sim state (not a live-input source
// like the schematic/game-over polls above), so it updates during replay too —
// the recorded ApplySchematicChoice drives m_sim->getArtifactCount() forward and
// the header bar must track it. Fires on the first frame (count 0 vs the -1
// sentinel), which also populates the win-count max (replacing the "0/?" label).
const int currentArtifactCount = m_sim->getArtifactCount();
if (currentArtifactCount != m_lastArtifactCount)
{
m_lastArtifactCount = currentArtifactCount;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArtifactCountChangedEvent>());
}
update();
}
void GameWorldView::paintGL()
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false);
m_renderer->render(painter, getCoordinates(), makeRenderFrame());
// Screen-anchored chrome over the finished world.
drawScreenSpace(painter);
if (m_debugDraw) { drawDebugOverlay(painter); }
drawPauseBorder(painter);
drawDeconstructBorder(painter);
drawReplayOverlay(painter);
}
WorldRenderFrame GameWorldView::makeRenderFrame() const
{
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, m_copiedConfig,
m_copyConfigFlashes, m_boxSelecting, m_boxStartTile,
m_boxCurrentTile, m_debugDraw};
}
// ---------------------------------------------------------------------------
// Coordinate helpers
// ---------------------------------------------------------------------------
WorldCoordinates GameWorldView::getCoordinates() const
{
return WorldCoordinates::scrolling(size(), m_config->world.heightTiles,
m_camera.getViewCenterXTiles());
}
float GameWorldView::getAsteroidLeftEdge() const
{
float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{
for (const QPoint& cell : b.bodyCells)
{
if (static_cast<float>(cell.x()) < leftX)
{
leftX = static_cast<float>(cell.x());
}
}
}
return leftX;
}
float GameWorldView::getEnemyStationRightEdge() const
{
float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles
+ m_config->world.regions.contestZoneWidth_tiles);
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&rightX](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (!f.isEnemy) { return; }
for (const QPoint& cell : sb.bodyCells)
{
const float cx = static_cast<float>(cell.x() + 1);
if (cx > rightX) { rightX = cx; }
}
});
return rightX;
}
ScrollBounds GameWorldView::getScrollBounds() const
{
// The camera clamps its view center to these, so the pan limits are the edges
// themselves: the view can pan left until the buildable/asteroid edge is
// centered, and right until the enemy stations are centered (revealing a little
// space beyond them).
return ScrollBounds{getAsteroidLeftEdge(), getEnemyStationRightEdge()};
}
// ---------------------------------------------------------------------------
// Placement helpers
// ---------------------------------------------------------------------------
bool GameWorldView::canPlaceBuildingHere(BuildingType type, QPoint anchor,
Rotation rot) const
{
return canPlaceBuilding(m_sim->getFactoryState(), m_sim->getConfig(),
type, anchor, rot);
}
std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
{
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{
for (const QPoint& cell : b.bodyCells)
{
if (cell == tile)
{
return b.id;
}
}
}
return std::nullopt;
}
std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const
{
for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState()))
{
for (const QPoint& cell : s.bodyCells)
{
if (cell == tile)
{
return s.id;
}
}
}
return std::nullopt;
}
void GameWorldView::pruneDespawnedDebris()
{
const std::vector<entt::entity>& selected = m_selection.getSelectedDebris();
if (selected.empty()) { return; }
// Keeps the debris that still exist, in the order the simulation reports them.
std::vector<entt::entity> live;
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(selected.begin(), selected.end(), info.entity) != selected.end())
{
live.push_back(info.entity);
}
}
if (live.size() != selected.size())
{
m_selection.setSelectedDebris(std::move(live));
}
}
void GameWorldView::pruneDespawnedActors()
{
const std::vector<entt::entity>& selected = m_selection.getSelectedActors();
if (selected.empty()) { return; }
EntityAdmin& admin = m_sim->getAdmin();
std::vector<entt::entity> live;
for (entt::entity e : selected)
{
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
&& admin.get<HealthComponent>(e).hp > 0.0f)
{
live.push_back(e);
}
}
m_selection.setSelectedActors(std::move(live));
}
void GameWorldView::stepSpeed(int delta)
{
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int kCount = 5;
int current = 2;
for (int i = 0; i < kCount; ++i)
{
if (std::abs(kSpeeds[i] - m_gameSpeedMultiplier) < 0.001)
{
current = i;
break;
}
}
const int next = std::max(0, std::min(kCount - 1, current + delta));
setGameSpeed(kSpeeds[next]);
}
void GameWorldView::placeBlueprintAtTile(QPoint center)
{
const Blueprint& bp = m_buildMode.getBlueprint();
for (const BlueprintBuilding& bb : bp.buildings)
{
// Locked building types are excluded from this placement entirely
// (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here.
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
if (!canPlaceBuildingHere(bb.type, center + bb.offset, bb.rotation)) { return; }
}
// Cost only applies to buildings that are genuinely new (not rotate-in-place),
// and excludes locked building types (REQ-LOCK-UI-BLUEPRINT).
int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value())
{
continue;
}
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { totalCost += def->cost; }
}
if (m_sim->getBuildingBlocksStock() < totalCost) { return; }
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
const QPoint anchor = center + bb.offset;
const std::optional<BuildingId> rotateTarget =
findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
std::make_shared<RotateInPlaceCommand>();
rotateCommand->id = *rotateTarget;
rotateCommand->newRotation = bb.rotation;
enqueueCommand(rotateCommand);
continue;
}
// Place-and-configure is one atomic command: commands apply at a deferred
// tick boundary, so the caller never sees the new BuildingId. Unlock
// gating stays here (UI-side pre-filter); only fields that should apply
// are set on the command.
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = bb.type;
command->anchor = anchor;
command->rotation = bb.rotation;
if (!bb.recipeId.empty())
{
if (bb.type == BuildingType::Shipyard)
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
{
command->recipeId = bb.recipeId;
}
}
else
{
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
command->recipeId = bb.recipeId;
}
}
}
command->shipLayout = bb.shipLayout;
if (bb.type == BuildingType::Splitter
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
{
// The splitter is still a construction site, so the filters carry
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
command->hasSplitterFilters = true;
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
}
enqueueCommand(command);
}
}
void GameWorldView::updateTunnelGhost()
{
// The connection preview and entry/exit switch only apply at a valid placement
// (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry.
if (!m_buildMode.isGhostValid())
{
m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
return;
}
const TunnelTileMap tunnels = collectTunnelTiles(m_sim->getFactoryState());
const TunnelLookup lookup = makeTunnelLookup(tunnels);
const TunnelCompletion completion =
resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(),
m_buildMode.getGhostRotation(),
m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
}
void GameWorldView::placeAtTile(QPoint tile)
{
if (!m_buildMode.isBuilderMode())
{
return;
}
const BuildingType type = m_buildMode.getEffectiveBuilderType();
const Rotation rotation = m_buildMode.getGhostRotation();
if (!canPlaceBuildingHere(type, tile, rotation))
{
return;
}
const std::optional<BuildingId> rotateTarget =
findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, rotation);
if (rotateTarget.has_value())
{
std::shared_ptr<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
command->id = *rotateTarget;
command->newRotation = rotation;
enqueueCommand(command);
return;
}
// For the splitter and tunnels, pre-validate occupancy + affordability so the
// optimistic UI update matches what the deferred command will do — isValidPlacement
// (above) already covered terrain/bounds. In tunnel mode the resolved type (entry
// or exit) is placed as-is (REQ-BLD-TUNNEL-MODE); there is no post-placement toggle.
// Belts are placed via the drag path (applyBeltDragPath), not here.
if (type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit)
{
if (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type))
{
enqueuePlaceBuilding(type, tile, rotation);
}
}
else
{
enqueuePlaceBuilding(type, tile, rotation);
}
}
// ---------------------------------------------------------------------------
// Belt drag placement (REQ-BLD-BELT-DRAG)
// ---------------------------------------------------------------------------
void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
{
QPoint endTile = cursorTile;
std::optional<Rotation> forcedEndRotation;
// If the cursor is over a non-belt building or construction site, snap the end
// tile to the input-capable adjacent tile closest to the cursor, pointing into
// the target (REQ-BLD-BELT-DRAG).
std::optional<BuildingId> targetId = buildingAtTile(cursorTile);
std::optional<BuildingType> targetType;
if (targetId.has_value())
{
if (const Building* building = findBuilding(m_sim->getFactoryState(), *targetId))
{
targetType = building->type;
}
}
else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
{
targetId = siteId;
if (const ConstructionSite* site = findSite(m_sim->getFactoryState(), *siteId))
{
targetType = site->type;
}
}
if (targetId.has_value() && targetType.has_value()
&& *targetType != BuildingType::Belt)
{
const std::vector<Port> inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *targetId);
std::optional<Port> best;
float bestDistanceSq = 0.0f;
for (const Port& port : inputPorts)
{
const QVector2D center(static_cast<float>(port.tile.x()) + 0.5f,
static_cast<float>(port.tile.y()) + 0.5f);
const float distanceSq = (center - m_cursorWorldPos).lengthSquared();
if (!best.has_value() || distanceSq < bestDistanceSq)
{
best = port;
bestDistanceSq = distanceSq;
}
}
if (best.has_value())
{
endTile = best->tile;
forcedEndRotation = best->direction;
}
}
std::vector<BeltPathTile> path = computeBeltDragPath(
m_buildMode.getBeltDragAnchor(), endTile, m_buildMode.getGhostRotation());
if (forcedEndRotation.has_value() && !path.empty())
{
// The end tile points into the target, overriding its incoming-step
// orientation (REQ-BLD-BELT-DRAG "Snapping to a building").
path.back().rotation = *forcedEndRotation;
}
m_buildMode.setBeltDragPath(std::move(path));
}
std::vector<BeltDragResolved> GameWorldView::resolveBeltDragPath() const
{
return ::resolveBeltDragPath(m_buildMode.getBeltDragPath(),
m_sim->getFactoryState(), m_sim->getConfig(),
m_sim->getBuildingBlocksStock());
}
void GameWorldView::applyBeltDragPath()
{
const std::vector<BeltDragResolved> resolved = resolveBeltDragPath();
for (std::size_t index = 0; index < resolved.size(); ++index)
{
const BeltDragResolved& item = resolved[index];
const BeltPathTile& entry = m_buildMode.getBeltDragPath()[index];
if (item.action == BeltTileAction::PlaceNew && item.affordable)
{
enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation);
}
else if (item.action == BeltTileAction::RotateInPlace)
{
std::shared_ptr<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
command->id = *item.rotateId;
command->newRotation = entry.rotation;
enqueueCommand(command);
}
}
}
// ---------------------------------------------------------------------------
// Port glyph helper
// ---------------------------------------------------------------------------
void GameWorldView::drawDebugOverlay(QPainter& painter)
{
const QStringList lines = {
tr("Accumulated Threat Level: %1")
.arg(m_sim->getThreatLevel(), 0, 'f', 1),
tr("Time until Wave: %1s")
.arg(ticksToSeconds(m_sim->getNormalGapRemainingTicks()), 0, 'f', 1),
tr("Threat Accumulation Rate: %1 threat/s")
.arg(m_sim->getThreatAccumulationRate(), 0, 'f', 1),
tr("Max Factory Production: %1 threat/s")
.arg(m_sim->getMaxFactoryProductionThreatRate(), 0, 'f', 1),
tr("Current Factory Production: %1 threat/s")
.arg(m_sim->getCurrentFactoryProductionThreatRate(), 0, 'f', 1),
};
QFont font = painter.font();
font.setPointSize(m_visuals->toast.fontSize);
painter.setFont(font);
const QFontMetrics fm = painter.fontMetrics();
const int lineH = fm.height();
const int padding = 8;
const int spacing = 4;
int textW = 0;
for (const QString& line : lines)
{
textW = std::max(textW, fm.horizontalAdvance(line));
}
const int bgW = textW + padding * 2;
const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2;
const QRect bgRect(padding, padding, bgW, bgH);
painter.fillRect(bgRect, QColor(0, 0, 0, 160));
painter.setPen(Qt::white);
int y = padding * 2;
for (const QString& line : lines)
{
const QRect textRect(padding * 2, y, textW, lineH);
painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line);
y += lineH + spacing;
}
}
void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
{
}
void GameWorldView::drawPauseBorder(QPainter& painter)
{
if (m_gameSpeedMultiplier > 0.0) { return; }
drawVignetteBorder(painter, QColor(0, 0, 0, 128)); // 50% black at the edge
}
void GameWorldView::drawDeconstructBorder(QPainter& painter)
{
if (!m_buildMode.isDeconstructMode()) { return; }
// Reuse the deconstruct overlay color (with its configured alpha) as the edge color.
drawVignetteBorder(painter, m_visuals->overlays.deconstructTint);
}
void GameWorldView::drawVignetteBorder(QPainter& painter, const QColor& edgeColor)
{
// Border thickness in pixels, capped so it never exceeds half the viewport on
// very small windows (which would leave no un-tinted center).
const qreal borderPx = std::min<qreal>(
100.0, std::min(width(), height()) / 2.0);
if (borderPx <= 0.0) { return; }
const QColor& edge = edgeColor; // full color (and alpha) at the viewport edge
QColor inner = edgeColor;
inner.setAlpha(0); // same color, fully transparent toward the center
const qreal w = width();
const qreal h = height();
painter.save();
// Each side is a linear gradient fading from `edge` at the viewport border to
// `inner` toward the center. The four sides are clipped to trapezoids that meet
// along the corner diagonals (a mitred picture frame), so the strips never
// overlap and don't double-darken the corners. Because the border thickness is
// the same on every side, both adjoining gradients evaluate equally along each
// 45-degree diagonal and join seamlessly.
const std::function<void(const QLinearGradient&, const QPolygonF&)> drawSide =
[&](const QLinearGradient& gradient, const QPolygonF& trapezoid) {
QPainterPath clip;
clip.addPolygon(trapezoid);
painter.save();
painter.setClipPath(clip);
painter.fillRect(QRectF(0, 0, w, h), gradient);
painter.restore();
};
QLinearGradient left(0, 0, borderPx, 0);
left.setColorAt(0.0, edge);
left.setColorAt(1.0, inner);
drawSide(left, QPolygonF({ QPointF(0, 0), QPointF(borderPx, borderPx),
QPointF(borderPx, h - borderPx), QPointF(0, h) }));
QLinearGradient right(w, 0, w - borderPx, 0);
right.setColorAt(0.0, edge);
right.setColorAt(1.0, inner);
drawSide(right, QPolygonF({ QPointF(w, 0), QPointF(w - borderPx, borderPx),
QPointF(w - borderPx, h - borderPx), QPointF(w, h) }));
QLinearGradient top(0, 0, 0, borderPx);
top.setColorAt(0.0, edge);
top.setColorAt(1.0, inner);
drawSide(top, QPolygonF({ QPointF(0, 0), QPointF(w, 0),
QPointF(w - borderPx, borderPx), QPointF(borderPx, borderPx) }));
QLinearGradient bottom(0, h, 0, h - borderPx);
bottom.setColorAt(0.0, edge);
bottom.setColorAt(1.0, inner);
drawSide(bottom, QPolygonF({ QPointF(0, h), QPointF(w, h),
QPointF(w - borderPx, h - borderPx), QPointF(borderPx, h - borderPx) }));
painter.restore();
}
void GameWorldView::drawReplayOverlay(QPainter& painter)
{
if (!m_replayPlayer) { return; }
painter.save();
QFont tag = painter.font();
tag.setPixelSize(16);
tag.setBold(true);
painter.setFont(tag);
painter.setPen(QColor(255, 220, 80));
painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));
// The replay is over once the recorded stream is exhausted or the run reached
// its terminal state (win / defeat) — matching where playback freezes above.
const bool ended = m_replayPlayer->isFinished()
|| m_sim->isWon() || m_sim->isGameOver();
if (ended)
{
// Dim the world so the end message reads clearly over it.
painter.fillRect(rect(), QColor(0, 0, 0, 140));
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
QString message;
if (desync.has_value())
{
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
painter.setPen(QColor(255, 90, 90));
}
else if (m_sim->isWon())
{
message = tr("Replay ended — Victory");
painter.setPen(QColor(120, 255, 120));
}
else if (m_sim->isGameOver())
{
message = tr("Replay ended — Defeat");
painter.setPen(QColor(255, 255, 255));
}
else
{
message = tr("Replay ended");
painter.setPen(QColor(255, 255, 255));
}
QFont big = painter.font();
big.setPixelSize(28);
painter.setFont(big);
painter.drawText(rect(), Qt::AlignCenter, message);
}
painter.restore();
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
void GameWorldView::keyPressEvent(QKeyEvent* event)
{
if (event->isAutoRepeat())
{
QOpenGLWidget::keyPressEvent(event);
return;
}
// Keys are turned into actions and published by the input mapper
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
// nothing is handled here directly.
if (m_inputMapper.handleKeyPress(event)) { return; }
QOpenGLWidget::keyPressEvent(event);
}
void GameWorldView::keyReleaseEvent(QKeyEvent* event)
{
if (event->isAutoRepeat())
{
QOpenGLWidget::keyReleaseEvent(event);
return;
}
if (m_inputMapper.handleKeyRelease(event)) { return; }
// Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG).
// Stays here rather than moving to the input mapper: Shift is the modifier of a
// mouse gesture, not a keyboard action of its own.
if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); }
QOpenGLWidget::keyReleaseEvent(event);
}
void GameWorldView::focusOutEvent(QFocusEvent* event)
{
// Without this, holding A while a modal opens (the escape menu, a schematic
// choice, game over) leaves the pan key held: the key-up goes to the dialog,
// never here, and the view pans on its own once the dialog closes. Panning runs
// on wall-clock time, so pausing does not mask it either.
m_inputMapper.releaseAll();
QOpenGLWidget::focusOutEvent(event);
}
void GameWorldView::mousePressEvent(QMouseEvent* event)
{
const WorldCoordinates coordinates = getCoordinates();
if (event->button() != Qt::LeftButton)
{
if (event->button() == Qt::RightButton)
{
if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt())
{
// Cancel the in-progress belt drag without placing anything;
// stay in belt builder mode (REQ-BLD-BELT-DRAG).
m_buildMode.cancelBeltDrag();
}
else if (m_buildMode.getMode() != BuildMode::None)
{
m_buildMode.exitCurrentMode();
}
else if (event->modifiers() & Qt::ShiftModifier)
{
// Shift + right-click copies a building's settings, but only in the
// default selection mode (REQ-BLD-COPY-CONFIG).
const QPoint tile = coordinates.widgetToTile(event->pos());
std::optional<BuildingId> id = buildingAtTile(tile);
if (!id.has_value()) { id = siteAtTile(tile); }
if (id.has_value()) { copyConfigFrom(*id); }
}
}
return;
}
const QPoint tile = coordinates.widgetToTile(event->pos());
if (m_buildMode.isBuilderMode())
{
if (m_buildMode.getBuilderType() == BuildingType::Belt)
{
// Deferred placement: start the drag and show the path ghost; nothing
// is placed until release (REQ-BLD-BELT-DRAG).
m_buildMode.beginBeltDrag(tile);
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
recomputeBeltDragPath(tile);
}
else
{
placeAtTile(tile);
}
}
else if (m_buildMode.isBlueprintMode())
{
placeBlueprintAtTile(tile);
}
else if (m_buildMode.isDeconstructMode())
{
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
m_boxSelecting = true;
m_boxStartTile = tile;
m_boxCurrentTile = tile;
}
else
{
// Shift + left-click applies the copied settings to a same-type building
// (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the
// selection. Only active in the default selection mode.
if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value())
{
std::optional<BuildingId> id = buildingAtTile(tile);
if (!id.has_value()) { id = siteAtTile(tile); }
if (id.has_value())
{
pasteConfigTo(*id);
return;
}
}
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
// Only a click that hit nothing starts a box drag. Starting one on a hit
// would re-resolve the same object as a 1x1 box on release and undo the
// click: a Ctrl+click would toggle the building off, then straight back on.
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
{
// selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag.
m_boxSelecting = true;
m_boxStartTile = tile;
m_boxCurrentTile = tile;
}
}
}
bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive)
{
// Point hit-test precedence: buildings win over actors, which win over debris
// (REQ-UI-SELECTION-CATEGORIES). What each hit then does to the existing
// selection is the controller's business, not this method's.
const SelectionMode mode = additive ? SelectionMode::Toggle : SelectionMode::Replace;
std::optional<BuildingId> buildingHit = buildingAtTile(tile);
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
if (buildingHit.has_value())
{
m_selection.selectBuildings({*buildingHit}, mode);
return true;
}
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (actorHit != entt::null)
{
m_selection.selectFieldObjects({actorHit}, {}, mode);
return true;
}
const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
if (debrisHit != entt::null)
{
m_selection.selectFieldObjects({}, {debrisHit}, mode);
return true;
}
// Empty space: a plain click clears the whole selection, Ctrl preserves it.
if (!additive) { m_selection.clearAll(); }
return false;
}
void GameWorldView::selectInBox(bool additive)
{
// Same precedence as a point click, over everything the box covers
// (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). The only difference is the
// mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;
const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
if (!boxIds.empty())
{
m_selection.selectBuildings(boxIds, mode);
return;
}
const std::vector<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> boxDebris =
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxActors.empty() || !boxDebris.empty())
{
m_selection.selectFieldObjects(boxActors, boxDebris, mode);
return;
}
// Empty box: a plain (non-additive) drag clears the current selection.
if (!additive) { m_selection.clearAll(); }
}
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
const WorldCoordinates coordinates = getCoordinates();
const QPoint tile = coordinates.widgetToTile(event->pos());
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
if (m_buildMode.isBuilderMode())
{
m_buildMode.setGhostTile(tile);
m_buildMode.setGhostValidity(
canPlaceBuildingHere(m_buildMode.getBuilderType(), tile,
m_buildMode.getGhostRotation()));
if (m_buildMode.isTunnelMode())
{
// Resolve entry vs exit and the completion partner for the new hover
// position and sub-tile cursor (REQ-BLD-TUNNEL-MODE).
updateTunnelGhost();
}
if (m_buildMode.isDraggingBelt())
{
// Belt drag: update the previewed path; placement happens on release
// (REQ-BLD-BELT-DRAG).
recomputeBeltDragPath(tile);
}
}
else if (m_buildMode.isBlueprintMode())
{
m_buildMode.setBlueprintGhostTile(tile);
}
else if (m_buildMode.isDeconstructMode())
{
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
if (m_boxSelecting) { m_boxCurrentTile = tile; }
}
else if (m_boxSelecting)
{
m_boxCurrentTile = tile;
}
}
void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
{
if (event->button() != Qt::LeftButton) { return; }
if (m_buildMode.isDraggingBelt())
{
// Apply the previewed belt path now that the button is released
// (REQ-BLD-BELT-DRAG).
applyBeltDragPath();
m_buildMode.cancelBeltDrag();
}
if (m_boxSelecting)
{
m_boxSelecting = false;
const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
if (m_buildMode.isDeconstructMode())
{
const FactoryState& factory = m_sim->getFactoryState();
// Split covered ids into construction sites (removed instantly) and
// operational deconstructible buildings (the HQ is protected; player
// defence stations are not Buildings and never appear in the box).
std::vector<BuildingId> sites;
std::vector<BuildingId> operational;
for (BuildingId id : boxIds)
{
if (const Building* b = findBuilding(factory, id))
{
if (b->type == BuildingType::Hq) { continue; }
operational.push_back(id);
}
else if (findSite(factory, id))
{
sites.push_back(id);
}
}
// Sites: instant demolition with the full refund (REQ-BLD-DECONSTRUCT).
for (BuildingId id : sites)
{
std::shared_ptr<DeconstructCommand> command =
std::make_shared<DeconstructCommand>();
command->id = id;
enqueueCommand(command);
}
// Operational buildings: if every covered one is already queued,
// un-queue them all; otherwise queue each one not yet queued. A plain
// click is the one-element case, giving the queue/un-queue toggle
// (REQ-BLD-DECONSTRUCT-BOX, REQ-BLD-DECONSTRUCT-CLICK).
bool allQueued = !operational.empty();
for (BuildingId id : operational)
{
if (!isQueuedForDeconstruction(factory, id)) { allQueued = false; break; }
}
for (BuildingId id : operational)
{
if (allQueued)
{
std::shared_ptr<CancelDeconstructionCommand> command =
std::make_shared<CancelDeconstructionCommand>();
command->id = id;
enqueueCommand(command);
}
else if (!isQueuedForDeconstruction(factory, id))
{
std::shared_ptr<DeconstructCommand> command =
std::make_shared<DeconstructCommand>();
command->id = id;
enqueueCommand(command);
}
}
m_buildMode.setDeconstructHoverBuildingId(std::nullopt);
return;
}
selectInBox((event->modifiers() & Qt::ControlModifier) != 0);
}
}
// ---------------------------------------------------------------------------
// Methods (formerly slots)
// ---------------------------------------------------------------------------
void GameWorldView::rotateGhost(bool clockwise)
{
if (m_buildMode.isBuilderMode())
{
m_buildMode.rotateGhost(clockwise);
m_buildMode.setGhostValidity(
canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(),
m_buildMode.getGhostRotation()));
// A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); }
// Rotating during a belt drag re-picks the path's primary axis immediately,
// without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
if (m_buildMode.isDraggingBelt())
{
recomputeBeltDragPath(m_buildMode.getGhostTile());
}
}
else if (m_buildMode.isBlueprintMode())
{
for (BlueprintBuilding& bb : m_buildMode.getMutableBlueprint().buildings)
{
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
if (clockwise)
{
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
else
{
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
}
bb.offset = QPoint(minX, minY);
bb.rotation = clockwise ? rotateClockwise(bb.rotation)
: rotateCounterClockwise(bb.rotation);
}
}
}
void GameWorldView::copyConfigFrom(BuildingId id)
{
const std::optional<BuildingConfig> config = readBuildingConfig(*m_sim, id);
if (!config.has_value()) { return; }
// Only cache when there is something to copy: a selected recipe / schematic
// (Miner, Assembler, Shipyard) or any Splitter (empty filters = accept-all).
// Building types with no settings (Smelter, Reprocessing Plant, Salvage Bay,
// belts / tunnels, HQ) leave any existing cache untouched (REQ-BLD-COPY-CONFIG).
if (!config->recipeId.has_value() && !config->isSplitter) { return; }
m_copiedConfig = config;
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
}
void GameWorldView::pasteConfigTo(BuildingId id)
{
if (!m_copiedConfig.has_value()) { return; }
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
// valid and available on the target: unlock state is global, so a selected
// recipe / schematic (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC) and splitter
// filter item types (REQ-LOCK-UI-SPLITTER) remain unlocked. Applying reuses the
// same configuration commands as the selected building panel, inheriting their
// buffer-clearing and mid-cycle-cancel semantics (REQ-MAT-INPUT-BUFFER,
// REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
if (source.isSplitter)
{
// Operational splitters are configured by tile; sites by BuildingId
// (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
{
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = building->anchor;
command->filterA = source.splitterFilterA;
command->filterB = source.splitterFilterB;
enqueueCommand(command);
}
else
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = id;
command->filterA = source.splitterFilterA;
command->filterB = source.splitterFilterB;
enqueueCommand(command);
}
return;
}
if (source.recipeId.has_value())
{
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = id;
command->recipeId = *source.recipeId;
enqueueCommand(command);
}
// For a shipyard the schematic (above) must be applied before its module
// layout, so both commands drain in order at the next tick boundary.
if (source.type == BuildingType::Shipyard && source.shipLayout.has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = id;
command->layout = *source.shipLayout;
enqueueCommand(command);
}
}
double GameWorldView::getGameSpeed() const
{
return m_gameSpeedMultiplier;
}
bool GameWorldView::isDebugDrawEnabled() const
{
return m_debugDraw;
}
void GameWorldView::resetFrameTimer()
{
m_frameTimer.restart();
}
void GameWorldView::setGameSpeed(double multiplier)
{
m_gameSpeedMultiplier = multiplier;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameSpeedChangedEvent>(m_gameSpeedMultiplier));
}
void GameWorldView::resetForNewGame()
{
// Leaves whichever mode was active, announcing that one exit rather than all
// three; a mode that was not active has nothing to announce.
m_buildMode.exitCurrentMode();
m_activeBeams.clear();
m_schematicChoiceShown = false;
m_selection.clearAll();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false;
m_camera.reset();
// Drops any key still held across the restart, which also republishes the pan
// direction so m_panDirection follows.
m_inputMapper.releaseAll();
m_gameOverShown = false;
m_winShown = false;
m_prevNonZeroSpeed = 1.0;
m_lastTick = Tick(-1);
m_lastBlocks = -1;
m_lastExpansionCost = -1;
m_lastBossCounter = -1;
m_lastBossCountdown = Tick(-1);
m_lastArtifactCount = -1;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(std::vector<BuildingId>{}));
setGameSpeed(1.0);
// Rebase the wall-clock time source so a fresh run starts from a clean time
// base. Without this, wall time accumulated while a modal (Game Over, Win, or
// the escape menu) was open would be converted into ticks on the new run,
// fast-forwarding it by the time the player spent in the dialog.
m_frameTimer.restart();
m_tickDriver.reset();
update();
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{
// Endpoint offset is a fraction of the target's visual size (REQ-SHP-FIRING-BEAM):
// half a ship's rendered radius, half a station's shorter footprint side, or
// half a piece of debris's rendered radius (debris is drawn at getTilePx()*0.2).
float maxRadius = 0.125f;
if (m_sim->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->getAdmin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<DebrisComponent>(event->target))
{
maxRadius = 0.1f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = *event;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
}
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{
m_buildMode.enterBuilderMode(event->type);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
{
m_buildMode.exitBuilderMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
{
m_buildMode.toggleDeconstructMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
m_buildMode.enterBlueprintMode(event->blueprint);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
{
m_buildMode.exitBlueprintMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
{
setGameSpeed(event->multiplier);
}
void GameWorldView::handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event)
{
m_panDirection = event->direction;
}
void GameWorldView::handleEvent(std::shared_ptr<const PauseToggleRequestedEvent> /*event*/)
{
if (m_gameSpeedMultiplier > 0.0)
{
m_prevNonZeroSpeed = m_gameSpeedMultiplier;
setGameSpeed(0.0);
}
else
{
setGameSpeed(m_prevNonZeroSpeed);
}
}
void GameWorldView::handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event)
{
stepSpeed(event->delta);
}
void GameWorldView::handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event)
{
rotateGhost(event->clockwise);
}
void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> /*event*/)
{
// One key backs out of whichever mode is active, and enters deconstruct mode
// when none is (REQ-UI-HOTKEYS).
// One key backs out of whichever mode is active, and enters deconstruct mode
// when none is (REQ-UI-HOTKEYS).
if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); }
else { m_buildMode.exitCurrentMode(); }
}
void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> /*event*/)
{
m_debugDraw = !m_debugDraw;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
}
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
// event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset)
{
m_viewResetPending = true;
}
enqueueCommand(event->command);
}
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
{
m_commandManager.enqueue(std::move(command));
}
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = type;
command->anchor = anchor;
command->rotation = rotation;
enqueueCommand(command);
}
bool GameWorldView::canAfford(BuildingType type) const
{
const BuildingDef* def = m_config->buildings.findBuildingDef(type);
if (!def) { return false; }
return m_sim->getBuildingBlocksStock() >= def->cost;
}