Files
dota_factory/src/ui/GameWorldView.cpp

1614 lines
57 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 "GameResetEvent.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();
// 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_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 (resolveBlueprintGhostHere(bb, center).action == BlueprintGhostAction::Invalid)
{
return;
}
}
// Only genuinely new buildings are charged for: a compatible overlap places nothing
// (REQ-UI-BLUEPRINT-OVERLAP) and a transfer changes settings only
// (REQ-UI-BLUEPRINT-TRANSFER). Locked building types are excluded from the total
// as well (REQ-LOCK-UI-BLUEPRINT).
int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
if (resolveBlueprintGhostHere(bb, center).action != BlueprintGhostAction::PlaceNew)
{
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 BlueprintGhostResolved resolved = resolveBlueprintGhostHere(bb, center);
// The building the blueprint wants is already there, facing the same way: leave
// it exactly as it is (REQ-UI-BLUEPRINT-OVERLAP).
if (resolved.action == BlueprintGhostAction::CompatibleOverlap) { continue; }
if (resolved.action == BlueprintGhostAction::Transfer)
{
transferConfigTo(*resolved.targetId, bb);
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;
command->recipeId = unlockedRecipeId(bb);
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);
}
}
BlueprintGhostResolved GameWorldView::resolveBlueprintGhostHere(const BlueprintBuilding& building,
QPoint center) const
{
// A single-building blueprint hit-tests the cursor for its transfer target; a
// constellation does not (REQ-UI-BLUEPRINT-TRANSFER). `center` is the cursor tile.
// The size is read from the blueprint as stored, before locked types are dropped, so
// the gesture behaves the same however much the player has unlocked.
const std::optional<QPoint> hoverTile =
m_buildMode.getBlueprint().buildings.size() == 1 ? std::make_optional(center)
: std::nullopt;
return resolveBlueprintGhost(m_sim->getFactoryState(), m_sim->getConfig(),
building.type, center + building.offset, building.rotation,
hoverTile);
}
std::string GameWorldView::unlockedRecipeId(const BlueprintBuilding& building) const
{
// A stored recipe or schematic is applied only while it is unlocked; a locked one
// yields no id at all rather than a stale one (REQ-LOCK-UI-BLUEPRINT). Shared by
// placement and configuration transfer so the two cannot gate differently.
if (building.recipeId.empty()) { return std::string(); }
if (building.type == BuildingType::Shipyard)
{
return m_sim->isSchematicUnlocked(building.recipeId) ? building.recipeId
: std::string();
}
const bool needsUnlockCheck = building.type == BuildingType::Miner
|| building.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(building.recipeId))
{
return building.recipeId;
}
return std::string();
}
void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& source)
{
// Hands the blueprint's settings to a building that is already there, changing
// nothing else -- no construction site, no cost, no rotation
// (REQ-UI-BLUEPRINT-TRANSFER). Every field is sent unconditionally, so a field the
// blueprint has nothing stored for clears the target's rather than leaving it: the
// target ends up identical to the source. Setting a value the building already holds
// is a no-op in the simulation (REQ-MAT-INPUT-BUFFER), which is what keeps clicking
// an already-matching building free of buffer and production-progress loss.
if (source.type == BuildingType::Splitter)
{
// Operational splitters are configured by tile, sites by BuildingId (mirrors
// SelectionPanel::onSplitterFilterChanged). Locked item types are dropped
// per REQ-LOCK-UI-BLUEPRINT.
const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim);
const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim);
if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
{
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = building->anchor;
command->filterA = filterA;
command->filterB = filterB;
enqueueCommand(command);
}
else
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = id;
command->filterA = filterA;
command->filterB = filterB;
enqueueCommand(command);
}
return;
}
std::shared_ptr<SetRecipeCommand> recipeCommand = std::make_shared<SetRecipeCommand>();
recipeCommand->id = id;
recipeCommand->recipeId = unlockedRecipeId(source);
enqueueCommand(recipeCommand);
// After the schematic, never before: a genuine schematic change resets the layout,
// and both commands drain in order at the next tick boundary.
if (source.type == BuildingType::Shipyard)
{
std::shared_ptr<SetShipLayoutCommand> layoutCommand =
std::make_shared<SetShipLayoutCommand>();
layoutCommand->id = id;
layoutCommand->layout = source.shipLayout.value_or(ShipLayoutConfig{});
enqueueCommand(layoutCommand);
}
}
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; }
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();
}
}
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
{
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);
}
}
}
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_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>{}));
// The one place a restart actually lands (the menu, game over and win dialogs only
// enqueue the command), so it is where presentation state belonging to the finished
// run is dropped -- e.g. the temporary blueprint (REQ-UI-BLUEPRINT-TEMP).
EventManager::getInstance()->sendEventImmediately(std::make_shared<GameResetEvent>());
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, SelectionPanel) 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;
}