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

- ra3.client::display: shared interactive loops; SDL and Vulkan backends
  implement only the primitives (init/present/poll_event/window_size/
  key_down/present_terrain). ra3.display picks the backend.
- Menu: maps by localized name (gamestrings.csf), red/gold theme, hover
  highlight, mouse + keyboard, wheel scroll, fullscreen and FPS/vsync
  options, loading progress bar.
- Terrain: continuous tile sampling via a texture array (REPEAT, uv =
  cell/(2*cellSize)) removes per-cell grid seams; SAGE blend ramp for
  material transitions; FPS label + top-right minimap overlays.
- Skip the skirmish sim for map views; reuse the Vulkan texture; no idle
  terrain redraw.
This commit is contained in:
EnderTheCoder
2026-09-20 02:20:39 +08:00
parent 702b4e29aa
commit b6d619254d
66 changed files with 37661 additions and 406 deletions
+90
View File
@@ -81,6 +81,25 @@ auto main() -> int {
check(game::to_string(game::faction::soviet) == "Soviet", "faction names round-trip");
// RA3 data: damage types, armour resolution and weapon target masks.
check(data::to_string(data::damage_type::auto_cannon) == "AUTO_CANNON", "damage type names round-trip");
check(data::damage_type_from_name("PRISM") == data::damage_type::prism, "damage type parses from asset name");
check(data::allied_scout_infantry_armor.adjust(data::damage_type::gun, 100.0F) == 1.0F, "scout armour takes 1% from GUN");
check(data::allied_scout_infantry_armor.adjust(data::damage_type::tesla, 100.0F) == 50.0F, "scout armour takes 50% from TESLA");
check(data::allied_anti_infantry_infantry_armor.adjust(data::damage_type::tesla, 100.0F) == 1000.0F, "peacekeeper armour takes 10x from TESLA");
check(data::allied_anti_infantry_infantry_armor.adjust(data::damage_type::unresistable, 100.0F) == 100.0F, "UNRESISTABLE bypasses armour");
check(data::maul_weapon.can_target(data::target_class::infantry), "the maul can target infantry");
check(!data::maul_weapon.can_target(data::target_class::vehicle), "the maul cannot target vehicles");
check(!data::maul_weapon.can_target(data::target_class::structure), "the maul cannot target structures");
check(!data::maul_weapon.can_target(data::target_class::aircraft), "the maul cannot target aircraft");
check(data::shotgun_weapon.can_target(data::target_class::infantry) && data::shotgun_weapon.can_target(data::target_class::structure),
"the shotgun hits infantry and structures");
check(!data::shotgun_weapon.can_target(data::target_class::aircraft), "the shotgun cannot hit aircraft");
check(data::cost_of(data::entity_kind::guardian_tank) == 950, "guardian tank costs 950");
check(data::max_health_of(data::entity_kind::power_plant) == 1000.0F, "power plant has 1000 health");
check(data::structure_of(data::entity_kind::power_plant).energy == 100, "power plant supplies 100 energy");
check(data::seconds_to_frames(1.0F) == 30U, "one second is 30 logic frames");
// RefPack: a literal-only stream decodes to its payload.
const std::vector<core::uint8> refpack{0x10, 0xFB, 0x00, 0x00, 0x03, 0xFF, 'A', 'B', 'C'};
check(fs::is_refpack(refpack), "literal RefPack stream is recognised");
@@ -120,11 +139,36 @@ auto main() -> int {
const auto composed = render::compose(decoded_tga, markers, scene);
check(composed.width() == 2U && composed.height() == 2U, "compose preserves size");
// Camera: wheel zoom, clamped pan and RA3-style edge scrolling.
render::view_camera camera;
camera.zoom = 2.0F;
camera.center_x = 0.5F;
camera.center_y = 0.5F;
camera.scroll(10.0F, 10.0F);
check(camera.center_x == 0.75F && camera.center_y == 0.75F, "camera clamps the centre to the map");
camera.zoom_by(100.0F);
check(camera.zoom == camera.max_zoom, "camera clamps the maximum zoom");
camera.zoom_by(0.001F);
check(camera.zoom == camera.min_zoom, "camera clamps the minimum zoom");
const auto fit_rect = camera.rect(512.0F, 512.0F, 1024.0F, 768.0F);
check(fit_rect.w == 768.0F && fit_rect.h == 768.0F && fit_rect.x == 128.0F && fit_rect.y == 0.0F, "camera letterboxes a square map into a wide window");
render::view_camera edge;
edge.zoom = 2.0F;
edge.center_x = 0.5F;
edge.center_y = 0.5F;
edge.edge_scroll(0.0F, 0.0F, 1024.0F, 768.0F, 1.0F);
check(edge.center_x < 0.5F && edge.center_y < 0.5F, "the cursor at the top-left edge scrolls up-left");
render::view_camera whole;
whole.edge_scroll(0.0F, 0.0F, 1024.0F, 768.0F, 1.0F);
check(whole.center_x == 0.5F && whole.center_y == 0.5F, "no edge scroll while the whole map fits");
// Skirmish: deterministic, resolves within the frame cap.
skirmish::match_config config;
config.seed = 42U;
const auto starts = skirmish::builtin_start_positions();
auto first = skirmish::skirmish_match::create(config, starts);
check(first.unit_count(0U) == 1U && first.unit_count(1U) == 1U, "each side starts with a construction yard");
const auto first_result = first.run();
auto second = skirmish::skirmish_match::create(config, starts);
const auto second_result = second.run();
@@ -132,6 +176,52 @@ auto main() -> int {
check(first_result.winner == 0 || first_result.winner == 1, "skirmish has a winner");
check(first_result.frames == second_result.frames && first_result.winner == second_result.winner, "skirmish is deterministic");
// Text/font: glyphs paint, spaces are blank and widths scale.
check(render::text_width("ABC", 2U) == 48U, "text width scales with glyph size");
render::image text_image(64U, 16U, render::black);
render::draw_text(text_image, 0, 0, "A", render::white, 2U);
int lit = 0;
for (std::size_t i = 0; i < 64U * 16U; ++i) {
if (text_image.data()[i] != render::black) ++lit;
}
check(lit > 0, "draw_text paints glyph pixels");
render::image space_image(64U, 16U, render::black);
render::draw_text(space_image, 0, 0, " ", render::white, 2U);
bool space_lit = false;
for (std::size_t i = 0; i < 64U * 16U; ++i) {
if (space_image.data()[i] != render::black) space_lit = true;
}
check(!space_lit, "a space paints nothing");
const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F);
check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window");
// Map display names: decode a synthetic `gamestrings.csf` `MAP:` label.
std::vector<core::uint8> csf;
const auto put_le32 = [&csf](std::uint32_t v) {
for (int i = 0; i < 4; ++i) csf.push_back(static_cast<core::uint8>(v >> (8 * i)));
};
csf.insert(csf.end(), {' ', 'F', 'S', 'C'});
put_le32(3U);
put_le32(1U);
put_le32(1U);
for (int i = 0; i < 8; ++i) csf.push_back(0U);
csf.insert(csf.end(), {' ', 'L', 'B', 'L'});
put_le32(1U);
const std::string csf_label = "MAP:MAP_MP_2_FEASEL4";
put_le32(static_cast<std::uint32_t>(csf_label.size()));
csf.insert(csf.end(), csf_label.begin(), csf_label.end());
csf.insert(csf.end(), {' ', 'R', 'T', 'S'});
const std::string csf_value = "Battlebase Beta";
put_le32(static_cast<std::uint32_t>(csf_value.size()));
for (const auto ch: csf_value) {
csf.push_back(static_cast<core::uint8>(ch ^ 0xFF));
csf.push_back(0xFFU);
}
const auto names = map::parse_map_names(csf);
check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)");
check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself");
if (failures == 0) {
std::puts("ra3_tests: OK");
}