Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35372da52 |
+33
-1
@@ -83,13 +83,45 @@ target_sources(ra3_skirmish
|
|||||||
target_link_libraries(ra3_skirmish PUBLIC ra3_core ra3_logic ra3_game ra3_map)
|
target_link_libraries(ra3_skirmish PUBLIC ra3_core ra3_logic ra3_game ra3_map)
|
||||||
openra3_target_defaults(ra3_skirmish)
|
openra3_target_defaults(ra3_skirmish)
|
||||||
|
|
||||||
|
# --- software renderer: framebuffer, TGA decode, BMP encode, map compositing ---
|
||||||
|
add_library(ra3_render STATIC)
|
||||||
|
target_sources(ra3_render
|
||||||
|
PUBLIC FILE_SET CXX_MODULES FILES
|
||||||
|
src/render/ra3.render.cppm
|
||||||
|
)
|
||||||
|
target_link_libraries(ra3_render PUBLIC ra3_core)
|
||||||
|
openra3_target_defaults(ra3_render)
|
||||||
|
|
||||||
|
# --- windowed viewer: SDL3 when available, otherwise a null backend ---
|
||||||
|
find_package(PkgConfig QUIET)
|
||||||
|
if(PkgConfig_FOUND)
|
||||||
|
pkg_check_modules(SDL3 QUIET IMPORTED_TARGET sdl3)
|
||||||
|
endif()
|
||||||
|
add_library(ra3_ui STATIC)
|
||||||
|
if(SDL3_FOUND)
|
||||||
|
target_sources(ra3_ui
|
||||||
|
PUBLIC FILE_SET CXX_MODULES FILES
|
||||||
|
src/ui/ra3.ui.sdl.cppm
|
||||||
|
)
|
||||||
|
target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render PkgConfig::SDL3)
|
||||||
|
message(STATUS "OpenRA3: SDL3 ${SDL3_VERSION} found - windowed viewer enabled")
|
||||||
|
else()
|
||||||
|
target_sources(ra3_ui
|
||||||
|
PUBLIC FILE_SET CXX_MODULES FILES
|
||||||
|
src/ui/ra3.ui.null.cppm
|
||||||
|
)
|
||||||
|
target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render)
|
||||||
|
message(STATUS "OpenRA3: SDL3 not found - offscreen BMP output only")
|
||||||
|
endif()
|
||||||
|
openra3_target_defaults(ra3_ui)
|
||||||
|
|
||||||
# --- umbrella module re-exporting the whole SDK ---
|
# --- umbrella module re-exporting the whole SDK ---
|
||||||
add_library(ra3 STATIC)
|
add_library(ra3 STATIC)
|
||||||
target_sources(ra3
|
target_sources(ra3
|
||||||
PUBLIC FILE_SET CXX_MODULES FILES
|
PUBLIC FILE_SET CXX_MODULES FILES
|
||||||
src/ra3.cppm
|
src/ra3.cppm
|
||||||
)
|
)
|
||||||
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish)
|
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_ui)
|
||||||
openra3_target_defaults(ra3)
|
openra3_target_defaults(ra3)
|
||||||
|
|
||||||
# --- the headless game executable ---
|
# --- the headless game executable ---
|
||||||
|
|||||||
+12
-4
@@ -17,16 +17,22 @@ RUN if [ -n "$APT_MIRROR" ]; then \
|
|||||||
|
|
||||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
for attempt in 1 2 3 4 5; do \
|
||||||
|
apt-get -o Acquire::Retries=5 update && break; \
|
||||||
|
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||||
|
sleep 5; \
|
||||||
|
done \
|
||||||
|
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
cmake \
|
cmake \
|
||||||
g++-16 \
|
g++-16 \
|
||||||
gdb \
|
gdb \
|
||||||
git \
|
git \
|
||||||
|
libsdl3-dev \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Pin the toolchain to GCC 16 (the distro default is still GCC 15).
|
# Pin the toolchain to GCC 16 (the distro default is still GCC 15).
|
||||||
ENV CC=gcc-16
|
ENV CC=gcc-16
|
||||||
@@ -48,9 +54,11 @@ FROM ubuntu:26.04 AS deploy
|
|||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get -o Acquire::Retries=5 update \
|
||||||
|
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||||
|
libsdl3-0 \
|
||||||
libstdc++6 \
|
libstdc++6 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=dev /work/build/bin/openra3 /app/openra3
|
COPY --from=dev /work/build/bin/openra3 /app/openra3
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ Ghidra.
|
|||||||
OpenRA3 is at **v0.0.1**. The engine compiles and runs headless, and a **minimal
|
OpenRA3 is at **v0.0.1**. The engine compiles and runs headless, and a **minimal
|
||||||
skirmish** is playable: it reads a real multiplayer map out of your install,
|
skirmish** is playable: it reads a real multiplayer map out of your install,
|
||||||
recovers the player start waypoints, and simulates two sides earning credits,
|
recovers the player start waypoints, and simulates two sides earning credits,
|
||||||
training units and fighting until one base falls. There is no renderer yet, so
|
training units and fighting until one base falls. The map and match state can be
|
||||||
the match is observed as a deterministic headless result.
|
**rendered** to a window (SDL3) or to an image.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4
|
$ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4
|
||||||
@@ -66,6 +66,8 @@ built-in test map so the project still builds and runs in CI.
|
|||||||
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
|
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
|
||||||
| `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints |
|
| `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints |
|
||||||
| `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition |
|
| `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition |
|
||||||
|
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing |
|
||||||
|
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer (null backend when SDL3 is absent) |
|
||||||
| `src/ra3.cppm` | umbrella module re-exporting the SDK |
|
| `src/ra3.cppm` | umbrella module re-exporting the SDK |
|
||||||
| `apps/openra3/main.cpp` | `maps` / `skirmish` CLI |
|
| `apps/openra3/main.cpp` | `maps` / `skirmish` CLI |
|
||||||
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
|
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
|
||||||
@@ -103,6 +105,32 @@ docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \
|
|||||||
`--frames N` caps the simulation length (default 15 minutes of game time at
|
`--frames N` caps the simulation length (default 15 minutes of game time at
|
||||||
30 Hz). The result is deterministic for a given map and seed.
|
30 Hz). The result is deterministic for a given map and seed.
|
||||||
|
|
||||||
|
## Rendering the map
|
||||||
|
|
||||||
|
The renderer draws the map's own overview art, a world grid and the match state
|
||||||
|
(start markers in yellow, player 0 in blue, player 1 in red).
|
||||||
|
|
||||||
|
**Offscreen image** (works anywhere, no display needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "/path/to/Red Alert 3:/game:ro" -v "$PWD/out:/out" openra3-dev:local \
|
||||||
|
/work/build/bin/openra3 render --game-dir /game --map map_mp_2_feasel4 --out /out/map.bmp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interactive window** (SDL3; drag to pan, wheel to zoom, Esc to quit). In a
|
||||||
|
container you need an X server on the host — on Windows run
|
||||||
|
[VcXsrv](https://sourceforge.net/projects/vcxsrv/) and launch it with "Disable
|
||||||
|
access control", then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "/path/to/Red Alert 3:/game:ro" -e DISPLAY=host.docker.internal:0.0 \
|
||||||
|
openra3-dev:local /work/build/bin/openra3 render --game-dir /game --map map_mp_2_feasel4
|
||||||
|
```
|
||||||
|
|
||||||
|
If no display is available the viewer falls back to writing `openra3_view.bmp`.
|
||||||
|
The unit overlay uses an approximate world scale (`--world-size`, default
|
||||||
|
5120); exact calibration from the map's heightmap is on the roadmap.
|
||||||
|
|
||||||
## Reverse engineering
|
## Reverse engineering
|
||||||
|
|
||||||
The reconstruction is driven by Ghidra against the retail binary. See
|
The reconstruction is driven by Ghidra against the retail binary. See
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -14,6 +16,7 @@ namespace {
|
|||||||
std::puts("usage:");
|
std::puts("usage:");
|
||||||
std::puts(" openra3 maps [--game-dir DIR]");
|
std::puts(" openra3 maps [--game-dir DIR]");
|
||||||
std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]");
|
std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]");
|
||||||
|
std::puts(" openra3 render [--game-dir DIR] [--map ID] [--frames N] [--seed N] [--world-size S] [--out FILE.bmp]");
|
||||||
std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo");
|
std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +101,92 @@ namespace {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entry name of a map's overview art (`<stem>_art.tga`). */
|
||||||
|
auto art_entry_for(const ra3::map::map_info &info) -> std::string {
|
||||||
|
auto entry = info.entry;
|
||||||
|
if (entry.size() > 4U) entry.resize(entry.size() - 4U);
|
||||||
|
entry += "_art.tga";
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto command_render(const std::vector<std::string> &args, const std::optional<std::filesystem::path> &game_dir) -> int {
|
||||||
|
const auto dir = ra3::fs::find_game_dir(game_dir);
|
||||||
|
if (!dir) {
|
||||||
|
std::puts("no Red Alert 3 install found; pass --game-dir or set RA3_GAME_DIR");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto archive = ra3::fs::big_archive::open(data_path(*dir, "MapsMultiplayer.big"));
|
||||||
|
const auto catalog = ra3::map::map_catalog::from_archive(archive);
|
||||||
|
const auto requested = option_value(args, "--map");
|
||||||
|
const ra3::map::map_info *info = nullptr;
|
||||||
|
if (requested) {
|
||||||
|
info = catalog.find(*requested);
|
||||||
|
} else if (catalog.size() > 0U) {
|
||||||
|
info = &catalog.maps().front();
|
||||||
|
}
|
||||||
|
if (info == nullptr) {
|
||||||
|
std::printf("map '%s' not found\n", requested ? requested->c_str() : "");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto tga = archive.read(art_entry_for(*info), true);
|
||||||
|
const auto art = ra3::render::decode_tga(tga);
|
||||||
|
|
||||||
|
ra3::render::scene_options scene;
|
||||||
|
scene.title = "OpenRA3 - " + info->id;
|
||||||
|
if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size);
|
||||||
|
|
||||||
|
ra3::skirmish::match_config config;
|
||||||
|
config.map_id = info->id;
|
||||||
|
if (const auto seed = option_value(args, "--seed")) config.seed = static_cast<ra3::core::uint32>(std::stoul(*seed));
|
||||||
|
if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast<ra3::core::uint32>(std::stoul(*frames));
|
||||||
|
|
||||||
|
auto starts = ra3::map::load_map(archive, *info).starts;
|
||||||
|
if (starts.size() < 2U) starts = ra3::skirmish::builtin_start_positions();
|
||||||
|
auto match = ra3::skirmish::skirmish_match::create(config, starts);
|
||||||
|
const auto summary = match.run();
|
||||||
|
|
||||||
|
std::vector<ra3::render::marker> markers;
|
||||||
|
for (const auto &player: match.players()) {
|
||||||
|
markers.push_back({static_cast<double>(player.start.x), static_cast<double>(player.start.y), ra3::render::yellow, 6});
|
||||||
|
}
|
||||||
|
for (const auto &entry: match.units()) {
|
||||||
|
if (!entry.alive) continue;
|
||||||
|
const auto color = entry.owner == 0U ? ra3::render::blue : ra3::render::red;
|
||||||
|
int radius = 2;
|
||||||
|
if (entry.cls == ra3::skirmish::unit_class::tank) radius = 4;
|
||||||
|
if (entry.cls == ra3::skirmish::unit_class::base) radius = 6;
|
||||||
|
markers.push_back({static_cast<double>(entry.position.x), static_cast<double>(entry.position.y), color, radius});
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto composed = ra3::render::compose(art, markers, scene);
|
||||||
|
|
||||||
|
const auto out = option_value(args, "--out");
|
||||||
|
const bool no_window = std::find(args.begin(), args.end(), "--no-window") != args.end();
|
||||||
|
bool shown = false;
|
||||||
|
if (!out && !no_window) {
|
||||||
|
ra3::ui::viewer_options viewer;
|
||||||
|
viewer.title = scene.title;
|
||||||
|
shown = ra3::ui::run_viewer(composed, viewer);
|
||||||
|
}
|
||||||
|
if (!shown) {
|
||||||
|
const auto path = out.value_or("openra3_view.bmp");
|
||||||
|
const auto bmp = ra3::render::encode_bmp(composed);
|
||||||
|
std::ofstream file(path, std::ios::binary);
|
||||||
|
if (!file) {
|
||||||
|
std::printf("cannot write %s\n", path.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
file.write(reinterpret_cast<const char *>(bmp.data()), static_cast<std::streamsize>(bmp.size()));
|
||||||
|
std::printf("wrote %s (%ux%u)\n", path.c_str(), composed.width(), composed.height());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("map=%s units P0=%d P1=%d winner=%d frames=%u\n", info->id.c_str(), summary.units_left[0], summary.units_left[1], summary.winner,
|
||||||
|
summary.frames);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto main(int argc, char **argv) -> int {
|
auto main(int argc, char **argv) -> int {
|
||||||
@@ -115,6 +204,7 @@ auto main(int argc, char **argv) -> int {
|
|||||||
|
|
||||||
if (command == "maps") return command_maps(game_dir);
|
if (command == "maps") return command_maps(game_dir);
|
||||||
if (command == "skirmish") return command_skirmish(rest, game_dir);
|
if (command == "skirmish") return command_skirmish(rest, game_dir);
|
||||||
|
if (command == "render") return command_render(rest, game_dir);
|
||||||
if (command == "help" || command == "--help" || command == "-h") {
|
if (command == "help" || command == "--help" || command == "-h") {
|
||||||
print_usage();
|
print_usage();
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
+31
-16
@@ -4,22 +4,26 @@ OpenRA3 is organised as a stack of C++ modules. Each layer may import the ones
|
|||||||
below it, never the ones above.
|
below it, never the ones above.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────────────┐
|
||||||
applications │ openra3 (apps/openra3) │
|
applications │ openra3 (apps/openra3) │
|
||||||
└───────────────┬───────────────┘
|
└───────────────────┬───────────────────┘
|
||||||
│ import ra3
|
│ import ra3
|
||||||
┌───────────────▼───────────────┐
|
┌───────────────────▼───────────────────┐
|
||||||
umbrella │ ra3 (re-exports everything) │
|
umbrella │ ra3 (re-exports everything) │
|
||||||
└───────────────┬───────────────┘
|
└───────────────────┬───────────────────┘
|
||||||
┌───────────────┬────────┴────────┬───────────────┐
|
┌──────────────┬───────────────┼───────────────┬──────────────┐
|
||||||
▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼
|
||||||
ra3.skirmish ra3.client ra3.game ra3.map
|
ra3.ui ra3.render ra3.skirmish ra3.client ra3.game
|
||||||
match rules display/loop RA3 sides map catalog
|
SDL3 window framebuffer match rules display/loop RA3 sides
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
└───────┬───────┴────────┬────────┘ │
|
└──────┬───────┘ └───────┬───────┘ │
|
||||||
▼ ▼ ▼
|
▼ ▼ │
|
||||||
ra3.logic ra3.core ra3.fs
|
ra3.core ra3.logic │
|
||||||
simulation types/math/random BIG4 + RefPack
|
simulation │
|
||||||
|
┌───────────────────────────────────────┬──────────────────────┘
|
||||||
|
▼ ▼
|
||||||
|
ra3.map ra3.fs
|
||||||
|
map catalog BIG4 + RefPack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module responsibilities
|
## Module responsibilities
|
||||||
@@ -64,6 +68,17 @@ The minimal match: two players, unit classes (harvester/infantry/tank/base),
|
|||||||
passive + harvester income, a simple build AI, movement and combat on a fixed
|
passive + harvester income, a simple build AI, movement and combat on a fixed
|
||||||
30 Hz step, and a base-destruction win condition. Fully deterministic.
|
30 Hz step, and a base-destruction win condition. Fully deterministic.
|
||||||
|
|
||||||
|
### `ra3.render`
|
||||||
|
A dependency-free software renderer: an ARGB8888 `image` framebuffer with
|
||||||
|
blit/line/circle primitives, a TGA decoder for the game's map art, a 24-bit BMP
|
||||||
|
encoder for headless output, and `compose` which overlays a world grid and
|
||||||
|
markers (start positions, live units) onto the map art.
|
||||||
|
|
||||||
|
### `ra3.ui`
|
||||||
|
The windowed viewer. The SDL3 backend streams the rendered image to a texture
|
||||||
|
with pan/zoom and Esc-to-quit; when the build has no SDL3, a null backend
|
||||||
|
returns `false` so the caller writes an offscreen image instead.
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
|
|
||||||
1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition
|
1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition
|
||||||
|
|||||||
+9
-5
@@ -11,12 +11,15 @@ that each one produces something that builds and runs.
|
|||||||
CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data
|
CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data
|
||||||
from a local install, recovers map start waypoints, and runs a
|
from a local install, recovers map start waypoints, and runs a
|
||||||
deterministic headless two-player skirmish to a decision.
|
deterministic headless two-player skirmish to a decision.
|
||||||
|
- [x] **v0.1.0 — map renderer + window.** Software ARGB framebuffer, TGA
|
||||||
|
decoder for the map art, BMP output, map/grid/unit compositing, and an
|
||||||
|
SDL3 window viewer (pan/zoom).
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **v0.1.0 — complete map parsing.** Parse `MPPositionList` for maps that do
|
- [ ] **v0.1.1 — correct world calibration.** Parse the map heightmap
|
||||||
not use `Player_N_Start` waypoints; recover map dimensions, terrain
|
dimensions (and `MPPositionList` starts) so the unit overlay lines up with
|
||||||
heightmap and placement grid; place real starting structures/units.
|
the map art instead of using an assumed world size.
|
||||||
- [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets
|
- [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets
|
||||||
(`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so
|
(`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so
|
||||||
units use the real balance numbers instead of OpenRA3's stand-ins.
|
units use the real balance numbers instead of OpenRA3's stand-ins.
|
||||||
@@ -25,8 +28,9 @@ that each one produces something that builds and runs.
|
|||||||
tree, pathfinding and shroud.
|
tree, pathfinding and shroud.
|
||||||
- [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack
|
- [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack
|
||||||
waves (the reference tree's `AI*` modules).
|
waves (the reference tree's `AI*` modules).
|
||||||
- [ ] **v0.5.0 — renderer.** A W3D/D3D9 (or portable Vulkan/OpenGL) client
|
- [ ] **v0.5.0 — full renderer.** Terrain heightmap rendering, W3D models and a
|
||||||
backend implementing `ra3::client::display`, plus input.
|
D3D9/Vulkan/OpenGL client backend implementing `ra3::client::display`,
|
||||||
|
plus full input.
|
||||||
- [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a
|
- [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a
|
||||||
skirmish end-to-end with a UI.
|
skirmish end-to-end with a UI.
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export module ra3.core;
|
|||||||
*/
|
*/
|
||||||
export namespace ra3::core {
|
export namespace ra3::core {
|
||||||
inline constexpr int version_major = 0;
|
inline constexpr int version_major = 0;
|
||||||
inline constexpr int version_minor = 0;
|
inline constexpr int version_minor = 1;
|
||||||
inline constexpr int version_patch = 1;
|
inline constexpr int version_patch = 0;
|
||||||
|
|
||||||
using real = float;
|
using real = float;
|
||||||
using int32 = std::int32_t;
|
using int32 = std::int32_t;
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export import ra3.game;
|
|||||||
export import ra3.fs;
|
export import ra3.fs;
|
||||||
export import ra3.map;
|
export import ra3.map;
|
||||||
export import ra3.skirmish;
|
export import ra3.skirmish;
|
||||||
|
export import ra3.render;
|
||||||
|
export import ra3.ui;
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
module;
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <span>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
export module ra3.render;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dependency-free software renderer: an ARGB framebuffer, a TGA decoder for
|
||||||
|
* the game's map art, a BMP encoder for headless output, and map compositing.
|
||||||
|
*
|
||||||
|
* The windowed viewer lives in `ra3.ui`; this module is pure computation so it
|
||||||
|
* builds and runs anywhere, including CI.
|
||||||
|
*/
|
||||||
|
export namespace ra3::render {
|
||||||
|
using ra3::core::uint8;
|
||||||
|
using ra3::core::uint32;
|
||||||
|
using ra3::core::usize;
|
||||||
|
|
||||||
|
/** Thrown when an image payload is malformed. */
|
||||||
|
class image_error : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pack 8-bit channels into the engine's 0xAARRGGBB pixel format. */
|
||||||
|
[[nodiscard]] constexpr auto argb(uint8 r, uint8 g, uint8 b, uint8 a = 255U) -> uint32 {
|
||||||
|
return (static_cast<uint32>(a) << 24U) | (static_cast<uint32>(r) << 16U) | (static_cast<uint32>(g) << 8U) | static_cast<uint32>(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr uint32 black = argb(0, 0, 0);
|
||||||
|
inline constexpr uint32 white = argb(255, 255, 255);
|
||||||
|
inline constexpr uint32 red = argb(220, 40, 40);
|
||||||
|
inline constexpr uint32 green = argb(60, 200, 80);
|
||||||
|
inline constexpr uint32 blue = argb(70, 120, 230);
|
||||||
|
inline constexpr uint32 yellow = argb(230, 210, 60);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-left-origin ARGB8888 image.
|
||||||
|
*/
|
||||||
|
class image {
|
||||||
|
public:
|
||||||
|
image() = default;
|
||||||
|
|
||||||
|
image(uint32 width, uint32 height, uint32 fill = black) : width_(width), height_(height), pixels_(static_cast<usize>(width) * height, fill) {}
|
||||||
|
|
||||||
|
[[nodiscard]] auto width() const -> uint32 { return width_; }
|
||||||
|
[[nodiscard]] auto height() const -> uint32 { return height_; }
|
||||||
|
[[nodiscard]] auto empty() const -> bool { return pixels_.empty(); }
|
||||||
|
[[nodiscard]] auto data() const -> const uint32 * { return pixels_.data(); }
|
||||||
|
[[nodiscard]] auto data() -> uint32 * { return pixels_.data(); }
|
||||||
|
|
||||||
|
auto set(int x, int y, uint32 color) -> void {
|
||||||
|
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
|
||||||
|
pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)] = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto fill(uint32 color) -> void { std::fill(pixels_.begin(), pixels_.end(), color); }
|
||||||
|
|
||||||
|
auto blend(int x, int y, uint32 color, uint8 alpha) -> void {
|
||||||
|
if (alpha == 0U) return;
|
||||||
|
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
|
||||||
|
auto &dst = pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)];
|
||||||
|
const auto inv = static_cast<uint32>(255U - alpha);
|
||||||
|
const auto mix = [alpha, inv](uint32 lo, uint32 hi) { return (hi * alpha + lo * inv) / 255U; };
|
||||||
|
dst = (0xFF000000U) | (mix((dst >> 16U) & 0xFFU, (color >> 16U) & 0xFFU) << 16U) | (mix((dst >> 8U) & 0xFFU, (color >> 8U) & 0xFFU) << 8U) |
|
||||||
|
mix(dst & 0xFFU, color & 0xFFU);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy `src` with its top-left at (dst_x, dst_y). */
|
||||||
|
auto blit(const image &src, int dst_x, int dst_y) -> void {
|
||||||
|
for (uint32 sy = 0; sy < src.height_; ++sy) {
|
||||||
|
for (uint32 sx = 0; sx < src.width_; ++sx) {
|
||||||
|
this->set(dst_x + static_cast<int>(sx), dst_y + static_cast<int>(sy), src.pixels_[static_cast<usize>(sy) * src.width_ + sx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto draw_rect(int x, int y, int w, int h, uint32 color) -> void {
|
||||||
|
for (int i = 0; i < h; ++i) {
|
||||||
|
for (int j = 0; j < w; ++j) this->set(x + j, y + i, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto draw_line(int x0, int y0, int x1, int y1, uint32 color) -> void {
|
||||||
|
const int dx = std::abs(x1 - x0);
|
||||||
|
const int dy = -std::abs(y1 - y0);
|
||||||
|
const int sx = x0 < x1 ? 1 : -1;
|
||||||
|
const int sy = y0 < y1 ? 1 : -1;
|
||||||
|
int error = dx + dy;
|
||||||
|
for (;;) {
|
||||||
|
this->set(x0, y0, color);
|
||||||
|
if (x0 == x1 && y0 == y1) break;
|
||||||
|
const int twice = 2 * error;
|
||||||
|
if (twice >= dy) {
|
||||||
|
error += dy;
|
||||||
|
x0 += sx;
|
||||||
|
}
|
||||||
|
if (twice <= dx) {
|
||||||
|
error += dx;
|
||||||
|
y0 += sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto fill_circle(int cx, int cy, int radius, uint32 color) -> void {
|
||||||
|
for (int y = -radius; y <= radius; ++y) {
|
||||||
|
for (int x = -radius; x <= radius; ++x) {
|
||||||
|
if (x * x + y * y <= radius * radius) this->set(cx + x, cy + y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto draw_circle(int cx, int cy, int radius, uint32 color) -> void {
|
||||||
|
for (int a = 0; a < 360; ++a) {
|
||||||
|
const auto rad = static_cast<double>(a) * 3.14159265358979323846 / 180.0;
|
||||||
|
this->set(cx + static_cast<int>(std::lround(std::cos(rad) * radius)), cy + static_cast<int>(std::lround(std::sin(rad) * radius)), color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint32 width_ = 0;
|
||||||
|
uint32 height_ = 0;
|
||||||
|
std::vector<uint32> pixels_;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a Truevision TGA image (types 2/3/10/11, 8/24/32-bit).
|
||||||
|
*
|
||||||
|
* RA3's map art and minimaps are uncompressed 24/32-bit TGAs with a
|
||||||
|
* bottom-left origin; the decoder handles both origins.
|
||||||
|
*
|
||||||
|
* @throws image_error if the header is short or the type is unsupported.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] inline auto decode_tga(std::span<const uint8> data) -> image {
|
||||||
|
if (data.size() < 18U) throw image_error("TGA too short");
|
||||||
|
const auto id_length = data[0];
|
||||||
|
const auto color_map_type = data[1];
|
||||||
|
const auto image_type = data[2];
|
||||||
|
const auto width = read_u16(data, 12);
|
||||||
|
const auto height = read_u16(data, 14);
|
||||||
|
const auto depth = data[16];
|
||||||
|
const auto descriptor = data[17];
|
||||||
|
|
||||||
|
if (color_map_type != 0U) throw image_error("color-mapped TGA unsupported");
|
||||||
|
const bool rle = image_type == 10U || image_type == 11U;
|
||||||
|
const bool grayscale = image_type == 3U || image_type == 11U;
|
||||||
|
if (!grayscale && image_type != 2U && image_type != 10U) throw image_error("unsupported TGA image type");
|
||||||
|
|
||||||
|
const usize bytes_per_pixel = static_cast<usize>(depth / 8U);
|
||||||
|
if (bytes_per_pixel < 1U || bytes_per_pixel > 4U) throw image_error("unsupported TGA depth");
|
||||||
|
|
||||||
|
const bool top_origin = (descriptor & 0x20U) != 0U;
|
||||||
|
image out(width, height);
|
||||||
|
std::vector<uint32> row(width);
|
||||||
|
|
||||||
|
usize pos = 18U + id_length;
|
||||||
|
auto read_pixel = [&]() -> uint32 {
|
||||||
|
uint8 r = 0;
|
||||||
|
uint8 g = 0;
|
||||||
|
uint8 b = 0;
|
||||||
|
if (grayscale) {
|
||||||
|
r = g = b = data[pos];
|
||||||
|
} else {
|
||||||
|
b = data[pos];
|
||||||
|
g = data[pos + 1U];
|
||||||
|
r = data[pos + 2U];
|
||||||
|
}
|
||||||
|
pos += bytes_per_pixel;
|
||||||
|
return argb(r, g, b);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (uint32 y = 0; y < height; ++y) {
|
||||||
|
if (rle) {
|
||||||
|
uint32 x = 0;
|
||||||
|
while (x < width) {
|
||||||
|
const auto packet = data[pos++];
|
||||||
|
const uint32 run = (static_cast<uint32>(packet) & 0x7FU) + 1U;
|
||||||
|
if ((packet & 0x80U) != 0U) {
|
||||||
|
const auto value = read_pixel();
|
||||||
|
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = value;
|
||||||
|
} else {
|
||||||
|
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = read_pixel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (uint32 x = 0; x < width; ++x) row[x] = read_pixel();
|
||||||
|
}
|
||||||
|
const auto dest_y = top_origin ? y : (height - 1U - y);
|
||||||
|
for (uint32 x = 0; x < width; ++x) out.set(static_cast<int>(x), static_cast<int>(dest_y), row[x]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an image as a 24-bit bottom-up BMP.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] inline auto encode_bmp(const image &source) -> std::vector<uint8> {
|
||||||
|
const auto width = static_cast<uint32>(source.width());
|
||||||
|
const auto height = static_cast<uint32>(source.height());
|
||||||
|
const uint32 row_bytes = ((width * 3U + 3U) / 4U) * 4U;
|
||||||
|
const uint32 pixel_bytes = row_bytes * height;
|
||||||
|
const uint32 file_size = 54U + pixel_bytes;
|
||||||
|
|
||||||
|
std::vector<uint8> out(file_size, 0U);
|
||||||
|
auto put32 = [&out](usize at, uint32 value) {
|
||||||
|
out[at] = static_cast<uint8>(value);
|
||||||
|
out[at + 1U] = static_cast<uint8>(value >> 8U);
|
||||||
|
out[at + 2U] = static_cast<uint8>(value >> 16U);
|
||||||
|
out[at + 3U] = static_cast<uint8>(value >> 24U);
|
||||||
|
};
|
||||||
|
auto put16 = [&out](usize at, uint16_t value) {
|
||||||
|
out[at] = static_cast<uint8>(value);
|
||||||
|
out[at + 1U] = static_cast<uint8>(value >> 8U);
|
||||||
|
};
|
||||||
|
|
||||||
|
out[0] = 'B';
|
||||||
|
out[1] = 'M';
|
||||||
|
put32(2, file_size);
|
||||||
|
put32(10, 54U); // pixel data offset
|
||||||
|
put32(14, 40U); // DIB header size
|
||||||
|
put32(18, width);
|
||||||
|
put32(22, height);
|
||||||
|
put16(26, 1U); // planes
|
||||||
|
put16(28, 24U); // bits per pixel
|
||||||
|
put32(34, pixel_bytes);
|
||||||
|
put32(38, 2835U); // 72 DPI
|
||||||
|
put32(42, 2835U);
|
||||||
|
|
||||||
|
for (uint32 y = 0; y < height; ++y) {
|
||||||
|
const auto src_y = height - 1U - y; // BMP is bottom-up
|
||||||
|
const auto row_base = 54U + static_cast<usize>(y) * row_bytes;
|
||||||
|
for (uint32 x = 0; x < width; ++x) {
|
||||||
|
const auto px = source.data()[static_cast<usize>(src_y) * width + x];
|
||||||
|
out[row_base + x * 3U] = static_cast<uint8>(px & 0xFFU);
|
||||||
|
out[row_base + x * 3U + 1U] = static_cast<uint8>((px >> 8U) & 0xFFU);
|
||||||
|
out[row_base + x * 3U + 2U] = static_cast<uint8>((px >> 16U) & 0xFFU);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point to overlay on a map, in world coordinates. */
|
||||||
|
struct marker {
|
||||||
|
double x = 0.0;
|
||||||
|
double y = 0.0;
|
||||||
|
uint32 color = white;
|
||||||
|
int radius = 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** How to composite a map scene. */
|
||||||
|
struct scene_options {
|
||||||
|
std::string title;
|
||||||
|
double world_width = 5120.0;
|
||||||
|
double world_height = 5120.0;
|
||||||
|
bool show_grid = true;
|
||||||
|
int grid_divisions = 8;
|
||||||
|
uint32 grid_color = argb(120, 140, 170);
|
||||||
|
uint32 background = argb(24, 28, 36);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite a map image with a world grid and marker overlays.
|
||||||
|
*
|
||||||
|
* World `(0,0)` maps to the bottom-left of `base`; world `+Y` points up, so
|
||||||
|
* the image is flipped vertically. This is the engine's own top-down view
|
||||||
|
* of the map; precise world calibration is a later milestone.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] inline auto compose(const image &base, std::span<const marker> markers, const scene_options &options) -> image {
|
||||||
|
image scene(base.width(), base.height(), options.background);
|
||||||
|
scene.blit(base, 0, 0);
|
||||||
|
|
||||||
|
const auto to_px = [&](double world_x, double world_y) -> std::pair<int, int> {
|
||||||
|
const auto fx = options.world_width > 0.0 ? world_x / options.world_width : 0.0;
|
||||||
|
const auto fy = options.world_height > 0.0 ? world_y / options.world_height : 0.0;
|
||||||
|
return {static_cast<int>(std::lround(fx * static_cast<double>(base.width()))),
|
||||||
|
static_cast<int>(std::lround((1.0 - fy) * static_cast<double>(base.height())))};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.show_grid && options.grid_divisions > 0) {
|
||||||
|
for (int i = 0; i <= options.grid_divisions; ++i) {
|
||||||
|
const auto fx = static_cast<double>(i) / options.grid_divisions;
|
||||||
|
const auto x = static_cast<int>(std::lround(fx * base.width()));
|
||||||
|
const auto y = static_cast<int>(std::lround(fx * base.height()));
|
||||||
|
scene.draw_line(x, 0, x, static_cast<int>(base.height()) - 1, options.grid_color);
|
||||||
|
scene.draw_line(0, y, static_cast<int>(base.width()) - 1, y, options.grid_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &point: markers) {
|
||||||
|
const auto [px, py] = to_px(point.x, point.y);
|
||||||
|
scene.fill_circle(px, py, point.radius, point.color);
|
||||||
|
scene.draw_circle(px, py, point.radius + 1, black);
|
||||||
|
}
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
module;
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
export module ra3.ui;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
import ra3.render;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback viewer used when the build has no SDL3. It never opens a window;
|
||||||
|
* the caller should write an offscreen image instead.
|
||||||
|
*/
|
||||||
|
export namespace ra3::ui {
|
||||||
|
/** Window parameters (mirrors the SDL backend). */
|
||||||
|
struct viewer_options {
|
||||||
|
std::string title = "OpenRA3";
|
||||||
|
int width = 1024;
|
||||||
|
int height = 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Always returns false: this build has no windowing backend. */
|
||||||
|
[[nodiscard]] inline auto run_viewer(const ra3::render::image &, const viewer_options &) -> bool { return false; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
module;
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
export module ra3.ui;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
import ra3.render;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal SDL3 window for viewing a rendered scene.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
[[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;
|
||||||
|
|
||||||
|
SDL_Window *window = SDL_CreateWindow(options.title.c_str(), options.width, options.height, SDL_WINDOW_RESIZABLE);
|
||||||
|
if (window == nullptr) {
|
||||||
|
SDL_Quit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_DestroyTexture(texture);
|
||||||
|
SDL_DestroyRenderer(renderer);
|
||||||
|
SDL_DestroyWindow(window);
|
||||||
|
SDL_Quit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,27 @@ auto main() -> int {
|
|||||||
std::error_code ignored;
|
std::error_code ignored;
|
||||||
std::filesystem::remove(archive_path, ignored);
|
std::filesystem::remove(archive_path, ignored);
|
||||||
|
|
||||||
|
// TGA: a 2x2 top-origin truecolor image decodes with correct pixels.
|
||||||
|
std::vector<core::uint8> tga{0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 24, 0x20};
|
||||||
|
const core::uint8 tga_pixels[] = {0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255};
|
||||||
|
tga.insert(tga.end(), tga_pixels, tga_pixels + 12);
|
||||||
|
const auto decoded_tga = render::decode_tga(tga);
|
||||||
|
check(decoded_tga.width() == 2U && decoded_tga.height() == 2U, "TGA dimensions");
|
||||||
|
check(decoded_tga.data()[0] == render::argb(255, 0, 0), "TGA top-left is red");
|
||||||
|
check(decoded_tga.data()[3] == render::argb(255, 255, 255), "TGA bottom-right is white");
|
||||||
|
|
||||||
|
const auto bmp = render::encode_bmp(decoded_tga);
|
||||||
|
check(bmp.size() > 54U && bmp[0] == 'B' && bmp[1] == 'M', "BMP header");
|
||||||
|
check(bmp[18] == 2U && bmp[19] == 0U && bmp[22] == 2U, "BMP dimensions");
|
||||||
|
|
||||||
|
// Compositing keeps the source size and accepts markers.
|
||||||
|
render::scene_options scene;
|
||||||
|
scene.world_width = 100.0;
|
||||||
|
scene.world_height = 100.0;
|
||||||
|
const std::vector<render::marker> markers{{10.0, 20.0, render::red, 3}};
|
||||||
|
const auto composed = render::compose(decoded_tga, markers, scene);
|
||||||
|
check(composed.width() == 2U && composed.height() == 2U, "compose preserves size");
|
||||||
|
|
||||||
// Skirmish: deterministic, resolves within the frame cap.
|
// Skirmish: deterministic, resolves within the frame cap.
|
||||||
skirmish::match_config config;
|
skirmish::match_config config;
|
||||||
config.seed = 42U;
|
config.seed = 42U;
|
||||||
|
|||||||
Reference in New Issue
Block a user