OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
module;
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.skirmish;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.map;
|
||||
export import ra3.game;
|
||||
import ra3.logic;
|
||||
|
||||
/**
|
||||
* A minimal, headless skirmish simulation.
|
||||
*
|
||||
* Two sides start on a real map's waypoint positions, earn credits, train
|
||||
* units and fight until one side's base is destroyed. The simulation is fully
|
||||
* deterministic: fixed 30 Hz steps, no wall-clock, and a seeded logic random
|
||||
* stream. There is no networking, no online service and no EA account - this is
|
||||
* offline skirmish only.
|
||||
*/
|
||||
export namespace ra3::skirmish {
|
||||
using ra3::core::coord3d;
|
||||
using ra3::core::int32;
|
||||
using ra3::core::real;
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::uint8;
|
||||
using ra3::core::usize;
|
||||
|
||||
/** Broad unit role. */
|
||||
enum class unit_class : uint8 { harvester, infantry, tank, base };
|
||||
|
||||
[[nodiscard]] constexpr auto to_string(unit_class value) -> std::string_view {
|
||||
switch (value) {
|
||||
case unit_class::harvester:
|
||||
return "Harvester";
|
||||
case unit_class::infantry:
|
||||
return "Infantry";
|
||||
case unit_class::tank:
|
||||
return "Tank";
|
||||
case unit_class::base:
|
||||
return "Base";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Static combat/economy stats for a unit class.
|
||||
*
|
||||
* These are OpenRA3's own balance values (the retail numbers live in
|
||||
* compiled assets that are not parsed yet), chosen so a skirmish resolves.
|
||||
*/
|
||||
struct unit_type {
|
||||
unit_class cls = unit_class::infantry;
|
||||
std::string_view name;
|
||||
real max_health = 1.0F;
|
||||
real speed = 0.0F;
|
||||
real weapon_damage = 0.0F;
|
||||
real weapon_range = 0.0F;
|
||||
real weapon_cooldown = 1.0F;
|
||||
int32 cost = 0;
|
||||
real build_time = 1.0F;
|
||||
bool mobile = false;
|
||||
bool produces = false;
|
||||
};
|
||||
|
||||
inline constexpr unit_type harvester_type{unit_class::harvester, "Harvester", 300.0F, 10.0F, 0.0F, 0.0F, 0.0F, 1400, 8.0F, true, false};
|
||||
inline constexpr unit_type infantry_type{unit_class::infantry, "Infantry", 120.0F, 12.0F, 8.0F, 55.0F, 1.0F, 150, 3.0F, true, false};
|
||||
inline constexpr unit_type tank_type{unit_class::tank, "Tank", 480.0F, 9.0F, 30.0F, 70.0F, 1.6F, 900, 8.0F, true, false};
|
||||
inline constexpr unit_type base_type{unit_class::base, "Base", 2500.0F, 0.0F, 20.0F, 90.0F, 2.0F, 0, 0.0F, false, true};
|
||||
|
||||
[[nodiscard]] constexpr auto unit_stats(unit_class value) -> const unit_type & {
|
||||
switch (value) {
|
||||
case unit_class::harvester:
|
||||
return harvester_type;
|
||||
case unit_class::infantry:
|
||||
return infantry_type;
|
||||
case unit_class::tank:
|
||||
return tank_type;
|
||||
case unit_class::base:
|
||||
return base_type;
|
||||
}
|
||||
return infantry_type;
|
||||
}
|
||||
|
||||
/** A live unit instance. */
|
||||
struct unit {
|
||||
uint32 id = 0;
|
||||
uint32 owner = 0;
|
||||
unit_class cls = unit_class::infantry;
|
||||
coord3d position{};
|
||||
real health = 0.0F;
|
||||
real max_health = 0.0F;
|
||||
real cooldown = 0.0F;
|
||||
bool alive = true;
|
||||
};
|
||||
|
||||
/** Per-player state for the match. */
|
||||
struct player_state {
|
||||
uint32 index = 0;
|
||||
std::string name;
|
||||
game::faction side = game::faction::none;
|
||||
bool human = false;
|
||||
int32 money = 0;
|
||||
real money_accumulator = 0.0F;
|
||||
coord3d start{};
|
||||
uint32 base_unit = 0;
|
||||
real build_timer = 0.0F;
|
||||
int32 kills = 0;
|
||||
int32 losses = 0;
|
||||
};
|
||||
|
||||
/** Skirmish parameters. */
|
||||
struct match_config {
|
||||
std::string map_id = "builtin";
|
||||
int32 starting_money = 10000;
|
||||
uint32 seed = 1;
|
||||
uint32 max_frames = 30U * 60U * 15U;
|
||||
real income_per_second = 25.0F;
|
||||
real harvester_income_per_second = 15.0F;
|
||||
};
|
||||
|
||||
/** Outcome of a completed (or capped) match. */
|
||||
struct match_result {
|
||||
bool decided = false;
|
||||
int32 winner = -1;
|
||||
uint32 frames = 0;
|
||||
int32 units_left[2] = {0, 0};
|
||||
int32 kills[2] = {0, 0};
|
||||
int32 losses[2] = {0, 0};
|
||||
};
|
||||
|
||||
/** Fallback start positions when a map yields none (CI, no game data). */
|
||||
[[nodiscard]] inline auto builtin_start_positions() -> std::vector<ra3::map::start_position> {
|
||||
return {{300.0F, 300.0F, 0.0F}, {1500.0F, 1500.0F, 0.0F}};
|
||||
}
|
||||
|
||||
/**
|
||||
* A running skirmish. Construct with `create`, then `run` or `step`.
|
||||
*/
|
||||
class skirmish_match {
|
||||
public:
|
||||
/**
|
||||
* Set up a two-player match.
|
||||
*
|
||||
* @param config Match parameters.
|
||||
* @param starts Player start positions (at least two recommended).
|
||||
*/
|
||||
[[nodiscard]] static auto create(const match_config &config, std::span<const ra3::map::start_position> starts) -> skirmish_match {
|
||||
skirmish_match match;
|
||||
match.config_ = config;
|
||||
match.random_.seed(config.seed);
|
||||
|
||||
const coord3d first = starts.size() >= 1U ? starts[0].as_coord() : coord3d{300.0F, 300.0F, 0.0F};
|
||||
const coord3d second = starts.size() >= 2U ? starts[1].as_coord() : coord3d{1500.0F, 1500.0F, 0.0F};
|
||||
|
||||
match.spawn_player(0U, "Commander", game::faction::allied, true, first);
|
||||
match.spawn_player(1U, "AI", game::faction::soviet, false, second);
|
||||
return match;
|
||||
}
|
||||
|
||||
/** Run until a base falls or the frame cap is reached. */
|
||||
auto run() -> match_result {
|
||||
while (!this->decided() && frame_ < config_.max_frames) this->step();
|
||||
return this->result();
|
||||
}
|
||||
|
||||
/** Advance exactly one 30 Hz frame. */
|
||||
auto step() -> void {
|
||||
constexpr real dt = 1.0F / 30.0F;
|
||||
this->apply_income(dt);
|
||||
this->run_ai(dt);
|
||||
this->update_units(dt);
|
||||
this->check_victory();
|
||||
++frame_;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto frame() const -> uint32 { return frame_; }
|
||||
[[nodiscard]] auto decided() const -> bool { return decided_; }
|
||||
[[nodiscard]] auto winner() const -> int32 { return winner_; }
|
||||
[[nodiscard]] auto players() const -> const std::vector<player_state> & { return players_; }
|
||||
[[nodiscard]] auto units() const -> const std::vector<unit> & { return units_; }
|
||||
|
||||
[[nodiscard]] auto unit_count(uint32 owner) const -> usize {
|
||||
usize count = 0;
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.alive && entry.owner == owner) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto result() const -> match_result {
|
||||
match_result summary;
|
||||
summary.decided = decided_;
|
||||
summary.winner = winner_;
|
||||
summary.frames = frame_;
|
||||
for (const auto &player: players_) {
|
||||
if (player.index < 2U) {
|
||||
summary.kills[player.index] = player.kills;
|
||||
summary.losses[player.index] = player.losses;
|
||||
summary.units_left[player.index] = static_cast<int32>(this->unit_count(player.index));
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr real build_interval_seconds = 4.0F;
|
||||
|
||||
auto spawn_player(uint32 index, std::string name, game::faction side, bool human, const coord3d &start) -> void {
|
||||
player_state player;
|
||||
player.index = index;
|
||||
player.name = std::move(name);
|
||||
player.side = side;
|
||||
player.human = human;
|
||||
player.money = config_.starting_money;
|
||||
player.start = start;
|
||||
player.base_unit = this->spawn_unit(index, unit_class::base, start);
|
||||
this->spawn_unit(index, unit_class::harvester, {start.x + 60.0F, start.y + 20.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::tank, {start.x + 40.0F, start.y + 50.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::tank, {start.x + 70.0F, start.y - 30.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::infantry, {start.x + 30.0F, start.y - 60.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::infantry, {start.x + 90.0F, start.y - 60.0F, 0.0F});
|
||||
players_.push_back(std::move(player));
|
||||
}
|
||||
|
||||
auto spawn_unit(uint32 owner, unit_class cls, const coord3d &position) -> uint32 {
|
||||
const auto &stats = unit_stats(cls);
|
||||
unit created;
|
||||
created.id = next_unit_id_++;
|
||||
created.owner = owner;
|
||||
created.cls = cls;
|
||||
created.position = position;
|
||||
created.health = stats.max_health;
|
||||
created.max_health = stats.max_health;
|
||||
units_.push_back(created);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
auto apply_income(real dt) -> void {
|
||||
for (auto &player: players_) {
|
||||
real rate = config_.income_per_second;
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.alive && entry.owner == player.index && entry.cls == unit_class::harvester) rate += config_.harvester_income_per_second;
|
||||
}
|
||||
player.money_accumulator += rate * dt;
|
||||
const auto whole = static_cast<int32>(player.money_accumulator);
|
||||
if (whole > 0) {
|
||||
player.money += whole;
|
||||
player.money_accumulator -= static_cast<real>(whole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto run_ai(real dt) -> void {
|
||||
for (auto &player: players_) {
|
||||
player.build_timer += dt;
|
||||
if (player.build_timer < build_interval_seconds) continue;
|
||||
player.build_timer = 0.0F;
|
||||
|
||||
const auto *base = this->find_unit(player.base_unit);
|
||||
if (base == nullptr || !base->alive) continue;
|
||||
if (player.money < tank_type.cost) continue;
|
||||
|
||||
player.money -= tank_type.cost;
|
||||
this->spawn_unit(player.index, unit_class::tank, {base->position.x + 50.0F, base->position.y - 50.0F, 0.0F});
|
||||
}
|
||||
}
|
||||
|
||||
auto update_units(real dt) -> void {
|
||||
for (auto &entry: units_) {
|
||||
if (!entry.alive) continue;
|
||||
if (entry.cooldown > 0.0F) entry.cooldown -= dt;
|
||||
if (entry.cls == unit_class::harvester) continue; // economy only, no combat
|
||||
|
||||
const auto &stats = unit_stats(entry.cls);
|
||||
auto *target = this->nearest_enemy(entry);
|
||||
if (target == nullptr) continue;
|
||||
|
||||
const auto dx = target->position.x - entry.position.x;
|
||||
const auto dy = target->position.y - entry.position.y;
|
||||
const auto dz = target->position.z - entry.position.z;
|
||||
const auto distance = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
if (distance > stats.weapon_range && stats.mobile && stats.speed > 0.0F) {
|
||||
if (distance > 1.0e-3F) {
|
||||
const auto travel = stats.speed * dt;
|
||||
entry.position.x += dx / distance * travel;
|
||||
entry.position.y += dy / distance * travel;
|
||||
}
|
||||
} else if (stats.weapon_damage > 0.0F && distance <= stats.weapon_range && entry.cooldown <= 0.0F) {
|
||||
entry.cooldown = stats.weapon_cooldown;
|
||||
target->health -= stats.weapon_damage;
|
||||
if (target->health <= 0.0F) {
|
||||
target->health = 0.0F;
|
||||
target->alive = false;
|
||||
this->player_ref(entry.owner).kills += 1;
|
||||
this->player_ref(target->owner).losses += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto check_victory() -> void {
|
||||
const bool first_alive = this->base_alive(0U);
|
||||
const bool second_alive = this->base_alive(1U);
|
||||
if (first_alive && second_alive) return;
|
||||
decided_ = true;
|
||||
if (first_alive) {
|
||||
winner_ = 0;
|
||||
} else if (second_alive) {
|
||||
winner_ = 1;
|
||||
} else {
|
||||
winner_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto base_alive(uint32 owner) const -> bool {
|
||||
const auto &player = players_.at(owner);
|
||||
const auto *base = this->find_unit(player.base_unit);
|
||||
return base != nullptr && base->alive;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto nearest_enemy(const unit &self) -> unit * {
|
||||
unit *best = nullptr;
|
||||
real best_distance = 1.0e30F;
|
||||
for (auto &candidate: units_) {
|
||||
if (!candidate.alive || candidate.owner == self.owner) continue;
|
||||
const auto dx = candidate.position.x - self.position.x;
|
||||
const auto dy = candidate.position.y - self.position.y;
|
||||
const auto squared = dx * dx + dy * dy;
|
||||
if (squared < best_distance) {
|
||||
best_distance = squared;
|
||||
best = &candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto find_unit(uint32 id) -> unit * {
|
||||
for (auto &entry: units_) {
|
||||
if (entry.id == id) return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto find_unit(uint32 id) const -> const unit * {
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.id == id) return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto player_ref(uint32 index) -> player_state & { return players_.at(index); }
|
||||
|
||||
match_config config_;
|
||||
ra3::core::random random_{1U};
|
||||
std::vector<player_state> players_;
|
||||
std::vector<unit> units_;
|
||||
uint32 next_unit_id_ = 1U;
|
||||
uint32 frame_ = 0U;
|
||||
bool decided_ = false;
|
||||
int32 winner_ = -1;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user