OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish

This commit is contained in:
EnderTheCoder
2026-09-12 00:26:45 +08:00
commit 3ad380c81e
24 changed files with 2630 additions and 0 deletions
+371
View File
@@ -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;
};
}