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