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
+381
View File
@@ -123,6 +123,264 @@ export namespace ra3::render {
std::vector<uint32> pixels_;
};
namespace detail {
// 8x12 bitmap font for ASCII 32..126 (baseline at row 9 so descenders fit);
// each row byte has bit 7 as the leftmost column, top row first.
inline constexpr std::array<std::array<uint8, 12>, 95> font8x8 = {{
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ' '
{0x00,0x40,0x40,0x40,0x40,0x40,0x00,0x00,0x40,0x00,0x00,0x00}, // '!'
{0x00,0x6C,0x48,0x48,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '"'
{0x00,0x14,0x14,0x28,0x7C,0x28,0x7C,0x28,0x50,0x50,0x00,0x00}, // '#'
{0x00,0x10,0x38,0x40,0x40,0x38,0x48,0x70,0x10,0x10,0x00,0x00}, // '$'
{0x00,0x20,0x50,0x20,0x0C,0x70,0x08,0x14,0x08,0x00,0x00,0x00}, // '%'
{0x00,0x00,0x00,0x18,0x20,0x20,0x54,0x48,0x34,0x00,0x00,0x00}, // '&'
{0x00,0x40,0x40,0x40,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // "'"
{0x00,0x20,0x20,0x40,0x40,0x40,0x40,0x40,0x40,0x20,0x20,0x00}, // '('
{0x00,0x40,0x40,0x20,0x20,0x20,0x20,0x20,0x20,0x40,0x40,0x00}, // ')'
{0x00,0x10,0x7C,0x10,0x28,0x08,0x00,0x00,0x00,0x00,0x00,0x00}, // '*'
{0x00,0x00,0x08,0x08,0x08,0x7F,0x08,0x08,0x08,0x00,0x00,0x00}, // '+'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x20,0x60,0x40,0x00}, // ','
{0x00,0x00,0x00,0x00,0x00,0x7C,0x00,0x00,0x00,0x00,0x00,0x00}, // '-'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // '.'
{0x00,0x04,0x04,0x08,0x08,0x10,0x10,0x20,0x20,0x40,0x00,0x00}, // '/'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '0'
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // '1'
{0x00,0x38,0x44,0x04,0x08,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // '2'
{0x00,0x38,0x44,0x04,0x18,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '3'
{0x00,0x0C,0x14,0x14,0x24,0x44,0x7E,0x04,0x0E,0x00,0x00,0x00}, // '4'
{0x00,0x3C,0x20,0x20,0x38,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '5'
{0x00,0x1C,0x20,0x40,0x78,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '6'
{0x00,0x7C,0x44,0x04,0x08,0x08,0x08,0x10,0x10,0x00,0x00,0x00}, // '7'
{0x00,0x38,0x44,0x44,0x38,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '8'
{0x00,0x38,0x44,0x44,0x44,0x3C,0x04,0x08,0x70,0x00,0x00,0x00}, // '9'
{0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // ':'
{0x00,0x00,0x00,0x30,0x30,0x00,0x00,0x30,0x60,0x40,0x00,0x00}, // ';'
{0x00,0x06,0x08,0x30,0x40,0x30,0x08,0x06,0x00,0x00,0x00,0x00}, // '<'
{0x00,0x00,0x00,0x00,0x7C,0x00,0x7C,0x00,0x00,0x00,0x00,0x00}, // '='
{0x00,0x60,0x10,0x0C,0x02,0x0C,0x10,0x60,0x00,0x00,0x00,0x00}, // '>'
{0x00,0x00,0x38,0x44,0x04,0x08,0x10,0x00,0x30,0x00,0x00,0x00}, // '?'
{0x38,0x44,0x44,0x4C,0x54,0x54,0x4C,0x40,0x44,0x38,0x00,0x00}, // '@'
{0x00,0x18,0x08,0x14,0x14,0x14,0x3E,0x22,0x77,0x00,0x00,0x00}, // 'A'
{0x00,0x7C,0x22,0x22,0x3C,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'B'
{0x00,0x3C,0x44,0x40,0x40,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'C'
{0x00,0x78,0x24,0x22,0x22,0x22,0x22,0x24,0x78,0x00,0x00,0x00}, // 'D'
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x22,0x7E,0x00,0x00,0x00}, // 'E'
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x20,0x70,0x00,0x00,0x00}, // 'F'
{0x00,0x3C,0x44,0x40,0x40,0x4E,0x44,0x44,0x38,0x00,0x00,0x00}, // 'G'
{0x00,0x77,0x22,0x22,0x3E,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'H'
{0x00,0x7C,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'I'
{0x00,0x3C,0x08,0x08,0x08,0x48,0x48,0x48,0x30,0x00,0x00,0x00}, // 'J'
{0x00,0x77,0x22,0x24,0x28,0x38,0x24,0x22,0x73,0x00,0x00,0x00}, // 'K'
{0x00,0x70,0x20,0x20,0x20,0x20,0x24,0x24,0x7C,0x00,0x00,0x00}, // 'L'
{0x00,0x77,0x36,0x36,0x2A,0x2A,0x22,0x22,0x77,0x00,0x00,0x00}, // 'M'
{0x00,0x77,0x32,0x32,0x2A,0x2A,0x2A,0x26,0x76,0x00,0x00,0x00}, // 'N'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'O'
{0x00,0x78,0x24,0x24,0x24,0x38,0x20,0x20,0x70,0x00,0x00,0x00}, // 'P'
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x1C,0x00,0x00}, // 'Q'
{0x00,0x7C,0x22,0x22,0x22,0x3C,0x24,0x22,0x71,0x00,0x00,0x00}, // 'R'
{0x00,0x34,0x4C,0x40,0x38,0x04,0x04,0x64,0x58,0x00,0x00,0x00}, // 'S'
{0x00,0x7F,0x49,0x08,0x08,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'T'
{0x00,0x77,0x22,0x22,0x22,0x22,0x22,0x22,0x1C,0x00,0x00,0x00}, // 'U'
{0x00,0x77,0x22,0x22,0x14,0x14,0x14,0x08,0x08,0x00,0x00,0x00}, // 'V'
{0x00,0x77,0x22,0x22,0x2A,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'W'
{0x00,0x63,0x22,0x14,0x08,0x08,0x14,0x22,0x63,0x00,0x00,0x00}, // 'X'
{0x00,0x77,0x22,0x14,0x14,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'Y'
{0x00,0x7C,0x44,0x08,0x10,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'Z'
{0x00,0x70,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x70,0x00}, // '['
{0x00,0x00,0x40,0x40,0x40,0x20,0x20,0x10,0x10,0x00,0x00,0x00}, // '\\'
{0x00,0x70,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x70,0x00}, // ']'
{0x00,0x10,0x10,0x28,0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '^'
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7F}, // '_'
{0x00,0x40,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '`'
{0x00,0x00,0x00,0x38,0x04,0x3C,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'a'
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'b'
{0x00,0x00,0x00,0x3C,0x44,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'c'
{0x00,0x0C,0x04,0x34,0x4C,0x44,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'd'
{0x00,0x00,0x00,0x38,0x44,0x7C,0x40,0x40,0x3C,0x00,0x00,0x00}, // 'e'
{0x00,0x1C,0x20,0x7C,0x20,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'f'
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x38,0x00}, // 'g'
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'h'
{0x00,0x10,0x00,0x70,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'i'
{0x00,0x10,0x00,0x78,0x08,0x08,0x08,0x08,0x08,0x08,0x70,0x00}, // 'j'
{0x00,0x60,0x20,0x2E,0x24,0x38,0x28,0x24,0x6E,0x00,0x00,0x00}, // 'k'
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'l'
{0x00,0x00,0x00,0x74,0x2A,0x2A,0x2A,0x2A,0x7F,0x00,0x00,0x00}, // 'm'
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'n'
{0x00,0x00,0x00,0x38,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'o'
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x3C,0x20,0x70,0x00}, // 'p'
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x0E,0x00}, // 'q'
{0x00,0x00,0x00,0x6C,0x30,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'r'
{0x00,0x00,0x00,0x3C,0x44,0x38,0x04,0x44,0x78,0x00,0x00,0x00}, // 's'
{0x00,0x00,0x20,0x7C,0x20,0x20,0x20,0x22,0x1C,0x00,0x00,0x00}, // 't'
{0x00,0x00,0x00,0x66,0x22,0x22,0x22,0x26,0x1B,0x00,0x00,0x00}, // 'u'
{0x00,0x00,0x00,0x77,0x22,0x22,0x14,0x14,0x08,0x00,0x00,0x00}, // 'v'
{0x00,0x00,0x00,0x77,0x22,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'w'
{0x00,0x00,0x00,0x66,0x24,0x18,0x18,0x24,0x66,0x00,0x00,0x00}, // 'x'
{0x00,0x00,0x00,0x77,0x22,0x12,0x14,0x0C,0x08,0x08,0x3C,0x00}, // 'y'
{0x00,0x00,0x00,0x7C,0x48,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'z'
{0x00,0x10,0x20,0x20,0x20,0x20,0x40,0x20,0x20,0x20,0x10,0x00}, // '{'
{0x00,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x00,0x00}, // '|'
{0x00,0x40,0x20,0x20,0x20,0x20,0x10,0x20,0x20,0x20,0x40,0x00}, // '}'
{0x00,0x00,0x00,0x00,0x00,0x24,0x58,0x00,0x00,0x00,0x00,0x00}, // '~'
}};
inline constexpr uint32 glyph_width = 8;
inline constexpr uint32 glyph_height = 12;
}
/** Pixel width of `text` drawn at `scale`. */
[[nodiscard]] inline auto text_width(std::string_view text, uint32 scale = 1U) -> uint32 {
return static_cast<uint32>(text.size()) * detail::glyph_width * std::max(1U, scale);
}
/** Draw `text` with its top-left at `(x, y)`, glyphs scaled by `scale`. */
inline auto draw_text(image &target, int x, int y, std::string_view text, uint32 color, uint32 scale = 1U) -> void {
scale = std::max(1U, scale);
int cursor = x;
for (const auto ch: text) {
const auto code = static_cast<unsigned char>(ch);
if (code >= 32U && code < 127U) {
const auto &glyph = detail::font8x8[code - 32U];
for (uint32 gy = 0; gy < detail::glyph_height; ++gy) {
const auto bits = glyph[gy];
if (bits == 0U) continue;
for (uint32 gx = 0; gx < detail::glyph_width; ++gx) {
if ((bits & (1U << (7U - gx))) == 0U) continue;
for (uint32 sy = 0; sy < scale; ++sy) {
for (uint32 sx = 0; sx < scale; ++sx) {
target.set(cursor + static_cast<int>(gx * scale + sx), y + static_cast<int>(gy * scale + sy), color);
}
}
}
}
}
cursor += static_cast<int>(detail::glyph_width * scale);
}
}
/**
* Backend-agnostic input for the interactive loops. The windowing backends
* (`ra3.vulkan` / `ra3.ui`) translate their native events into this and hand
* it to the shared `ra3::client::display` loops.
*/
enum class ui_key : uint8 { none, up, down, left, right, page_up, page_down, confirm, cancel, tab, backspace };
enum class ui_event_type : uint8 { none, quit, key, text, mouse_move, mouse_button, wheel };
struct ui_event {
ui_event_type type = ui_event_type::none;
ui_key key = ui_key::none;
char character = '\0';
float x = 0.0F;
float y = 0.0F;
float dx = 0.0F;
float dy = 0.0F;
float wheel = 0.0F;
bool left = false;
};
/** A warm red/gold loading screen with a progress bar (0..1). */
[[nodiscard]] inline auto compose_progress(uint32 width, uint32 height, float progress, std::string_view label) -> image {
const auto background = argb(34, 9, 8);
const auto panel = argb(58, 14, 12);
const auto edge = argb(120, 30, 22);
const auto gold = argb(236, 190, 80);
const auto cream = argb(246, 228, 192);
image img(width, height, background);
const auto k = static_cast<double>(height) / 720.0;
const auto S = [k](double v) { return static_cast<int>(std::lround(v * k)); };
const auto scale = static_cast<uint32>(std::max(1, static_cast<int>(std::lround(2.0 * k))));
const auto t = std::clamp(progress, 0.0F, 1.0F);
const auto title = std::string_view{"OpenRA3"};
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(title, scale + 1U))) / 2, S(250), title, gold, scale + 1U);
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(label, scale))) / 2, S(300), label, cream, scale);
const int bar_x = S(240);
const int bar_y = S(360);
const int bar_w = static_cast<int>(width) - 2 * bar_x;
const int bar_h = S(28);
img.draw_rect(bar_x - S(3), bar_y - S(3), bar_w + S(6), bar_h + S(6), edge);
img.draw_rect(bar_x, bar_y, bar_w, bar_h, panel);
img.draw_rect(bar_x, bar_y, static_cast<int>(static_cast<float>(bar_w) * t), bar_h, gold);
char percent[32];
std::snprintf(percent, sizeof(percent), "%d%%", static_cast<int>(t * 100.0F + 0.5F));
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(percent, scale))) / 2, bar_y + bar_h + S(18), percent, cream, scale);
return img;
}
/**
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`.
* `cap == 0` means vertical sync, `cap < 0` means uncapped.
*/
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image {
char text[64];
if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
} else if (cap < 0) {
std::snprintf(text, sizeof(text), "FPS: %u", fps);
} else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
}
const auto w = text_width(text, 1U) + 8U;
const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U);
return img;
}
/**
* Box-downsample `source` so its width is at most `max_width` (aspect
* preserved). Returns a copy when it is already small enough.
*/
[[nodiscard]] inline auto downscale(const image &source, uint32 max_width) -> image {
if (source.empty() || source.width() <= max_width) return source;
const auto scale = static_cast<uint32>(std::ceil(static_cast<double>(source.width()) / static_cast<double>(max_width)));
const auto w = std::max(1U, source.width() / scale);
const auto h = std::max(1U, source.height() / scale);
image out(w, h);
for (uint32 y = 0; y < h; ++y) {
for (uint32 x = 0; x < w; ++x) {
uint32 r = 0;
uint32 g = 0;
uint32 b = 0;
uint32 n = 0;
for (uint32 dy = 0; dy < scale; ++dy) {
for (uint32 dx = 0; dx < scale; ++dx) {
const auto sx = std::min(source.width() - 1U, x * scale + dx);
const auto sy = std::min(source.height() - 1U, y * scale + dy);
const auto px = source.data()[static_cast<usize>(sy) * source.width() + sx];
r += (px >> 16U) & 0xFFU;
g += (px >> 8U) & 0xFFU;
b += px & 0xFFU;
++n;
}
}
out.set(static_cast<int>(x), static_cast<int>(y), argb(static_cast<uint8>(r / n), static_cast<uint8>(g / n), static_cast<uint8>(b / n)));
}
}
return out;
}
/**
* Copy `full` (a whole-map overview) and mark the camera's location with a
* gold rectangle. `u`/`v` are the normalized map position (v is y-down).
*/
[[nodiscard]] inline auto compose_minimap(const image &full, float u, float v) -> image {
image out = full;
if (full.empty()) return out;
const int r = std::max(4, static_cast<int>(full.width()) / 48);
const int x = std::clamp(static_cast<int>(u * static_cast<float>(full.width())), r, static_cast<int>(full.width()) - r - 1);
const int y = std::clamp(static_cast<int>(v * static_cast<float>(full.height())), r, static_cast<int>(full.height()) - r - 1);
const auto gold = argb(240, 200, 90);
out.draw_rect(x - r, y - r, 2 * r, 2, gold);
out.draw_rect(x - r, y + r - 2, 2 * r, 2, gold);
out.draw_rect(x - r, y - r, 2, 2 * r, gold);
out.draw_rect(x + r - 2, y - r, 2, 2 * r, gold);
return out;
}
[[nodiscard]] inline auto read_u16(std::span<const uint8> data, usize at) -> uint32 {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U);
}
@@ -297,4 +555,127 @@ export namespace ra3::render {
}
return scene;
}
/**
* The destination of the map image on screen, in window coordinates.
*
* `x`/`y` is the top-left corner and `w`/`h` the size, in the same units as
* the window. The viewer draws `image * w` into this rectangle; whatever is
* left of the window stays background, so the map is letterboxed instead of
* stretched.
*/
struct view_rect {
float x = 0.0F;
float y = 0.0F;
float w = 0.0F;
float h = 0.0F;
};
/** Letterbox an `iw`x`ih` image into a `ww`x`wh` window, preserving aspect. */
[[nodiscard]] inline auto fit_rect(float iw, float ih, float ww, float wh) -> view_rect {
if (iw <= 0.0F || ih <= 0.0F || ww <= 0.0F || wh <= 0.0F) return {};
const auto scale = std::min(ww / iw, wh / ih);
const auto w = iw * scale;
const auto h = ih * scale;
return {(ww - w) * 0.5F, (wh - h) * 0.5F, w, h};
}
/**
* A 2D pan/zoom camera over a raster map, reproducing the Red Alert 3
* tactical view controls:
*
* - the wheel zooms (zoom 1 === the whole map fits the window);
* - pushing the cursor against a screen edge scrolls the view;
* - the view is clamped so it never leaves the map.
*
* `center_x`/`center_y` are the normalized image position (0..1, y down)
* held at the centre of the viewport. The camera mirrors the retail view
* object (`TheTacticalView`, retail `ra3_1.12.game` `0x00cdb7b4`), whose
* zoom is the scalar the debug overlay prints at `0x00c0b900`; the
* per-map scroll scaling is `cameraScrollSpeedScalar` (map data table at
* `0x00c11a54`).
*/
struct view_camera {
float zoom = 1.0F;
float center_x = 0.5F;
float center_y = 0.5F;
float min_zoom = 1.0F;
float max_zoom = 24.0F;
float zoom_step = 1.15F;
float edge_scroll_viewports_per_second = 0.55F; ///< speed of a full edge push
float edge_margin = 24.0F; ///< pixels from the border
/** Keep the visible window inside the image. */
auto clamp_center() -> void {
const float half = 0.5F / zoom;
center_x = std::clamp(center_x, half, 1.0F - half);
center_y = std::clamp(center_y, half, 1.0F - half);
}
/** Zoom by `factor` (wheel up > 1) about the viewport centre. */
auto zoom_by(float factor) -> void {
zoom = std::clamp(zoom * factor, min_zoom, max_zoom);
this->clamp_center();
}
/** Scroll directly by a normalized image delta. */
auto scroll(float dx, float dy) -> void {
center_x += dx;
center_y += dy;
this->clamp_center();
}
/**
* Push the camera when the cursor `(mouse_x, mouse_y)` is within
* `edge_margin` of a window edge. `dt` is the frame time in seconds.
*/
auto edge_scroll(float mouse_x, float mouse_y, float window_w, float window_h, float dt) -> void {
if (zoom <= min_zoom + 1.0e-4F || window_w <= 0.0F || window_h <= 0.0F) return;
float dir_x = 0.0F;
float dir_y = 0.0F;
if (mouse_x <= edge_margin) {
dir_x = -1.0F;
} else if (mouse_x >= window_w - edge_margin) {
dir_x = 1.0F;
}
if (mouse_y <= edge_margin) {
dir_y = -1.0F;
} else if (mouse_y >= window_h - edge_margin) {
dir_y = 1.0F;
}
if (dir_x == 0.0F && dir_y == 0.0F) return;
const auto step = (1.0F / zoom) * edge_scroll_viewports_per_second * dt;
this->scroll(dir_x * step, dir_y * step);
}
/** Resolve the image destination rectangle for a window of `window_w` x `window_h`. */
[[nodiscard]] auto rect(float image_w, float image_h, float window_w, float window_h) const -> view_rect {
if (image_w <= 0.0F || image_h <= 0.0F) return {};
const auto fit = std::min(window_w / image_w, window_h / image_h);
const auto scale = fit * zoom;
const auto w = image_w * scale;
const auto h = image_h * scale;
auto x = window_w * 0.5F - w * center_x;
auto y = window_h * 0.5F - h * center_y;
x = w <= window_w ? (window_w - w) * 0.5F : std::clamp(x, window_w - w, 0.0F);
y = h <= window_h ? (window_h - h) * 0.5F : std::clamp(y, window_h - h, 0.0F);
return {x, y, w, h};
}
};
/**
* A perspective camera aimed at a ground target, like the retail tactical
* view. The wheel changes `height` (moving the camera closer/farther), not
* an image scale; `yaw`/`pitch` orbit it.
*/
struct camera3d {
float target_x = 0.0F; ///< World position the camera looks at.
float target_y = 0.0F;
float yaw = 0.0F; ///< Radians; 0 looks toward +Y.
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
float height = 420.0F; ///< Camera height above the target's ground.
float fov = 0.85F; ///< Vertical field of view, radians.
float min_height = 120.0F;
float max_height = 1600.0F;
};
}