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:
+485
-5
@@ -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"; }
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user