schematic selection dialog
This commit is contained in:
@@ -10,7 +10,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicDropEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
27
src/lib/core/DisplayName.cpp
Normal file
27
src/lib/core/DisplayName.cpp
Normal file
@@ -0,0 +1,27 @@
|
||||
#include "DisplayName.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
std::string toDisplayName(const std::string& id)
|
||||
{
|
||||
std::string result;
|
||||
bool nextUpper = true;
|
||||
for (char c : id)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
result += ' ';
|
||||
nextUpper = true;
|
||||
}
|
||||
else if (nextUpper)
|
||||
{
|
||||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
nextUpper = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
5
src/lib/core/DisplayName.h
Normal file
5
src/lib/core/DisplayName.h
Normal file
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
std::string toDisplayName(const std::string& id);
|
||||
22
src/lib/core/SchematicChoiceOption.h
Normal file
22
src/lib/core/SchematicChoiceOption.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
enum class SchematicType
|
||||
{
|
||||
Ship,
|
||||
Module,
|
||||
Recipe
|
||||
};
|
||||
|
||||
// One option presented to the player in the schematic choice dialog
|
||||
// (REQ-DEF-SCHEMATIC-DROP). Built by the simulation when enemy stations are
|
||||
// destroyed; the UI reads these to populate the dialog.
|
||||
struct SchematicChoiceOption
|
||||
{
|
||||
std::string schematicId;
|
||||
SchematicType type;
|
||||
std::string displayName;
|
||||
bool isNewUnlock;
|
||||
int targetLevel;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Emitted in tick step 9 (Deaths & loot) when a destroyed enemy-defence-station
|
||||
// set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast
|
||||
// (REQ-UI-SCHEMATIC-TOAST); wasNewUnlock chooses between the "unlocked" and
|
||||
// "level -> N" wording. isModuleSchematic selects ship vs. module toast text.
|
||||
struct SchematicDropEvent
|
||||
{
|
||||
std::string schematicId; // matches ShipDef::id or ModuleDef::id in the config.
|
||||
int newLevel;
|
||||
bool wasNewUnlock;
|
||||
bool isModuleSchematic;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
14
src/lib/eventsystem/event/SchematicChoicesAvailableEvent.h
Normal file
14
src/lib/eventsystem/event/SchematicChoicesAvailableEvent.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "Event.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
|
||||
class SchematicChoicesAvailableEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit SchematicChoicesAvailableEvent(std::vector<SchematicChoiceOption> choices)
|
||||
: choices(std::move(choices)) {}
|
||||
const std::vector<SchematicChoiceOption> choices;
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "Simulation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
|
||||
#include "AiSystem.h"
|
||||
#include "DisplayName.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "DynamicBodySystem.h"
|
||||
@@ -135,7 +137,7 @@ void Simulation::reset(unsigned int seed)
|
||||
m_currentEnemyStationEntities[0] = entt::null;
|
||||
m_currentEnemyStationEntities[1] = entt::null;
|
||||
m_fireEvents.clear();
|
||||
m_schematicDropEvents.clear();
|
||||
m_pendingSchematicChoices.clear();
|
||||
|
||||
m_admin.clear();
|
||||
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
|
||||
@@ -532,11 +534,11 @@ void Simulation::tickDeathsAndLoot()
|
||||
const int destroyedLevel = m_waveSystem->generation();
|
||||
m_waveSystem->onEnemyStationsDestroyed();
|
||||
placeEnemyStationSet(m_waveSystem->generation());
|
||||
awardSchematicDrop(destroyedLevel);
|
||||
generateSchematicChoices(destroyedLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::awardSchematicDrop(int destroyedStationLevel)
|
||||
void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
{
|
||||
enum class DropType { Ship, Module, Recipe };
|
||||
struct PoolEntry { std::string id; DropType type; };
|
||||
@@ -572,32 +574,78 @@ void Simulation::awardSchematicDrop(int destroyedStationLevel)
|
||||
pool.push_back({def.id, DropType::Recipe});
|
||||
}
|
||||
|
||||
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1);
|
||||
const PoolEntry& chosen = pool[static_cast<std::size_t>(dist(m_rng))];
|
||||
if (pool.empty()) { return; }
|
||||
|
||||
if (chosen.type == DropType::Recipe)
|
||||
const int numChoices = std::min(static_cast<int>(pool.size()), 3);
|
||||
m_pendingSchematicChoices.clear();
|
||||
|
||||
for (int i = 0; i < numChoices; ++i)
|
||||
{
|
||||
m_unlockedRecipeSchematicIds.insert(chosen.id);
|
||||
recomputeUnlocked();
|
||||
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
|
||||
const int roll = dist(m_rng);
|
||||
const std::size_t rollIdx = static_cast<std::size_t>(roll);
|
||||
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
|
||||
std::swap(pool[rollIdx], pool[endIdx]);
|
||||
const PoolEntry& entry = pool[endIdx];
|
||||
|
||||
SchematicChoiceOption option;
|
||||
option.schematicId = entry.id;
|
||||
|
||||
if (entry.type == DropType::Ship)
|
||||
{
|
||||
option.type = SchematicType::Ship;
|
||||
option.displayName = toDisplayName(entry.id);
|
||||
const SchematicState& state = m_schematicLevels.at(entry.id);
|
||||
option.isNewUnlock = !state.unlocked;
|
||||
option.targetLevel = state.level + 1;
|
||||
}
|
||||
else if (entry.type == DropType::Module)
|
||||
{
|
||||
option.type = SchematicType::Module;
|
||||
option.displayName = toDisplayName(entry.id);
|
||||
const SchematicState& state = m_moduleSchematicLevels.at(entry.id);
|
||||
option.isNewUnlock = !state.unlocked;
|
||||
option.targetLevel = state.level + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
option.type = SchematicType::Recipe;
|
||||
option.isNewUnlock = true;
|
||||
option.targetLevel = 0;
|
||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
||||
{
|
||||
if (def.id == entry.id && !def.outputs.empty())
|
||||
{
|
||||
option.displayName = toDisplayName(def.outputs[0].item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m_pendingSchematicChoices.push_back(option);
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::applySchematicChoice(int choiceIndex)
|
||||
{
|
||||
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
||||
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
|
||||
|
||||
if (chosen.type == SchematicType::Recipe)
|
||||
{
|
||||
m_unlockedRecipeSchematicIds.insert(chosen.schematicId);
|
||||
}
|
||||
else
|
||||
{
|
||||
SchematicState& state = (chosen.type == DropType::Module)
|
||||
? m_moduleSchematicLevels.at(chosen.id)
|
||||
: m_schematicLevels.at(chosen.id);
|
||||
const bool wasNew = !state.unlocked;
|
||||
SchematicState& state = (chosen.type == SchematicType::Module)
|
||||
? m_moduleSchematicLevels.at(chosen.schematicId)
|
||||
: m_schematicLevels.at(chosen.schematicId);
|
||||
state.unlocked = true;
|
||||
state.level += 1;
|
||||
|
||||
SchematicDropEvent evt;
|
||||
evt.schematicId = chosen.id;
|
||||
evt.newLevel = state.level;
|
||||
evt.wasNewUnlock = wasNew;
|
||||
evt.isModuleSchematic = (chosen.type == DropType::Module);
|
||||
m_schematicDropEvents.push_back(evt);
|
||||
|
||||
recomputeUnlocked();
|
||||
}
|
||||
|
||||
recomputeUnlocked();
|
||||
m_pendingSchematicChoices.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -704,13 +752,17 @@ std::vector<FireEvent> Simulation::drainFireEvents()
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<SchematicDropEvent> Simulation::drainSchematicDropEvents()
|
||||
const std::vector<SchematicChoiceOption>& Simulation::getPendingSchematicChoices() const
|
||||
{
|
||||
std::vector<SchematicDropEvent> result;
|
||||
result.swap(m_schematicDropEvents);
|
||||
return result;
|
||||
return m_pendingSchematicChoices;
|
||||
}
|
||||
|
||||
bool Simulation::hasSchematicChoicesPending() const
|
||||
{
|
||||
return !m_pendingSchematicChoices.empty();
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "SchematicDropEvent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "BuildingType.h"
|
||||
#include "BuildingId.h"
|
||||
#include "EventHandler.h"
|
||||
@@ -52,8 +52,16 @@ public:
|
||||
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
|
||||
std::vector<FireEvent> drainFireEvents();
|
||||
|
||||
// Returns all schematic drop events since the last drain.
|
||||
std::vector<SchematicDropEvent> drainSchematicDropEvents();
|
||||
// Returns the pending schematic choices (empty if no drop is pending).
|
||||
const std::vector<SchematicChoiceOption>& getPendingSchematicChoices() const;
|
||||
|
||||
// Returns true if there are pending schematic choices waiting for player input.
|
||||
bool hasSchematicChoicesPending() const;
|
||||
|
||||
// Applies the player's chosen schematic from the pending choices.
|
||||
// choiceIndex must be in [0, pendingChoices.size()).
|
||||
// Clears the pending choices after application.
|
||||
void applySchematicChoice(int choiceIndex);
|
||||
|
||||
Tick currentTick() const;
|
||||
int buildingBlocksStock() const;
|
||||
@@ -108,8 +116,8 @@ private:
|
||||
// Tick step 9: remove dead ships and buildings, drop scrap, handle push.
|
||||
void tickDeathsAndLoot();
|
||||
|
||||
// Award a random schematic drop (REQ-DEF-SCHEMATIC-DROP) and emit the event.
|
||||
void awardSchematicDrop(int destroyedStationLevel);
|
||||
// Generate up to 3 schematic choices (REQ-DEF-SCHEMATIC-DROP) for the player.
|
||||
void generateSchematicChoices(int destroyedStationLevel);
|
||||
|
||||
GameConfig m_config;
|
||||
std::mt19937 m_rng;
|
||||
@@ -158,5 +166,5 @@ private:
|
||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||
|
||||
std::vector<FireEvent> m_fireEvents;
|
||||
std::vector<SchematicDropEvent> m_schematicDropEvents;
|
||||
std::vector<SchematicChoiceOption> m_pendingSchematicChoices;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "GameConfig.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
@@ -16,7 +17,7 @@ static GameConfig loadConfig()
|
||||
}
|
||||
|
||||
// Zeros the HP of both enemy defence stations and advances one tick so that
|
||||
// tickDeathsAndLoot fires, triggering the push and schematic drop.
|
||||
// tickDeathsAndLoot fires, triggering the push and schematic choices.
|
||||
static void killEnemyStations(Simulation& sim)
|
||||
{
|
||||
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
@@ -30,15 +31,25 @@ static void killEnemyStations(Simulation& sim)
|
||||
sim.tick();
|
||||
}
|
||||
|
||||
// Kills enemy stations and applies the first schematic choice (index 0).
|
||||
static void killEnemyStationsAndApply(Simulation& sim)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
if (sim.hasSchematicChoicesPending())
|
||||
{
|
||||
sim.applySchematicChoice(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroys station sets until recipeId is unlocked or maxDestructions is reached.
|
||||
// Returns true if the recipe is unlocked on exit.
|
||||
// Applies schematic choice 0 after each destruction. Returns true if unlocked.
|
||||
static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
|
||||
int maxDestructions = 150)
|
||||
{
|
||||
for (int i = 0; i < maxDestructions; ++i)
|
||||
{
|
||||
if (sim.isRecipeUnlocked(recipeId)) { return true; }
|
||||
killEnemyStations(sim);
|
||||
killEnemyStationsAndApply(sim);
|
||||
}
|
||||
return sim.isRecipeUnlocked(recipeId);
|
||||
}
|
||||
@@ -175,7 +186,7 @@ TEST_CASE("RecipeSchematic: recipe whose output is not implicitly unlocked is ne
|
||||
Simulation sim(loadConfig());
|
||||
for (int i = 0; i < 50; ++i)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
killEnemyStationsAndApply(sim);
|
||||
}
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
|
||||
}
|
||||
@@ -186,7 +197,7 @@ TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not a
|
||||
// advanced_circuit has unlock_at_station_level = 1. Destroying a single
|
||||
// level-0 station set must not award it regardless of the RNG outcome.
|
||||
Simulation sim(loadConfig());
|
||||
killEnemyStations(sim);
|
||||
killEnemyStationsAndApply(sim);
|
||||
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
|
||||
}
|
||||
|
||||
@@ -209,25 +220,35 @@ TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not a
|
||||
// Destroy 30 more station sets; the recipe is no longer in the pool.
|
||||
for (int i = 0; i < 30; ++i)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
killEnemyStationsAndApply(sim);
|
||||
}
|
||||
|
||||
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
|
||||
}
|
||||
|
||||
TEST_CASE("RecipeSchematic: no SchematicDropEvent is emitted for a recipe schematic drop",
|
||||
TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
sim.drainSchematicDropEvents(); // clear any startup events
|
||||
|
||||
awaitRecipeUnlock(sim, "quick_circuit");
|
||||
|
||||
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
|
||||
for (const SchematicDropEvent& ev : events)
|
||||
bool foundRecipeChoice = false;
|
||||
for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
|
||||
{
|
||||
CHECK(ev.schematicId != "quick_circuit");
|
||||
killEnemyStations(sim);
|
||||
if (sim.hasSchematicChoicesPending())
|
||||
{
|
||||
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
||||
{
|
||||
if (opt.type == SchematicType::Recipe)
|
||||
{
|
||||
foundRecipeChoice = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
sim.applySchematicChoice(0);
|
||||
}
|
||||
}
|
||||
CHECK(foundRecipeChoice);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -61,11 +61,11 @@ TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
|
||||
REQUIRE(sim.drainFireEvents().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation::drainSchematicDropEvents returns empty initially", "[simulation]")
|
||||
TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
REQUIRE(sim.drainSchematicDropEvents().empty());
|
||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
#include "StationBodyComponent.h"
|
||||
#include "WeaponComponent.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
@@ -247,7 +249,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
|
||||
REQUIRE(enemyCount == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
|
||||
TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
@@ -259,11 +261,13 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
|
||||
|
||||
sim.tick();
|
||||
|
||||
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
|
||||
REQUIRE(events.size() == 1);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
|
||||
REQUIRE(choices.size() >= 1);
|
||||
REQUIRE(choices.size() <= 3);
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
|
||||
TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
@@ -274,23 +278,68 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
|
||||
REQUIRE(events.size() == 1);
|
||||
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
|
||||
REQUIRE_FALSE(choices.empty());
|
||||
|
||||
bool validId = false;
|
||||
for (const ShipDef& def : sim.config().ships.ships)
|
||||
for (const SchematicChoiceOption& opt : choices)
|
||||
{
|
||||
if (def.id == events[0].schematicId) { validId = true; break; }
|
||||
}
|
||||
if (!validId)
|
||||
{
|
||||
for (const ModuleDef& def : sim.config().modules.modules)
|
||||
bool validId = false;
|
||||
for (const ShipDef& def : sim.config().ships.ships)
|
||||
{
|
||||
if (def.id == events[0].schematicId) { validId = true; break; }
|
||||
if (def.id == opt.schematicId) { validId = true; break; }
|
||||
}
|
||||
if (!validId)
|
||||
{
|
||||
for (const ModuleDef& def : sim.config().modules.modules)
|
||||
{
|
||||
if (def.id == opt.schematicId) { validId = true; break; }
|
||||
}
|
||||
}
|
||||
if (!validId)
|
||||
{
|
||||
for (const RecipeDef& def : sim.config().recipes.recipes)
|
||||
{
|
||||
if (def.id == opt.schematicId) { validId = true; break; }
|
||||
}
|
||||
}
|
||||
REQUIRE(validId);
|
||||
}
|
||||
REQUIRE(validId);
|
||||
REQUIRE(events[0].newLevel >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
{
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
|
||||
std::set<std::string> ids;
|
||||
for (const SchematicChoiceOption& opt : choices)
|
||||
{
|
||||
ids.insert(opt.schematicId);
|
||||
}
|
||||
REQUIRE(ids.size() == choices.size());
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
|
||||
{
|
||||
Simulation sim(loadConfig(), 42);
|
||||
|
||||
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
|
||||
{
|
||||
if (f.isEnemy) { h.hp = -1.0f; }
|
||||
});
|
||||
|
||||
sim.tick();
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
sim.applySchematicChoice(0);
|
||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||
}
|
||||
|
||||
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "BuildButtonGrid.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
|
||||
#include <QGridLayout>
|
||||
@@ -8,35 +7,7 @@
|
||||
#include <QSignalMapper>
|
||||
|
||||
#include "BuildingType.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QString displayName(const std::string& id)
|
||||
{
|
||||
QString result;
|
||||
bool nextUpper = true;
|
||||
for (char c : id)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
result += ' ';
|
||||
nextUpper = true;
|
||||
}
|
||||
else if (nextUpper)
|
||||
{
|
||||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
nextUpper = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#include "DisplayName.h"
|
||||
|
||||
|
||||
BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
|
||||
@@ -62,7 +33,7 @@ BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
|
||||
m_types.push_back(def.type);
|
||||
m_costs[def.type] = def.cost;
|
||||
|
||||
const QString label = displayName(def.id)
|
||||
const QString label = QString::fromStdString(toDisplayName(def.id))
|
||||
+ "\n" + tr("%1 Blocks").arg(def.cost);
|
||||
QPushButton* btn = new QPushButton(label, this);
|
||||
btn->setCheckable(true);
|
||||
|
||||
@@ -11,6 +11,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -26,5 +27,6 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
#include "BossWaveUpdatedEvent.h"
|
||||
#include "BuildingBlocksChangedEvent.h"
|
||||
#include "GameSpeedChangedEvent.h"
|
||||
#include "SchematicChoicesAvailableEvent.h"
|
||||
#include "TickAdvancedEvent.h"
|
||||
|
||||
namespace
|
||||
@@ -68,31 +69,6 @@ Rotation rotateCounterClockwise(Rotation r)
|
||||
return Rotation::East;
|
||||
}
|
||||
|
||||
|
||||
QString toDisplayName(const std::string& id)
|
||||
{
|
||||
QString result;
|
||||
bool nextUpper = true;
|
||||
for (char c : id)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
result += ' ';
|
||||
nextUpper = true;
|
||||
}
|
||||
else if (nextUpper)
|
||||
{
|
||||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
nextUpper = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||||
{
|
||||
switch (direction)
|
||||
@@ -129,6 +105,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
, m_scrollLeft(false)
|
||||
, m_scrollRight(false)
|
||||
, m_gameOverShown(false)
|
||||
, m_schematicChoiceShown(false)
|
||||
{
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
setMouseTracking(true);
|
||||
@@ -188,31 +165,6 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Drain schematic drop events → toasts
|
||||
{
|
||||
const std::vector<SchematicDropEvent> drops =
|
||||
m_sim->drainSchematicDropEvents();
|
||||
for (const SchematicDropEvent& ev : drops)
|
||||
{
|
||||
const QString name = toDisplayName(ev.schematicId);
|
||||
ToastEntry toast;
|
||||
if (ev.isModuleSchematic)
|
||||
{
|
||||
toast.text = ev.wasNewUnlock
|
||||
? tr("Module unlocked: ") + name
|
||||
: name + tr(" production level -> ") + QString::number(ev.newLevel);
|
||||
}
|
||||
else
|
||||
{
|
||||
toast.text = ev.wasNewUnlock
|
||||
? tr("Schematic unlocked: ") + name
|
||||
: name + tr(" production level -> ") + QString::number(ev.newLevel);
|
||||
}
|
||||
toast.createdWallMs = m_wallMs;
|
||||
m_toasts.push_back(toast);
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old beams
|
||||
{
|
||||
std::vector<ActiveBeam> live;
|
||||
@@ -226,19 +178,6 @@ void GameWorldView::onFrame()
|
||||
m_activeBeams = std::move(live);
|
||||
}
|
||||
|
||||
// Expire old toasts
|
||||
{
|
||||
std::vector<ToastEntry> live;
|
||||
for (const ToastEntry& t : m_toasts)
|
||||
{
|
||||
if (m_wallMs - t.createdWallMs < kToastLifetimeMs)
|
||||
{
|
||||
live.push_back(t);
|
||||
}
|
||||
}
|
||||
m_toasts = std::move(live);
|
||||
}
|
||||
|
||||
// Apply held scroll
|
||||
{
|
||||
const float delta = kScrollSpeedTilesPerSec
|
||||
@@ -276,6 +215,18 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
@@ -1097,41 +1048,8 @@ void GameWorldView::drawOverlays(QPainter& painter)
|
||||
}
|
||||
}
|
||||
|
||||
void GameWorldView::drawScreenSpace(QPainter& painter)
|
||||
void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
|
||||
{
|
||||
painter.resetTransform();
|
||||
|
||||
const int margin = 8;
|
||||
const int toastW = 320;
|
||||
const int toastH = 36;
|
||||
const int spacing = 4;
|
||||
|
||||
QFont toastFont = painter.font();
|
||||
toastFont.setPointSize(m_visuals->toast.fontSize);
|
||||
painter.setFont(toastFont);
|
||||
|
||||
int y = margin;
|
||||
for (const ToastEntry& toast : m_toasts)
|
||||
{
|
||||
const qint64 age = m_wallMs - toast.createdWallMs;
|
||||
double opacity = 1.0;
|
||||
if (age > kToastFadeStartMs)
|
||||
{
|
||||
opacity = 1.0 - static_cast<double>(age - kToastFadeStartMs)
|
||||
/ static_cast<double>(kToastLifetimeMs - kToastFadeStartMs);
|
||||
opacity = std::max(0.0, opacity);
|
||||
}
|
||||
|
||||
painter.setOpacity(opacity);
|
||||
const int x = width() - toastW - margin;
|
||||
const QRect toastRect(x, y, toastW, toastH);
|
||||
painter.fillRect(toastRect, m_visuals->toast.bg);
|
||||
painter.setPen(m_visuals->toast.fg);
|
||||
painter.drawText(toastRect.adjusted(8, 0, -8, 0),
|
||||
Qt::AlignVCenter | Qt::AlignLeft, toast.text);
|
||||
painter.setOpacity(1.0);
|
||||
y += toastH + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1526,7 +1444,7 @@ void GameWorldView::resetForNewGame()
|
||||
exitBuilderMode();
|
||||
exitBlueprintMode();
|
||||
m_activeBeams.clear();
|
||||
m_toasts.clear();
|
||||
m_schematicChoiceShown = false;
|
||||
m_ghostRotation = Rotation::East;
|
||||
m_ghostValid = false;
|
||||
m_demolishMode = false;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Blueprint.h"
|
||||
#include "SchematicDropEvent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "BuildingType.h"
|
||||
#include "BuildingId.h"
|
||||
#include "FireEvent.h"
|
||||
@@ -125,15 +125,7 @@ private:
|
||||
QVector2D targetOffset;
|
||||
};
|
||||
|
||||
struct ToastEntry
|
||||
{
|
||||
QString text;
|
||||
qint64 createdWallMs;
|
||||
};
|
||||
|
||||
static constexpr qint64 kBeamLifetimeMs = 300;
|
||||
static constexpr qint64 kToastLifetimeMs = 4000;
|
||||
static constexpr qint64 kToastFadeStartMs = 3500;
|
||||
static constexpr float kScrollSpeedTilesPerSec = 10.0f;
|
||||
|
||||
Simulation* m_sim;
|
||||
@@ -151,7 +143,6 @@ private:
|
||||
QTimer* m_renderTimer;
|
||||
|
||||
std::vector<ActiveBeam> m_activeBeams;
|
||||
std::vector<ToastEntry> m_toasts;
|
||||
|
||||
std::optional<BuildingType> m_builderType;
|
||||
Rotation m_ghostRotation;
|
||||
@@ -176,6 +167,7 @@ private:
|
||||
bool m_scrollLeft;
|
||||
bool m_scrollRight;
|
||||
bool m_gameOverShown;
|
||||
bool m_schematicChoiceShown;
|
||||
|
||||
Tick m_lastTick = Tick(-1);
|
||||
int m_lastBlocks = -1;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "BuildingSystem.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "SchematicChoiceDialog.h"
|
||||
#include "HeaderBar.h"
|
||||
#include "SelectedBuildingPanel.h"
|
||||
#include "ShipLayoutBlueprintSerializer.h"
|
||||
@@ -125,12 +126,12 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
|
||||
}
|
||||
}
|
||||
|
||||
registerForEvent();
|
||||
registerForEvents();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow()
|
||||
{
|
||||
unregisterForEvent();
|
||||
unregisterForEvents();
|
||||
}
|
||||
|
||||
void MainWindow::resizeEvent(QResizeEvent* event)
|
||||
@@ -176,6 +177,19 @@ void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> e
|
||||
m_buildButtonGrid->updateAffordability(event->blocks);
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
SchematicChoiceDialog dialog(event->choices, this);
|
||||
dialog.exec();
|
||||
|
||||
m_sim->applySchematicChoice(dialog.getChosenIndex());
|
||||
|
||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||
}
|
||||
|
||||
void MainWindow::onEscapeMenuRequested()
|
||||
{
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "BuildingBlocksChangedEvent.h"
|
||||
#include "BuildingId.h"
|
||||
#include "EventHandler.h"
|
||||
#include "SchematicChoicesAvailableEvent.h"
|
||||
#include "ShipLayoutBlueprint.h"
|
||||
#include "Tick.h"
|
||||
#include "VisualsConfig.h"
|
||||
@@ -22,7 +23,8 @@ class QCloseEvent;
|
||||
class QResizeEvent;
|
||||
|
||||
class MainWindow : public QWidget,
|
||||
public EventHandler<BuildingBlocksChangedEvent>
|
||||
public CombinedEventHandler<BuildingBlocksChangedEvent,
|
||||
SchematicChoicesAvailableEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -36,6 +38,7 @@ protected:
|
||||
|
||||
private:
|
||||
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) override;
|
||||
void layoutPanels();
|
||||
|
||||
private slots:
|
||||
|
||||
99
src/ui/SchematicChoiceDialog.cpp
Normal file
99
src/ui/SchematicChoiceDialog.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "SchematicChoiceDialog.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
SchematicChoiceDialog::SchematicChoiceDialog(
|
||||
const std::vector<SchematicChoiceOption>& options,
|
||||
QWidget* parent)
|
||||
: QDialog(parent)
|
||||
, m_chosenIndex(0)
|
||||
{
|
||||
setWindowTitle(tr("Schematic Drop"));
|
||||
setWindowFlags(windowFlags() & ~Qt::WindowCloseButtonHint);
|
||||
setModal(true);
|
||||
|
||||
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||
|
||||
QLabel* titleLabel = new QLabel(tr("Choose a schematic to unlock:"), this);
|
||||
QFont titleFont = titleLabel->font();
|
||||
titleFont.setPointSize(titleFont.pointSize() + 2);
|
||||
titleFont.setBold(true);
|
||||
titleLabel->setFont(titleFont);
|
||||
titleLabel->setAlignment(Qt::AlignCenter);
|
||||
mainLayout->addWidget(titleLabel);
|
||||
|
||||
QHBoxLayout* optionsLayout = new QHBoxLayout();
|
||||
mainLayout->addLayout(optionsLayout);
|
||||
|
||||
for (int i = 0; i < static_cast<int>(options.size()); ++i)
|
||||
{
|
||||
const SchematicChoiceOption& option = options[static_cast<std::size_t>(i)];
|
||||
|
||||
QWidget* card = new QWidget(this);
|
||||
QVBoxLayout* cardLayout = new QVBoxLayout(card);
|
||||
card->setStyleSheet("QWidget { border: 1px solid gray; padding: 8px; }");
|
||||
|
||||
QLabel* nameLabel = new QLabel(QString::fromStdString(option.displayName), card);
|
||||
QFont nameFont = nameLabel->font();
|
||||
nameFont.setPointSize(nameFont.pointSize() + 1);
|
||||
nameFont.setBold(true);
|
||||
nameLabel->setFont(nameFont);
|
||||
nameLabel->setAlignment(Qt::AlignCenter);
|
||||
cardLayout->addWidget(nameLabel);
|
||||
|
||||
QString typeText;
|
||||
if (option.type == SchematicType::Ship)
|
||||
{
|
||||
typeText = tr("Ship");
|
||||
}
|
||||
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);
|
||||
|
||||
QPushButton* selectButton = new QPushButton(tr("Select"), card);
|
||||
cardLayout->addWidget(selectButton);
|
||||
|
||||
const int index = i;
|
||||
connect(selectButton, &QPushButton::clicked, this, [this, index]()
|
||||
{
|
||||
onOptionClicked(index);
|
||||
});
|
||||
|
||||
optionsLayout->addWidget(card);
|
||||
}
|
||||
}
|
||||
|
||||
int SchematicChoiceDialog::getChosenIndex() const
|
||||
{
|
||||
return m_chosenIndex;
|
||||
}
|
||||
|
||||
void SchematicChoiceDialog::onOptionClicked(int index)
|
||||
{
|
||||
m_chosenIndex = index;
|
||||
accept();
|
||||
}
|
||||
23
src/ui/SchematicChoiceDialog.h
Normal file
23
src/ui/SchematicChoiceDialog.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
#include "SchematicChoiceOption.h"
|
||||
|
||||
class SchematicChoiceDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
int getChosenIndex() const;
|
||||
|
||||
private:
|
||||
void onOptionClicked(int index);
|
||||
|
||||
int m_chosenIndex;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
#include "ShipLayoutDialog.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <functional>
|
||||
|
||||
#include "DisplayName.h"
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
@@ -20,30 +21,6 @@ namespace
|
||||
|
||||
const int kCellSize = 32;
|
||||
|
||||
QString displayName(const std::string& id)
|
||||
{
|
||||
QString result;
|
||||
bool nextUpper = true;
|
||||
for (char c : id)
|
||||
{
|
||||
if (c == '_')
|
||||
{
|
||||
result += ' ';
|
||||
nextUpper = true;
|
||||
}
|
||||
else if (nextUpper)
|
||||
{
|
||||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
nextUpper = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
|
||||
{
|
||||
if (grid.empty())
|
||||
@@ -478,7 +455,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
m_moduleButtons.push_back(nullptr);
|
||||
continue;
|
||||
}
|
||||
const QString label = displayName(def.id)
|
||||
const QString label = QString::fromStdString(toDisplayName(def.id))
|
||||
+ "\n" + QString::fromStdString(def.glyph);
|
||||
QPushButton* btn = new QPushButton(label, this);
|
||||
btn->setCheckable(true);
|
||||
|
||||
Reference in New Issue
Block a user