1614 lines
57 KiB
C++
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;
|
|
}
|