Files
ReSkate/Extension/UI/Overlay/map_download_card.cpp
T
AyeZeeBB e344d585f4 Vote for the next map when a map's time is up
A dedicated server that rotates its maps can now ask players where to
go next instead of taking the pool's next map.

Server
- maps.rotation_vote: when the timer runs out, a vote between three
  maps drawn at random from the pool, "Random map" and "Stay here".
  The most votes wins, a tie is drawn, and with no votes it is the
  pool's next map. It ends early once everyone has voted.
- maps.rotation_vote_seconds (10-120, default 30) and
  maps.rotation_extend_minutes (0-240, default 15; 0 leaves "Stay
  here" out).
- maps.rotation_announce: a card when the map has 10, 5 and 1 minutes
  left.
- The vote's maps are drawn a minute ahead and sent to games
  (PacketKind::next_maps), so their pictures are ready when it opens.
- /vote extend: a built-in vote that gives the map in play
  rotation_extend_minutes more. On by default (votes.extend_map), and
  only runs while the maps are on a timer. "extend" is now a reserved
  vote name.
- Console: rotation vote on|off, rotation announce on|off,
  rotation stay <minutes>|off, rotation extend [minutes],
  votes extend ..., vote extend.

Game
- The vote opens as a popup in the middle of the screen: a tile per
  answer with its picture, name, share of the votes and count. It
  holds input as the map download card does, and works with a
  controller (D-pad, A, B), the keyboard and the mouse.
- Pictures: a mod map's icon from its folder, or from Thunderstore
  when it is not installed; the game's own maps show their loading
  screen picture, cut to its middle square. "Random map", "Stay here"
  and a map without a picture are drawn as shapes.
- Older servers and polls are unchanged: the vote is a poll, so it can
  still be answered with /1, /2... in chat.

Protocol 50: games and servers both need this build.
2026-10-11 17:21:27 -05:00

571 lines
34 KiB
C++

#include "overlay_internal.h"
#include "Engine/Game/Input/controller_bindings.h"
#include <format>
#include <mutex>
#include <wincodec.h>
// The card for a map a multiplayer session needs and this PC does not have
// (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author,
// version and description, then what is being asked or done. It asks whether to download it,
// in the middle of the screen with the controls under it as the game's own menus have them.
// Once the player has said yes it becomes a small card at the right edge, with the download,
// the install and the apply, so they can skate on while they wait; it goes when the map is in
// the game and starts loading. Both in the look of ReSkate's pages in the pause menu (hub_page.cpp).
//
// Answered by a controller (D-pad to pick, A to take the pick, B for no), the keyboard (arrow
// keys, Enter, Esc), the vote binds, or a click: the overlay has the pointer while
// the card asks (State::prompt_pointer). The runtime reads those and holds the game's input meanwhile.
//
// The icon is the package's own, a PNG the downloader hands over (set_map_download_icon). It
// is decoded here and goes to the GPU from the render thread (overlay_render.cpp), into the
// overlay's second texture slot; the first is ImGui's atlas, which is filled once at startup.
namespace dingosdk::overlay {
namespace {
using Microsoft::WRL::ComPtr;
std::atomic<MapDownloadFeed> feed{};
std::atomic<MapDownloadAnswer> answer{};
std::mutex image_mutex;
detail::CardPixels current; // the icon in use, kept to upload again after a device reset
bool waiting{}; // `current` has not gone to the GPU yet
unsigned image_serial{};
std::atomic<ImTextureID> image{}; // on the GPU and drawable; 0 for none
// The texture is the next-map vote's pictures (set_map_vote_icons), not the card's icon: the
// one slot is whichever was set last. Which square is whose, under image_mutex.
std::atomic<bool> image_is_atlas{};
std::vector<std::string> vote_icon_names;
std::vector<bool> vote_icon_has;
ImTextureID card_icon() { return image_is_atlas.load() ? ImTextureID{} : image.load(); }
// Straight-alpha RGBA, at most 512 pixels a side (an icon is 256). With `side`, scaled to a
// square of that many pixels (the vote's pictures share one texture).
bool decode(const std::string& bytes, detail::CardPixels& out, UINT side = 0) {
const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
bool ok{};
{
ComPtr<IWICImagingFactory> factory;
ComPtr<IWICStream> stream;
ComPtr<IWICBitmapDecoder> decoder;
ComPtr<IWICBitmapFrameDecode> frame;
ComPtr<IWICFormatConverter> converter;
ComPtr<IWICBitmapScaler> scaler;
UINT width{}, height{};
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory))) &&
SUCCEEDED(factory->CreateStream(&stream)) &&
SUCCEEDED(stream->InitializeFromMemory(reinterpret_cast<BYTE*>(const_cast<char*>(bytes.data())), static_cast<DWORD>(bytes.size()))) &&
SUCCEEDED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnDemand, &decoder)) &&
SUCCEEDED(decoder->GetFrame(0, &frame)) && SUCCEEDED(frame->GetSize(&width, &height)) && width && height &&
width <= (side ? 2048U : 512U) && height <= (side ? 2048U : 512U) && SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
(!side || (SUCCEEDED(factory->CreateBitmapScaler(&scaler)) &&
SUCCEEDED(scaler->Initialize(frame.Get(), side, side, WICBitmapInterpolationModeFant)))) &&
SUCCEEDED(converter->Initialize(side ? static_cast<IWICBitmapSource*>(scaler.Get()) : static_cast<IWICBitmapSource*>(frame.Get()),
GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom))) {
if (side) width = height = side;
out.rgba.resize(static_cast<std::size_t>(width) * height * 4);
out.width = width;
out.height = height;
ok = SUCCEEDED(converter->CopyPixels(nullptr, width * 4, static_cast<UINT>(out.rgba.size()), out.rgba.data()));
}
}
if (SUCCEEDED(com)) CoUninitialize();
return ok;
}
}
void set_map_download_feed(MapDownloadFeed value) noexcept { feed.store(value); }
void set_map_download_answer(MapDownloadAnswer value) noexcept { answer.store(value); }
void set_map_download_icon(const std::string& png) noexcept {
try {
detail::CardPixels pixels;
const bool decoded = !png.empty() && png.size() <= 4 * 1024 * 1024 && decode(png, pixels);
std::lock_guard lock(image_mutex);
image.store(0);
image_is_atlas.store(false);
pixels.serial = ++image_serial;
current = decoded ? std::move(pixels) : detail::CardPixels{};
current.serial = image_serial;
waiting = decoded;
} catch (...) {}
}
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
bool take_card_pixels(CardPixels& out) {
std::lock_guard lock(image_mutex);
if (!waiting) return false;
waiting = false;
out = current; // (kept: a device reset uploads it again)
return true;
}
void card_image_uploaded(ImTextureID texture, unsigned serial) {
std::lock_guard lock(image_mutex);
if (serial == image_serial) image.store(texture);
}
void card_image_lost() {
std::lock_guard lock(image_mutex);
image.store(0);
waiting = !current.rgba.empty();
}
namespace {
MapDownloadCard card; // this frame's, read by map_download_pending
ImU32 faded(ImU32 colour, float alpha) {
const auto base = static_cast<float>((colour >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
return (colour & ~IM_COL32_A_MASK) | (static_cast<ImU32>(base * std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f) << IM_COL32_A_SHIFT);
}
std::string megabytes(std::uint64_t bytes) {
const auto mb = static_cast<double>(bytes) / (1024.0 * 1024.0);
return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : std::format("{:.0f} MB", mb);
}
}
bool map_download_pending() {
const auto read = feed.load();
card = read ? read() : MapDownloadCard{};
// (Nothing is shown once the map is in the game: its loading has the game's own screen.)
if (card.stage == 5) card.stage = 0;
state().prompt_pointer.store(card.stage == 1 || map_vote_asking());
return card.stage != 0;
}
namespace {
// After the player said yes: a small card at the right edge, out of the way of skating, with
// what is being fetched and how far it is.
void draw_progress(State& s) {
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
const bool downloading = card.stage == 2, applying = card.stage == 4;
const char* title = downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP" : "APPLYING MAP";
const std::string status = downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...";
const float width = 820 * u, inset = 30 * u, icon = 132 * u, between = 26 * u, bar = 12 * u;
const float label_size = 24 * u, name_size = 38 * u, text_size = 26 * u;
const float column = width - inset * 2 - icon - between;
const float height = inset + std::max(icon, label_size + 12 * u + name_size + 18 * u + bar + 14 * u + text_size) + inset;
// Beside where a vote's card goes (the middle of the right edge): above it.
const ImVec2 min(std::floor(display.x - 48 * u - width), std::floor(display.y * 0.2f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 235), 0x4d2, u * 2);
const float left = min.x + inset, top = min.y + inset;
const ImVec2 icon_min(left, top), icon_max(left + icon, top + icon);
if (const auto texture = card_icon()) draw->AddImage(texture, icon_min, icon_max);
else skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c1, u * 2);
const float x = left + icon + between, right = max.x - inset;
float y = top;
draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title);
y += label_size + 12 * u;
draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true);
draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, card.map.c_str());
draw->PopClipRect();
y += name_size + 18 * u;
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (applying && card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(x, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(x, y), ImVec2(x + column * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (column * 0.75f);
draw->AddRectFilled(ImVec2(x + where, y), ImVec2(x + where + column * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 14 * u;
draw->PushClipRect(ImVec2(x, y - text_size), ImVec2(right, y + text_size * 2), true);
draw->AddText(body, text_size, ImVec2(x, y), theme::muted, status.c_str());
draw->PopClipRect();
}
}
void draw_map_download() {
if (!card.stage) return;
auto& s = state();
if (card.stage != 1) return draw_progress(s);
// Drawn as the pages of the game's pause menu are (hub_page.cpp): the same unit (one of
// the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to
// press and the keycaps under it.
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
auto* heading = s.menu.heading ? s.menu.heading : bold;
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_dim = IM_COL32(150, 140, 86, 255);
const bool asking = card.stage == 1, downloading = card.stage == 2, applying = card.stage == 4;
const bool clickable = asking || s.visible.load() || s.console_visible.load();
const char* host = card.server ? "server" : "host";
const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; };
const char* title = asking ? (!card.have.empty() ? "MAP VERSION" : card.moved ? "MAP CHANGED" : "MAP NEEDED") : downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP"
: applying ? "APPLYING MAP" : "JOINING";
// Why it is asked: the session moved here with the player in it, or the player is joining.
const auto why = card.moved ? std::format("The {} changed to this map, and you need it to stay in the session.", host)
: std::format("The {} is on this map, and you need it to join.", host);
const std::string status = asking ? (!card.have.empty() ? std::format("The {} runs version {} of this map and you have {}. Update to its version?", host, card.version, card.have)
: why + (card.installed ? " You have it, switched off. Switch it on?" : " Download it?"))
: downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: applying ? (card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...")
: std::string("Loading the map...");
struct Fact { const char* name; std::string value; };
std::vector<Fact> facts{{"Author", card.author}};
if (!card.version.empty()) facts.push_back({"Version", card.version});
facts.push_back({"From", card.installed ? std::string("Your Mods folder, switched off") : std::string("Thunderstore")});
// The controls under the card: the pad's own names for its buttons when one is connected.
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad, true);
struct Hint { std::string key; const char* what; };
std::vector<Hint> hints;
if (asking) {
hints.push_back({pad.available ? std::string("D-pad") : std::string("< >"), "Pick"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Select"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Cancel"});
}
const float key_scale = u * 2, hint_gap = 36 * u;
float hints_width{};
for (const auto& hint : hints)
hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what);
if (!hints.empty()) hints_width += hint_gap * static_cast<float>(hints.size() - 1);
const float width = 1500 * u, inset = 44 * u, icon = 300 * u, between = 44 * u, tall = 110 * u, bar = 14 * u;
const float inner = width - inset * 2, column = inner - icon - between;
const float label_size = 28 * u, name_size = 64 * u, fact_size = 34 * u, text_size = 32 * u;
const auto name_extent = heading->CalcTextSizeA(name_size, FLT_MAX, column, card.map.c_str());
const auto described = card.description.empty() ? ImVec2{} : body->CalcTextSizeA(text_size, FLT_MAX, inner, card.description.c_str());
const float beside = name_extent.y + 22 * u + static_cast<float>(facts.size()) * 54 * u;
const float top_block = std::max(icon, beside);
const auto status_extent = body->CalcTextSizeA(text_size, FLT_MAX, inner, status.c_str());
const bool progress = downloading || applying;
float height = inset + label_size + 26 * u + top_block + inset + status_extent.y + inset;
if (!card.description.empty()) height += described.y + 36 * u;
if (progress) height += bar + 30 * u;
if (asking) height += 30 * u + tall;
if (downloading) height += 16 * u + label_size;
const float under = hints.empty() ? 0.0f : 34 * u + 20 * key_scale;
// In the middle while it asks; near the top, out of the way, while the player skates on.
const float top = asking ? (display.y - height - under) * 0.5f : 150 * u;
const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor(top)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
// While it asks, what is behind is dimmed: the pause menu takes no input then, nor the game.
if (asking) draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110));
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d1, u * 2);
const float left = min.x + inset, right = max.x - inset;
float y = min.y + inset;
draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, title);
y += label_size + 26 * u;
// The icon, or a plain tile until it is here (and for a mod that has none).
const ImVec2 icon_min(left, y), icon_max(left + icon, y + icon);
if (const auto texture = card_icon()) draw->AddImage(texture, icon_min, icon_max);
else {
skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c0, u * 2);
const std::string mark = "MAP";
draw->AddText(heading, 56 * u, ImVec2(left + (icon - width_of(heading, 56 * u, mark)) * 0.5f, y + (icon - 56 * u) * 0.5f), theme::muted, mark.c_str());
}
// Beside it, the map's name and what is known of the mod, as a session's facts are listed.
const float text_left = left + icon + between;
float text_y = y;
draw->AddText(heading, name_size, ImVec2(text_left, text_y), theme::paper, card.map.c_str(), nullptr, column);
text_y += name_extent.y + 22 * u;
for (const auto& fact : facts) {
draw->AddText(body, fact_size, ImVec2(text_left, text_y), theme::muted, fact.name);
draw->PushClipRect(ImVec2(text_left + 220 * u, text_y - fact_size), ImVec2(right, text_y + fact_size * 2), true);
draw->AddText(bold, fact_size, ImVec2(std::max(text_left + 220 * u, right - width_of(bold, fact_size, fact.value)), text_y), theme::paper, fact.value.c_str());
draw->PopClipRect();
text_y += 54 * u;
}
y += top_block + inset;
if (!card.description.empty()) {
draw->AddText(body, text_size, ImVec2(left, y), theme::muted, card.description.c_str(), nullptr, inner);
y += described.y + 36 * u;
}
if (progress) {
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + inner * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (inner * 0.75f);
draw->AddRectFilled(ImVec2(left + where, y), ImVec2(left + where + inner * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 30 * u;
}
draw->AddText(body, text_size, ImVec2(left, y), asking ? theme::paper : theme::muted, status.c_str(), nullptr, inner);
y += status_extent.y;
if (downloading) {
y += 16 * u;
draw->AddText(body, label_size, ImVec2(left, y), theme::muted, "You can keep skating while it downloads.");
return;
}
if (!asking) return;
y += 30 * u;
// The game's two looks for a button: the yellow note for the thing to do, a tile for the
// way out. The picked one is the lit one: the note full yellow in a blue edge, the tile blue.
const float button_width = (inner - 24 * u) * 0.5f;
const auto button = [&](float at, bool yes, unsigned seed) {
const ImVec2 a(at, y), b(at + button_width, y + tall);
const bool picked = card.choice == yes;
const bool hovered = clickable && ImGui::IsMouseHoveringRect(a, b, false);
const bool lit = picked || hovered;
const ImU32 fill = yes ? (lit ? sticky : sticky_dim) : (lit ? theme::blue : skate_theme::tile_light);
skate_theme::rough_rect(draw, a, b, fill, seed, u * 2);
if (yes && picked) draw->AddRect(a, b, theme::blue, 0, 0, 6 * u);
const std::string label = yes ? (!card.have.empty() ? "UPDATE" : card.installed ? "SWITCH ON" : "DOWNLOAD") : "CANCEL";
const float size = 40 * u;
draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, label)) * 0.5f, a.y + (tall - size) * 0.5f),
yes || lit ? skate_theme::black : theme::paper, label.c_str());
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
if (const auto reply = answer.load()) reply(yes);
};
button(left, true, 0x51);
button(left + button_width + 24 * u, false, 0x52);
// The controls, under the card and in its middle.
float x = min.x + (width - hints_width) * 0.5f;
const float hint_y = max.y + 34 * u;
for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap;
}
// ---------------------------------------------------------------- the vote for the next map
// A dedicated server's vote for the next map (Server/server_votes.cpp: the rotation's vote) is
// a poll whose answers are maps. It comes up as the maps to pick from: each with its picture,
// its name and its votes, in the middle of the screen as the map download's question is, and
// answered the same ways (D-pad or arrow keys to pick, A or Enter to vote, B or Esc to close
// it, or a click). Voting closes it; the vote then shows as the poll's small card, where the
// answer can still be changed.
namespace {
std::atomic<std::uint32_t> vote_shown{}; // the vote the popup is up for (its id)
std::atomic<std::uint32_t> vote_closed{}; // the vote it was answered or closed for
std::atomic<ULONGLONG> vote_drawn_at{}; // when it was last drawn
std::atomic<int> vote_pick{}, vote_options{}; // the picked answer, of how many
std::atomic<bool> vote_mine_seen{};
}
} // namespace dingosdk::overlay::detail
namespace dingosdk::overlay {
bool map_vote_asking() noexcept {
const auto shown = detail::vote_shown.load();
return shown && shown != detail::vote_closed.load() && GetTickCount64() - detail::vote_drawn_at.load() < 400;
}
void map_vote_move(int by) noexcept {
const int options = detail::vote_options.load();
if (options > 0) detail::vote_pick.store(((detail::vote_pick.load() + by) % options + options) % options);
}
void map_vote_dismiss() noexcept { detail::vote_closed.store(detail::vote_shown.load()); }
void map_vote_take() noexcept {
if (!map_vote_asking()) return;
try {
detail::queue_multiplayer_action("vote", std::to_string(detail::vote_pick.load() + 1));
} catch (...) {}
map_vote_dismiss();
}
void set_map_vote_icons(const std::vector<MapVoteIcon>& icons) noexcept {
try {
// One texture for them all: a row of squares, a map to each.
constexpr UINT side = 256;
detail::CardPixels atlas;
std::vector<std::string> names;
std::vector<bool> has;
if (!icons.empty()) {
atlas.width = side * static_cast<UINT>(icons.size());
atlas.height = side;
atlas.rgba.assign(static_cast<std::size_t>(atlas.width) * side * 4, 0);
}
bool any{};
for (std::size_t i = 0; i < icons.size(); ++i) {
names.push_back(icons[i].name);
detail::CardPixels one;
bool ok{};
if (icons[i].side == side && icons[i].rgba.size() == static_cast<std::size_t>(side) * side * 4) {
one.rgba = icons[i].rgba; // pixels already, at the atlas' size
one.width = one.height = side;
ok = true;
} else {
ok = !icons[i].png.empty() && icons[i].png.size() <= 4 * 1024 * 1024 && decode(icons[i].png, one, side) &&
one.width == side && one.height == side;
}
has.push_back(ok);
any |= ok;
if (!ok) continue;
for (UINT row = 0; row < side; ++row)
std::memcpy(atlas.rgba.data() + (static_cast<std::size_t>(row) * atlas.width + i * side) * 4,
one.rgba.data() + static_cast<std::size_t>(row) * side * 4, side * 4);
}
std::lock_guard lock(image_mutex);
vote_icon_names = std::move(names);
vote_icon_has = std::move(has);
// (Nothing to show: the card's icon, if one is up, is left alone.)
if (!any) {
if (image_is_atlas.load()) {
image.store(0);
current = detail::CardPixels{};
current.serial = ++image_serial;
waiting = false;
image_is_atlas.store(false);
}
return;
}
image.store(0);
atlas.serial = ++image_serial;
current = std::move(atlas);
waiting = true;
image_is_atlas.store(true);
} catch (...) {}
}
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
bool draw_map_vote(const MultiplayerVote& vote) {
const bool open = vote.id && vote.next_map && vote.outcome == 0 && vote.may_vote && vote.answers.size() >= 2;
if (!open) return false;
// A new vote comes up with nothing picked over from the last; one already answered (from
// the keys or chat) is not put in the way again.
if (vote_shown.exchange(vote.id) != vote.id) {
vote_pick.store(0);
vote_mine_seen.store(vote.mine != 0);
if (vote.mine) vote_closed.store(vote.id);
} else if (vote.mine && !vote_mine_seen.exchange(true)) {
vote_closed.store(vote.id);
}
if (vote_closed.load() == vote.id) return false;
vote_drawn_at.store(GetTickCount64());
const int options = static_cast<int>(vote.answers.size());
vote_options.store(options);
const int pick = std::clamp(vote_pick.load(), 0, options - 1);
auto& s = state();
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
auto* heading = s.menu.heading ? s.menu.heading : bold;
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255);
const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; };
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad, true);
struct Hint { std::string key; const char* what; };
const std::vector<Hint> hints{
{pad.available ? std::string("D-pad") : std::string("< >"), "Pick"},
{pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Vote"},
{pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Close"}};
const float key_scale = u * 2, hint_gap = 36 * u;
float hints_width{};
for (const auto& hint : hints)
hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what);
hints_width += hint_gap * static_cast<float>(hints.size() - 1);
// The tiles in a row, as many as fit the screen's width at their size, smaller when there are more.
const float inset = 44 * u, gap = 24 * u, label_size = 28 * u, title_size = 64 * u;
const float most = display.x - 200 * u - inset * 2;
const float tile = std::min(520 * u, (most - gap * static_cast<float>(options - 1)) / static_cast<float>(options));
const float pad_in = 20 * u, icon = tile - pad_in * 2, name_size = std::min(36 * u, tile * 0.075f), count_size = 30 * u, bar = 12 * u;
const float tile_height = pad_in + icon + 18 * u + name_size + 14 * u + bar + 12 * u + count_size + pad_in;
const float width = inset * 2 + tile * static_cast<float>(options) + gap * static_cast<float>(options - 1);
const float height = inset + label_size + 20 * u + title_size + 30 * u + tile_height + inset;
const float under = 34 * u + 20 * key_scale;
const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor((display.y - height - under) * 0.5f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110));
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d3, u * 2);
const float left = min.x + inset, right = max.x - inset;
float y = min.y + inset;
draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, "TIME IS UP ON THIS MAP");
{
const auto time = std::to_string(vote.seconds) + " s";
draw->AddText(bold, label_size, ImVec2(right - width_of(bold, label_size, time), y), theme::paper, time.c_str());
}
y += label_size + 20 * u;
draw->AddText(heading, title_size, ImVec2(left, y), theme::paper, "VOTE FOR THE NEXT MAP");
y += title_size + 30 * u;
// Which square of the atlas is each answer's, if it has one.
std::vector<int> cell(static_cast<std::size_t>(options), -1);
int cells{};
ImTextureID texture{};
{
std::lock_guard lock(image_mutex);
cells = static_cast<int>(vote_icon_names.size());
if (image_is_atlas.load()) texture = image.load();
for (int i = 0; i < options; ++i)
for (int k = 0; k < cells; ++k)
if (vote_icon_has[static_cast<std::size_t>(k)] && vote_icon_names[static_cast<std::size_t>(k)] == vote.answers[static_cast<std::size_t>(i)]) cell[static_cast<std::size_t>(i)] = k;
}
unsigned total{};
for (const auto count : vote.counts) total += count;
for (int i = 0; i < options; ++i) {
const auto& name = vote.answers[static_cast<std::size_t>(i)];
const unsigned count = static_cast<std::size_t>(i) < vote.counts.size() ? vote.counts[static_cast<std::size_t>(i)] : 0;
const ImVec2 a(left + (tile + gap) * static_cast<float>(i), y), b(a.x + tile, a.y + tile_height);
const bool hovered = ImGui::IsMouseHoveringRect(a, b, false);
if (hovered && (ImGui::GetIO().MouseDelta.x != 0 || ImGui::GetIO().MouseDelta.y != 0)) vote_pick.store(i);
const bool picked = pick == i, mine = vote.mine == static_cast<std::uint8_t>(i + 1);
skate_theme::rough_rect(draw, a, b, picked ? theme::blue : skate_theme::tile, 0x600 + static_cast<unsigned>(i), u * 2);
const ImVec2 icon_min(a.x + pad_in, a.y + pad_in), icon_max(icon_min.x + icon, icon_min.y + icon);
if (texture && cell[static_cast<std::size_t>(i)] >= 0 && cells > 0) {
const float from = static_cast<float>(cell[static_cast<std::size_t>(i)]) / static_cast<float>(cells);
draw->AddImage(texture, icon_min, icon_max, ImVec2(from, 0), ImVec2(from + 1.0f / static_cast<float>(cells), 1));
} else {
// No picture: the game's own maps, "Random map" and "Stay here", and one still on its way.
// Each is drawn as shapes on its own colour, sharp at any size (text this large is not).
const bool random = name == "Random map", stay = name == "Stay here";
constexpr ImU32 ink = IM_COL32(26, 26, 26, 255), green = IM_COL32(96, 190, 128, 255);
skate_theme::rough_rect(draw, icon_min, icon_max, random ? sticky : stay ? green : IM_COL32(52, 56, 62, 255), 0x620 + static_cast<unsigned>(i), u * 2);
const ImVec2 mid((icon_min.x + icon_max.x) * 0.5f, (icon_min.y + icon_max.y) * 0.5f);
const float r = icon * 0.26f, line = std::max(2.0f, icon * 0.035f);
if (random) {
// A die showing five.
draw->AddRectFilled(ImVec2(mid.x - r, mid.y - r), ImVec2(mid.x + r, mid.y + r), ink, r * 0.22f);
const float pip = r * 0.15f, out = r * 0.5f;
for (const auto& at : {ImVec2(0, 0), ImVec2(-1, -1), ImVec2(1, -1), ImVec2(-1, 1), ImVec2(1, 1)})
draw->AddCircleFilled(ImVec2(mid.x + at.x * out, mid.y + at.y * out), pip, sticky, 24);
} else if (stay) {
// A clock, and a plus for the time it adds.
draw->AddCircle(mid, r, ink, 64, line);
draw->AddLine(mid, ImVec2(mid.x, mid.y - r * 0.62f), ink, line);
draw->AddLine(mid, ImVec2(mid.x + r * 0.45f, mid.y + r * 0.2f), ink, line);
draw->AddCircleFilled(mid, line * 0.8f, ink, 16);
const ImVec2 plus(mid.x + r * 0.95f, mid.y - r * 0.95f);
const float arm = r * 0.26f;
draw->AddCircleFilled(plus, arm * 1.7f, ink, 32);
draw->AddLine(ImVec2(plus.x - arm, plus.y), ImVec2(plus.x + arm, plus.y), green, line);
draw->AddLine(ImVec2(plus.x, plus.y - arm), ImVec2(plus.x, plus.y + arm), green, line);
} else {
// A map without a picture: hills under a sun.
constexpr ImU32 far_hill = IM_COL32(104, 112, 122, 255), near_hill = IM_COL32(138, 146, 156, 255);
const float base = mid.y + r * 0.75f;
draw->AddCircleFilled(ImVec2(mid.x + r * 0.7f, mid.y - r * 0.7f), r * 0.24f, sticky, 32);
draw->AddTriangleFilled(ImVec2(mid.x - r * 0.2f, base), ImVec2(mid.x + r * 0.55f, mid.y - r * 0.2f), ImVec2(mid.x + r * 1.3f, base), far_hill);
draw->AddTriangleFilled(ImVec2(mid.x - r * 1.3f, base), ImVec2(mid.x - r * 0.35f, mid.y - r * 0.65f), ImVec2(mid.x + r * 0.6f, base), near_hill);
}
}
float ty = icon_max.y + 18 * u;
draw->PushClipRect(ImVec2(a.x + pad_in, ty - name_size), ImVec2(b.x - pad_in, ty + name_size * 2), true);
draw->AddText(bold, name_size, ImVec2(a.x + pad_in, ty), theme::paper, name.c_str());
draw->PopClipRect();
ty += name_size + 14 * u;
draw->AddRectFilled(ImVec2(a.x + pad_in, ty), ImVec2(b.x - pad_in, ty + bar), picked ? IM_COL32(0, 0, 0, 90) : skate_theme::tile_light, 4 * u);
if (total && count)
draw->AddRectFilled(ImVec2(a.x + pad_in, ty), ImVec2(a.x + pad_in + icon * static_cast<float>(count) / static_cast<float>(total), ty + bar), sticky, 4 * u);
ty += bar + 12 * u;
const auto votes = std::to_string(count) + (count == 1 ? " vote" : " votes") + (mine ? " - yours" : "");
draw->AddText(body, count_size, ImVec2(a.x + pad_in, ty), mine ? sticky : picked ? theme::paper : theme::muted, votes.c_str());
if (picked) draw->AddRect(a, b, sticky, 0, 0, 6 * u);
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
vote_pick.store(i);
map_vote_take();
}
}
float x = min.x + (width - hints_width) * 0.5f;
const float hint_y = max.y + 34 * u;
for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap;
return true;
}
} // namespace dingosdk::overlay::detail