allow to unlock modules when destroying defence stations

This commit is contained in:
2026-06-10 21:43:48 +02:00
parent aad094f842
commit af96b95f61
19 changed files with 203 additions and 51 deletions

View File

@@ -1,5 +1,6 @@
[[module]]
id = "armor_plate"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "armor_plate_module", amount = 1}]
player_production_level = 1
@@ -14,6 +15,7 @@ added_hp_formula = "40"
[[module]]
id = "sensor_booster"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "sensor_booster_module", amount = 1}]
player_production_level = 1
@@ -28,6 +30,7 @@ added_sensor_range_m_formula = "50"
[[module]]
id = "manuvering_thrusters"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "manuvering_thrusters_module", amount = 1}]
player_production_level = 1
@@ -43,6 +46,7 @@ added_maneuvering_acceleration_mpss_formula = "10"
[[module]]
id = "afterburner"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "afterburner_module", amount = 1}]
player_production_level = 1
@@ -58,6 +62,7 @@ added_main_acceleration_mpss_formula = "60"
[[module]]
id = "weapon_upgrade"
unlock_at_station_level = -1
surface_mask = [
"OO",
"O ",
@@ -75,6 +80,7 @@ multiplied_damage_formula = "1.2"
[[module]]
id = "weapon_primer"
unlock_at_station_level = -1
surface_mask = [
"OO",
"O ",
@@ -92,6 +98,7 @@ multiplied_attack_rate_hz_formula = "1.2"
[[module]]
id = "weapon_stabilizer"
unlock_at_station_level = -1
surface_mask = [
"OO",
"O ",
@@ -110,6 +117,7 @@ multiplied_attack_rate_hz_formula = "0.8"
[[module]]
id = "laser_cannon_xs"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "laser_cannon_xs_module", amount = 1}]
player_production_level = 1
@@ -126,6 +134,7 @@ attack_rate_hz_formula = "2.0"
[[module]]
id = "laser_cannon_s"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OO"]
@@ -144,6 +153,7 @@ attack_rate_hz_formula = "1.5"
[[module]]
id = "salvager"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}]
player_production_level = 1
@@ -160,6 +170,7 @@ collection_rate_hz_formula = "0.5"
[[module]]
id = "repair_tool"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}]
player_production_level = 1

View File

@@ -1,5 +1,6 @@
[[module]]
id = "armor_plate"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
@@ -13,6 +14,7 @@ multiplied_hp_formula = "1.5"
[[module]]
id = "sensor_booster"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 1}]
player_production_level = 1
@@ -26,6 +28,7 @@ added_sensor_range_m_formula = "100"
[[module]]
id = "weapon_upgrade"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
player_production_level = 1
@@ -39,6 +42,7 @@ multiplied_damage_formula = "1.2"
[[module]]
id = "laser_cannon"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
@@ -54,6 +58,7 @@ attack_rate_hz_formula = "2.0"
[[module]]
id = "salvager"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
@@ -69,6 +74,7 @@ collection_rate_hz_formula = "0.5"
[[module]]
id = "repair_tool"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}]
player_production_level = 1
@@ -83,6 +89,7 @@ repair_range_m_formula = "800"
[[module]]
id = "weapon_primer"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
@@ -96,6 +103,7 @@ multiplied_attack_rate_hz_formula = "1.2"
[[module]]
id = "weapon_stabilizer"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
@@ -110,6 +118,7 @@ multiplied_attack_rate_hz_formula = "0.8"
[[module]]
id = "afterburner"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
@@ -124,6 +133,7 @@ added_main_acceleration_mpss_formula = "60"
[[module]]
id = "maneuvering_thrusters"
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1

View File

@@ -8,7 +8,7 @@ Config files use the TOML format. The following config files drive game paramete
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities.
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, threat cost formula, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
- **modules.toml** — per module type: id, surface mask, materials list, player production level, production time, threat cost, fill color, glyph, and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, threat cost, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
@@ -179,7 +179,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `id` — unique identifier, also used as the display name in the UI.
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
- `materials` — list of materials required per instance (added to the ship's build cost).
- `player_production_level`fixed level for this module type; used as `x` in its stat formulas.
- `player_production_level`initial level for this module type; used as `x` in its stat formulas. Incremented by 1 on each duplicate schematic drop (REQ-DEF-SCHEMATIC-DROP).
- `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked.
- `production_time_seconds` — time added to the ship's production cycle per instance.
- `threat_cost` — threat cost added to the ship's threat cost per instance.
- `fill_color` — fill color used to render this module's cells in the layout grid.
@@ -224,7 +225,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
The dialog contains:
- **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode.
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL).
- **Center** (below the grid): A grid of module selection buttons (one per module type defined in `modules.toml`) plus a "Remove" button. Each module button shows the module id and its glyph.
- **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph.
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
@@ -254,7 +255,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list.
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
- REQ-MOD-UI-BLUEPRINT-STARTUP: At application startup, layout blueprints are loaded from `ship_layouts.toml` in the same directory as the application executable. Blueprint entries missing required fields (`name` or `ship_type`) are silently skipped. If the file does not exist, the blueprint list starts empty with no error. If the file exists but cannot be parsed (malformed TOML), a modal error dialog describes the failure and the blueprint list starts empty.
@@ -269,7 +270,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within range.
- REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all schematics whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set. If the player does not yet have that schematic, it is unlocked. If the player already has it, the schematic's `[ship.schematic].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all **ship and module schematics** whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set. If the player does not yet have that schematic, it is unlocked: ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
## Threat Level & Enemy Waves
@@ -327,9 +328,11 @@ The screen is divided into three vertical sections:
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-SCHEMATIC-TOAST: When a schematic is unlocked or leveled up (REQ-DEF-SCHEMATIC-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the schematic's `ships.toml [ship.schematic].display_name`. Toast text:
- **New unlock**: `Schematic unlocked: <Ship Name>`
- **Level-up (duplicate drop)**: `<Ship Name> production level → N` (where N is the new level).
- REQ-UI-SCHEMATIC-TOAST: When a schematic is unlocked or leveled up (REQ-DEF-SCHEMATIC-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. Toast text:
- **Ship schematic — new unlock**: `Schematic unlocked: <Ship Name>` (where `<Ship Name>` is `ships.toml [ship.schematic].display_name`).
- **Ship schematic — level-up (duplicate drop)**: `<Ship Name> production level → N` (where N is the new level).
- **Module schematic — new unlock**: `Module unlocked: <Module Id>` (where `<Module Id>` is the module's `id` from `modules.toml`).
- **Module schematic — level-up (duplicate drop)**: `<Module Id> production level → N` (where N is the new level).
If multiple toasts arrive in close succession, they stack vertically in a queue (most recent at the top) and each fades out independently after its own 4-second lifetime.
- REQ-UI-HOTKEYS: Global keyboard shortcuts:

View File

@@ -571,6 +571,8 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>(
mt["unlock_at_station_level"].value_or<int64_t>(-1));
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.playerProductionLevel = static_cast<int>(requireInt(
mt["player_production_level"], file, elemPath + ".player_production_level"));

View File

@@ -40,6 +40,7 @@ struct ModuleRepairCapability
struct ModuleDef
{
std::string id;
int unlockAtStationLevel;
std::vector<std::string> surfaceMask;
std::vector<RecipeIngredient> materials;
int playerProductionLevel;

View File

@@ -5,10 +5,11 @@
// 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.
// "level -> N" wording. isModuleSchematic selects ship vs. module toast text.
struct SchematicDropEvent
{
std::string schematicId; // matches ShipDef::id in the config.
std::string schematicId; // matches ShipDef::id or ModuleDef::id in the config.
int newLevel;
bool wasNewUnlock;
bool isModuleSchematic;
};

View File

@@ -56,7 +56,8 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
QVector2D position, bool isEnemy,
const std::optional<ShipLayoutConfig>& layout)
const std::optional<ShipLayoutConfig>& layout,
const std::map<std::string, int>& moduleLevelOverrides)
{
const ShipDef* def = findShipDef(schematicId);
assert(def != nullptr);
@@ -105,7 +106,9 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(modDef->playerProductionLevel);
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : modDef->playerProductionLevel);
if (modDef->weaponCapability)
{
@@ -176,7 +179,9 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(modDef->playerProductionLevel);
const auto overIt2 = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt2 != moduleLevelOverrides.end() ? overIt2->second : modDef->playerProductionLevel);
for (const ModuleStatModifier& sm : modDef->statModifiers)
{

View File

@@ -1,5 +1,6 @@
#pragma once
#include <map>
#include <optional>
#include <string>
@@ -19,7 +20,8 @@ public:
entt::entity spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy = false,
const std::optional<ShipLayoutConfig>& layout = std::nullopt);
const std::optional<ShipLayoutConfig>& layout = std::nullopt,
const std::map<std::string, int>& moduleLevelOverrides = {});
void despawn(entt::entity entity);
// Reset all movement intents to priority 0 before behavior systems run.

View File

@@ -17,7 +17,8 @@
ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId,
int level,
const std::vector<PlacedModule>& modules)
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides)
{
ShipStats result{};
@@ -70,7 +71,9 @@ ShipStats calculateShipStats(const GameConfig& config,
const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(def->playerProductionLevel);
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
if (def->weaponCapability)
{
@@ -108,7 +111,9 @@ ShipStats calculateShipStats(const GameConfig& config,
const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(def->playerProductionLevel);
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
for (const ModuleStatModifier& sm : def->statModifiers)
{

View File

@@ -1,5 +1,6 @@
#pragma once
#include <map>
#include <optional>
#include <string>
#include <vector>
@@ -50,7 +51,8 @@ struct ShipStats
ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId,
int level,
const std::vector<PlacedModule>& modules);
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides = {});
ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity);

View File

@@ -51,7 +51,13 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{
return;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
std::map<std::string, int> moduleLevels;
for (const auto& [mId, mState] : m_moduleSchematicLevels)
{
moduleLevels[mId] = mState.level;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
@@ -62,7 +68,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
// Initialize schematic unlock state.
// Initialize ship schematic unlock state.
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
@@ -71,6 +77,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_schematicLevels[def.id] = state;
}
// Initialize module schematic unlock state.
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state;
}
placeInitialStructures();
registerForEvents();
}
@@ -124,7 +139,13 @@ void Simulation::reset(unsigned int seed)
{
return;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout);
std::map<std::string, int> moduleLevels;
for (const auto& [mId, mState] : m_moduleSchematicLevels)
{
moduleLevels[mId] = mState.level;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
@@ -144,6 +165,15 @@ void Simulation::reset(unsigned int seed)
m_schematicLevels[def.id] = state;
}
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state;
}
placeInitialStructures();
}
@@ -482,20 +512,28 @@ void Simulation::tickDeathsAndLoot()
void Simulation::awardSchematicDrop(int destroyedStationLevel)
{
std::vector<std::string> ids;
ids.reserve(m_config.ships.ships.size());
std::vector<std::pair<std::string, bool>> pool; // (id, isModule)
for (const ShipDef& def : m_config.ships.ships)
{
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
{
ids.push_back(def.id);
pool.push_back({def.id, false});
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
{
pool.push_back({def.id, true});
}
}
std::uniform_int_distribution<int> dist(0, static_cast<int>(ids.size()) - 1);
const std::string chosen = ids[static_cast<std::size_t>(dist(m_rng))];
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1);
const auto& [chosen, isModule] = pool[static_cast<std::size_t>(dist(m_rng))];
SchematicState& state = m_schematicLevels.at(chosen);
SchematicState& state = isModule
? m_moduleSchematicLevels.at(chosen)
: m_schematicLevels.at(chosen);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
@@ -504,6 +542,7 @@ void Simulation::awardSchematicDrop(int destroyedStationLevel)
evt.schematicId = chosen;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
evt.isModuleSchematic = isModule;
m_schematicDropEvents.push_back(evt);
}
@@ -586,6 +625,28 @@ bool Simulation::isSchematicUnlocked(const std::string& shipId) const
return it->second.unlocked;
}
int Simulation::moduleSchematicLevel(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
int cost = 0;

View File

@@ -62,10 +62,14 @@ public:
Tick bossCountdownTicks() const;
Tick normalGapRemainingTicks() const;
// Schematic state queries.
// Ship schematic state queries.
int schematicLevel(const std::string& shipId) const;
bool isSchematicUnlocked(const std::string& shipId) const;
// Module schematic state queries.
int moduleSchematicLevel(const std::string& moduleId) const;
bool isModuleSchematicUnlocked(const std::string& moduleId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
@@ -125,6 +129,7 @@ private:
int level;
};
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
EntityAdmin m_admin;
BeltSystem m_beltSystem;

View File

@@ -16,6 +16,8 @@
#include "ShipSystem.h"
#include "StationBodyComponent.h"
#include "WeaponComponent.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "Tick.h"
#include "WaveSystem.h"
@@ -278,10 +280,13 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
bool validId = false;
for (const ShipDef& def : sim.config().ships.ships)
{
if (def.id == events[0].schematicId)
if (def.id == events[0].schematicId) { validId = true; break; }
}
if (!validId)
{
validId = true;
break;
for (const ModuleDef& def : sim.config().modules.modules)
{
if (def.id == events[0].schematicId) { validId = true; break; }
}
}
REQUIRE(validId);

View File

@@ -194,16 +194,19 @@ void GameWorldView::onFrame()
m_sim->drainSchematicDropEvents();
for (const SchematicDropEvent& ev : drops)
{
const QString shipName = toDisplayName(ev.schematicId);
const QString name = toDisplayName(ev.schematicId);
ToastEntry toast;
if (ev.wasNewUnlock)
if (ev.isModuleSchematic)
{
toast.text = "Schematic unlocked: " + shipName;
toast.text = ev.wasNewUnlock
? tr("Module unlocked: ") + name
: name + tr(" production level -> ") + QString::number(ev.newLevel);
}
else
{
toast.text = shipName + " production level -> "
+ QString::number(ev.newLevel);
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);

View File

@@ -1,5 +1,8 @@
#include "MainWindow.h"
#include <map>
#include <set>
#include <QApplication>
#include <QCloseEvent>
#include <QFile>
@@ -233,8 +236,22 @@ void MainWindow::onLayoutDialogRequested(BuildingId shipyardId)
currentLayout = *b->shipLayout;
}
std::set<std::string> unlockedModuleIds;
std::map<std::string, int> moduleLevels;
for (const ModuleDef& def : m_sim->config().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
unlockedModuleIds.insert(def.id);
}
moduleLevels[def.id] = m_sim->moduleSchematicLevel(def.id);
}
ShipLayoutDialog dialog(&m_sim->config(), b->recipeId, currentLayout,
m_layoutBlueprints, this);
m_layoutBlueprints,
std::move(unlockedModuleIds),
std::move(moduleLevels),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
m_sim->buildings().setShipLayout(shipyardId, *dialog.result());

View File

@@ -387,10 +387,14 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
const std::string& shipId,
const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
std::map<std::string, int> moduleLevels,
QWidget* parent)
: QDialog(parent)
, m_config(config)
, m_shipId(shipId)
, m_unlockedModuleIds(std::move(unlockedModuleIds))
, m_moduleLevels(std::move(moduleLevels))
, m_rows(0)
, m_cols(0)
, m_placedModules(currentLayout.placedModules)
@@ -469,6 +473,11 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
for (int i = 0; i < static_cast<int>(config->modules.modules.size()); ++i)
{
const ModuleDef& def = config->modules.modules[i];
if (m_unlockedModuleIds.count(def.id) == 0)
{
m_moduleButtons.push_back(nullptr);
continue;
}
const QString label = displayName(def.id)
+ "\n" + QString::fromStdString(def.glyph);
QPushButton* btn = new QPushButton(label, this);
@@ -509,7 +518,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
{
for (QPushButton* btn : m_moduleButtons)
{
btn->setChecked(false);
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = -1;
m_removeButton->setChecked(true);
@@ -628,14 +637,14 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
{
if (m_activeModuleIndex == index)
{
m_moduleButtons[index]->setChecked(false);
if (m_moduleButtons[index]) { m_moduleButtons[index]->setChecked(false); }
m_activeModuleIndex = -2;
}
else
{
for (int i = 0; i < static_cast<int>(m_moduleButtons.size()); ++i)
{
m_moduleButtons[i]->setChecked(i == index);
if (m_moduleButtons[i]) { m_moduleButtons[i]->setChecked(i == index); }
}
m_removeButton->setChecked(false);
m_activeModuleIndex = index;
@@ -717,7 +726,7 @@ void ShipLayoutDialog::updateStats()
break;
}
}
m_statsPanel->refresh(m_shipId, level, m_placedModules);
m_statsPanel->refresh(m_shipId, level, m_placedModules, m_moduleLevels);
}
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
@@ -779,13 +788,13 @@ void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modu
for (const PlacedModule& pm : modules)
{
// Validate module type exists.
// Validate module type exists and is unlocked.
const ModuleDef* def = nullptr;
for (const ModuleDef& d : m_config->modules.modules)
{
if (d.id == pm.moduleId) { def = &d; break; }
}
if (!def) { continue; }
if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; }
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
bool valid = true;

View File

@@ -1,6 +1,8 @@
#pragma once
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
@@ -24,6 +26,8 @@ public:
const std::string& shipId,
const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
std::map<std::string, int> moduleLevels,
QWidget* parent = nullptr);
std::optional<ShipLayoutConfig> result() const;
@@ -57,6 +61,8 @@ private:
const GameConfig* m_config;
std::string m_shipId;
std::set<std::string> m_unlockedModuleIds;
std::map<std::string, int> m_moduleLevels;
std::vector<std::string> m_shipLayout;
int m_rows;
int m_cols;

View File

@@ -108,9 +108,11 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
void ShipStatsPanel::refresh(const std::string& shipId,
int level,
const std::vector<PlacedModule>& modules)
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides)
{
const ShipStats stats = calculateShipStats(*m_config, shipId, level, modules);
const ShipStats stats = calculateShipStats(*m_config, shipId, level, modules,
moduleLevelOverrides);
const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText);
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <map>
#include <string>
#include <vector>
@@ -20,7 +21,8 @@ public:
void refresh(const std::string& shipId,
int level,
const std::vector<PlacedModule>& modules);
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides = {});
void refreshFromLive(const ShipStats& stats, float currentHp);