v0.3.0: display abstraction, map-browser menu, SAGE terrain tiling/blends

- ra3.client::display: shared interactive loops; SDL and Vulkan backends
  implement only the primitives (init/present/poll_event/window_size/
  key_down/present_terrain). ra3.display picks the backend.
- Menu: maps by localized name (gamestrings.csf), red/gold theme, hover
  highlight, mouse + keyboard, wheel scroll, fullscreen and FPS/vsync
  options, loading progress bar.
- Terrain: continuous tile sampling via a texture array (REPEAT, uv =
  cell/(2*cellSize)) removes per-cell grid seams; SAGE blend ramp for
  material transitions; FPS label + top-right minimap overlays.
- Skip the skirmish sim for map views; reuse the Vulkan texture; no idle
  terrain redraw.
This commit is contained in:
EnderTheCoder
2026-09-20 02:20:39 +08:00
parent 702b4e29aa
commit b6d619254d
66 changed files with 37661 additions and 406 deletions
+485 -5
View File
@@ -4,27 +4,501 @@ import std;
import ra3.core;
import ra3.logic;
import ra3.render;
import ra3.terrain;
/**
* The presentation layer: an abstract output surface plus the client facade
* that owns the simulation and drives the frame loop.
*
* A future W3D/D3D9 backend implements the same `display` interface; the
* default headless backend keeps OpenRA3 runnable on a CPU-only box.
* Backends implement only the low-level primitives (`init`, `present`,
* `poll_event`, `window_size`, `key_down`, `shutdown`) and, optionally,
* `present_terrain`; the interactive loops (menu, image viewer, camera viewer,
* GPU terrain) and the loading screen live here once, so input mapping and the
* frame loop never diverge between the SDL and Vulkan backends. The loops
* assume the display is already initialized, which lets one window host a menu,
* a loading bar and a viewer in sequence.
*/
export namespace ra3::client {
using ra3::core::uint32;
using ra3::render::image;
using ra3::render::ui_event;
using ra3::render::ui_event_type;
using ra3::render::ui_key;
using ra3::render::view_rect;
/** Window parameters for a display. */
struct display_options {
std::string title = "OpenRA3";
int width = 1280;
int height = 720;
bool fullscreen = false;
int fps_limit = 0; ///< 0 = vertical sync (present throttled to the display).
};
/** Small overlays drawn on top of the GPU terrain (top-left FPS, corner minimap). */
struct terrain_overlay {
image label; ///< top-left label (may be empty)
image minimap; ///< bottom-right overview (may be empty)
bool label_changed = true;
bool minimap_changed = true;
};
/**
* Abstract output surface presented once per logic frame.
* Abstract presentation surface.
*/
class display {
public:
virtual ~display() = default;
virtual auto begin_frame() -> void = 0;
virtual auto end_frame() -> void = 0;
virtual auto begin_frame() -> void {}
virtual auto end_frame() -> void {}
/** Create the window and rendering objects. */
[[nodiscard]] virtual auto init(const display_options &options) -> bool = 0;
/** Draw `frame` scaled into `dest` (window coordinates). `changed` is
* false when `frame` is the same image as the previous call, so the
* backend may skip re-uploading its texture. */
[[nodiscard]] virtual auto present(const image &frame, const view_rect &dest, bool changed) -> bool = 0;
/** Pop one OS event into `out`; returns false when the queue is empty. */
[[nodiscard]] virtual auto poll_event(ui_event &out) -> bool = 0;
/** Current drawable size in pixels. */
[[nodiscard]] virtual auto window_size() const -> std::pair<int, int> = 0;
/** Whether a key is currently held (for panning). */
[[nodiscard]] virtual auto key_down(ui_key key) const -> bool = 0;
virtual auto shutdown() -> void = 0;
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
/**
* Optional GPU terrain path. The default reports `false`, so a low-end
* backend simply does not offer it; the Vulkan backend overrides it.
* `overlays` (FPS label, minimap) are drawn on top of the terrain.
*/
[[nodiscard]] virtual auto present_terrain(const ra3::terrain::gpu_terrain &, const ra3::render::camera3d &, float, const terrain_overlay &) -> bool {
return false;
}
/** Whether this backend can draw the GPU terrain path. */
[[nodiscard]] virtual auto supports_terrain() const -> bool { return false; }
int fps_limit_ = 0; ///< 0 = vsync, >0 = target FPS cap, <0 = uncapped.
/** Throttle an idle frame according to the configured limit. */
auto sleep_frame() const -> void {
if (fps_limit_ > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, 1000 / fps_limit_)));
} else if (fps_limit_ < 0) {
std::this_thread::yield();
}
// fps_limit_ == 0: vertical sync already blocks in present().
}
// ---- shared interactive loops (assume `init` succeeded) --------------
/** Letterbox `frame` into the current window. */
[[nodiscard]] auto present_fit(const image &frame, bool changed) -> bool {
const auto [w, h] = this->window_size();
return this->present(frame, ra3::render::fit_rect(static_cast<float>(frame.width()), static_cast<float>(frame.height()), static_cast<float>(w),
static_cast<float>(h)),
changed);
}
/** Draw a warm loading screen with a progress bar (0..1). */
auto present_progress(float progress, std::string_view label) -> void {
const auto [w, h] = this->window_size();
const auto frame = ra3::render::compose_progress(static_cast<uint32>(std::max(1, w)), static_cast<uint32>(std::max(1, h)), progress, label);
this->present_fit(frame, true);
}
/**
* Run a staged loader on this display: `load` is called with a `report`
* callback that draws a progress bar and pumps events (returning false
* if the user closed the window).
*/
template<typename Loader>
[[nodiscard]] auto run_with_progress(Loader &&load) -> bool {
this->present_progress(0.0F, "Loading...");
const auto report = [&](float progress, std::string_view label) -> bool {
this->present_progress(progress, label);
ui_event event;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) return false;
}
return true;
};
return load(report);
}
/**
* Interactive menu. `frame` returns the image to display for an input
* event, or `nullopt` when nothing changed (so the previous frame is
* kept and nothing is recomposed/re-uploaded). It sets `done` to end
* the loop. `frame` is called with the current drawable size so text
* stays crisp on HiDPI displays.
*/
[[nodiscard]] auto menu_loop(const std::function<std::optional<image>(const ui_event &, uint32, uint32, bool &)> &frame) -> bool {
auto [sw, sh] = this->window_size();
const auto w = static_cast<uint32>(std::max(1, sw));
const auto h = static_cast<uint32>(std::max(1, sh));
bool done = false;
auto first = frame({}, w, h, done);
if (!first) return false;
image current = std::move(*first);
if (!this->present_fit(current, true)) return false;
while (!done) {
ui_event event;
bool idle = true;
bool changed = false;
while (this->poll_event(event)) {
idle = false;
if (event.type == ui_event_type::quit) {
done = true;
break;
}
auto result = frame(event, w, h, done);
if (done) break;
if (result) {
current = std::move(*result);
changed = true;
}
}
if (done) break;
if (changed) {
if (!this->present_fit(current, true)) break;
} else if (idle) {
if (!this->present_fit(current, false)) break;
this->sleep_frame();
}
}
return true;
}
/**
* Pan/zoom viewer over a raster image, reproducing the retail tactical
* view controls (wheel zoom, edge scroll, drag pan).
*/
[[nodiscard]] auto image_loop(const image &scene, ra3::render::view_camera camera) -> bool {
if (scene.empty()) return false;
camera.min_zoom = 1.0F;
camera.clamp_center();
float mouse_x = 0.0F;
float mouse_y = 0.0F;
bool running = true;
auto last = std::chrono::steady_clock::now();
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
if (event.key == ui_key::cancel) {
running = false;
} else if (event.key == ui_key::page_up) {
camera.zoom_by(camera.zoom_step);
} else if (event.key == ui_key::page_down) {
camera.zoom_by(1.0F / camera.zoom_step);
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
drag_x += event.dx;
drag_y += event.dy;
}
} else if (event.type == ui_event_type::wheel) {
camera.zoom_by(event.wheel > 0.0F ? camera.zoom_step : (1.0F / camera.zoom_step));
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
const auto [w, h] = this->window_size();
auto view = camera.rect(static_cast<float>(scene.width()), static_cast<float>(scene.height()), static_cast<float>(w), static_cast<float>(h));
if (view.w > 0.0F && view.h > 0.0F && (drag_x != 0.0F || drag_y != 0.0F)) {
camera.scroll(-drag_x / view.w, -drag_y / view.h);
}
camera.edge_scroll(mouse_x, mouse_y, static_cast<float>(w), static_cast<float>(h), dt);
view = camera.rect(static_cast<float>(scene.width()), static_cast<float>(scene.height()), static_cast<float>(w), static_cast<float>(h));
if (!this->present(scene, view, false)) break;
this->sleep_frame();
}
return true;
}
/**
* Interactive 3D camera over a software-rendered scene: the provider
* re-renders whenever the camera moves.
*/
[[nodiscard]] auto camera_loop(const std::function<image(const ra3::render::camera3d &, uint32, uint32)> &provider, ra3::render::camera3d camera) -> bool {
auto [window_w, window_h] = this->window_size();
image current = provider(camera, static_cast<uint32>(std::max(1, window_w)), static_cast<uint32>(std::max(1, window_h)));
if (current.empty()) return false;
if (!this->present(current, {0.0F, 0.0F, static_cast<float>(window_w), static_cast<float>(window_h)}, true)) return false;
float mouse_x = 0.5F * static_cast<float>(window_w);
float mouse_y = 0.5F * static_cast<float>(window_h);
bool running = true;
auto last = std::chrono::steady_clock::now();
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
bool dirty = false;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
switch (event.key) {
case ui_key::cancel: running = false; break;
case ui_key::page_up: camera.height = std::clamp(camera.height / 1.15F, camera.min_height, camera.max_height); dirty = true; break;
case ui_key::page_down: camera.height = std::clamp(camera.height * 1.15F, camera.min_height, camera.max_height); dirty = true; break;
default: break;
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
drag_x += event.dx;
drag_y += event.dy;
}
} else if (event.type == ui_event_type::wheel) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
dirty = true;
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
if (drag_x != 0.0F || drag_y != 0.0F) {
camera.yaw -= drag_x * 0.005F;
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
dirty = true;
}
float move_x = 0.0F;
float move_y = 0.0F;
if (this->key_down(ui_key::up)) move_y += 1.0F;
if (this->key_down(ui_key::down)) move_y -= 1.0F;
if (this->key_down(ui_key::left)) move_x -= 1.0F;
if (this->key_down(ui_key::right)) move_x += 1.0F;
if (mouse_x <= 24.0F) {
move_x -= 1.0F;
} else if (mouse_x >= static_cast<float>(window_w) - 24.0F) {
move_x += 1.0F;
}
if (mouse_y <= 24.0F) {
move_y += 1.0F;
} else if (mouse_y >= static_cast<float>(window_h) - 24.0F) {
move_y -= 1.0F;
}
if (move_x != 0.0F || move_y != 0.0F) {
const auto fwd_x = std::sin(camera.yaw);
const auto fwd_y = std::cos(camera.yaw);
const auto right_x = std::cos(camera.yaw);
const auto right_y = -std::sin(camera.yaw);
const auto step = camera.height * 0.9F * dt;
camera.target_x += (fwd_x * move_y + right_x * move_x) * step;
camera.target_y += (fwd_y * move_y + right_y * move_x) * step;
dirty = true;
}
const auto latest = this->window_size();
if (latest.first != window_w || latest.second != window_h) {
window_w = latest.first;
window_h = latest.second;
dirty = true;
}
if (dirty) {
current = provider(camera, static_cast<uint32>(std::max(1, window_w)), static_cast<uint32>(std::max(1, window_h)));
if (current.empty()) break;
dirty = false;
}
if (!this->present(current, {0.0F, 0.0F, static_cast<float>(window_w), static_cast<float>(window_h)}, true)) break;
this->sleep_frame();
}
return true;
}
/**
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop
* here owns the camera controls. The top-left shows the FPS (current /
* cap) and, when `minimap_overview` is not empty, a corner minimap with
* the camera location is drawn.
*/
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
const auto world_w = static_cast<float>(terrain.width) * 10.0F;
const auto world_h = static_cast<float>(terrain.height) * 10.0F;
camera.target_x = std::clamp(camera.target_x, 0.0F, world_w);
camera.target_y = std::clamp(camera.target_y, 0.0F, world_h);
auto [window_w, window_h] = this->window_size();
const auto start = std::chrono::steady_clock::now();
auto last = start;
float mouse_x = 0.5F * static_cast<float>(window_w);
float mouse_y = 0.5F * static_cast<float>(window_h);
terrain_overlay overlay;
uint32 fps = 0;
int fps_frames = 0;
auto fps_window = start;
auto minimap_time = start;
bool running = true;
bool presented = false;
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
bool camera_moved = false;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
switch (event.key) {
case ui_key::cancel: running = false; break;
case ui_key::page_up: camera.height = std::clamp(camera.height / 1.15F, camera.min_height, camera.max_height); camera_moved = true; break;
case ui_key::page_down: camera.height = std::clamp(camera.height * 1.15F, camera.min_height, camera.max_height); camera_moved = true; break;
default: break;
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
drag_x += event.dx;
drag_y += event.dy;
}
} else if (event.type == ui_event_type::wheel) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
camera_moved = true;
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
if (drag_x != 0.0F || drag_y != 0.0F) {
camera.yaw -= drag_x * 0.005F;
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
camera_moved = true;
}
float move_x = 0.0F;
float move_y = 0.0F;
if (this->key_down(ui_key::up)) move_y += 1.0F;
if (this->key_down(ui_key::down)) move_y -= 1.0F;
if (this->key_down(ui_key::left)) move_x -= 1.0F;
if (this->key_down(ui_key::right)) move_x += 1.0F;
if (mouse_x <= 24.0F) {
move_x -= 1.0F;
} else if (mouse_x >= static_cast<float>(window_w) - 24.0F) {
move_x += 1.0F;
}
if (mouse_y <= 24.0F) {
move_y += 1.0F;
} else if (mouse_y >= static_cast<float>(window_h) - 24.0F) {
move_y -= 1.0F;
}
if (move_x != 0.0F || move_y != 0.0F) {
const auto fwd_x = std::sin(camera.yaw);
const auto fwd_y = std::cos(camera.yaw);
const auto right_x = std::cos(camera.yaw);
const auto right_y = -std::sin(camera.yaw);
const auto step = camera.height * 0.9F * dt;
camera.target_x += (fwd_x * move_y + right_x * move_x) * step;
camera.target_y += (fwd_y * move_y + right_y * move_x) * step;
camera_moved = true;
}
camera.target_x = std::clamp(camera.target_x, 0.0F, world_w);
camera.target_y = std::clamp(camera.target_y, 0.0F, world_h);
const auto latest = this->window_size();
if (latest.first != window_w || latest.second != window_h) {
window_w = latest.first;
window_h = latest.second;
camera_moved = true;
}
if (camera_moved) {
++fps_frames;
const auto window_s = std::chrono::duration<float>(now - fps_window).count();
if (window_s >= 0.4F) {
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
fps_frames = 0;
fps_window = now;
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_);
overlay.label_changed = true;
}
} else {
fps_frames = 0;
fps_window = now;
}
const auto since_minimap = std::chrono::duration<float>(now - minimap_time).count();
if (!minimap_overview.empty() && (overlay.minimap.empty() || (camera_moved && since_minimap >= 0.15F))) {
const auto u = camera.target_x / world_w;
const auto v = 1.0F - camera.target_y / world_h;
overlay.minimap = ra3::render::compose_minimap(minimap_overview, u, v);
overlay.minimap_changed = true;
minimap_time = now;
}
// Render only when the view changes: an idle terrain costs nothing.
if (camera_moved || !presented) {
const auto time_s = std::chrono::duration<float>(now - start).count();
if (!this->present_terrain(terrain, camera, time_s, overlay)) {
return presented;
}
overlay.label_changed = false;
overlay.minimap_changed = false;
presented = true;
}
this->sleep_frame();
}
return true;
}
// ---- one-shot helpers (own the lifecycle) ----------------------------
[[nodiscard]] auto run_menu(const display_options &options, const std::function<std::optional<image>(const ui_event &, uint32, uint32, bool &)> &frame) -> bool {
if (!this->init(options)) return false;
(void)this->menu_loop(frame);
this->shutdown();
return true;
}
[[nodiscard]] auto run_image(const display_options &options, const image &scene, ra3::render::view_camera camera) -> bool {
if (scene.empty() || !this->init(options)) return false;
(void)this->image_loop(scene, camera);
this->shutdown();
return true;
}
[[nodiscard]] auto run_camera(const display_options &options, const std::function<image(const ra3::render::camera3d &, uint32, uint32)> &provider,
ra3::render::camera3d camera) -> bool {
if (!this->init(options)) return false;
(void)this->camera_loop(provider, camera);
this->shutdown();
return true;
}
[[nodiscard]] auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera) -> bool {
if (!this->init(options)) return false;
(void)this->terrain_loop(terrain, camera, {});
this->shutdown();
return true;
}
};
/**
@@ -34,6 +508,12 @@ export namespace ra3::client {
public:
auto begin_frame() -> void override {}
auto end_frame() -> void override {}
[[nodiscard]] auto init(const display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const image &, const view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "headless"; }
};
+2 -2
View File
@@ -11,8 +11,8 @@ import std;
*/
export namespace ra3::core {
inline constexpr int version_major = 0;
inline constexpr int version_minor = 2;
inline constexpr int version_patch = 1;
inline constexpr int version_minor = 3;
inline constexpr int version_patch = 0;
using real = float;
using int32 = std::int32_t;
+615
View File
@@ -0,0 +1,615 @@
export module ra3.data;
import std;
export import ra3.core;
/**
* Faithful Red Alert 3 gameplay data.
*
* Every number in this module is taken from the retail game's own asset
* definitions. Two sources back it, both auditable:
*
* 1. EA's open-sourced RA3 asset XML (`CnC_Modding_Support/Red Alert 3/Xml`,
* GPLv3) - the `*.xml` that `BinaryAssetBuilder` compiles into `*.big`.
* The `ra3_id` on each record is the originating `GameObject` id, so a
* value can be looked up in `Allied/Structures/AlliedPowerPlant.xml`,
* `Allied/Infantry/AlliedScoutInfantry.xml`, etc.
* 2. The shipped `ArmorTemplate` percentages, cross-checked against the
* compiled assets by the sibling `ra3-headless` extractor.
*
* The *semantics* of the fields (armour adjusts damage per damage type, a
* weapon's `AntiMask` decides what it may target, `UNRESISTABLE` bypasses
* armour) follow SAGE and are corroborated by the OpenSAGE re-implementation
* (GPLv3). Where a value is not yet recovered the field is documented as such.
*
* Nothing here is loaded from disk yet: the compiled `global.bin` /
* `static.*.bin` deserialiser is a later milestone, so the base-game balance
* used by `ra3.skirmish` is pinned as constants.
*/
export namespace ra3::data {
using ra3::core::int32;
using ra3::core::uint8;
using ra3::core::uint32;
using ra3::core::usize;
/**
* World units per terrain cell. RA3's `TheTerrainLogic` grid stores
* `1 / cellSize` at `grid + 0x38` and the layer reports `cellSize == 10`.
*/
inline constexpr float cell_size = 10.0F;
inline constexpr float inv_cell_size = 1.0F / cell_size;
/**
* The simulation advances on a fixed 30 Hz logic tick, matching SAGE.
*
* Note: the sibling `ra3-sim` paces its own simulator at 15 ticks/s from
* `DAT_00cdbc1c = 1000 / 15`; `ra3-headless` (which drives the real binary)
* reports a fixed 30 Hz logic rate. OpenRA3 keeps the 30 Hz SAGE cadence
* used elsewhere in this codebase until the discrepancy is settled.
*/
inline constexpr int logic_frames_per_second = ra3::core::logic_frames_per_second;
/** Convert a duration in seconds to a whole number of logic frames. */
[[nodiscard]] constexpr auto seconds_to_frames(float seconds) -> uint32 {
return static_cast<uint32>(seconds * static_cast<float>(logic_frames_per_second) + 0.5F);
}
// ---------------------------------------------------------------------
// Damage types
// ---------------------------------------------------------------------
/**
* RA3's damage types, recovered from the keys of the shipped
* `ArmorTemplate` tables and the `DamageType` of every `WeaponTemplate`.
*
* This is *not* the Generals/ZH set (`EXPLOSION`/`SMALL_ARMS`/...): RA3
* replaced it with these. `unresistable` is special - it skips the armour
* multiplier entirely.
*/
enum class damage_type : uint8 {
gun,
melee,
concussive,
auto_cannon,
rocket,
flak,
cannon,
prism,
tesla,
explosive,
impact,
sniper,
grenade,
radiation,
magic,
crush,
healing,
unresistable,
count,
};
inline constexpr usize damage_type_count = static_cast<usize>(damage_type::count);
[[nodiscard]] constexpr auto to_string(damage_type value) -> std::string_view {
switch (value) {
case damage_type::gun: return "GUN";
case damage_type::melee: return "MELEE";
case damage_type::concussive: return "CONCUSSIVE";
case damage_type::auto_cannon: return "AUTO_CANNON";
case damage_type::rocket: return "ROCKET";
case damage_type::flak: return "FLAK";
case damage_type::cannon: return "CANNON";
case damage_type::prism: return "PRISM";
case damage_type::tesla: return "TESLA";
case damage_type::explosive: return "EXPLOSIVE";
case damage_type::impact: return "IMPACT";
case damage_type::sniper: return "SNIPER";
case damage_type::grenade: return "GRENADE";
case damage_type::radiation: return "RADIATION";
case damage_type::magic: return "MAGIC";
case damage_type::crush: return "CRUSH";
case damage_type::healing: return "HEALING";
case damage_type::unresistable: return "UNRESISTABLE";
case damage_type::count: break;
}
return "UNKNOWN";
}
/** Parse an asset damage-type name (e.g. `"AUTO_CANNON"`). */
[[nodiscard]] inline auto damage_type_from_name(std::string_view name) -> damage_type {
for (usize i = 0; i < damage_type_count; ++i) {
const auto value = static_cast<damage_type>(i);
if (to_string(value) == name) return value;
}
return damage_type::gun;
}
// ---------------------------------------------------------------------
// Armour
// ---------------------------------------------------------------------
/**
* A `ArmorTemplate`: a per-damage-type damage multiplier.
*
* Values are fractions (1.0 == 100%). Unlisted types default to 100%,
* except when the template declares a `DEFAULT` (`default_fraction`), as
* `InvulnerableArmor` does with 0. Matches OpenSAGE's `ArmorTemplate`.
*/
struct armor_set {
std::string_view id;
std::array<float, damage_type_count> multipliers{};
float default_fraction = 1.0F;
/** Apply this armour. `unresistable` is never scaled. */
[[nodiscard]] constexpr auto adjust(damage_type type, float damage) const -> float {
if (type == damage_type::unresistable) return damage;
const auto scaled = damage * multipliers[static_cast<usize>(type)];
return scaled < 0.0F ? 0.0F : scaled;
}
};
/**
* Build an armour set. `vs` entries are the raw percentages from the
* shipped `ArmorTemplate` (`100.0` means normal damage).
*/
constexpr auto make_armor(std::string_view id, float default_percent, std::initializer_list<std::pair<damage_type, float>> vs) -> armor_set {
armor_set armor{};
armor.id = id;
armor.default_fraction = default_percent / 100.0F;
armor.multipliers.fill(armor.default_fraction);
for (const auto &[type, percent]: vs) armor.multipliers[static_cast<usize>(type)] = percent / 100.0F;
return armor;
}
/**
* The armour templates used by the modelled units, copied verbatim from the
* shipped `ArmorTemplate` tables (`ra3-headless/out/armors.json`).
*/
inline constexpr armor_set allied_scout_infantry_armor = make_armor(
"AlliedScoutInfantryArmor", 100.0F,
{{damage_type::gun, 1.0F}, {damage_type::melee, 50.0F}, {damage_type::concussive, 50.0F}, {damage_type::auto_cannon, 50.0F},
{damage_type::rocket, 50.0F}, {damage_type::flak, 50.0F}, {damage_type::cannon, 50.0F}, {damage_type::prism, 50.0F},
{damage_type::tesla, 50.0F}, {damage_type::explosive, 50.0F}, {damage_type::impact, 50.0F}});
inline constexpr armor_set allied_anti_infantry_infantry_armor = make_armor(
"AlliedAntiInfantryInfantryArmor", 100.0F,
{{damage_type::sniper, 100.0F}, {damage_type::cannon, 20.0F}, {damage_type::rocket, 20.0F}, {damage_type::grenade, 100.0F},
{damage_type::gun, 100.0F}, {damage_type::melee, 100.0F}, {damage_type::auto_cannon, 150.0F}, {damage_type::impact, 100.0F},
{damage_type::explosive, 100.0F}, {damage_type::flak, 10.0F}, {damage_type::prism, 100.0F}, {damage_type::tesla, 1000.0F},
{damage_type::radiation, 100.0F}});
inline constexpr armor_set soviet_anti_infantry_infantry_armor = make_armor(
"SovietAntiInfantryInfantryArmor", 100.0F,
{{damage_type::sniper, 100.0F}, {damage_type::cannon, 20.0F}, {damage_type::rocket, 20.0F}, {damage_type::grenade, 100.0F},
{damage_type::gun, 100.0F}, {damage_type::melee, 100.0F}, {damage_type::auto_cannon, 150.0F}, {damage_type::impact, 100.0F},
{damage_type::explosive, 100.0F}, {damage_type::flak, 10.0F}, {damage_type::prism, 100.0F}, {damage_type::tesla, 1000.0F},
{damage_type::radiation, 100.0F}});
inline constexpr armor_set soviet_scout_infantry_armor = make_armor(
"SovietScoutInfantryArmor", 100.0F,
{{damage_type::gun, 1.0F}, {damage_type::melee, 50.0F}, {damage_type::concussive, 50.0F}, {damage_type::auto_cannon, 50.0F},
{damage_type::rocket, 50.0F}, {damage_type::flak, 50.0F}, {damage_type::cannon, 50.0F}, {damage_type::prism, 50.0F},
{damage_type::tesla, 50.0F}, {damage_type::explosive, 50.0F}, {damage_type::impact, 50.0F}});
inline constexpr armor_set allied_miner_armor =
make_armor("AlliedMinerArmor", 100.0F,
{{damage_type::melee, 0.0F}, {damage_type::sniper, 0.0F}, {damage_type::gun, 50.0F}, {damage_type::auto_cannon, 100.0F},
{damage_type::impact, 100.0F}, {damage_type::flak, 100.0F}, {damage_type::rocket, 100.0F}, {damage_type::cannon, 150.0F},
{damage_type::prism, 150.0F}, {damage_type::tesla, 150.0F}, {damage_type::explosive, 100.0F},
{damage_type::concussive, 100.0F}, {damage_type::radiation, 5.0F}});
inline constexpr armor_set allied_anti_vehicle_vehicle_tech1_armor = make_armor(
"AlliedAntiVehicleVehicleTech1Armor", 100.0F,
{{damage_type::melee, 0.0F}, {damage_type::sniper, 0.0F}, {damage_type::gun, 25.0F}, {damage_type::auto_cannon, 100.0F},
{damage_type::impact, 75.0F}, {damage_type::rocket, 75.0F}, {damage_type::flak, 100.0F}, {damage_type::cannon, 170.0F},
{damage_type::prism, 170.0F}, {damage_type::tesla, 170.0F}, {damage_type::explosive, 100.0F}, {damage_type::radiation, 5.0F}});
inline constexpr armor_set soviet_anti_vehicle_vehicle_tech1_armor = make_armor(
"SovietAntiVehicleVehicleTech1Armor", 100.0F,
{{damage_type::melee, 0.0F}, {damage_type::sniper, 0.0F}, {damage_type::gun, 50.0F}, {damage_type::auto_cannon, 25.0F},
{damage_type::impact, 75.0F}, {damage_type::flak, 100.0F}, {damage_type::rocket, 100.0F}, {damage_type::cannon, 100.0F},
{damage_type::prism, 100.0F}, {damage_type::tesla, 100.0F}, {damage_type::explosive, 100.0F}, {damage_type::concussive, 100.0F},
{damage_type::radiation, 5.0F}});
/** The faction structure armour shared by con yard, power plant, barracks, refinery and war factory. */
inline constexpr armor_set allied_structure_armor = make_armor(
"AlliedConYardArmor", 100.0F,
{{damage_type::sniper, 0.0F}, {damage_type::cannon, 100.0F}, {damage_type::rocket, 100.0F}, {damage_type::grenade, 100.0F},
{damage_type::gun, 100.0F}, {damage_type::melee, 0.0F}, {damage_type::auto_cannon, 50.0F}, {damage_type::impact, 100.0F},
{damage_type::explosive, 125.0F}, {damage_type::flak, 100.0F}, {damage_type::prism, 100.0F}, {damage_type::tesla, 100.0F},
{damage_type::radiation, 0.0F}});
inline constexpr armor_set soviet_structure_armor = make_armor(
"SovietConYardArmor", 100.0F,
{{damage_type::sniper, 0.0F}, {damage_type::cannon, 100.0F}, {damage_type::rocket, 100.0F}, {damage_type::grenade, 100.0F},
{damage_type::gun, 100.0F}, {damage_type::melee, 0.0F}, {damage_type::auto_cannon, 50.0F}, {damage_type::impact, 100.0F},
{damage_type::explosive, 125.0F}, {damage_type::flak, 100.0F}, {damage_type::prism, 100.0F}, {damage_type::tesla, 100.0F},
{damage_type::radiation, 0.0F}});
/** Neutral armour: everything does normal damage. */
inline constexpr armor_set neutral_armor = make_armor("NoArmor", 100.0F, {});
// ---------------------------------------------------------------------
// Target classification and weapon anti-mask
// ---------------------------------------------------------------------
/** Coarse target classes, derived from a `GameObject`'s `KindOf` flags. */
namespace target_class {
inline constexpr uint32 none = 0U;
inline constexpr uint32 infantry = 1U << 0U;
inline constexpr uint32 vehicle = 1U << 1U;
inline constexpr uint32 structure = 1U << 2U;
inline constexpr uint32 aircraft = 1U << 3U;
inline constexpr uint32 ground = infantry | vehicle | structure;
inline constexpr uint32 any = 0xFFFFFFFFU;
}
/**
* A `WeaponTemplate`'s `AntiMask`. RA3's tokens are recovered from the
* shipped weapons (`ra3-headless/out/weapons.json`).
*/
namespace anti {
inline constexpr uint32 ground = 1U << 0U;
inline constexpr uint32 water = 1U << 1U;
inline constexpr uint32 structure = 1U << 2U;
inline constexpr uint32 infantry = 1U << 3U;
inline constexpr uint32 vehicle = 1U << 4U;
inline constexpr uint32 airborne_vehicle = 1U << 5U;
inline constexpr uint32 airborne_infantry = 1U << 6U;
inline constexpr uint32 submerged = 1U << 7U;
inline constexpr uint32 lifted_ground_unit = 1U << 8U;
inline constexpr uint32 mine = 1U << 9U;
inline constexpr uint32 projectile = 1U << 10U;
inline constexpr uint32 small_missile = 1U << 11U;
inline constexpr uint32 ballistic_missile = 1U << 12U;
inline constexpr uint32 parachute = 1U << 13U;
}
// ---------------------------------------------------------------------
// Weapons
// ---------------------------------------------------------------------
/** Primary weapon parameters. Timings are in seconds. */
struct weapon_data {
std::string_view ra3_id;
float attack_range = 0.0F;
float damage = 0.0F;
damage_type type = damage_type::gun;
float splash_radius = 0.0F;
float weapon_speed = 0.0F;
uint32 clip_size = 1;
float reload_seconds = 1.0F;
float firing_seconds = 0.5F;
uint32 anti_mask = 0U;
/** Classes this weapon may never target (the nugget's `SpecialObjectFilter`). */
uint32 excluded_classes = target_class::none;
/** One hit kills any valid target (the maul's `InstakillNugget`). */
bool instakill = false;
bool projectile = false;
/**
* Can this weapon target an object of `target` class?
*
* `ANTI_GROUND` covers all ground classes; the airborne flags cover
* aircraft. A weapon with no anti-air flag cannot hit aircraft, which is
* why an attack dog can never maul a plane.
*/
[[nodiscard]] constexpr auto can_target(uint32 target) const -> bool {
if ((target & excluded_classes) != 0U) return false;
if ((target & target_class::aircraft) != 0U) {
return (anti_mask & (anti::airborne_vehicle | anti::airborne_infantry)) != 0U;
}
if ((anti_mask & anti::ground) != 0U && (target & target_class::ground) != 0U) return true;
if ((anti_mask & anti::infantry) != 0U && (target & target_class::infantry) != 0U) return true;
if ((anti_mask & anti::vehicle) != 0U && (target & target_class::vehicle) != 0U) return true;
if ((anti_mask & anti::structure) != 0U && (target & target_class::structure) != 0U) return true;
return false;
}
};
/**
* The primary weapons of the modelled units, from the shipped
* `WeaponTemplate`s.
*/
inline constexpr weapon_data maul_weapon{
"AlliedScoutInfantryMaul", 30.0F, 1.0F, damage_type::unresistable, 0.0F, 125.0F, 1U, 1.5F, 0.5F, anti::ground | anti::water,
target_class::vehicle | target_class::structure | target_class::aircraft, true, false};
inline constexpr weapon_data shotgun_weapon{"AlliedAntiInfantryInfantryShotgun",
150.0F,
40.0F,
damage_type::gun,
155.0F,
750.0F,
1U,
1.0F,
0.5F,
anti::ground | anti::structure | anti::water,
target_class::none,
false,
true};
inline constexpr weapon_data ak47_weapon{"SovietAntiInfantryInfantryAK47",
150.0F,
5.0F,
damage_type::gun,
0.0F,
750.0F,
1U,
0.5F,
0.25F,
anti::water | anti::ground | anti::structure,
target_class::none,
false,
true};
inline constexpr weapon_data guardian_cannon_weapon{"AlliedAntiVehicleVehicleTech1Cannon",
150.0F,
60.0F,
damage_type::cannon,
0.0F,
999999.0F,
1U,
1.8F,
0.2F,
anti::water | anti::ground | anti::structure,
target_class::none,
false,
true};
inline constexpr weapon_data rhino_cannon_weapon{"SovietAntiVehicleVehicleTech1CannonWeapon",
150.0F,
35.0F,
damage_type::cannon,
0.0F,
999999.0F,
1U,
1.0F,
0.2F,
anti::water | anti::ground | anti::structure,
target_class::none,
false,
true};
// ---------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------
/** Everything the skirmish simulation can place. */
enum class entity_kind : uint8 {
none = 0,
attack_dog, ///< AlliedScoutInfantry
peacekeeper, ///< AlliedAntiInfantryInfantry
conscript, ///< SovietAntiInfantryInfantry
guardian_tank, ///< AlliedAntiVehicleVehicleTech1
rhino_tank, ///< SovietAntiVehicleVehicleTech1
miner, ///< AlliedMiner
construction_yard,
power_plant,
barracks,
war_factory,
refinery,
count,
};
inline constexpr usize entity_kind_count = static_cast<usize>(entity_kind::count);
[[nodiscard]] constexpr auto to_string(entity_kind value) -> std::string_view {
switch (value) {
case entity_kind::attack_dog: return "attack_dog";
case entity_kind::peacekeeper: return "peacekeeper";
case entity_kind::conscript: return "conscript";
case entity_kind::guardian_tank: return "guardian_tank";
case entity_kind::rhino_tank: return "rhino_tank";
case entity_kind::miner: return "miner";
case entity_kind::construction_yard: return "construction_yard";
case entity_kind::power_plant: return "power_plant";
case entity_kind::barracks: return "barracks";
case entity_kind::war_factory: return "war_factory";
case entity_kind::refinery: return "refinery";
case entity_kind::none: break;
case entity_kind::count: break;
}
return "none";
}
[[nodiscard]] constexpr auto is_unit(entity_kind kind) -> bool {
switch (kind) {
case entity_kind::attack_dog:
case entity_kind::peacekeeper:
case entity_kind::conscript:
case entity_kind::guardian_tank:
case entity_kind::rhino_tank:
case entity_kind::miner: return true;
default: return false;
}
}
[[nodiscard]] constexpr auto is_structure(entity_kind kind) -> bool {
switch (kind) {
case entity_kind::construction_yard:
case entity_kind::power_plant:
case entity_kind::barracks:
case entity_kind::war_factory:
case entity_kind::refinery: return true;
default: return false;
}
}
/** Target class of an entity, from its `KindOf` (`INFANTRY`/`VEHICLE`/...). */
[[nodiscard]] constexpr auto class_of(entity_kind kind) -> uint32 {
switch (kind) {
case entity_kind::attack_dog:
case entity_kind::peacekeeper:
case entity_kind::conscript: return target_class::infantry;
case entity_kind::guardian_tank:
case entity_kind::rhino_tank:
case entity_kind::miner: return target_class::vehicle;
case entity_kind::construction_yard:
case entity_kind::power_plant:
case entity_kind::barracks:
case entity_kind::war_factory:
case entity_kind::refinery: return target_class::structure;
default: return target_class::none;
}
}
/** Mobile unit parameters. */
struct unit_data {
entity_kind kind = entity_kind::none;
std::string_view ra3_id;
int32 cost = 0;
float build_seconds = 0.0F;
float max_health = 0.0F;
const armor_set *armor = &neutral_armor;
float speed = 0.0F; ///< LocomotorSet Speed, world units per second.
float vision = 0.0F; ///< VisionInfo VisionRange.
float shroud_clear = 0.0F; ///< VisionInfo ShroudClearingRange.
float radius = 0.0F; ///< Geometry MajorRadius.
const weapon_data *weapon = nullptr;
entity_kind built_by = entity_kind::none; ///< Producer structure.
};
/** Structure parameters. */
struct structure_data {
entity_kind kind = entity_kind::none;
std::string_view ra3_id;
int32 cost = 0;
float build_seconds = 0.0F;
float max_health = 0.0F;
const armor_set *armor = &neutral_armor;
int32 energy = 0; ///< `EnergyProduction`: positive supplies, negative draws.
float vision = 0.0F;
float shroud_clear = 0.0F;
float radius = 0.0F;
int size_cells = 1; ///< Footprint, cells per side.
entity_kind prerequisite = entity_kind::none; ///< Prerequisite structure.
bool is_producer = false; ///< Can build units.
bool is_refinery = false; ///< Runs an ore extraction cycle.
};
/**
* Mobile units. Health, cost, build time, speed and armour are the retail
* `GameObject`/`LocomotorSet`/`ArmorTemplate` values.
*/
inline constexpr std::array<unit_data, entity_kind_count> units{{
{}, // none
{entity_kind::attack_dog, "AlliedScoutInfantry", 200, 2.0F, 30.0F, &allied_scout_infantry_armor, 100.0F, 200.0F, 750.0F, 7.0F, &maul_weapon,
entity_kind::barracks},
{entity_kind::peacekeeper, "AlliedAntiInfantryInfantry", 200, 5.0F, 150.0F, &allied_anti_infantry_infantry_armor, 50.0F, 200.0F, 500.0F, 7.0F,
&shotgun_weapon, entity_kind::barracks},
{entity_kind::conscript, "SovietAntiInfantryInfantry", 100, 4.0F, 100.0F, &soviet_anti_infantry_infantry_armor, 50.0F, 200.0F, 500.0F, 5.0F,
&ak47_weapon, entity_kind::barracks},
{entity_kind::guardian_tank, "AlliedAntiVehicleVehicleTech1", 950, 10.0F, 480.0F, &allied_anti_vehicle_vehicle_tech1_armor, 80.0F, 200.0F, 500.0F,
20.0F, &guardian_cannon_weapon, entity_kind::war_factory},
{entity_kind::rhino_tank, "SovietAntiVehicleVehicleTech1", 1000, 10.0F, 550.0F, &soviet_anti_vehicle_vehicle_tech1_armor, 75.0F, 200.0F, 500.0F,
20.0F, &rhino_cannon_weapon, entity_kind::war_factory},
{entity_kind::miner, "AlliedMiner", 1000, 20.0F, 500.0F, &allied_miner_armor, 50.0F, 200.0F, 500.0F, 20.0F, nullptr, entity_kind::refinery},
{}, {}, {}, {}, {},
}};
/**
* Structures. `energy` is the `EnergyProduction` attribute; `build_seconds`
* is `BuildTime`. Soviet `BuildTime` is not set on the base structures (the
* extractor reads 0), so the Allied values are used for both sides and the
* discrepancy is documented rather than invented.
*/
inline constexpr std::array<structure_data, entity_kind_count> structures{{
{}, {}, {}, {}, {}, {}, {}, // none + the six units
{entity_kind::construction_yard, "AlliedConstructionYard", 5000, 25.0F, 4000.0F, &allied_structure_armor, 50, 150.0F, 1000.0F, 60.0F, 3,
entity_kind::none, false, false},
{entity_kind::power_plant, "AlliedPowerPlant", 800, 10.0F, 1000.0F, &allied_structure_armor, 100, 150.0F, 300.0F, 30.0F, 2,
entity_kind::none, false, false},
{entity_kind::barracks, "AlliedBarracks", 500, 10.0F, 1000.0F, &allied_structure_armor, -25, 150.0F, 300.0F, 45.0F, 2, entity_kind::none,
true, false},
{entity_kind::war_factory, "AlliedWarFactory", 2000, 20.0F, 2500.0F, &allied_structure_armor, -50, 150.0F, 500.0F, 60.0F, 3,
entity_kind::none, true, false},
{entity_kind::refinery, "AlliedRefinery", 2000, 20.0F, 2000.0F, &allied_structure_armor, -50, 150.0F, 500.0F, 60.0F, 3,
entity_kind::power_plant, false, true},
}};
/** Look up a mobile unit. Only valid when `is_unit(kind)`. */
[[nodiscard]] inline auto unit_of(entity_kind kind) -> const unit_data & { return units.at(static_cast<usize>(kind)); }
/** Look up a structure. Only valid when `is_structure(kind)`. */
[[nodiscard]] inline auto structure_of(entity_kind kind) -> const structure_data & { return structures.at(static_cast<usize>(kind)); }
/** Cost in credits. */
[[nodiscard]] inline auto cost_of(entity_kind kind) -> int32 {
if (is_unit(kind)) return unit_of(kind).cost;
if (is_structure(kind)) return structure_of(kind).cost;
return 0;
}
/** Build time in logic frames. */
[[nodiscard]] inline auto build_frames_of(entity_kind kind) -> uint32 {
if (is_unit(kind)) return seconds_to_frames(unit_of(kind).build_seconds);
if (is_structure(kind)) return seconds_to_frames(structure_of(kind).build_seconds);
return 0U;
}
/** Maximum health. */
[[nodiscard]] inline auto max_health_of(entity_kind kind) -> float {
if (is_unit(kind)) return unit_of(kind).max_health;
if (is_structure(kind)) return structure_of(kind).max_health;
return 0.0F;
}
/** Armour template. */
[[nodiscard]] inline auto armor_of(entity_kind kind) -> const armor_set & {
if (is_unit(kind)) return *unit_of(kind).armor;
if (is_structure(kind)) return *structure_of(kind).armor;
return neutral_armor;
}
/** Vision range in world units. */
[[nodiscard]] inline auto vision_of(entity_kind kind) -> float {
if (is_unit(kind)) return unit_of(kind).vision;
if (is_structure(kind)) return structure_of(kind).vision;
return 0.0F;
}
/** The weapon a unit fires, or `nullptr` (structures, miners). */
[[nodiscard]] inline auto weapon_of(entity_kind kind) -> const weapon_data * {
return is_unit(kind) ? unit_of(kind).weapon : nullptr;
}
// ---------------------------------------------------------------------
// Economy and world rules
// ---------------------------------------------------------------------
/**
* Ore economy, from `OreNodeBehaviour` and `MpGameRules`.
*
* A refinery runs one extraction cycle: `MOVE_TO_EXTRACT + EXTRACT +
* MOVE_TO_DELIVER + DELIVERY` = 11 s for 250 credits while its node has
* ore. The engine actually spawns a separate miner; OpenRA3 folds the cycle
* into the refinery (a documented simplification, as in `ra3-sim`).
*/
struct economy_data {
int32 starting_credits = 10000; ///< MpGameRules SkirmishStartCash LoCash.
int32 credits_step = 5000; ///< SkirmishStartCash ChoiceStepAmount.
int32 credits_max = 40000; ///< SkirmishStartCash HiCash.
int32 ore_per_delivery = 250; ///< OreNodeBehaviour DeliveryAmount.
int32 ore_per_delivery_empty = 60; ///< OreNodeBehaviour DeliveryAmountWhenEmpty.
int32 ore_node_capacity = 30000; ///< OreNodeBehaviour MaximumGatheredValue.
float extraction_seconds = 11.0F; ///< 3.5 + 2 + 3.5 + 2.
};
/** Miscellaneous world rules. */
struct world_data {
float standard_shroud_clear = 500.0F; ///< Define STANDARD_SHROUD_CLEAR.
float scout_shroud_clear = 750.0F; ///< Define SCOUT_SHROUD_CLEAR.
int low_power_production_divisor = 2; ///< Low power halves production speed.
int low_power_mining_divisor = 2; ///< Low power halves refinery income.
};
inline constexpr economy_data economy{};
inline constexpr world_data world_rules{};
/** Ore extracted per completed refinery cycle, given the remaining node ore. */
[[nodiscard]] constexpr auto ore_per_cycle(int32 node_remaining) -> int32 {
return node_remaining > 0 ? economy.ore_per_delivery : economy.ore_per_delivery_empty;
}
}
+98
View File
@@ -0,0 +1,98 @@
export module ra3.display;
import std;
export import ra3.core;
import ra3.render;
import ra3.terrain;
import ra3.client;
import ra3.ui;
import ra3.vulkan;
/**
* Backend selection for the presentation layer.
*
* The app talks only to `ra3::client::display`; this module picks the concrete
* backend (Vulkan when available, otherwise SDL) so no caller has to know which
* one is in use. The GPU terrain path is Vulkan-only and reports failure so the
* caller can fall back to the software renderer.
*/
export namespace ra3::display {
using ra3::client::display_options;
using ra3::render::camera3d;
using ra3::render::image;
using ra3::render::ui_event;
using ra3::render::view_camera;
using menu_frame = std::function<std::optional<image>(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>;
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
/** The backend that actually initialized, or `none`. */
enum class backend { none, vulkan, sdl };
/**
* Create and initialize the preferred display (Vulkan, then SDL), or return
* null when neither starts. The caller owns the display and may reuse it for
* a menu, a loading bar and a viewer in sequence.
*/
[[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> {
const auto try_vulkan = [&]() -> std::unique_ptr<ra3::client::display> {
auto d = std::make_unique<ra3::vulkan::vulkan_display>();
if (d->init(options)) return d;
return nullptr;
};
const auto try_sdl = [&]() -> std::unique_ptr<ra3::client::display> {
auto d = std::make_unique<ra3::ui::sdl_display>();
if (d->init(options)) return d;
return nullptr;
};
if (prefer_vulkan) {
if (auto d = try_vulkan()) return d;
return try_sdl();
}
if (auto d = try_sdl()) return d;
return try_vulkan();
}
/**
* Run `action` on the first display that initializes: Vulkan, then SDL.
*
* `action` must call one of the shared `display` loops; it returns false to
* mean "this backend did not start", so the next one is tried.
*/
template<typename Fn>
[[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool {
if (prefer_vulkan) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true;
}
if (auto sdl = std::make_unique<ra3::ui::sdl_display>(); action(*sdl)) return true;
if (!prefer_vulkan) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true;
}
return false;
}
/** Interactive menu on the first available backend. */
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame) -> bool {
return with_display(true, [&](ra3::client::display &d) { return d.run_menu(options, frame); });
}
/** Pan/zoom image viewer on the first available backend. */
[[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); });
}
/** Software 3D camera viewer on the first available backend. */
[[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_camera(options, provider, camera); });
}
/**
* GPU terrain viewer. Vulkan only: returns false when Vulkan is unavailable
* so the caller can render offscreen instead.
*/
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool {
auto vk = std::make_unique<ra3::vulkan::vulkan_display>();
return vk->run_terrain(options, terrain, camera);
}
}
+217 -2
View File
@@ -40,11 +40,12 @@ export namespace ra3::map {
uint32 unpacked_size = 0;
};
/** The result of loading a map: its metadata and recovered start positions. */
/** The result of loading a map: its metadata, recovered start positions and the raw `CkMp` tree. */
struct loaded_map {
map_info info;
std::vector<start_position> starts;
usize ckmp_size = 0;
std::vector<uint8> bytes; ///< The uncompressed `CkMp` payload (terrain/objects parsed from here).
};
namespace detail {
@@ -181,6 +182,219 @@ export namespace ra3::map {
std::vector<map_info> maps_;
};
/**
* Normalise a loose `.map` payload to bare `CkMp` bytes.
*
* Accepts either the raw `.big` payload (the `EAR\0` wrapper around a
* RefPack stream, as `ra3tools` extracts it), a bare RefPack stream, or an
* already-unwrapped `CkMp` tree (as our own `extract` writes).
*/
[[nodiscard]] inline auto to_ckmp(std::span<const uint8> raw) -> std::vector<uint8> {
std::span<const uint8> payload = raw;
if (raw.size() >= 8U && std::memcmp(raw.data(), "EAR\0", 4) == 0) payload = raw.subspan(8);
return fs::maybe_decompress(payload);
}
/** Recover `Player_N_Start` waypoints from raw, uncompressed `CkMp` bytes. */
[[nodiscard]] inline auto starts_from_ckmp(std::span<const uint8> ckmp) -> std::vector<start_position> {
auto starts = detail::extract_start_positions(ckmp);
if (detail::distinct_positions(starts) < 2U) starts.clear();
return starts;
}
/** Map id -> localized display name, keyed by lowercased id. */
struct map_name_table {
std::unordered_map<std::string, std::string> names;
[[nodiscard]] auto empty() const -> bool { return names.empty(); }
/** The display name for `id`, or `id` itself when the string table has none. */
[[nodiscard]] auto lookup(std::string_view id) const -> std::string {
auto key = std::string{id};
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (const auto it = names.find(key); it != names.end() && !it->second.empty()) return it->second;
return std::string{id};
}
};
namespace detail {
/**
* Decode one SAGE `.csf` string value.
*
* RA3's `gamestrings.csf` stores each UTF-16 code unit with the low byte
* XORed by `0xFF` (the high byte is the padding `0xFF`); undoing that
* yields the plain ASCII/UTF-8 text.
*/
[[nodiscard]] inline auto decode_csf_string(std::span<const uint8> raw) -> std::string {
std::string value;
for (usize i = 0; i + 1U < raw.size(); i += 2U) {
const auto ch = static_cast<char>(static_cast<uint8>(raw[i] ^ 0xFFU));
if (ch == '\0') break;
value.push_back(ch);
}
return value;
}
}
/**
* Parse `MAP:<id>` display names out of a SAGE `.csf` string table.
*
* The map list UI reads its labels from the install's `data\gamestrings.csf`
* under the key `MAP:<UPPERCASE_ID>` (e.g. `MAP:MAP_MP_2_FEASEL4` is
* "Battlebase Beta"). Values are byte-XORed with `0xFF`.
*/
[[nodiscard]] inline auto parse_map_names(std::span<const uint8> csf) -> map_name_table {
map_name_table table;
if (csf.size() < 24U || std::memcmp(csf.data(), " FSC", 4) != 0) return table;
const auto read = [&](usize p) -> uint32 {
return static_cast<uint32>(csf[p]) | (static_cast<uint32>(csf[p + 1U]) << 8U) | (static_cast<uint32>(csf[p + 2U]) << 16U) |
(static_cast<uint32>(csf[p + 3U]) << 24U);
};
usize pos = 24; // header: magic + version + label/string counts + 8 reserved bytes
while (pos + 4U <= csf.size()) {
if (std::memcmp(csf.data() + pos, " LBL", 4) != 0) {
++pos;
continue;
}
pos += 4;
if (pos + 4U > csf.size()) break;
const auto count = read(pos);
pos += 4;
std::vector<std::string> labels;
labels.reserve(count);
bool ok = true;
for (uint32 i = 0; i < count; ++i) {
if (pos + 4U > csf.size()) {
ok = false;
break;
}
const auto len = read(pos);
pos += 4;
if (pos + len > csf.size()) {
ok = false;
break;
}
labels.emplace_back(reinterpret_cast<const char *>(csf.data() + pos), len);
pos += len;
}
if (!ok) break;
for (const auto &label: labels) {
if (pos + 4U > csf.size() || std::memcmp(csf.data() + pos, " RTS", 4) != 0) {
ok = false;
break;
}
pos += 4;
if (pos + 4U > csf.size()) {
ok = false;
break;
}
const auto chars = read(pos);
pos += 4;
const auto bytes = static_cast<usize>(chars) * 2U;
if (pos + bytes > csf.size()) {
ok = false;
break;
}
auto value = detail::decode_csf_string(csf.subspan(pos, bytes));
pos += bytes;
constexpr std::string_view prefix = "MAP:";
if (label.size() > prefix.size() && label.compare(0, prefix.size(), prefix) == 0) {
auto id = label.substr(prefix.size());
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (!value.empty()) table.names.try_emplace(std::move(id), std::move(value));
}
}
if (!ok) break;
}
return table;
}
/** Load display names from the install's newest English string table. */
[[nodiscard]] inline auto load_map_names_from_data_dir(const std::filesystem::path &data_dir) -> map_name_table {
std::error_code ec;
std::filesystem::path chosen;
int best_version = -1;
for (const auto &entry: std::filesystem::directory_iterator(data_dir, ec)) {
if (!entry.is_regular_file()) continue;
const auto name = entry.path().filename().string();
if (name.rfind("Lang-English", 0) != 0 || !name.ends_with(".big")) continue;
const auto mid = name.substr(12, name.size() - 16U);
int version = 0;
try {
version = std::stoi(mid);
} catch (const std::exception &) {
continue;
}
if (version > best_version) {
best_version = version;
chosen = entry.path();
}
}
if (chosen.empty()) chosen = data_dir / "English.big";
if (!std::filesystem::exists(chosen, ec)) return {};
try {
const auto archive = fs::big_archive::open(chosen);
const auto matches = archive.find("gamestrings.csf");
if (matches.empty()) return {};
return parse_map_names(archive.read(matches.front()->name, true));
} catch (const std::exception &) {
return {};
}
}
/** Read `<assets>/maps/map_names.tsv`, or any `gamestrings.csf` under `assets`. */
[[nodiscard]] inline auto load_map_names(const std::filesystem::path &assets) -> map_name_table {
const auto read_file = [](const std::filesystem::path &path) {
std::ifstream in(path, std::ios::binary);
return std::vector<uint8>{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
};
std::error_code ec;
const auto tsv = assets / "maps" / "map_names.tsv";
if (std::filesystem::exists(tsv, ec)) {
const auto bytes = read_file(tsv);
map_name_table table;
std::string text{bytes.begin(), bytes.end()};
for (std::size_t start = 0; start < text.size();) {
const auto end = text.find('\n', start);
const auto line = text.substr(start, end == std::string::npos ? std::string::npos : end - start);
start = end == std::string::npos ? text.size() : end + 1U;
const auto tab = line.find('\t');
if (tab == std::string::npos || tab == 0U) continue;
auto id = line.substr(0, tab);
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
table.names.try_emplace(std::move(id), line.substr(tab + 1U));
}
if (!table.empty()) return table;
}
for (const auto &entry: std::filesystem::recursive_directory_iterator(assets, ec)) {
if (!entry.is_regular_file() || entry.path().filename() != "gamestrings.csf") continue;
return parse_map_names(read_file(entry.path()));
}
return {};
}
/** Serialize a name table as `id<TAB>name` lines (tabs/newlines stripped). */
[[nodiscard]] inline auto serialize_map_names(const map_name_table &table) -> std::string {
std::vector<std::string> ids;
ids.reserve(table.names.size());
for (const auto &[id, _]: table.names) ids.push_back(id);
std::sort(ids.begin(), ids.end());
std::string out;
for (const auto &id: ids) {
auto name = table.names.at(id);
std::replace(name.begin(), name.end(), '\t', ' ');
std::replace(name.begin(), name.end(), '\n', ' ');
out += id;
out += '\t';
out += name;
out += '\n';
}
return out;
}
/**
* Load a map: unwrap the `EAR\0` + RefPack container and recover the
* player start waypoints.
@@ -201,7 +415,8 @@ export namespace ra3::map {
// Layer 2: the wrapper body is RefPack again, yielding the `CkMp` tree.
auto ckmp = fs::maybe_decompress(payload);
result.ckmp_size = ckmp.size();
result.starts = detail::extract_start_positions(ckmp);
result.starts = starts_from_ckmp(ckmp);
result.bytes = std::move(ckmp);
// Maps that keep starts in `MPPositionList` instead of `Player_N_Start`
// waypoints yield fewer than two distinct points; signal "unknown" so
// the caller can fall back rather than spawn everyone at the origin.
+4
View File
@@ -9,9 +9,13 @@ export module ra3;
export import ra3.core;
export import ra3.logic;
export import ra3.client;
export import ra3.data;
export import ra3.game;
export import ra3.fs;
export import ra3.map;
export import ra3.skirmish;
export import ra3.render;
export import ra3.terrain;
export import ra3.ui;
export import ra3.vulkan;
export import ra3.display;
+381
View File
@@ -123,6 +123,264 @@ export namespace ra3::render {
std::vector<uint32> pixels_;
};
namespace detail {
// 8x12 bitmap font for ASCII 32..126 (baseline at row 9 so descenders fit);
// each row byte has bit 7 as the leftmost column, top row first.
inline constexpr std::array<std::array<uint8, 12>, 95> font8x8 = {{
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ' '
{0x00,0x40,0x40,0x40,0x40,0x40,0x00,0x00,0x40,0x00,0x00,0x00}, // '!'
{0x00,0x6C,0x48,0x48,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '"'
{0x00,0x14,0x14,0x28,0x7C,0x28,0x7C,0x28,0x50,0x50,0x00,0x00}, // '#'
{0x00,0x10,0x38,0x40,0x40,0x38,0x48,0x70,0x10,0x10,0x00,0x00}, // '$'
{0x00,0x20,0x50,0x20,0x0C,0x70,0x08,0x14,0x08,0x00,0x00,0x00}, // '%'
{0x00,0x00,0x00,0x18,0x20,0x20,0x54,0x48,0x34,0x00,0x00,0x00}, // '&'
{0x00,0x40,0x40,0x40,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // "'"
{0x00,0x20,0x20,0x40,0x40,0x40,0x40,0x40,0x40,0x20,0x20,0x00}, // '('
{0x00,0x40,0x40,0x20,0x20,0x20,0x20,0x20,0x20,0x40,0x40,0x00}, // ')'
{0x00,0x10,0x7C,0x10,0x28,0x08,0x00,0x00,0x00,0x00,0x00,0x00}, // '*'
{0x00,0x00,0x08,0x08,0x08,0x7F,0x08,0x08,0x08,0x00,0x00,0x00}, // '+'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x20,0x60,0x40,0x00}, // ','
{0x00,0x00,0x00,0x00,0x00,0x7C,0x00,0x00,0x00,0x00,0x00,0x00}, // '-'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // '.'
{0x00,0x04,0x04,0x08,0x08,0x10,0x10,0x20,0x20,0x40,0x00,0x00}, // '/'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '0'
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // '1'
{0x00,0x38,0x44,0x04,0x08,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // '2'
{0x00,0x38,0x44,0x04,0x18,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '3'
{0x00,0x0C,0x14,0x14,0x24,0x44,0x7E,0x04,0x0E,0x00,0x00,0x00}, // '4'
{0x00,0x3C,0x20,0x20,0x38,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '5'
{0x00,0x1C,0x20,0x40,0x78,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '6'
{0x00,0x7C,0x44,0x04,0x08,0x08,0x08,0x10,0x10,0x00,0x00,0x00}, // '7'
{0x00,0x38,0x44,0x44,0x38,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '8'
{0x00,0x38,0x44,0x44,0x44,0x3C,0x04,0x08,0x70,0x00,0x00,0x00}, // '9'
{0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // ':'
{0x00,0x00,0x00,0x30,0x30,0x00,0x00,0x30,0x60,0x40,0x00,0x00}, // ';'
{0x00,0x06,0x08,0x30,0x40,0x30,0x08,0x06,0x00,0x00,0x00,0x00}, // '<'
{0x00,0x00,0x00,0x00,0x7C,0x00,0x7C,0x00,0x00,0x00,0x00,0x00}, // '='
{0x00,0x60,0x10,0x0C,0x02,0x0C,0x10,0x60,0x00,0x00,0x00,0x00}, // '>'
{0x00,0x00,0x38,0x44,0x04,0x08,0x10,0x00,0x30,0x00,0x00,0x00}, // '?'
{0x38,0x44,0x44,0x4C,0x54,0x54,0x4C,0x40,0x44,0x38,0x00,0x00}, // '@'
{0x00,0x18,0x08,0x14,0x14,0x14,0x3E,0x22,0x77,0x00,0x00,0x00}, // 'A'
{0x00,0x7C,0x22,0x22,0x3C,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'B'
{0x00,0x3C,0x44,0x40,0x40,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'C'
{0x00,0x78,0x24,0x22,0x22,0x22,0x22,0x24,0x78,0x00,0x00,0x00}, // 'D'
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x22,0x7E,0x00,0x00,0x00}, // 'E'
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x20,0x70,0x00,0x00,0x00}, // 'F'
{0x00,0x3C,0x44,0x40,0x40,0x4E,0x44,0x44,0x38,0x00,0x00,0x00}, // 'G'
{0x00,0x77,0x22,0x22,0x3E,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'H'
{0x00,0x7C,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'I'
{0x00,0x3C,0x08,0x08,0x08,0x48,0x48,0x48,0x30,0x00,0x00,0x00}, // 'J'
{0x00,0x77,0x22,0x24,0x28,0x38,0x24,0x22,0x73,0x00,0x00,0x00}, // 'K'
{0x00,0x70,0x20,0x20,0x20,0x20,0x24,0x24,0x7C,0x00,0x00,0x00}, // 'L'
{0x00,0x77,0x36,0x36,0x2A,0x2A,0x22,0x22,0x77,0x00,0x00,0x00}, // 'M'
{0x00,0x77,0x32,0x32,0x2A,0x2A,0x2A,0x26,0x76,0x00,0x00,0x00}, // 'N'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'O'
{0x00,0x78,0x24,0x24,0x24,0x38,0x20,0x20,0x70,0x00,0x00,0x00}, // 'P'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x1C,0x00,0x00}, // 'Q'
{0x00,0x7C,0x22,0x22,0x22,0x3C,0x24,0x22,0x71,0x00,0x00,0x00}, // 'R'
{0x00,0x34,0x4C,0x40,0x38,0x04,0x04,0x64,0x58,0x00,0x00,0x00}, // 'S'
{0x00,0x7F,0x49,0x08,0x08,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'T'
{0x00,0x77,0x22,0x22,0x22,0x22,0x22,0x22,0x1C,0x00,0x00,0x00}, // 'U'
{0x00,0x77,0x22,0x22,0x14,0x14,0x14,0x08,0x08,0x00,0x00,0x00}, // 'V'
{0x00,0x77,0x22,0x22,0x2A,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'W'
{0x00,0x63,0x22,0x14,0x08,0x08,0x14,0x22,0x63,0x00,0x00,0x00}, // 'X'
{0x00,0x77,0x22,0x14,0x14,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'Y'
{0x00,0x7C,0x44,0x08,0x10,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'Z'
{0x00,0x70,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x70,0x00}, // '['
{0x00,0x00,0x40,0x40,0x40,0x20,0x20,0x10,0x10,0x00,0x00,0x00}, // '\\'
{0x00,0x70,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x70,0x00}, // ']'
{0x00,0x10,0x10,0x28,0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '^'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7F}, // '_'
{0x00,0x40,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '`'
{0x00,0x00,0x00,0x38,0x04,0x3C,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'a'
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'b'
{0x00,0x00,0x00,0x3C,0x44,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'c'
{0x00,0x0C,0x04,0x34,0x4C,0x44,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'd'
{0x00,0x00,0x00,0x38,0x44,0x7C,0x40,0x40,0x3C,0x00,0x00,0x00}, // 'e'
{0x00,0x1C,0x20,0x7C,0x20,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'f'
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x38,0x00}, // 'g'
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'h'
{0x00,0x10,0x00,0x70,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'i'
{0x00,0x10,0x00,0x78,0x08,0x08,0x08,0x08,0x08,0x08,0x70,0x00}, // 'j'
{0x00,0x60,0x20,0x2E,0x24,0x38,0x28,0x24,0x6E,0x00,0x00,0x00}, // 'k'
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'l'
{0x00,0x00,0x00,0x74,0x2A,0x2A,0x2A,0x2A,0x7F,0x00,0x00,0x00}, // 'm'
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'n'
{0x00,0x00,0x00,0x38,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'o'
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x3C,0x20,0x70,0x00}, // 'p'
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x0E,0x00}, // 'q'
{0x00,0x00,0x00,0x6C,0x30,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'r'
{0x00,0x00,0x00,0x3C,0x44,0x38,0x04,0x44,0x78,0x00,0x00,0x00}, // 's'
{0x00,0x00,0x20,0x7C,0x20,0x20,0x20,0x22,0x1C,0x00,0x00,0x00}, // 't'
{0x00,0x00,0x00,0x66,0x22,0x22,0x22,0x26,0x1B,0x00,0x00,0x00}, // 'u'
{0x00,0x00,0x00,0x77,0x22,0x22,0x14,0x14,0x08,0x00,0x00,0x00}, // 'v'
{0x00,0x00,0x00,0x77,0x22,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'w'
{0x00,0x00,0x00,0x66,0x24,0x18,0x18,0x24,0x66,0x00,0x00,0x00}, // 'x'
{0x00,0x00,0x00,0x77,0x22,0x12,0x14,0x0C,0x08,0x08,0x3C,0x00}, // 'y'
{0x00,0x00,0x00,0x7C,0x48,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'z'
{0x00,0x10,0x20,0x20,0x20,0x20,0x40,0x20,0x20,0x20,0x10,0x00}, // '{'
{0x00,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x00,0x00}, // '|'
{0x00,0x40,0x20,0x20,0x20,0x20,0x10,0x20,0x20,0x20,0x40,0x00}, // '}'
{0x00,0x00,0x00,0x00,0x00,0x24,0x58,0x00,0x00,0x00,0x00,0x00}, // '~'
}};
inline constexpr uint32 glyph_width = 8;
inline constexpr uint32 glyph_height = 12;
}
/** Pixel width of `text` drawn at `scale`. */
[[nodiscard]] inline auto text_width(std::string_view text, uint32 scale = 1U) -> uint32 {
return static_cast<uint32>(text.size()) * detail::glyph_width * std::max(1U, scale);
}
/** Draw `text` with its top-left at `(x, y)`, glyphs scaled by `scale`. */
inline auto draw_text(image &target, int x, int y, std::string_view text, uint32 color, uint32 scale = 1U) -> void {
scale = std::max(1U, scale);
int cursor = x;
for (const auto ch: text) {
const auto code = static_cast<unsigned char>(ch);
if (code >= 32U && code < 127U) {
const auto &glyph = detail::font8x8[code - 32U];
for (uint32 gy = 0; gy < detail::glyph_height; ++gy) {
const auto bits = glyph[gy];
if (bits == 0U) continue;
for (uint32 gx = 0; gx < detail::glyph_width; ++gx) {
if ((bits & (1U << (7U - gx))) == 0U) continue;
for (uint32 sy = 0; sy < scale; ++sy) {
for (uint32 sx = 0; sx < scale; ++sx) {
target.set(cursor + static_cast<int>(gx * scale + sx), y + static_cast<int>(gy * scale + sy), color);
}
}
}
}
}
cursor += static_cast<int>(detail::glyph_width * scale);
}
}
/**
* Backend-agnostic input for the interactive loops. The windowing backends
* (`ra3.vulkan` / `ra3.ui`) translate their native events into this and hand
* it to the shared `ra3::client::display` loops.
*/
enum class ui_key : uint8 { none, up, down, left, right, page_up, page_down, confirm, cancel, tab, backspace };
enum class ui_event_type : uint8 { none, quit, key, text, mouse_move, mouse_button, wheel };
struct ui_event {
ui_event_type type = ui_event_type::none;
ui_key key = ui_key::none;
char character = '\0';
float x = 0.0F;
float y = 0.0F;
float dx = 0.0F;
float dy = 0.0F;
float wheel = 0.0F;
bool left = false;
};
/** A warm red/gold loading screen with a progress bar (0..1). */
[[nodiscard]] inline auto compose_progress(uint32 width, uint32 height, float progress, std::string_view label) -> image {
const auto background = argb(34, 9, 8);
const auto panel = argb(58, 14, 12);
const auto edge = argb(120, 30, 22);
const auto gold = argb(236, 190, 80);
const auto cream = argb(246, 228, 192);
image img(width, height, background);
const auto k = static_cast<double>(height) / 720.0;
const auto S = [k](double v) { return static_cast<int>(std::lround(v * k)); };
const auto scale = static_cast<uint32>(std::max(1, static_cast<int>(std::lround(2.0 * k))));
const auto t = std::clamp(progress, 0.0F, 1.0F);
const auto title = std::string_view{"OpenRA3"};
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(title, scale + 1U))) / 2, S(250), title, gold, scale + 1U);
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(label, scale))) / 2, S(300), label, cream, scale);
const int bar_x = S(240);
const int bar_y = S(360);
const int bar_w = static_cast<int>(width) - 2 * bar_x;
const int bar_h = S(28);
img.draw_rect(bar_x - S(3), bar_y - S(3), bar_w + S(6), bar_h + S(6), edge);
img.draw_rect(bar_x, bar_y, bar_w, bar_h, panel);
img.draw_rect(bar_x, bar_y, static_cast<int>(static_cast<float>(bar_w) * t), bar_h, gold);
char percent[32];
std::snprintf(percent, sizeof(percent), "%d%%", static_cast<int>(t * 100.0F + 0.5F));
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(percent, scale))) / 2, bar_y + bar_h + S(18), percent, cream, scale);
return img;
}
/**
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`.
* `cap == 0` means vertical sync, `cap < 0` means uncapped.
*/
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image {
char text[64];
if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
} else if (cap < 0) {
std::snprintf(text, sizeof(text), "FPS: %u", fps);
} else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
}
const auto w = text_width(text, 1U) + 8U;
const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U);
return img;
}
/**
* Box-downsample `source` so its width is at most `max_width` (aspect
* preserved). Returns a copy when it is already small enough.
*/
[[nodiscard]] inline auto downscale(const image &source, uint32 max_width) -> image {
if (source.empty() || source.width() <= max_width) return source;
const auto scale = static_cast<uint32>(std::ceil(static_cast<double>(source.width()) / static_cast<double>(max_width)));
const auto w = std::max(1U, source.width() / scale);
const auto h = std::max(1U, source.height() / scale);
image out(w, h);
for (uint32 y = 0; y < h; ++y) {
for (uint32 x = 0; x < w; ++x) {
uint32 r = 0;
uint32 g = 0;
uint32 b = 0;
uint32 n = 0;
for (uint32 dy = 0; dy < scale; ++dy) {
for (uint32 dx = 0; dx < scale; ++dx) {
const auto sx = std::min(source.width() - 1U, x * scale + dx);
const auto sy = std::min(source.height() - 1U, y * scale + dy);
const auto px = source.data()[static_cast<usize>(sy) * source.width() + sx];
r += (px >> 16U) & 0xFFU;
g += (px >> 8U) & 0xFFU;
b += px & 0xFFU;
++n;
}
}
out.set(static_cast<int>(x), static_cast<int>(y), argb(static_cast<uint8>(r / n), static_cast<uint8>(g / n), static_cast<uint8>(b / n)));
}
}
return out;
}
/**
* Copy `full` (a whole-map overview) and mark the camera's location with a
* gold rectangle. `u`/`v` are the normalized map position (v is y-down).
*/
[[nodiscard]] inline auto compose_minimap(const image &full, float u, float v) -> image {
image out = full;
if (full.empty()) return out;
const int r = std::max(4, static_cast<int>(full.width()) / 48);
const int x = std::clamp(static_cast<int>(u * static_cast<float>(full.width())), r, static_cast<int>(full.width()) - r - 1);
const int y = std::clamp(static_cast<int>(v * static_cast<float>(full.height())), r, static_cast<int>(full.height()) - r - 1);
const auto gold = argb(240, 200, 90);
out.draw_rect(x - r, y - r, 2 * r, 2, gold);
out.draw_rect(x - r, y + r - 2, 2 * r, 2, gold);
out.draw_rect(x - r, y - r, 2, 2 * r, gold);
out.draw_rect(x + r - 2, y - r, 2, 2 * r, gold);
return out;
}
[[nodiscard]] inline auto read_u16(std::span<const uint8> data, usize at) -> uint32 {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U);
}
@@ -297,4 +555,127 @@ export namespace ra3::render {
}
return scene;
}
/**
* The destination of the map image on screen, in window coordinates.
*
* `x`/`y` is the top-left corner and `w`/`h` the size, in the same units as
* the window. The viewer draws `image * w` into this rectangle; whatever is
* left of the window stays background, so the map is letterboxed instead of
* stretched.
*/
struct view_rect {
float x = 0.0F;
float y = 0.0F;
float w = 0.0F;
float h = 0.0F;
};
/** Letterbox an `iw`x`ih` image into a `ww`x`wh` window, preserving aspect. */
[[nodiscard]] inline auto fit_rect(float iw, float ih, float ww, float wh) -> view_rect {
if (iw <= 0.0F || ih <= 0.0F || ww <= 0.0F || wh <= 0.0F) return {};
const auto scale = std::min(ww / iw, wh / ih);
const auto w = iw * scale;
const auto h = ih * scale;
return {(ww - w) * 0.5F, (wh - h) * 0.5F, w, h};
}
/**
* A 2D pan/zoom camera over a raster map, reproducing the Red Alert 3
* tactical view controls:
*
* - the wheel zooms (zoom 1 === the whole map fits the window);
* - pushing the cursor against a screen edge scrolls the view;
* - the view is clamped so it never leaves the map.
*
* `center_x`/`center_y` are the normalized image position (0..1, y down)
* held at the centre of the viewport. The camera mirrors the retail view
* object (`TheTacticalView`, retail `ra3_1.12.game` `0x00cdb7b4`), whose
* zoom is the scalar the debug overlay prints at `0x00c0b900`; the
* per-map scroll scaling is `cameraScrollSpeedScalar` (map data table at
* `0x00c11a54`).
*/
struct view_camera {
float zoom = 1.0F;
float center_x = 0.5F;
float center_y = 0.5F;
float min_zoom = 1.0F;
float max_zoom = 24.0F;
float zoom_step = 1.15F;
float edge_scroll_viewports_per_second = 0.55F; ///< speed of a full edge push
float edge_margin = 24.0F; ///< pixels from the border
/** Keep the visible window inside the image. */
auto clamp_center() -> void {
const float half = 0.5F / zoom;
center_x = std::clamp(center_x, half, 1.0F - half);
center_y = std::clamp(center_y, half, 1.0F - half);
}
/** Zoom by `factor` (wheel up > 1) about the viewport centre. */
auto zoom_by(float factor) -> void {
zoom = std::clamp(zoom * factor, min_zoom, max_zoom);
this->clamp_center();
}
/** Scroll directly by a normalized image delta. */
auto scroll(float dx, float dy) -> void {
center_x += dx;
center_y += dy;
this->clamp_center();
}
/**
* Push the camera when the cursor `(mouse_x, mouse_y)` is within
* `edge_margin` of a window edge. `dt` is the frame time in seconds.
*/
auto edge_scroll(float mouse_x, float mouse_y, float window_w, float window_h, float dt) -> void {
if (zoom <= min_zoom + 1.0e-4F || window_w <= 0.0F || window_h <= 0.0F) return;
float dir_x = 0.0F;
float dir_y = 0.0F;
if (mouse_x <= edge_margin) {
dir_x = -1.0F;
} else if (mouse_x >= window_w - edge_margin) {
dir_x = 1.0F;
}
if (mouse_y <= edge_margin) {
dir_y = -1.0F;
} else if (mouse_y >= window_h - edge_margin) {
dir_y = 1.0F;
}
if (dir_x == 0.0F && dir_y == 0.0F) return;
const auto step = (1.0F / zoom) * edge_scroll_viewports_per_second * dt;
this->scroll(dir_x * step, dir_y * step);
}
/** Resolve the image destination rectangle for a window of `window_w` x `window_h`. */
[[nodiscard]] auto rect(float image_w, float image_h, float window_w, float window_h) const -> view_rect {
if (image_w <= 0.0F || image_h <= 0.0F) return {};
const auto fit = std::min(window_w / image_w, window_h / image_h);
const auto scale = fit * zoom;
const auto w = image_w * scale;
const auto h = image_h * scale;
auto x = window_w * 0.5F - w * center_x;
auto y = window_h * 0.5F - h * center_y;
x = w <= window_w ? (window_w - w) * 0.5F : std::clamp(x, window_w - w, 0.0F);
y = h <= window_h ? (window_h - h) * 0.5F : std::clamp(y, window_h - h, 0.0F);
return {x, y, w, h};
}
};
/**
* A perspective camera aimed at a ground target, like the retail tactical
* view. The wheel changes `height` (moving the camera closer/farther), not
* an image scale; `yaw`/`pitch` orbit it.
*/
struct camera3d {
float target_x = 0.0F; ///< World position the camera looks at.
float target_y = 0.0F;
float yaw = 0.0F; ///< Radians; 0 looks toward +Y.
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
float height = 420.0F; ///< Camera height above the target's ground.
float fov = 0.85F; ///< Vertical field of view, radians.
float min_height = 120.0F;
float max_height = 1600.0F;
};
}
+254 -133
View File
@@ -5,16 +5,29 @@ import std;
export import ra3.core;
export import ra3.map;
export import ra3.game;
export import ra3.data;
import ra3.logic;
/**
* A minimal, headless skirmish simulation.
* A headless skirmish simulation driven by the real Red Alert 3 balance.
*
* 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.
* Two sides start on a real map's waypoints, build a base, extract ore and
* fight. The mechanics mirror SAGE where it matters and use the retail numbers
* from `ra3.data`:
*
* - damage is resolved through the target's `ArmorTemplate` for the weapon's
* damage type (`UNRESISTABLE` bypasses it);
* - a weapon may only target what its `AntiMask` allows (an attack dog's maul
* can never touch a structure or vehicle);
* - structures and units are built through a pay-as-you-go queue gated on
* power, cost and the tech prerequisite;
* - refineries run the ore extraction cycle against a finite node;
* - a side is defeated when it has no structures left, and the last side
* standing wins (the engine's team-wipe victory).
*
* 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;
@@ -23,75 +36,29 @@ export namespace ra3::skirmish {
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
using ra3::data::entity_kind;
/** 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. */
/** A live entity instance. */
struct unit {
uint32 id = 0;
uint32 owner = 0;
unit_class cls = unit_class::infantry;
entity_kind kind = entity_kind::none;
coord3d position{};
real health = 0.0F;
real max_health = 0.0F;
real cooldown = 0.0F;
real cooldown = 0.0F; ///< Seconds until the weapon may fire again.
real cycle_timer = 0.0F; ///< Refinery ore-extraction progress, seconds.
bool alive = true;
};
/** One entry in a player's production queue. */
struct build_job {
entity_kind kind = entity_kind::none;
uint32 progress = 0; ///< Frames completed.
uint32 total = 0; ///< Frames required.
int32 paid = 0; ///< Credits paid so far (pay-as-you-go).
};
/** Per-player state for the match. */
struct player_state {
uint32 index = 0;
@@ -102,19 +69,22 @@ export namespace ra3::skirmish {
real money_accumulator = 0.0F;
coord3d start{};
uint32 base_unit = 0;
real build_timer = 0.0F;
real think_timer = 0.0F;
int32 kills = 0;
int32 losses = 0;
int32 power_produced = 0;
int32 power_consumed = 0;
int32 ore_remaining = 0; ///< Credits left in this side's ore node(s).
std::vector<build_job> queue;
};
/** Skirmish parameters. */
struct match_config {
std::string map_id = "builtin";
int32 starting_money = 10000;
int32 starting_money = ra3::data::economy.starting_credits;
uint32 seed = 1;
uint32 max_frames = 30U * 60U * 15U;
real income_per_second = 25.0F;
real harvester_income_per_second = 15.0F;
uint32 max_units_per_player = 30U;
};
/** Outcome of a completed (or capped) match. */
@@ -156,16 +126,19 @@ export namespace ra3::skirmish {
return match;
}
/** Run until a base falls or the frame cap is reached. */
/** Run until a side is wiped out or the frame cap is reached. */
auto run() -> match_result {
while (!this->decided() && frame_ < config_.max_frames) this->step();
if (!decided_) this->decide_on_timeout();
return this->result();
}
/** Advance exactly one 30 Hz frame. */
auto step() -> void {
constexpr real dt = 1.0F / 30.0F;
this->apply_income(dt);
this->update_power();
this->update_economy(dt);
this->update_production();
this->run_ai(dt);
this->update_units(dt);
this->check_victory();
@@ -202,7 +175,7 @@ export namespace ra3::skirmish {
}
private:
static constexpr real build_interval_seconds = 4.0F;
static constexpr real ai_think_seconds = 2.0F;
auto spawn_player(uint32 index, std::string name, game::faction side, bool human, const coord3d &start) -> void {
player_state player;
@@ -212,66 +185,168 @@ export namespace ra3::skirmish {
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});
player.ore_remaining = ra3::data::economy.ore_node_capacity;
player.base_unit = this->spawn_unit(index, entity_kind::construction_yard, start);
players_.push_back(std::move(player));
}
auto spawn_unit(uint32 owner, unit_class cls, const coord3d &position) -> uint32 {
const auto &stats = unit_stats(cls);
auto spawn_unit(uint32 owner, entity_kind kind, const coord3d &position) -> uint32 {
unit created;
created.id = next_unit_id_++;
created.owner = owner;
created.cls = cls;
created.kind = kind;
created.position = position;
created.health = stats.max_health;
created.max_health = stats.max_health;
created.health = ra3::data::max_health_of(kind);
created.max_health = created.health;
units_.push_back(created);
return created.id;
}
auto apply_income(real dt) -> void {
/** Sum structure energy; a side is low on power when it draws more than it makes. */
auto update_power() -> void {
for (auto &player: players_) {
real rate = config_.income_per_second;
player.power_produced = 0;
player.power_consumed = 0;
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);
if (!entry.alive || entry.owner != player.index || !ra3::data::is_structure(entry.kind)) continue;
const auto energy = ra3::data::structure_of(entry.kind).energy;
if (energy >= 0) {
player.power_produced += energy;
} else {
player.power_consumed += -energy;
}
}
}
}
[[nodiscard]] auto low_power(uint32 owner) const -> bool {
const auto &player = players_.at(owner);
return player.power_consumed > player.power_produced;
}
/**
* Ore extraction: every refinery runs the RA3 cycle (11 s for 250
* credits) against the side's finite node. Under low power the cycle is
* half speed.
*/
auto update_economy(real dt) -> void {
for (auto &player: players_) {
const auto rate = low_power(player.index) ? (1.0F / static_cast<real>(ra3::data::world_rules.low_power_mining_divisor)) : 1.0F;
for (auto &entry: units_) {
if (!entry.alive || entry.owner != player.index || !ra3::data::is_structure(entry.kind)) continue;
if (!ra3::data::structure_of(entry.kind).is_refinery) continue;
entry.cycle_timer += dt * rate;
if (entry.cycle_timer < ra3::data::economy.extraction_seconds) continue;
entry.cycle_timer -= ra3::data::economy.extraction_seconds;
const auto yield = ra3::data::ore_per_cycle(player.ore_remaining);
player.ore_remaining = player.ore_remaining > yield ? player.ore_remaining - yield : 0;
player.money += yield;
}
}
}
/**
* Work each player's production queue. Cost is paid as the job
* progresses, a job stalls while unaffordable, and low power halves the
* production rate.
*/
auto update_production() -> void {
for (auto &player: players_) {
if (player.queue.empty()) continue;
auto &job = player.queue.front();
const auto cost = ra3::data::cost_of(job.kind);
const auto advance = low_power(player.index) ? (frame_ % static_cast<uint32>(ra3::data::world_rules.low_power_production_divisor) == 0U) : true;
if (advance && job.total > 0U) {
const auto next_progress = job.progress + 1U;
const auto target_paid = static_cast<int32>((static_cast<std::int64_t>(cost) * next_progress) / job.total);
const auto delta = target_paid - job.paid;
if (delta <= player.money) {
player.money -= delta;
job.paid = target_paid;
job.progress = next_progress;
}
}
if (job.progress < job.total) continue;
if (ra3::data::is_structure(job.kind)) {
const auto &base = this->find_unit(player.base_unit);
const coord3d at = base != nullptr && base->alive ? base->position : player.start;
const auto offset = static_cast<real>(ra3::data::structure_of(job.kind).size_cells) * ra3::data::cell_size + 40.0F;
this->spawn_unit(player.index, job.kind, {at.x + offset, at.y + offset * static_cast<real>(player.queue.size()), 0.0F});
} else {
const auto *producer = this->find_producer(player.index, ra3::data::unit_of(job.kind).built_by);
if (producer == nullptr) continue; // wait for the producer
this->spawn_unit(player.index, job.kind, {producer->position.x + 40.0F, producer->position.y + 40.0F, 0.0F});
}
player.queue.erase(player.queue.begin());
}
}
/** A simple scripted opponent: build a base, then train an army. */
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;
player.think_timer += dt;
if (player.think_timer < ai_think_seconds) continue;
player.think_timer = 0.0F;
if (!player.queue.empty()) continue;
if (this->unit_count(player.index) >= config_.max_units_per_player) continue;
const auto *base = this->find_unit(player.base_unit);
if (base == nullptr || !base->alive) continue;
if (player.money < tank_type.cost) continue;
if (this->enqueue_if_missing(player, entity_kind::power_plant)) continue;
if (this->enqueue_if_missing(player, entity_kind::refinery)) continue;
if (this->enqueue_if_missing(player, entity_kind::barracks)) continue;
if (this->enqueue_if_missing(player, entity_kind::war_factory)) 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});
// Army composition: roughly half infantry, half tanks.
const bool want_infantry = (frame_ / 30U) % 2U == 0U;
const auto choice = want_infantry ? entity_kind::peacekeeper : entity_kind::guardian_tank;
if (player.money >= ra3::data::cost_of(choice) * 2) this->enqueue(player, choice);
}
}
/** Queue `kind` when the side has none and can afford it. */
auto enqueue_if_missing(player_state &player, entity_kind kind) -> bool {
for (const auto &entry: units_) {
if (entry.alive && entry.owner == player.index && entry.kind == kind) return false;
}
const auto &data = ra3::data::structure_of(kind);
if (data.prerequisite != entity_kind::none) {
bool have_prereq = false;
for (const auto &entry: units_) {
if (entry.alive && entry.owner == player.index && entry.kind == data.prerequisite) have_prereq = true;
}
if (!have_prereq) return false;
}
if (player.money < ra3::data::cost_of(kind)) return false;
return this->enqueue(player, kind);
}
auto enqueue(player_state &player, entity_kind kind) -> bool {
if (player.queue.size() >= 9U) return false;
build_job job;
job.kind = kind;
job.total = ra3::data::build_frames_of(kind);
player.queue.push_back(job);
return true;
}
/**
* Move and fight. A unit seeks the nearest enemy its weapon can legally
* target; with no such target it advances on the enemy base so a match
* always converges.
*/
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);
const auto *weapon = ra3::data::weapon_of(entry.kind);
if (weapon == nullptr) continue;
auto *target = this->nearest_target(entry, *weapon);
if (target == nullptr) continue;
const auto dx = target->position.x - entry.position.x;
@@ -279,50 +354,52 @@ export namespace ra3::skirmish {
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;
if (distance > weapon->attack_range) {
const auto speed = ra3::data::unit_of(entry.kind).speed;
if (speed > 0.0F && distance > 1.0e-3F) {
const auto travel = 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;
continue;
}
if (entry.cooldown > 0.0F) continue;
entry.cooldown = weapon->reload_seconds + weapon->firing_seconds;
this->apply_damage(entry, *target, *weapon);
if (weapon->splash_radius > 0.0F) {
for (auto &other: units_) {
if (!other.alive || other.owner == entry.owner || &other == target) continue;
if (!weapon->can_target(ra3::data::class_of(other.kind))) continue;
const auto ox = other.position.x - target->position.x;
const auto oy = other.position.y - target->position.y;
if (ox * ox + oy * oy <= weapon->splash_radius * weapon->splash_radius) this->apply_damage(entry, other, *weapon);
}
}
}
}
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;
}
/** Resolve one hit through the target's armour. */
auto apply_damage(const unit &attacker, unit &target, const ra3::data::weapon_data &weapon) -> void {
if (!target.alive) return;
const auto damage = weapon.instakill ? target.max_health : ra3::data::armor_of(target.kind).adjust(weapon.type, weapon.damage);
target.health -= damage;
if (target.health > 0.0F) return;
target.health = 0.0F;
target.alive = false;
this->player_ref(attacker.owner).kills += 1;
this->player_ref(target.owner).losses += 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 * {
/** Nearest enemy this weapon may target, or `nullptr`. */
[[nodiscard]] auto nearest_target(const unit &self, const ra3::data::weapon_data &weapon) -> unit * {
unit *best = nullptr;
real best_distance = 1.0e30F;
for (auto &candidate: units_) {
if (!candidate.alive || candidate.owner == self.owner) continue;
if (!weapon.can_target(ra3::data::class_of(candidate.kind))) 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;
@@ -334,6 +411,43 @@ export namespace ra3::skirmish {
return best;
}
/** Defeat is losing every structure; the last side standing wins. */
auto check_victory() -> void {
const bool first_alive = this->has_structures(0U);
const bool second_alive = this->has_structures(1U);
if (first_alive && second_alive) return;
decided_ = true;
winner_ = first_alive ? 0 : (second_alive ? 1 : -1);
}
/** Frame cap: the higher-scoring side wins, with a deterministic tie-break. */
auto decide_on_timeout() -> void {
decided_ = true;
const auto first = this->score_tuple(0U);
const auto second = this->score_tuple(1U);
winner_ = first == second ? -1 : (first > second ? 0 : 1);
}
[[nodiscard]] auto score_tuple(uint32 owner) const -> std::tuple<int32, int32, int32> {
const auto &player = players_.at(owner);
return {this->score(owner), player.kills, static_cast<int32>(this->unit_count(owner))};
}
[[nodiscard]] auto score(uint32 owner) const -> int32 {
int32 total = players_.at(owner).money;
for (const auto &entry: units_) {
if (entry.alive && entry.owner == owner) total += ra3::data::cost_of(entry.kind);
}
return total;
}
[[nodiscard]] auto has_structures(uint32 owner) const -> bool {
for (const auto &entry: units_) {
if (entry.alive && entry.owner == owner && ra3::data::is_structure(entry.kind)) return true;
}
return false;
}
[[nodiscard]] auto find_unit(uint32 id) -> unit * {
for (auto &entry: units_) {
if (entry.id == id) return &entry;
@@ -348,6 +462,13 @@ export namespace ra3::skirmish {
return nullptr;
}
[[nodiscard]] auto find_producer(uint32 owner, entity_kind kind) -> unit * {
for (auto &entry: units_) {
if (entry.alive && entry.owner == owner && entry.kind == kind) return &entry;
}
return nullptr;
}
[[nodiscard]] auto player_ref(uint32 index) -> player_state & { return players_.at(index); }
match_config config_;
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -3,19 +3,21 @@ export module ra3.ui;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* Fallback viewer used when the build has no SDL3. It never opens a window;
* the caller should write an offscreen image instead.
* Fallback SDL backend used when the build has no SDL3. It never opens a window;
* `init` fails so the caller can fall back to an offscreen image.
*/
export namespace ra3::ui {
/** Window parameters (mirrors the SDL backend). */
struct viewer_options {
std::string title = "OpenRA3";
int width = 1024;
int height = 768;
class sdl_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "sdl(null)"; }
};
/** Always returns false: this build has no windowing backend. */
[[nodiscard]] inline auto run_viewer(const ra3::render::image &, const viewer_options &) -> bool { return false; }
}
+158 -66
View File
@@ -7,85 +7,177 @@ export module ra3.ui;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* A minimal SDL3 window for viewing a rendered scene.
* The SDL3 presentation backend.
*
* Controls: drag with the left mouse button to pan, wheel to zoom, Esc/close
* to quit. When no display is available `run_viewer` returns false so the
* caller can fall back to an offscreen image.
* SDL3 is the platform layer: it owns the window, the input, and (here) a
* low-end `SDL_Renderer` blit path. Only the low-level display primitives live
* in this module; the interactive loops are shared in `ra3::client::display`.
* The Vulkan backend implements the same interface.
*/
export namespace ra3::ui {
/** Window parameters. */
struct viewer_options {
std::string title = "OpenRA3";
int width = 1024;
int height = 768;
};
/**
* Show `scene` in a window until the user quits.
*
* @return true if the window ran; false if video init or window creation
* failed (typically no display).
* An SDL_Renderer-backed display: cheap, dependency-light, and used as the
* fallback when Vulkan is unavailable.
*/
[[nodiscard]] inline auto run_viewer(const ra3::render::image &scene, const viewer_options &options) -> bool {
if (scene.empty()) return false;
if (!SDL_Init(SDL_INIT_VIDEO)) return false;
class sdl_display final : public ra3::client::display {
public:
sdl_display() = default;
sdl_display(const sdl_display &) = delete;
auto operator=(const sdl_display &) -> sdl_display & = delete;
~sdl_display() override { this->shutdown(); }
SDL_Window *window = SDL_CreateWindow(options.title.c_str(), options.width, options.height, SDL_WINDOW_RESIZABLE);
if (window == nullptr) {
SDL_Quit();
return false;
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
if (!SDL_Init(SDL_INIT_VIDEO)) return false;
this->fps_limit_ = options.fps_limit;
window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height,
SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
if (window_ == nullptr) {
SDL_Quit();
return false;
}
SDL_RaiseWindow(window_);
renderer_ = SDL_CreateRenderer(window_, nullptr);
if (renderer_ == nullptr) {
this->shutdown();
return false;
}
SDL_SetRenderVSync(renderer_, options.fps_limit == 0 ? 1 : 0);
SDL_StartTextInput(window_);
return true;
}
SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr);
if (renderer == nullptr) {
SDL_DestroyWindow(window);
SDL_Quit();
return false;
}
SDL_Texture *texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, static_cast<int>(scene.width()),
static_cast<int>(scene.height()));
if (texture == nullptr) {
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return false;
}
SDL_UpdateTexture(texture, nullptr, scene.data(), static_cast<int>(scene.width()) * 4);
SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST);
float zoom = 1.0F;
float pan_x = 0.0F;
float pan_y = 0.0F;
bool running = true;
SDL_Event event;
while (running) {
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
if (frame.empty() || renderer_ == nullptr) return false;
if (changed || texture_ == nullptr || frame.width() != texture_w_ || frame.height() != texture_h_) {
if (texture_ != nullptr) SDL_DestroyTexture(texture_);
texture_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, static_cast<int>(frame.width()),
static_cast<int>(frame.height()));
if (texture_ == nullptr) return false;
texture_w_ = frame.width();
texture_h_ = frame.height();
SDL_SetTextureScaleMode(texture_, SDL_SCALEMODE_LINEAR);
SDL_UpdateTexture(texture_, nullptr, frame.data(), static_cast<int>(frame.width()) * 4);
}
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255);
SDL_RenderClear(renderer_);
const SDL_FRect dst{dest.x, dest.y, dest.w, dest.h};
SDL_RenderTexture(renderer_, texture_, nullptr, &dst);
SDL_RenderPresent(renderer_);
return true;
}
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
} else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) {
running = false;
} else if (event.type == SDL_EVENT_MOUSE_WHEEL) {
zoom *= event.wheel.y > 0.0F ? 1.1F : (1.0F / 1.1F);
zoom = std::clamp(zoom, 0.1F, 20.0F);
} else if (event.type == SDL_EVENT_MOUSE_MOTION && (event.motion.state & SDL_BUTTON_LMASK) != 0U) {
pan_x += event.motion.xrel;
pan_y += event.motion.yrel;
switch (event.type) {
case SDL_EVENT_QUIT:
out = {};
out.type = ra3::render::ui_event_type::quit;
return true;
case SDL_EVENT_KEY_DOWN:
if (const auto key = map_key(event.key.key); key != ra3::render::ui_key::none) {
out = {};
out.type = ra3::render::ui_event_type::key;
out.key = key;
return true;
}
break;
case SDL_EVENT_TEXT_INPUT:
if (event.text.text[0] != '\0') {
out = {};
out.type = ra3::render::ui_event_type::text;
out.character = event.text.text[0];
return true;
}
break;
case SDL_EVENT_MOUSE_MOTION:
out = {};
out.type = ra3::render::ui_event_type::mouse_move;
out.x = event.motion.x;
out.y = event.motion.y;
out.dx = event.motion.xrel;
out.dy = event.motion.yrel;
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
return true;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
out = {};
out.type = ra3::render::ui_event_type::mouse_button;
out.x = event.button.x;
out.y = event.button.y;
out.left = event.button.button == SDL_BUTTON_LEFT;
return true;
case SDL_EVENT_MOUSE_WHEEL:
out = {};
out.type = ra3::render::ui_event_type::wheel;
out.wheel = event.wheel.y;
return true;
default:
break;
}
}
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
const SDL_FRect dst{pan_x, pan_y, static_cast<float>(scene.width()) * zoom, static_cast<float>(scene.height()) * zoom};
SDL_RenderTexture(renderer, texture, nullptr, &dst);
SDL_RenderPresent(renderer);
return false;
}
SDL_DestroyTexture(texture);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return true;
}
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
int w = 0;
int h = 0;
SDL_GetWindowSizeInPixels(window_, &w, &h);
return {w, h};
}
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
const bool *keys = SDL_GetKeyboardState(nullptr);
if (keys == nullptr) return false;
switch (key) {
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP];
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN];
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT];
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT];
default: return false;
}
}
auto shutdown() -> void override {
if (window_ != nullptr) SDL_StopTextInput(window_);
if (texture_ != nullptr) SDL_DestroyTexture(texture_);
if (renderer_ != nullptr) SDL_DestroyRenderer(renderer_);
if (window_ != nullptr) SDL_DestroyWindow(window_);
SDL_Quit();
texture_ = nullptr;
renderer_ = nullptr;
window_ = nullptr;
texture_w_ = 0;
texture_h_ = 0;
}
[[nodiscard]] auto name() const -> std::string_view override { return "sdl"; }
private:
[[nodiscard]] static auto map_key(SDL_Keycode key) -> ra3::render::ui_key {
using ra3::render::ui_key;
switch (key) {
case SDLK_UP: return ui_key::up;
case SDLK_DOWN: return ui_key::down;
case SDLK_LEFT: return ui_key::left;
case SDLK_RIGHT: return ui_key::right;
case SDLK_PAGEUP: return ui_key::page_up;
case SDLK_PAGEDOWN: return ui_key::page_down;
case SDLK_RETURN:
case SDLK_KP_ENTER: return ui_key::confirm;
case SDLK_ESCAPE: return ui_key::cancel;
case SDLK_TAB: return ui_key::tab;
case SDLK_BACKSPACE: return ui_key::backspace;
default: return ui_key::none;
}
}
SDL_Window *window_ = nullptr;
SDL_Renderer *renderer_ = nullptr;
SDL_Texture *texture_ = nullptr;
ra3::core::uint32 texture_w_ = 0;
ra3::core::uint32 texture_h_ = 0;
};
}
+25
View File
@@ -0,0 +1,25 @@
export module ra3.vulkan;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* Fallback Vulkan backend used when the build has no Vulkan loader (for example
* the Windows cross-build without a Vulkan SDK). `init` fails so the caller can
* select another display.
*/
export namespace ra3::vulkan {
class vulkan_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "vulkan(null)"; }
};
}
File diff suppressed because it is too large Load Diff