1957 lines
66 KiB
C++
1957 lines
66 KiB
C++
#include "GameWorldView.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 <QFont>
|
|
#include <QKeyEvent>
|
|
#include <QMessageBox>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPen>
|
|
#include <QPolygonF>
|
|
#include <QStringList>
|
|
#include <QTimer>
|
|
|
|
#include "AttackBehavior.h"
|
|
#include "BeltSystem.h"
|
|
#include "Building.h"
|
|
#include "BuildingSystem.h"
|
|
#include "Command.h"
|
|
#include "ReplayPlayer.h"
|
|
#include "ReplayReader.h"
|
|
#include "ReplayRecorder.h"
|
|
#include "DemolishModeChangedEvent.h"
|
|
#include "EntityHitTest.h"
|
|
#include "EntitySelectedEvent.h"
|
|
#include "EventManager.h"
|
|
#include "FacingComponent.h"
|
|
#include "FactionComponent.h"
|
|
#include "GameOverEvent.h"
|
|
#include "HealthComponent.h"
|
|
#include "PositionComponent.h"
|
|
#include "RepairBehavior.h"
|
|
#include "SalvageScrapBehavior.h"
|
|
#include "ScrapSystem.h"
|
|
#include "SelectionChangedEvent.h"
|
|
#include "SensorRangeComponent.h"
|
|
#include "ShipIdentityComponent.h"
|
|
#include "ShipSystem.h"
|
|
#include "Simulation.h"
|
|
#include "StationBodyComponent.h"
|
|
#include "ScrapDataComponent.h"
|
|
#include "SurfaceMask.h"
|
|
#include "Tick.h"
|
|
#include "EscapeMenuRequestedEvent.h"
|
|
#include "TracePrintRequestedEvent.h"
|
|
#include "BuildHotkeyPressedEvent.h"
|
|
#include "BossWaveUpdatedEvent.h"
|
|
#include "BuilderModeExitedEvent.h"
|
|
#include "BlueprintModeExitedEvent.h"
|
|
#include "BuildingBlocksChangedEvent.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;
|
|
}
|
|
|
|
QPoint portBodyTile(QPoint portTile, Rotation direction)
|
|
{
|
|
switch (direction)
|
|
{
|
|
case Rotation::East: return portTile + QPoint(-1, 0);
|
|
case Rotation::West: return portTile + QPoint( 1, 0);
|
|
case Rotation::North: return portTile + QPoint( 0, 1);
|
|
case Rotation::South: return portTile + QPoint( 0, -1);
|
|
}
|
|
return portTile;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
|
|
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
|
const VisualsConfig* visuals, const std::string& configDir,
|
|
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_scrollXTiles(0.0f)
|
|
, m_ghostRotation(Rotation::East)
|
|
, m_ghostValid(false)
|
|
, m_dragging(false)
|
|
, m_demolishMode(false)
|
|
, m_demolishHoverBuildingId(kInvalidBuildingId)
|
|
, m_debugDraw(false)
|
|
, m_rng(std::random_device{}())
|
|
, m_boxSelecting(false)
|
|
, m_scrollLeft(false)
|
|
, m_scrollRight(false)
|
|
, m_gameOverShown(false)
|
|
, m_schematicChoiceShown(false)
|
|
{
|
|
setFocusPolicy(Qt::StrongFocus);
|
|
setMouseTracking(true);
|
|
|
|
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->currentTick());
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
// 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->currentTick();
|
|
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);
|
|
}
|
|
|
|
// Apply held scroll
|
|
{
|
|
const float delta = kScrollSpeedTilesPerSec
|
|
* static_cast<float>(elapsed) / 1000.0f;
|
|
const float scrollBefore = m_scrollXTiles;
|
|
if (m_scrollLeft) { m_scrollXTiles -= delta; }
|
|
if (m_scrollRight) { m_scrollXTiles += delta; }
|
|
clampScroll();
|
|
|
|
// 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 && m_scrollXTiles != scrollBefore)
|
|
{
|
|
m_boxCurrentTile = widgetToTile(mapFromGlobal(QCursor::pos()));
|
|
}
|
|
}
|
|
|
|
// Fire events for any state that changed since the last frame
|
|
{
|
|
const Tick newTick = m_sim->currentTick();
|
|
const int newBlocks = m_sim->buildingBlocksStock();
|
|
const int newExpCost = m_sim->currentExpansionCost();
|
|
const int newBoss = m_sim->bossWaveCounter();
|
|
const Tick newCountdown = m_sim->bossCountdownTicks();
|
|
|
|
if (newTick != m_lastTick)
|
|
{
|
|
m_lastTick = newTick;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<TickAdvancedEvent>(newTick));
|
|
}
|
|
if (newBlocks != m_lastBlocks)
|
|
{
|
|
m_lastBlocks = newBlocks;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
|
|
}
|
|
if (newExpCost != m_lastExpansionCost)
|
|
{
|
|
m_lastExpansionCost = newExpCost;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
|
|
}
|
|
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
|
|
{
|
|
m_lastBossCounter = newBoss;
|
|
m_lastBossCountdown = newCountdown;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BossWaveUpdatedEvent>(newBoss, newCountdown));
|
|
}
|
|
}
|
|
|
|
// 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->artifactCount() 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->artifactCount();
|
|
if (currentArtifactCount != m_lastArtifactCount)
|
|
{
|
|
m_lastArtifactCount = currentArtifactCount;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<ArtifactCountChangedEvent>(
|
|
currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount));
|
|
}
|
|
|
|
update();
|
|
}
|
|
|
|
void GameWorldView::paintGL()
|
|
{
|
|
QPainter painter(this);
|
|
painter.setRenderHint(QPainter::Antialiasing, false);
|
|
|
|
drawTiles(painter);
|
|
drawBuildings(painter);
|
|
drawStations(painter);
|
|
drawBeltItems(painter);
|
|
drawScrap(painter);
|
|
if (m_debugDraw)
|
|
{
|
|
drawDebugSensorRanges(painter);
|
|
drawDebugTargetLines(painter);
|
|
drawDebugOverlay(painter);
|
|
}
|
|
drawShips(painter);
|
|
drawBeams(painter);
|
|
drawOverlays(painter);
|
|
drawScreenSpace(painter);
|
|
drawReplayOverlay(painter);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Coordinate helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
float GameWorldView::tilePx() const
|
|
{
|
|
if (m_config->world.heightTiles <= 0) { return 1.0f; }
|
|
return static_cast<float>(height()) / static_cast<float>(m_config->world.heightTiles);
|
|
}
|
|
|
|
float GameWorldView::viewportWidthTiles() const
|
|
{
|
|
return static_cast<float>(width()) / tilePx();
|
|
}
|
|
|
|
QPointF GameWorldView::worldToWidget(QVector2D worldPos) const
|
|
{
|
|
return QPointF(
|
|
static_cast<qreal>((worldPos.x() - m_scrollXTiles) * tilePx()),
|
|
static_cast<qreal>(worldPos.y() * tilePx()));
|
|
}
|
|
|
|
QPointF GameWorldView::tileToWidget(QPoint tile) const
|
|
{
|
|
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
|
static_cast<float>(tile.y())));
|
|
}
|
|
|
|
QPoint GameWorldView::widgetToTile(QPoint widgetPt) const
|
|
{
|
|
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + m_scrollXTiles;
|
|
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
|
|
return QPoint(static_cast<int>(std::floor(wx)), static_cast<int>(std::floor(wy)));
|
|
}
|
|
|
|
QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const
|
|
{
|
|
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + m_scrollXTiles;
|
|
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
|
|
return QVector2D(wx, wy);
|
|
}
|
|
|
|
QRectF GameWorldView::tileRect(QPoint tile) const
|
|
{
|
|
const QPointF tl = tileToWidget(tile);
|
|
return QRectF(tl.x(), tl.y(),
|
|
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
|
|
}
|
|
|
|
QRect GameWorldView::viewportRect() const
|
|
{
|
|
const int left = static_cast<int>(std::floor(m_scrollXTiles)) - 1;
|
|
const int top = 0;
|
|
const int right = static_cast<int>(std::ceil(m_scrollXTiles + viewportWidthTiles())) + 1;
|
|
const int bottom = m_config->world.heightTiles;
|
|
return QRect(left, top, right - left, bottom - top);
|
|
}
|
|
|
|
float GameWorldView::asteroidLeftEdge() const
|
|
{
|
|
float leftX = -static_cast<float>(m_sim->currentAsteroidWidth_tiles());
|
|
for (const Building& b : m_sim->buildings().allBuildings())
|
|
{
|
|
for (const QPoint& cell : b.bodyCells)
|
|
{
|
|
if (static_cast<float>(cell.x()) < leftX)
|
|
{
|
|
leftX = static_cast<float>(cell.x());
|
|
}
|
|
}
|
|
}
|
|
return leftX;
|
|
}
|
|
|
|
float GameWorldView::enemyStationRightEdge() const
|
|
{
|
|
float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles
|
|
+ m_config->world.regions.contestZoneWidth_tiles);
|
|
m_sim->admin().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;
|
|
}
|
|
|
|
void GameWorldView::clampScroll()
|
|
{
|
|
const float leftBound = asteroidLeftEdge();
|
|
const float rightBound = enemyStationRightEdge() - viewportWidthTiles();
|
|
m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Placement helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const
|
|
{
|
|
for (const BuildingDef& def : m_config->buildings.buildings)
|
|
{
|
|
if (def.type == type) { return &def; }
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
|
|
Rotation rot) const
|
|
{
|
|
// Terrain and world-bounds validity are owned by the simulation
|
|
// (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy /
|
|
// rotate-in-place check.
|
|
if (!m_sim->buildings().isPlacementValid(type, anchor, rot))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const BuildingDef* def = findBuildingDef(type);
|
|
if (!def) { return false; }
|
|
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
|
|
|
|
bool anyOccupied = false;
|
|
for (const QPoint& relCell : parsed.bodyCells)
|
|
{
|
|
if (m_sim->buildings().isTileOccupied(anchor + relCell))
|
|
{
|
|
anyOccupied = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (anyOccupied)
|
|
{
|
|
return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
BuildingId GameWorldView::buildingAtTile(QPoint tile) const
|
|
{
|
|
for (const Building& b : m_sim->buildings().allBuildings())
|
|
{
|
|
for (const QPoint& cell : b.bodyCells)
|
|
{
|
|
if (cell == tile)
|
|
{
|
|
return b.id;
|
|
}
|
|
}
|
|
}
|
|
return kInvalidBuildingId;
|
|
}
|
|
|
|
BuildingId GameWorldView::siteAtTile(QPoint tile) const
|
|
{
|
|
for (const ConstructionSite& s : m_sim->buildings().allSites())
|
|
{
|
|
for (const QPoint& cell : s.bodyCells)
|
|
{
|
|
if (cell == tile)
|
|
{
|
|
return s.id;
|
|
}
|
|
}
|
|
}
|
|
return kInvalidBuildingId;
|
|
}
|
|
|
|
|
|
std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
|
|
{
|
|
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity))
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
return m_sim->admin().get<PositionComponent>(entity).value;
|
|
}
|
|
|
|
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_blueprintMode;
|
|
|
|
for (const BlueprintBuilding& bb : bp.buildings)
|
|
{
|
|
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
|
|
}
|
|
|
|
// Cost only applies to buildings that are genuinely new (not rotate-in-place).
|
|
int totalCost = 0;
|
|
for (const BlueprintBuilding& bb : bp.buildings)
|
|
{
|
|
if (m_sim->buildings().findRotateInPlaceTarget(
|
|
bb.type, center + bb.offset, bb.rotation).has_value())
|
|
{
|
|
continue;
|
|
}
|
|
const BuildingDef* def = findBuildingDef(bb.type);
|
|
if (def) { totalCost += def->cost; }
|
|
}
|
|
if (m_sim->buildingBlocksStock() < totalCost) { return; }
|
|
|
|
for (const BlueprintBuilding& bb : bp.buildings)
|
|
{
|
|
const QPoint anchor = center + bb.offset;
|
|
const std::optional<BuildingId> rotateTarget =
|
|
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
|
if (rotateTarget.has_value())
|
|
{
|
|
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
|
std::make_shared<RotateInPlaceCommand>();
|
|
rotateCommand->id = *rotateTarget;
|
|
rotateCommand->newRotation = bb.rotation;
|
|
enqueueCommand(rotateCommand);
|
|
continue;
|
|
}
|
|
|
|
// Place-and-configure is one atomic command: commands apply at a deferred
|
|
// tick boundary, so the caller never sees the new BuildingId. Unlock
|
|
// gating stays here (UI-side pre-filter); only fields that should apply
|
|
// are set on the command.
|
|
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
|
command->type = bb.type;
|
|
command->anchor = anchor;
|
|
command->rotation = bb.rotation;
|
|
|
|
if (!bb.recipeId.empty())
|
|
{
|
|
if (bb.type == BuildingType::Shipyard)
|
|
{
|
|
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
|
{
|
|
command->recipeId = bb.recipeId;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|
|
|| bb.type == BuildingType::Assembler;
|
|
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
|
{
|
|
command->recipeId = bb.recipeId;
|
|
}
|
|
}
|
|
}
|
|
|
|
command->shipLayout = bb.shipLayout;
|
|
|
|
if (bb.type == BuildingType::Splitter
|
|
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
|
{
|
|
// The splitter is still a construction site, so the filters carry
|
|
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
|
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
|
command->hasSplitterFilters = true;
|
|
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
|
|
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
|
|
}
|
|
|
|
enqueueCommand(command);
|
|
}
|
|
}
|
|
|
|
void GameWorldView::placeAtTile(QPoint tile)
|
|
{
|
|
if (!m_builderType.has_value())
|
|
{
|
|
return;
|
|
}
|
|
const BuildingType type = *m_builderType;
|
|
|
|
if (!isValidPlacement(type, tile, m_ghostRotation))
|
|
{
|
|
return;
|
|
}
|
|
|
|
const std::optional<BuildingId> rotateTarget =
|
|
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
|
if (rotateTarget.has_value())
|
|
{
|
|
std::shared_ptr<RotateInPlaceCommand> command =
|
|
std::make_shared<RotateInPlaceCommand>();
|
|
command->id = *rotateTarget;
|
|
command->newRotation = m_ghostRotation;
|
|
enqueueCommand(command);
|
|
return;
|
|
}
|
|
|
|
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
|
|
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
|
|
// optimistic UI update matches what the deferred command will do — isValidPlacement
|
|
// (above) already covered terrain/bounds.
|
|
if (type == BuildingType::Belt)
|
|
{
|
|
if (m_beltDragTiles.count(tile) > 0)
|
|
{
|
|
return;
|
|
}
|
|
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
|
{
|
|
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
|
m_beltDragTiles.insert(tile);
|
|
}
|
|
}
|
|
else if (type == BuildingType::Splitter
|
|
|| type == BuildingType::TunnelEntry
|
|
|| type == BuildingType::TunnelExit)
|
|
{
|
|
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
|
{
|
|
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
|
if (type == BuildingType::TunnelEntry)
|
|
{
|
|
m_builderType = BuildingType::TunnelExit;
|
|
}
|
|
else if (type == BuildingType::TunnelExit)
|
|
{
|
|
m_builderType = BuildingType::TunnelEntry;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Port glyph helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void GameWorldView::drawPortGlyph(QPainter& painter, QPoint bodyTile,
|
|
Rotation direction, const QColor& color)
|
|
{
|
|
const float px = tilePx();
|
|
const QRectF tr = tileRect(bodyTile);
|
|
const QPointF center(tr.x() + static_cast<qreal>(px) * 0.5,
|
|
tr.y() + static_cast<qreal>(px) * 0.5);
|
|
|
|
QPointF offset;
|
|
const char* ch;
|
|
switch (direction)
|
|
{
|
|
case Rotation::East: offset = QPointF(px * 0.25f, 0); ch = ">"; break;
|
|
case Rotation::West: offset = QPointF(-px * 0.25f, 0); ch = "<"; break;
|
|
case Rotation::North: offset = QPointF(0, -px * 0.25f); ch = "^"; break;
|
|
case Rotation::South: offset = QPointF(0, px * 0.25f); ch = "v"; break;
|
|
default: return;
|
|
}
|
|
|
|
const qreal half = static_cast<qreal>(px) * 0.3;
|
|
const QPointF pos = center + offset;
|
|
const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0);
|
|
|
|
QFont f = painter.font();
|
|
f.setPixelSize(std::max(6, static_cast<int>(px * 0.4f)));
|
|
painter.setFont(f);
|
|
painter.setPen(color);
|
|
painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void GameWorldView::drawTiles(QPainter& painter)
|
|
{
|
|
const int leftTile = static_cast<int>(std::floor(m_scrollXTiles)) - 1;
|
|
const int rightTile = leftTile + static_cast<int>(std::ceil(viewportWidthTiles())) + 2;
|
|
const int bottomTile = m_config->world.heightTiles;
|
|
|
|
painter.setPen(Qt::NoPen);
|
|
for (int x = leftTile; x <= rightTile; ++x)
|
|
{
|
|
const QColor& fill = (x < 0)
|
|
? m_visuals->asteroid.fill
|
|
: m_visuals->space.fill;
|
|
for (int y = 0; y < bottomTile; ++y)
|
|
{
|
|
painter.fillRect(tileRect(QPoint(x, y)), fill);
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameWorldView::drawBuildings(QPainter& painter)
|
|
{
|
|
for (const Building& b : m_sim->buildings().allBuildings())
|
|
{
|
|
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
|
m_visuals->buildings.find(b.type);
|
|
if (it == m_visuals->buildings.end()) { continue; }
|
|
const BuildingVisuals& bv = it->second;
|
|
|
|
painter.setPen(Qt::NoPen);
|
|
for (const QPoint& cell : b.bodyCells)
|
|
{
|
|
painter.fillRect(tileRect(cell), bv.fill);
|
|
}
|
|
|
|
const QPointF tl = tileToWidget(b.anchor);
|
|
const QRectF bboxRect(tl.x(), tl.y(),
|
|
b.footprint.width() * static_cast<qreal>(tilePx()),
|
|
b.footprint.height() * static_cast<qreal>(tilePx()));
|
|
|
|
painter.setPen(QPen(bv.outline, 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect);
|
|
|
|
if (!bv.glyph.isEmpty())
|
|
{
|
|
painter.setPen(bv.outline);
|
|
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
|
|
}
|
|
|
|
for (const Port& port : b.outputPorts)
|
|
{
|
|
drawPortGlyph(painter, portBodyTile(port.tile, port.direction),
|
|
port.direction, bv.outline);
|
|
}
|
|
|
|
bool selected = false;
|
|
for (BuildingId selId : m_selectedBuildingIds)
|
|
{
|
|
if (selId == b.id) { selected = true; break; }
|
|
}
|
|
if (selected)
|
|
{
|
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1));
|
|
}
|
|
}
|
|
|
|
painter.setOpacity(0.5);
|
|
for (const ConstructionSite& s : m_sim->buildings().allSites())
|
|
{
|
|
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
|
m_visuals->buildings.find(s.type);
|
|
if (it == m_visuals->buildings.end()) { continue; }
|
|
const BuildingVisuals& bv = it->second;
|
|
|
|
for (const QPoint& cell : s.bodyCells)
|
|
{
|
|
painter.fillRect(tileRect(cell), bv.fill);
|
|
}
|
|
|
|
const QPointF tl = tileToWidget(s.anchor);
|
|
const QRectF bboxRect(tl.x(), tl.y(),
|
|
s.footprint.width() * static_cast<qreal>(tilePx()),
|
|
s.footprint.height() * static_cast<qreal>(tilePx()));
|
|
painter.setPen(QPen(bv.outline, 1, Qt::DashLine));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect);
|
|
|
|
bool selected = false;
|
|
for (BuildingId selId : m_selectedBuildingIds)
|
|
{
|
|
if (selId == s.id) { selected = true; break; }
|
|
}
|
|
if (selected)
|
|
{
|
|
painter.setOpacity(1.0);
|
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1));
|
|
painter.setOpacity(0.5);
|
|
}
|
|
|
|
const BuildingDef* siteDef = findBuildingDef(s.type);
|
|
if (siteDef)
|
|
{
|
|
// Glyph + progress percentage
|
|
const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds);
|
|
int pct = 0;
|
|
if (s.completesAt > 0 && durationTicks > 0)
|
|
{
|
|
const Tick elapsed = m_sim->currentTick()
|
|
- (s.completesAt - durationTicks);
|
|
pct = static_cast<int>(
|
|
std::max(Tick(0), std::min(durationTicks, elapsed))
|
|
* 100 / durationTicks);
|
|
}
|
|
const QString pctText = QString::number(pct) + "%";
|
|
|
|
painter.setPen(bv.outline);
|
|
if (!bv.glyph.isEmpty())
|
|
{
|
|
const QRectF topHalf(bboxRect.x(), bboxRect.y(),
|
|
bboxRect.width(), bboxRect.height() * 0.5);
|
|
const QRectF botHalf(bboxRect.x(),
|
|
bboxRect.y() + bboxRect.height() * 0.5,
|
|
bboxRect.width(), bboxRect.height() * 0.5);
|
|
painter.drawText(topHalf, Qt::AlignCenter, bv.glyph);
|
|
painter.drawText(botHalf, Qt::AlignCenter, pctText);
|
|
}
|
|
else
|
|
{
|
|
painter.drawText(bboxRect, Qt::AlignCenter, pctText);
|
|
}
|
|
|
|
// Port glyphs
|
|
const ParsedSurfaceMask siteMask =
|
|
parseSurfaceMask(siteDef->surfaceMask, s.rotation);
|
|
for (const Port& port : siteMask.outputPorts)
|
|
{
|
|
const QPoint absBody = s.anchor
|
|
+ portBodyTile(port.tile, port.direction);
|
|
drawPortGlyph(painter, absBody, port.direction, bv.outline);
|
|
}
|
|
}
|
|
}
|
|
painter.setOpacity(1.0);
|
|
}
|
|
|
|
void GameWorldView::drawBeltItems(QPainter& painter)
|
|
{
|
|
const float halfPx = tilePx() * 0.5f * 0.5f;
|
|
const QRect vr = viewportRect();
|
|
|
|
m_sim->belts().forEachVisualItem(vr, [&](const VisualItem& vi)
|
|
{
|
|
const std::map<std::string, ItemVisuals>::const_iterator it =
|
|
m_visuals->items.find(vi.type.id);
|
|
if (it == m_visuals->items.end()) { return; }
|
|
|
|
const QPointF center = worldToWidget(
|
|
QVector2D(static_cast<float>(vi.worldPos.x()),
|
|
static_cast<float>(vi.worldPos.y())));
|
|
const QRectF rect(center.x() - halfPx, center.y() - halfPx,
|
|
halfPx * 2, halfPx * 2);
|
|
painter.fillRect(rect, it->second.fill);
|
|
painter.setPen(QPen(it->second.outline, 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(rect);
|
|
});
|
|
}
|
|
|
|
void GameWorldView::drawScrap(QPainter& painter)
|
|
{
|
|
const float r = tilePx() * 0.2f;
|
|
for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
|
|
{
|
|
const QPointF center = worldToWidget(scrap.position);
|
|
painter.setBrush(QColor(128, 110, 90));
|
|
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
|
painter.drawEllipse(center,
|
|
static_cast<qreal>(r), static_cast<qreal>(r));
|
|
}
|
|
}
|
|
|
|
void GameWorldView::drawStations(QPainter& painter)
|
|
{
|
|
m_sim->admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
|
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f,
|
|
const HealthComponent& h)
|
|
{
|
|
const BuildingType visType = f.isEnemy
|
|
? BuildingType::EnemyDefenceStation
|
|
: BuildingType::PlayerDefenceStation;
|
|
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
|
m_visuals->buildings.find(visType);
|
|
if (it == m_visuals->buildings.end()) { return; }
|
|
const BuildingVisuals& bv = it->second;
|
|
|
|
painter.setPen(Qt::NoPen);
|
|
for (const QPoint& cell : sb.bodyCells)
|
|
{
|
|
painter.fillRect(tileRect(cell), bv.fill);
|
|
}
|
|
|
|
const QPointF tl = tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y()));
|
|
const QRectF bboxRect(tl.x(), tl.y(),
|
|
sb.footprint.width() * static_cast<qreal>(tilePx()),
|
|
sb.footprint.height() * static_cast<qreal>(tilePx()));
|
|
|
|
painter.setPen(QPen(bv.outline, 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect);
|
|
|
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
|
{
|
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2));
|
|
}
|
|
|
|
// HP bar below footprint.
|
|
if (h.maxHp > 0.0f)
|
|
{
|
|
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
|
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
|
|
const qreal barY = bboxRect.bottom() + 1.0;
|
|
const qreal barW = bboxRect.width();
|
|
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
|
|
QColor(60, 60, 60));
|
|
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
|
|
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
}
|
|
});
|
|
}
|
|
|
|
void GameWorldView::drawShips(QPainter& painter)
|
|
{
|
|
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
|
FactionComponent, HealthComponent>(
|
|
[&](entt::entity e, const ShipIdentityComponent& si,
|
|
const PositionComponent& pos, const FacingComponent& facing,
|
|
const FactionComponent& fac, const HealthComponent& h)
|
|
{
|
|
const std::map<std::string, ShipVisuals>::const_iterator it =
|
|
m_visuals->ships.find(si.schematicId);
|
|
if (it == m_visuals->ships.end()) { return; }
|
|
|
|
const QPointF center = worldToWidget(pos.value);
|
|
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
|
const QVector2D perp(-dir.y(), dir.x());
|
|
|
|
const float fwd = tilePx() * 0.45f;
|
|
const float side = tilePx() * 0.25f;
|
|
|
|
QPolygonF tri;
|
|
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
|
|
center.y() + static_cast<qreal>(dir.y() * fwd))
|
|
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
|
|
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
|
|
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
|
|
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
|
|
|
|
painter.setPen(QPen(it->second.outline, 1));
|
|
painter.setBrush(it->second.fill);
|
|
painter.drawPolygon(tri);
|
|
|
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
|
{
|
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
|
painter.setBrush(Qt::NoBrush);
|
|
const qreal r = static_cast<qreal>(fwd) + 2.0;
|
|
painter.drawEllipse(center, r, r);
|
|
}
|
|
|
|
if (h.maxHp > 0.0f)
|
|
{
|
|
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
|
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
|
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
|
|
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
|
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
|
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
|
|
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
|
|
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
}
|
|
});
|
|
}
|
|
|
|
void GameWorldView::drawDebugSensorRanges(QPainter& painter)
|
|
{
|
|
painter.setBrush(Qt::NoBrush);
|
|
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
|
FactionComponent, SensorRangeComponent>(
|
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
|
const PositionComponent& pos, const FacingComponent& /*facing*/,
|
|
const FactionComponent& /*fac*/, const SensorRangeComponent& sensor)
|
|
{
|
|
const std::map<std::string, ShipVisuals>::const_iterator it =
|
|
m_visuals->ships.find(si.schematicId);
|
|
if (it == m_visuals->ships.end()) { return; }
|
|
|
|
const QPointF center = worldToWidget(pos.value);
|
|
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
|
* static_cast<qreal>(tilePx());
|
|
QColor circleColor = it->second.outline;
|
|
circleColor.setAlpha(77);
|
|
painter.setPen(QPen(circleColor, 1));
|
|
painter.drawEllipse(center, radiusPx, radiusPx);
|
|
});
|
|
}
|
|
|
|
void GameWorldView::drawDebugTargetLines(QPainter& painter)
|
|
{
|
|
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
|
// own schematic fill. Shared by the attack, repair and salvage target lines.
|
|
const std::function<void(const std::string&, const QVector2D&, const QVector2D&)>
|
|
drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
|
|
const QVector2D& to)
|
|
{
|
|
const std::map<std::string, ShipVisuals>::const_iterator it =
|
|
m_visuals->ships.find(schematicId);
|
|
if (it == m_visuals->ships.end()) { return; }
|
|
|
|
QColor lineColor = it->second.fill;
|
|
lineColor.setAlpha(128);
|
|
painter.setPen(QPen(lineColor, 1));
|
|
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
|
};
|
|
|
|
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, AttackBehavior>(
|
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
|
const PositionComponent& pos, const AttackBehavior& attack)
|
|
{
|
|
if (!attack.currentTarget.has_value()) { return; }
|
|
|
|
const std::optional<QVector2D> targetPos =
|
|
entityPosition(*attack.currentTarget);
|
|
if (!targetPos.has_value()) { return; }
|
|
|
|
drawTargetLine(si.schematicId, pos.value, *targetPos);
|
|
});
|
|
|
|
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
|
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
|
const PositionComponent& pos, const RepairBehavior& repair)
|
|
{
|
|
if (!repair.currentTarget.has_value()) { return; }
|
|
|
|
const std::optional<QVector2D> targetPos =
|
|
entityPosition(*repair.currentTarget);
|
|
if (!targetPos.has_value()) { return; }
|
|
|
|
drawTargetLine(si.schematicId, pos.value, *targetPos);
|
|
});
|
|
|
|
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
|
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
|
const PositionComponent& pos, const SalvageScrapBehavior& salvage)
|
|
{
|
|
if (!salvage.scrapTarget.has_value()) { return; }
|
|
|
|
drawTargetLine(si.schematicId, pos.value, *salvage.scrapTarget);
|
|
});
|
|
}
|
|
|
|
void GameWorldView::drawDebugOverlay(QPainter& painter)
|
|
{
|
|
painter.resetTransform();
|
|
|
|
const QStringList lines = {
|
|
tr("Accumulated Threat Level: %1")
|
|
.arg(m_sim->threatLevel(), 0, 'f', 1),
|
|
tr("Time until Wave: %1s")
|
|
.arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1),
|
|
tr("Threat Accumulation Rate: %1 threat/s")
|
|
.arg(m_sim->threatAccumulationRate(), 0, 'f', 1),
|
|
tr("Max Factory Production: %1 threat/s")
|
|
.arg(m_sim->maxFactoryProductionThreatRate(), 0, 'f', 1),
|
|
tr("Current Factory Production: %1 threat/s")
|
|
.arg(m_sim->currentFactoryProductionThreatRate(), 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::drawBeams(QPainter& painter)
|
|
{
|
|
for (const ActiveBeam& beam : m_activeBeams)
|
|
{
|
|
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
|
|
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
|
|
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
|
|
|
|
QColor color = m_visuals->beams.weaponColor;
|
|
switch (beam.event.kind)
|
|
{
|
|
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
|
|
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
|
|
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
|
}
|
|
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
|
painter.drawLine(worldToWidget(*shooterPos),
|
|
worldToWidget(*targetPos + beam.targetOffset));
|
|
}
|
|
}
|
|
|
|
void GameWorldView::drawOverlays(QPainter& painter)
|
|
{
|
|
// Builder-mode ghost
|
|
if (m_builderType.has_value())
|
|
{
|
|
drawBuildingGhost(painter, *m_builderType, m_ghostTile,
|
|
m_ghostRotation, m_ghostValid);
|
|
}
|
|
|
|
// Blueprint placement ghost
|
|
if (m_blueprintMode.has_value())
|
|
{
|
|
for (const BlueprintBuilding& bb : m_blueprintMode->buildings)
|
|
{
|
|
const QPoint anchor = m_blueprintGhostTile + bb.offset;
|
|
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
|
|
drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid);
|
|
}
|
|
}
|
|
|
|
// Demolish hover tint
|
|
if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
|
|
{
|
|
const Building* b = m_sim->buildings().findBuilding(m_demolishHoverBuildingId);
|
|
if (b)
|
|
{
|
|
for (const QPoint& cell : b->bodyCells)
|
|
{
|
|
painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Box-select rectangle
|
|
if (m_boxSelecting)
|
|
{
|
|
const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()),
|
|
std::min(m_boxStartTile.y(), m_boxCurrentTile.y()));
|
|
const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1,
|
|
std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1);
|
|
const QRectF selRect(tileToWidget(tl), tileToWidget(br));
|
|
painter.setPen(QPen(m_visuals->overlays.selectionRect, 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(selRect);
|
|
}
|
|
}
|
|
|
|
void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
|
|
QPoint anchorTile, Rotation rotation,
|
|
bool valid)
|
|
{
|
|
const BuildingDef* def = findBuildingDef(type);
|
|
if (!def) { return; }
|
|
|
|
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
|
m_visuals->buildings.find(type);
|
|
if (it == m_visuals->buildings.end()) { return; }
|
|
const BuildingVisuals& bv = it->second;
|
|
|
|
// Valid ghosts show the building type's own colors; invalid ghosts override
|
|
// with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The
|
|
// invalid color's RGB is taken at full opacity so it does not double-dim
|
|
// against the setOpacity below (the configured color carries its own alpha).
|
|
const QColor invalidColor(m_visuals->overlays.ghostInvalid.red(),
|
|
m_visuals->overlays.ghostInvalid.green(),
|
|
m_visuals->overlays.ghostInvalid.blue());
|
|
const QColor fillColor = valid ? bv.fill : invalidColor;
|
|
const QColor lineColor = valid ? bv.outline : invalidColor;
|
|
|
|
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
|
if (parsed.bodyCells.empty()) { return; }
|
|
|
|
painter.setOpacity(0.5);
|
|
|
|
QPoint minCell = parsed.bodyCells.front();
|
|
QPoint maxCell = parsed.bodyCells.front();
|
|
for (const QPoint& cell : parsed.bodyCells)
|
|
{
|
|
painter.fillRect(tileRect(anchorTile + cell), fillColor);
|
|
minCell.setX(std::min(minCell.x(), cell.x()));
|
|
minCell.setY(std::min(minCell.y(), cell.y()));
|
|
maxCell.setX(std::max(maxCell.x(), cell.x()));
|
|
maxCell.setY(std::max(maxCell.y(), cell.y()));
|
|
}
|
|
|
|
const QPointF tl = tileToWidget(anchorTile + minCell);
|
|
const QRectF bboxRect(tl.x(), tl.y(),
|
|
(maxCell.x() - minCell.x() + 1) * static_cast<qreal>(tilePx()),
|
|
(maxCell.y() - minCell.y() + 1) * static_cast<qreal>(tilePx()));
|
|
|
|
painter.setPen(QPen(lineColor, 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(bboxRect);
|
|
|
|
if (!bv.glyph.isEmpty())
|
|
{
|
|
painter.setPen(lineColor);
|
|
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
|
|
}
|
|
|
|
for (const Port& port : parsed.outputPorts)
|
|
{
|
|
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction),
|
|
port.direction, lineColor);
|
|
}
|
|
|
|
painter.setOpacity(1.0);
|
|
}
|
|
|
|
void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
|
|
{
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
|
|
// physical digit independent of keyboard layout and Shift (with Shift held, key()
|
|
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
|
|
const quint32 virtualKey = event->nativeVirtualKey();
|
|
if (virtualKey >= 0x31 && virtualKey <= 0x39)
|
|
{
|
|
const int digit = static_cast<int>(virtualKey - 0x30);
|
|
const bool shift = (event->modifiers() & Qt::ShiftModifier) != 0;
|
|
std::optional<BuildingType> type;
|
|
if (!shift)
|
|
{
|
|
switch (digit)
|
|
{
|
|
case 1: type = BuildingType::Belt; break;
|
|
case 2: type = BuildingType::Splitter; break;
|
|
case 3: type = BuildingType::TunnelEntry; break;
|
|
case 4: type = BuildingType::TunnelExit; break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
switch (digit)
|
|
{
|
|
case 1: type = BuildingType::Miner; break;
|
|
case 2: type = BuildingType::Smelter; break;
|
|
case 3: type = BuildingType::Assembler; break;
|
|
case 4: type = BuildingType::Shipyard; break;
|
|
case 5: type = BuildingType::SalvageBay; break;
|
|
case 6: type = BuildingType::ReprocessingPlant; break;
|
|
}
|
|
}
|
|
if (type.has_value())
|
|
{
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BuildHotkeyPressedEvent>(*type));
|
|
return;
|
|
}
|
|
}
|
|
|
|
switch (event->key())
|
|
{
|
|
case Qt::Key_A:
|
|
m_scrollLeft = true;
|
|
break;
|
|
case Qt::Key_D:
|
|
m_scrollRight = true;
|
|
break;
|
|
case Qt::Key_Space:
|
|
if (m_gameSpeedMultiplier > 0.0)
|
|
{
|
|
m_prevNonZeroSpeed = m_gameSpeedMultiplier;
|
|
setGameSpeed(0.0);
|
|
}
|
|
else
|
|
{
|
|
setGameSpeed(m_prevNonZeroSpeed);
|
|
}
|
|
break;
|
|
case Qt::Key_W:
|
|
stepSpeed(+1);
|
|
break;
|
|
case Qt::Key_S:
|
|
stepSpeed(-1);
|
|
break;
|
|
case Qt::Key_R:
|
|
rotateGhost(event->modifiers() & Qt::ShiftModifier);
|
|
break;
|
|
case Qt::Key_Q:
|
|
if (m_builderType.has_value()) { exitBuilderMode(); }
|
|
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
|
else { toggleDemolishMode(); }
|
|
break;
|
|
case Qt::Key_Escape:
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<EscapeMenuRequestedEvent>());
|
|
break;
|
|
case Qt::Key_F3:
|
|
m_debugDraw = !m_debugDraw;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
|
|
break;
|
|
case Qt::Key_F4:
|
|
EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>());
|
|
break;
|
|
default:
|
|
QOpenGLWidget::keyPressEvent(event);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void GameWorldView::keyReleaseEvent(QKeyEvent* event)
|
|
{
|
|
if (event->isAutoRepeat())
|
|
{
|
|
QOpenGLWidget::keyReleaseEvent(event);
|
|
return;
|
|
}
|
|
if (event->key() == Qt::Key_A) { m_scrollLeft = false; }
|
|
if (event->key() == Qt::Key_D) { m_scrollRight = false; }
|
|
QOpenGLWidget::keyReleaseEvent(event);
|
|
}
|
|
|
|
void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|
{
|
|
if (event->button() != Qt::LeftButton)
|
|
{
|
|
if (event->button() == Qt::RightButton)
|
|
{
|
|
if (m_builderType.has_value()) { exitBuilderMode(); }
|
|
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
|
else if (m_demolishMode) { toggleDemolishMode(); }
|
|
}
|
|
return;
|
|
}
|
|
|
|
const QPoint tile = widgetToTile(event->pos());
|
|
|
|
if (m_builderType.has_value())
|
|
{
|
|
const BuildingType type = *m_builderType;
|
|
if (type == BuildingType::Belt || type == BuildingType::Splitter)
|
|
{
|
|
m_dragging = true;
|
|
m_beltDragTiles.clear();
|
|
placeAtTile(tile);
|
|
}
|
|
else
|
|
{
|
|
placeAtTile(tile);
|
|
}
|
|
}
|
|
else if (m_blueprintMode.has_value())
|
|
{
|
|
placeBlueprintAtTile(tile);
|
|
}
|
|
else if (m_demolishMode)
|
|
{
|
|
BuildingId hovered = buildingAtTile(tile);
|
|
if (hovered == kInvalidBuildingId)
|
|
{
|
|
hovered = siteAtTile(tile);
|
|
}
|
|
if (hovered != kInvalidBuildingId)
|
|
{
|
|
const Building* b = m_sim->buildings().findBuilding(hovered);
|
|
const bool isProtected = b && b->type == BuildingType::Hq;
|
|
if (!isProtected)
|
|
{
|
|
std::shared_ptr<DemolishCommand> command =
|
|
std::make_shared<DemolishCommand>();
|
|
command->id = hovered;
|
|
enqueueCommand(command);
|
|
m_demolishHoverBuildingId = kInvalidBuildingId;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const QVector2D worldPos = widgetToWorld(event->pos());
|
|
const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos);
|
|
|
|
if (hitEntity != entt::null)
|
|
{
|
|
m_selectedBuildingIds.clear();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
|
m_selectedEntity = hitEntity;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<EntitySelectedEvent>(hitEntity));
|
|
}
|
|
else
|
|
{
|
|
if (m_selectedEntity.has_value())
|
|
{
|
|
m_selectedEntity = std::nullopt;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<EntitySelectedEvent>(std::nullopt));
|
|
}
|
|
|
|
BuildingId id = buildingAtTile(tile);
|
|
if (id == kInvalidBuildingId)
|
|
{
|
|
id = siteAtTile(tile);
|
|
}
|
|
if (id != kInvalidBuildingId)
|
|
{
|
|
if (event->modifiers() & Qt::ControlModifier)
|
|
{
|
|
bool found = false;
|
|
std::vector<BuildingId> newSel;
|
|
for (BuildingId sel : m_selectedBuildingIds)
|
|
{
|
|
if (sel == id) { found = true; }
|
|
else { newSel.push_back(sel); }
|
|
}
|
|
if (!found) { newSel.push_back(id); }
|
|
m_selectedBuildingIds = newSel;
|
|
}
|
|
else
|
|
{
|
|
m_selectedBuildingIds = { id };
|
|
}
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
|
}
|
|
else
|
|
{
|
|
if (!(event->modifiers() & Qt::ControlModifier))
|
|
{
|
|
m_selectedBuildingIds.clear();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
|
}
|
|
m_boxSelecting = true;
|
|
m_boxStartTile = tile;
|
|
m_boxCurrentTile = tile;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
|
{
|
|
const QPoint tile = widgetToTile(event->pos());
|
|
|
|
if (m_builderType.has_value())
|
|
{
|
|
m_ghostTile = tile;
|
|
m_ghostValid = isValidPlacement(*m_builderType, tile, m_ghostRotation);
|
|
|
|
if (m_dragging)
|
|
{
|
|
placeAtTile(tile);
|
|
}
|
|
}
|
|
else if (m_blueprintMode.has_value())
|
|
{
|
|
m_blueprintGhostTile = tile;
|
|
}
|
|
else if (m_demolishMode)
|
|
{
|
|
m_demolishHoverBuildingId = buildingAtTile(tile);
|
|
}
|
|
else if (m_boxSelecting)
|
|
{
|
|
m_boxCurrentTile = tile;
|
|
}
|
|
}
|
|
|
|
void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
|
{
|
|
if (event->button() != Qt::LeftButton) { return; }
|
|
|
|
if (m_dragging)
|
|
{
|
|
m_dragging = false;
|
|
m_beltDragTiles.clear();
|
|
}
|
|
|
|
if (m_boxSelecting)
|
|
{
|
|
m_boxSelecting = false;
|
|
|
|
const int x0 = std::min(m_boxStartTile.x(), m_boxCurrentTile.x());
|
|
const int y0 = std::min(m_boxStartTile.y(), m_boxCurrentTile.y());
|
|
const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x());
|
|
const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y());
|
|
|
|
std::vector<BuildingId> boxSel;
|
|
for (const Building& b : m_sim->buildings().allBuildings())
|
|
{
|
|
for (const QPoint& cell : b.bodyCells)
|
|
{
|
|
if (cell.x() >= x0 && cell.x() <= x1
|
|
&& cell.y() >= y0 && cell.y() <= y1)
|
|
{
|
|
boxSel.push_back(b.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for (const ConstructionSite& s : m_sim->buildings().allSites())
|
|
{
|
|
for (const QPoint& cell : s.bodyCells)
|
|
{
|
|
if (cell.x() >= x0 && cell.x() <= x1
|
|
&& cell.y() >= y0 && cell.y() <= y1)
|
|
{
|
|
boxSel.push_back(s.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!(event->modifiers() & Qt::ControlModifier))
|
|
{
|
|
m_selectedBuildingIds = boxSel;
|
|
}
|
|
else
|
|
{
|
|
for (BuildingId id : boxSel)
|
|
{
|
|
bool found = false;
|
|
for (BuildingId sel : m_selectedBuildingIds)
|
|
{
|
|
if (sel == id) { found = true; break; }
|
|
}
|
|
if (!found) { m_selectedBuildingIds.push_back(id); }
|
|
}
|
|
}
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Methods (formerly slots)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void GameWorldView::toggleDemolishMode()
|
|
{
|
|
if (m_demolishMode)
|
|
{
|
|
m_demolishMode = false;
|
|
m_demolishHoverBuildingId = kInvalidBuildingId;
|
|
}
|
|
else
|
|
{
|
|
if (m_builderType.has_value()) { exitBuilderMode(); }
|
|
if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
|
m_demolishMode = true;
|
|
}
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DemolishModeChangedEvent>(m_demolishMode));
|
|
}
|
|
|
|
void GameWorldView::rotateGhost(bool clockwise)
|
|
{
|
|
if (m_builderType.has_value())
|
|
{
|
|
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
|
|
: rotateCounterClockwise(m_ghostRotation);
|
|
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
|
|
}
|
|
else if (m_blueprintMode.has_value())
|
|
{
|
|
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
|
|
{
|
|
const BuildingDef* def = findBuildingDef(bb.type);
|
|
if (!def) { continue; }
|
|
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
|
|
int minX = INT_MAX, minY = INT_MAX;
|
|
for (const QPoint& cell : mask.bodyCells)
|
|
{
|
|
const QPoint abs = bb.offset + cell;
|
|
if (clockwise)
|
|
{
|
|
minX = std::min(minX, -abs.y());
|
|
minY = std::min(minY, abs.x());
|
|
}
|
|
else
|
|
{
|
|
minX = std::min(minX, abs.y());
|
|
minY = std::min(minY, -abs.x());
|
|
}
|
|
}
|
|
bb.offset = QPoint(minX, minY);
|
|
bb.rotation = clockwise ? rotateClockwise(bb.rotation)
|
|
: rotateCounterClockwise(bb.rotation);
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameWorldView::enterBuilderMode(BuildingType type)
|
|
{
|
|
m_builderType = type;
|
|
m_ghostRotation = Rotation::East;
|
|
m_ghostValid = false;
|
|
m_demolishMode = false;
|
|
m_blueprintMode.reset();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DemolishModeChangedEvent>(false));
|
|
}
|
|
|
|
void GameWorldView::enterBlueprintMode(Blueprint blueprint)
|
|
{
|
|
if (m_builderType.has_value()) { exitBuilderMode(); }
|
|
m_demolishMode = false;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DemolishModeChangedEvent>(false));
|
|
m_blueprintGhostTile = m_ghostTile;
|
|
m_blueprintMode = std::move(blueprint);
|
|
}
|
|
|
|
void GameWorldView::exitBlueprintMode()
|
|
{
|
|
m_blueprintMode.reset();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BlueprintModeExitedEvent>());
|
|
}
|
|
|
|
void GameWorldView::exitBuilderMode()
|
|
{
|
|
m_builderType.reset();
|
|
m_beltDragTiles.clear();
|
|
m_dragging = false;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BuilderModeExitedEvent>());
|
|
}
|
|
|
|
double GameWorldView::gameSpeed() 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()
|
|
{
|
|
exitBuilderMode();
|
|
exitBlueprintMode();
|
|
m_activeBeams.clear();
|
|
m_schematicChoiceShown = false;
|
|
m_ghostRotation = Rotation::East;
|
|
m_ghostValid = false;
|
|
m_demolishMode = false;
|
|
m_demolishHoverBuildingId = kInvalidBuildingId;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DemolishModeChangedEvent>(false));
|
|
m_selectedBuildingIds.clear();
|
|
m_boxSelecting = false;
|
|
m_scrollXTiles = 0.0f;
|
|
m_scrollLeft = false;
|
|
m_scrollRight = false;
|
|
m_gameOverShown = false;
|
|
m_winShown = false;
|
|
m_prevNonZeroSpeed = 1.0;
|
|
m_lastTick = Tick(-1);
|
|
m_lastBlocks = -1;
|
|
m_lastExpansionCost = -1;
|
|
m_lastBossCounter = -1;
|
|
m_lastBossCountdown = Tick(-1);
|
|
m_lastArtifactCount = -1;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<SelectionChangedEvent>(std::vector<BuildingId>{}));
|
|
setGameSpeed(1.0);
|
|
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 scrap pile's rendered radius (scrap is drawn at tilePx()*0.2).
|
|
float maxRadius = 0.125f;
|
|
if (m_sim->admin().isValid(event->target)
|
|
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
|
|
{
|
|
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
|
|
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
|
|
maxRadius = shorter / 2.0f;
|
|
}
|
|
else if (m_sim->admin().isValid(event->target)
|
|
&& m_sim->admin().hasAll<ScrapDataComponent>(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)
|
|
{
|
|
enterBuilderMode(event->type);
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
|
|
{
|
|
exitBuilderMode();
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> /*event*/)
|
|
{
|
|
toggleDemolishMode();
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
|
|
{
|
|
enterBlueprintMode(event->blueprint);
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
|
|
{
|
|
exitBlueprintMode();
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
|
|
{
|
|
setGameSpeed(event->multiplier);
|
|
}
|
|
|
|
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
|
|
{
|
|
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
|
|
// event; GameWorldView owns the CommandManager and enqueues them.
|
|
if (event->command && event->command->kind == CommandKind::Reset)
|
|
{
|
|
m_viewResetPending = true;
|
|
}
|
|
enqueueCommand(event->command);
|
|
}
|
|
|
|
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
|
|
{
|
|
m_commandManager.enqueue(std::move(command));
|
|
}
|
|
|
|
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
|
{
|
|
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
|
command->type = type;
|
|
command->anchor = anchor;
|
|
command->rotation = rotation;
|
|
enqueueCommand(command);
|
|
}
|
|
|
|
bool GameWorldView::canAfford(BuildingType type) const
|
|
{
|
|
const BuildingDef* def = findBuildingDef(type);
|
|
if (!def) { return false; }
|
|
return m_sim->buildingBlocksStock() >= def->cost;
|
|
}
|