mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-21 16:16:41 +02:00
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.
152 lines
6.5 KiB
C++
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
|