diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index b67ca18..8ba6195 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -29,6 +29,10 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1] target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10% +[artifacts] +artifact_chance_formula = "0.05 * x" # 5% chance per station level +artifact_win_count = 3 + [waves] threat_rate_formula = "x" ship_level_formula = "1" diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index d7424e0..811f44d 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -29,6 +29,10 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1] target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10% +[artifacts] +artifact_chance_formula = "0.05 * x" # 5% chance per station level +artifact_win_count = 3 + [waves] threat_rate_formula = "x" ship_level_formula = "1 + x / 10" diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index c21c538..136ad9c 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -301,6 +301,9 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula"); cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis"); + cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula"); + cfg.artifacts.artifactWinCount = static_cast(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count")); + return cfg; } diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 764e18d..f0b7491 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -47,6 +47,13 @@ struct WorldTargeting double hysteresis; // fractional margin a challenger must beat the current target by }; +// Artifact win condition (REQ-WIN-ARTIFACT-COUNT, REQ-WIN-SCREEN). +struct WorldArtifacts +{ + Formula artifactChanceFormula; // x = station level, result clamped to [0,1] + int artifactWinCount; +}; + struct WorldConfig { int heightTiles; // REQ-GW-HEIGHT @@ -65,4 +72,5 @@ struct WorldConfig WorldPush push; WorldWaves waves; WorldTargeting targeting; + WorldArtifacts artifacts; }; diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index aa3a8e4..5554276 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -7,7 +7,8 @@ enum class SchematicType { Ship, Module, - Recipe + Recipe, + Artifact }; // One option presented to the player in the schematic choice dialog diff --git a/src/lib/eventsystem/event/ArtifactCountChangedEvent.h b/src/lib/eventsystem/event/ArtifactCountChangedEvent.h new file mode 100644 index 0000000..258d99b --- /dev/null +++ b/src/lib/eventsystem/event/ArtifactCountChangedEvent.h @@ -0,0 +1,11 @@ +#pragma once + +#include "Event.h" + +class ArtifactCountChangedEvent : public Event +{ +public: + ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {} + const int count; + const int winCount; +}; diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 80fb640..a7011fe 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -9,6 +9,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h diff --git a/src/lib/eventsystem/event/WinEvent.h b/src/lib/eventsystem/event/WinEvent.h new file mode 100644 index 0000000..338d894 --- /dev/null +++ b/src/lib/eventsystem/event/WinEvent.h @@ -0,0 +1,5 @@ +#pragma once + +#include "Event.h" + +class WinEvent : public Event {}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 07d92de..e49128a 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -134,6 +134,8 @@ void Simulation::reset(unsigned int seed) m_nextBuildingId = 1; m_buildingBlocksStock = m_config.world.startingBuildingBlocks; m_gameOver = false; + m_isWon = false; + m_artifactCount = 0; m_hqBuildingId = kInvalidBuildingId; m_hqProxyEntity = entt::null; m_playerStation1Entity = entt::null; @@ -584,7 +586,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) if (pool.empty()) { return; } - const int numChoices = std::min(static_cast(pool.size()), 3); + const double artifactChance = std::clamp( + m_config.world.artifacts.artifactChanceFormula.evaluate( + static_cast(destroyedStationLevel)), + 0.0, 1.0); + std::uniform_real_distribution realDist(0.0, 1.0); + const bool artifactRolled = realDist(m_rng) < artifactChance; + + const int numChoices = std::min(static_cast(pool.size()), artifactRolled ? 2 : 3); m_pendingSchematicChoices.clear(); const std::set currentShipIds = getUnlockedShipSchematicIds(); @@ -657,6 +666,17 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) m_pendingSchematicChoices.push_back(option); } + + if (artifactRolled) + { + SchematicChoiceOption artifactOption; + artifactOption.schematicId = ""; + artifactOption.type = SchematicType::Artifact; + artifactOption.displayName = "Artifact"; + artifactOption.isNewUnlock = false; + artifactOption.targetLevel = 0; + m_pendingSchematicChoices.push_back(std::move(artifactOption)); + } } void Simulation::applySchematicChoice(int choiceIndex) @@ -664,6 +684,17 @@ void Simulation::applySchematicChoice(int choiceIndex) assert(choiceIndex >= 0 && choiceIndex < static_cast(m_pendingSchematicChoices.size())); const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast(choiceIndex)]; + if (chosen.type == SchematicType::Artifact) + { + m_artifactCount += 1; + if (m_artifactCount >= m_config.world.artifacts.artifactWinCount) + { + m_isWon = true; + } + m_pendingSchematicChoices.clear(); + return; + } + if (chosen.type == SchematicType::Recipe) { m_unlockedRecipeSchematicIds.insert(chosen.schematicId); @@ -866,6 +897,16 @@ bool Simulation::isGameOver() const return m_gameOver; } +bool Simulation::isWon() const +{ + return m_isWon; +} + +int Simulation::artifactCount() const +{ + return m_artifactCount; +} + double Simulation::threatLevel() const { return m_waveSystem->threatLevel(); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 173e2ee..c1ccb28 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -68,6 +68,8 @@ public: Tick currentTick() const; int buildingBlocksStock() const; bool isGameOver() const; + bool isWon() const; + int artifactCount() const; double threatLevel() const; double threatAccumulationRate() const; double maxFactoryProductionThreatRate() const; @@ -131,7 +133,9 @@ private: Tick m_nextDepartureTick; BuildingId m_nextBuildingId; int m_buildingBlocksStock; - bool m_gameOver = false; + bool m_gameOver = false; + bool m_isWon = false; + int m_artifactCount = 0; // Pre-placed structure IDs. BuildingId m_hqBuildingId; // Building id (for belt integration) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index fcbe533..3b2e2ac 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -251,6 +251,24 @@ void GameWorldView::onFrame() m_schematicChoiceShown = false; } + // Artifact count update + const int currentArtifactCount = m_sim->artifactCount(); + if (currentArtifactCount != m_lastArtifactCount) + { + m_lastArtifactCount = currentArtifactCount; + EventManager::getInstance()->sendEventImmediately( + std::make_shared( + currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount)); + } + + // Win check + if (m_sim->isWon() && !m_winShown) + { + m_winShown = true; + m_gameSpeedMultiplier = 0.0; + EventManager::getInstance()->sendEventImmediately(std::make_shared()); + } + // Game over check if (m_sim->isGameOver() && !m_gameOverShown) { @@ -1667,12 +1685,14 @@ void GameWorldView::resetForNewGame() m_scrollXTiles = 0.0f; m_scrollLeft = false; m_scrollRight = false; - m_gameOverShown = false; - m_prevNonZeroSpeed = 1.0; + m_gameOverShown = false; + m_winShown = false; + m_prevNonZeroSpeed = 1.0; m_lastTick = Tick(-1); m_lastBlocks = -1; m_lastBossCounter = -1; m_lastBossCountdown = Tick(-1); + m_lastArtifactCount = -1; EventManager::getInstance()->sendEventImmediately( std::make_shared(std::vector{})); setGameSpeed(1.0); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 082ef52..26eb24c 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -25,8 +25,10 @@ #include "ExitBlueprintModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h" #include "DebugDrawToggledEvent.h" +#include "ArtifactCountChangedEvent.h" #include "BeamFiredEvent.h" #include "SchematicChoiceOption.h" +#include "WinEvent.h" #include "SpeedChangeRequestedEvent.h" #include "entt/entity/entity.hpp" @@ -191,10 +193,12 @@ private: bool m_scrollLeft; bool m_scrollRight; bool m_gameOverShown; + bool m_winShown; bool m_schematicChoiceShown; Tick m_lastTick = Tick(-1); int m_lastBlocks = -1; int m_lastBossCounter = -1; Tick m_lastBossCountdown = Tick(-1); + int m_lastArtifactCount = -1; }; diff --git a/src/ui/HeaderBar.cpp b/src/ui/HeaderBar.cpp index 83cf533..d87c165 100644 --- a/src/ui/HeaderBar.cpp +++ b/src/ui/HeaderBar.cpp @@ -22,11 +22,13 @@ HeaderBar::HeaderBar(QWidget* parent) layout->setContentsMargins(8, 4, 8, 4); layout->setSpacing(8); - m_timeLabel = new QLabel("00:00", this); - m_blocksLabel = new QLabel(tr("Blocks: 0"), this); - m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this); + m_timeLabel = new QLabel("00:00", this); + m_blocksLabel = new QLabel(tr("Blocks: 0"), this); + m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this); + m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this); layout->addWidget(m_timeLabel); layout->addWidget(m_blocksLabel); + layout->addWidget(m_artifactsLabel); layout->addStretch(); layout->addWidget(m_bossLabel); @@ -88,6 +90,11 @@ void HeaderBar::handleEvent(std::shared_ptr event) .arg(bossSeconds % 60, 2, 10, QChar('0'))); } +void HeaderBar::handleEvent(std::shared_ptr event) +{ + m_artifactsLabel->setText(tr("Artifacts: %1/%2").arg(event->count).arg(event->winCount)); +} + void HeaderBar::onSpeedButton(int index) { if (index >= 0 && index < kSpeedCount) diff --git a/src/ui/HeaderBar.h b/src/ui/HeaderBar.h index 5d5e731..4cbba9d 100644 --- a/src/ui/HeaderBar.h +++ b/src/ui/HeaderBar.h @@ -4,6 +4,7 @@ #include +#include "ArtifactCountChangedEvent.h" #include "BossWaveUpdatedEvent.h" #include "BuildingBlocksChangedEvent.h" #include "EventHandler.h" @@ -18,7 +19,8 @@ class HeaderBar : public QWidget, public CombinedEventHandler + BossWaveUpdatedEvent, + ArtifactCountChangedEvent> { Q_OBJECT @@ -34,9 +36,11 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; QLabel* m_timeLabel; QLabel* m_blocksLabel; + QLabel* m_artifactsLabel; QLabel* m_bossLabel; std::vector m_speedButtons; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3a25574..f5f83b0 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -313,3 +313,42 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) close(); } } + +void MainWindow::handleEvent(std::shared_ptr /*event*/) +{ + const Tick tick = m_sim->currentTick(); + const int totalSeconds = static_cast(ticksToSeconds(tick)); + const int minutes = totalSeconds / 60; + const int seconds = totalSeconds % 60; + + QMessageBox box(this); + box.setWindowTitle(tr("Won!")); + box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0'))); + QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); + box.addButton(tr("Quit"), QMessageBox::RejectRole); + box.exec(); + + if (box.clickedButton() == restartBtn) + { + try + { + GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); + m_visuals = std::move(newVisuals); + m_sim->reset(std::move(newConfig)); + } + catch (const std::exception& e) + { + QMessageBox::critical(this, tr("Config Error"), + tr("Failed to reload config:\n%1").arg(e.what())); + return; + } + m_gameWorldView->resetForNewGame(); + } + else + { + close(); + } +} diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 9c0996a..c7d2dfb 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -11,6 +11,7 @@ #include "EventHandler.h" #include "GameOverEvent.h" #include "LayoutDialogRequestedEvent.h" +#include "WinEvent.h" #include "RecipeSelectionRequestedEvent.h" #include "SchematicChoicesAvailableEvent.h" #include "ShipLayoutBlueprint.h" @@ -30,6 +31,7 @@ class MainWindow : public QWidget, public CombinedEventHandler @@ -48,6 +50,7 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 92b8091..1ffb2f5 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -45,60 +45,69 @@ SchematicChoiceDialog::SchematicChoiceDialog( nameLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(nameLabel); - QString typeText; - if (option.type == SchematicType::Ship) + if (option.type == SchematicType::Artifact) { - typeText = tr("Ship"); - } - else if (option.type == SchematicType::Module) - { - typeText = tr("Module"); + QLabel* artifactTypeLabel = new QLabel(tr("Artifact"), card); + artifactTypeLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(artifactTypeLabel); } else { - typeText = tr("Recipe"); - } - QLabel* typeLabel = new QLabel(typeText, card); - typeLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(typeLabel); - - QString statusText; - if (option.isNewUnlock) - { - statusText = tr("New unlock"); - } - else - { - statusText = tr("Level up -> %1").arg(option.targetLevel); - } - QLabel* statusLabel = new QLabel(statusText, card); - statusLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(statusLabel); - - QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); - QFont unlocksHeaderFont = unlocksHeaderLabel->font(); - unlocksHeaderFont.setBold(true); - unlocksHeaderLabel->setFont(unlocksHeaderFont); - unlocksHeaderLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(unlocksHeaderLabel); - - QString unlocksText; - if (option.newlyUnlockedItemNames.empty()) - { - unlocksText = tr("None"); - } - else - { - QStringList itemLines; - for (const std::string& itemName : option.newlyUnlockedItemNames) + QString typeText; + if (option.type == SchematicType::Ship) { - itemLines << QString::fromStdString(itemName); + typeText = tr("Ship"); } - unlocksText = itemLines.join("\n"); + else if (option.type == SchematicType::Module) + { + typeText = tr("Module"); + } + else + { + typeText = tr("Recipe"); + } + QLabel* typeLabel = new QLabel(typeText, card); + typeLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(typeLabel); + + QString statusText; + if (option.isNewUnlock) + { + statusText = tr("New unlock"); + } + else + { + statusText = tr("Level up -> %1").arg(option.targetLevel); + } + QLabel* statusLabel = new QLabel(statusText, card); + statusLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(statusLabel); + + QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); + QFont unlocksHeaderFont = unlocksHeaderLabel->font(); + unlocksHeaderFont.setBold(true); + unlocksHeaderLabel->setFont(unlocksHeaderFont); + unlocksHeaderLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksHeaderLabel); + + QString unlocksText; + if (option.newlyUnlockedItemNames.empty()) + { + unlocksText = tr("None"); + } + else + { + QStringList itemLines; + for (const std::string& itemName : option.newlyUnlockedItemNames) + { + itemLines << QString::fromStdString(itemName); + } + unlocksText = itemLines.join("\n"); + } + QLabel* unlocksLabel = new QLabel(unlocksText, card); + unlocksLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksLabel); } - QLabel* unlocksLabel = new QLabel(unlocksText, card); - unlocksLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(unlocksLabel); QPushButton* selectButton = new QPushButton(tr("Select"), card); cardLayout->addWidget(selectButton);