Files
ReSkate/Extension/Assets/map_icons.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

152 lines
6.5 KiB
C++

#include "map_icons.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/ui_textures.h"
#include "Engine/Vfs/game_textures.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Session/session.h"
#include "Extension/UI/Overlay/overlay.h"
#include <atomic>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace dingosdk::map_icons {
namespace {
namespace fs = std::filesystem;
namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr char community[] = "reskate";
constexpr std::uintmax_t largest_icon = 4 * 1024 * 1024;
std::atomic<unsigned> wanted_serial{~0U}; // the server's list the pictures in hand are for
std::atomic<bool> fetching{};
// Icons already found this run, by package without its version: a pool's maps come round again.
std::mutex cache_mutex;
std::map<std::string, std::string> cache;
std::string read_bytes(const fs::path& path) {
std::error_code error;
const auto size = fs::file_size(path, error);
if (error || !size || size > largest_icon) return {};
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>());
}
// One map's icon as PNG bytes, or nothing: the game's own maps have none, and a package that
// is neither installed nor on Thunderstore has none to give.
std::string icon_of(const std::string& name_text) {
const auto name = package::parse_name(name_text);
if (!name) return {};
const auto folder = name->folder();
{
std::lock_guard lock(cache_mutex);
if (const auto found = cache.find(folder); found != cache.end()) return found->second;
}
std::string bytes;
try {
bytes = read_bytes(mods::engine_data_root() / mods::mods_folder / folder / L"icon.png");
if (bytes.empty()) {
if (const auto answer = https::get_text(package::details_url(*name), 512 * 1024, 15, agent)) {
const auto choice = package::choose(*answer, *name, community);
if (!choice.icon.empty())
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent))
bytes = *png;
}
}
} catch (const std::exception&) {
bytes.clear();
}
std::lock_guard lock(cache_mutex);
cache[folder] = bytes; // (an empty one too: it is not asked for again this run)
return bytes;
}
// One of the game's own maps: the picture on its loading screen, cut to the square in its
// middle and scaled to the overlay's size. Nothing for a map without one, or when it cannot be
// read (another game build).
constexpr std::uint32_t picture_side = 256;
std::vector<unsigned char> game_picture(const std::string& map) {
namespace textures = addr::ui_textures;
for (const auto& picture : textures::map_pictures) {
if (picture.map != map) continue;
static std::mutex pictures_mutex;
static std::map<std::string, std::vector<unsigned char>> pictures;
std::lock_guard lock(pictures_mutex);
if (const auto found = pictures.find(map); found != pictures.end()) return found->second;
std::vector<unsigned char> square;
try {
vfs::GameTextures reader(mods::engine_data_root());
// Fitted into 512 a side with its shape kept: 512 by 288.
const auto wide = reader.read(textures::map_picture_toc, picture.bundle, picture.name, picture_side * 2);
const auto side = std::min(wide.width, wide.height);
if (!side) throw std::runtime_error("an empty picture");
frostbite::Image cut;
cut.width = cut.height = side;
cut.rgba.resize(static_cast<std::size_t>(side) * side * 4);
const auto left = (wide.width - side) / 2, top = (wide.height - side) / 2;
for (std::uint32_t row = 0; row < side; ++row)
std::copy_n(wide.rgba.begin() + (static_cast<std::size_t>(top + row) * wide.width + left) * 4, static_cast<std::size_t>(side) * 4,
cut.rgba.begin() + static_cast<std::size_t>(row) * side * 4);
const auto scaled = side == picture_side ? cut : frostbite::resize(cut, picture_side, picture_side);
square.assign(scaled.rgba.begin(), scaled.rgba.end());
// (A loading screen's picture is opaque; make sure of it.)
for (std::size_t i = 3; i < square.size(); i += 4) square[i] = 255;
} catch (const std::exception& error) {
square.clear();
try {
logging::log(logging::Level::warning, logging::Channel::assets, "Next map vote: no picture for {}: {}.", map, error.what());
} catch (...) {}
}
pictures[map] = square; // (an empty one too: it is not read again this run)
return square;
}
return {};
}
void fetch(unsigned serial, std::vector<multiplayer::NextMapWanted> maps) {
std::vector<overlay::MapVoteIcon> icons;
std::size_t found{};
for (auto& map : maps) {
overlay::MapVoteIcon icon;
icon.png = icon_of(map.package);
if (icon.png.empty() && map.package.empty()) {
icon.rgba = game_picture(map.name);
if (!icon.rgba.empty()) icon.side = picture_side;
}
found += !icon.png.empty() || !icon.rgba.empty();
icon.name = std::move(map.name);
icons.push_back(std::move(icon));
}
// (The server may have moved on meanwhile: the next tick fetches for what it says now.)
if (wanted_serial.load() == serial) {
overlay::set_map_vote_icons(icons);
try {
logging::log(logging::Level::info, logging::Channel::assets, "Next map vote: {} map(s) to choose from, {} with a picture.", icons.size(), found);
} catch (...) {}
}
fetching.store(false);
}
} // namespace
void tick() {
const auto serial = multiplayer::next_maps_serial();
if (serial == wanted_serial.load() || fetching.load()) return;
wanted_serial.store(serial);
auto maps = multiplayer::next_maps();
if (maps.empty()) return overlay::set_map_vote_icons({});
fetching.store(true);
// Detached, as the map download's work is: the game may close while it runs.
std::thread(fetch, serial, std::move(maps)).detach();
}
} // namespace dingosdk::map_icons