Files
ReSkate/Extension/UI/Overlay/item_browser_overlay.cpp
T
AyeZeeBB 89a97caed7 Plugins: an item grid's own list and marks, items worn on top, keys kept from the game, the menu's category icons
General APIs from pull request 269 (the player's own item tabs and outfit layers), whose tabs,
panels and prompts are now a plugin's (contrib/plugins/item-tabs, kept on this machine):
- reskate.menu.grid_show(keys): a plugin's list in the item grid that has the focus, drawn from
  every grid of the screen; reskate.menu.grid_mark(keys): the game's check on tiles. menu.grid()
  also says whether a list shows, whether it is empty, and where the grid's own prompts end.
- reskate.outfit.on_top(items) and on_top_state(): items worn on top of the outfit in the slots it
  leaves empty, the item given the slot's category, a slot tried and left if the game reports a
  failed load (ReSkate.layer_slots.txt remembers which load where); paid store items refused.
  Part of the shared outfit service, with reskate.outfit.set.
- reskate.input.game_keys(keys): presses the game does not see, by any way it reads the keyboard;
  "Delete" is a key name.
- d:game_image draws the customization menu's 17 category icons ("cas_tops", ...).
dingosdk_plugin_tests skips the test of a contrib plugin that is not on this machine.
2026-10-21 19:18:01 -05:00

371 lines
18 KiB
C++

#include "overlay_internal.h"
#include "Engine/Core/Platform/path_text.h"
#include <imgui_internal.h>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <mutex>
#include <thread>
// The skater item grids' search bar and key prompts, drawn over the game's own menu in its style.
// The grids themselves are the game's (Extension/Customization/item_browser.cpp); this only shows
// what is going on and takes the typing.
namespace dingosdk::overlay {
namespace {
std::atomic<const ItemBrowserHost*> item_browser_host{};
}
void set_item_browser(const ItemBrowserHost* host) noexcept { item_browser_host.store(host); }
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
namespace {
struct Panel {
ItemBrowserView view;
std::array<char, 96> input{};
bool stick_down{};
std::string pushed; // the search text the host was last given
};
Panel& panel() { static auto* value = new Panel; return *value; }
const ItemBrowserHost* host() { return item_browser_host.load(); }
// The icon of the mod the highlighted item is from: read and decoded off this thread, uploaded by
// the render thread to the SRV heap's fourth slot (overlay_render.cpp, upload_mod_icon).
struct ModIcon {
std::mutex mutex;
std::string wanted, tried; // the icon asked for, and the last one the loader finished
bool loading{};
// Icons decoded lately (a mod with none as no pixels), so going back to a mod's item shows its
// icon at once: at most `kept`, 64 KB each.
static constexpr std::size_t kept = 32;
std::vector<std::pair<std::string, CardPixels>> decoded;
CardPixels current; // kept to upload again after a device reset
std::string current_path;
bool waiting{};
unsigned serial{};
ImTextureID texture{};
std::string ready_path; // the icon the texture holds
};
ModIcon& mod_icon() { static auto* value = new ModIcon; return *value; } // process lifetime
void load_mod_icons() {
auto& m = mod_icon();
for (;;) {
std::string path;
{
std::lock_guard lock(m.mutex);
if (m.wanted == m.tried) {
m.loading = false;
return;
}
path = m.wanted;
}
CardPixels pixels;
bool ok{};
try {
std::ifstream input(std::filesystem::path(std::u8string(path.begin(), path.end())), std::ios::binary);
const std::string png{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
ok = !png.empty() && png.size() <= 4 * 1024 * 1024 && decode_picture(png, pixels, 128);
} catch (...) {}
if (!ok) pixels = {};
std::lock_guard lock(m.mutex);
m.tried = path;
if (m.decoded.size() >= ModIcon::kept) m.decoded.erase(m.decoded.begin());
m.decoded.emplace_back(path, pixels);
if (ok && path == m.wanted) {
pixels.serial = ++m.serial;
m.current = std::move(pixels);
m.current_path = path;
m.waiting = true;
}
}
}
// Asks for this icon (empty: none); the icon's texture once it is on the GPU, else 0.
ImTextureID want_mod_icon(const std::string& path) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (path.empty()) return {};
if (m.wanted != path) {
m.wanted = path;
// One decoded lately goes straight to the GPU (in the next frame).
const auto known = std::ranges::find(m.decoded, path, &std::pair<std::string, CardPixels>::first);
if (known != m.decoded.end()) {
m.tried = path;
if (!known->second.rgba.empty() && m.ready_path != path) {
m.current = known->second;
m.current.serial = ++m.serial;
m.current_path = path;
m.waiting = true;
}
} else if (!m.loading) {
try {
std::thread(load_mod_icons).detach();
m.loading = true;
} catch (...) {}
}
}
return m.ready_path == path ? m.texture : ImTextureID{};
}
// A five-pointed star, the mark favorites carry on their tiles.
void star(ImDrawList* draw, ImVec2 centre, float radius, ImU32 colour) {
std::array<ImVec2, 10> points;
for (int i = 0; i < 10; ++i) {
const float angle = -1.5707963f + static_cast<float>(i) * 0.6283185f, r = i % 2 ? radius * 0.42f : radius;
points[static_cast<std::size_t>(i)] = ImVec2(centre.x + std::cos(angle) * r, centre.y + std::sin(angle) * r);
}
// Concave: fan it from the centre.
for (int i = 0; i < 10; ++i)
draw->AddTriangleFilled(centre, points[static_cast<std::size_t>(i)], points[static_cast<std::size_t>((i + 1) % 10)], colour);
}
// Where the game's menu is across the screen: its left edge and width. The full width up to 21:9;
// on a wider screen a 21:9 frame in the middle.
std::pair<float, float> menu_frame(ImVec2 display) {
const float width = std::min(display.x, display.y * 21.0f / 9.0f);
return {(display.x - width) * 0.5f, width};
}
// `text` cut short with "..." to fit `width`.
std::string fitted(ImFont* font, float size, const std::string& text, float width) {
if (font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x <= width) return text;
std::string cut = text;
while (!cut.empty()) {
do cut.pop_back(); while (!cut.empty() && (static_cast<unsigned char>(cut.back()) & 0xc0) == 0x80);
while (!cut.empty() && cut.back() == ' ') cut.pop_back();
if (font->CalcTextSizeA(size, FLT_MAX, 0, (cut + "...").c_str()).x <= width) break;
}
return cut + "...";
}
// Which mod the highlighted item is from, inside the game's own item card (the rarity tag, the
// name and the description at the bottom of the screen): the mod's icon, what it is to the item,
// its name, who made it, its version and how many items it has. The card grows upward from a
// fixed bottom edge as its text wraps; a mod's item has three empty description lines
// (Customization/local_cosmetic_catalog.cpp), so its last three lines are always the same place.
// As measured on 1280x720 and on 2000 wide at nine heights: the game lays its menu out across
// the screen's width up to 21:9, centred past that (menu_frame); it sizes it by the smaller of its
// two scales (`u`); the prompt row's bottom margin goes with the screen's height, the item card
// sits on the prompt row, its left edge is 45.25% of the frame and it is 319 wide.
void draw_mod_card(ImDrawList* draw, const ItemBrowserView& view, ImFont* heading, ImFont* body) {
if (view.mod_title.empty()) return;
const auto display = ImGui::GetIO().DisplaySize;
const auto [left, width] = menu_frame(display);
const float u = std::min(width / 1280.0f, display.y / 720.0f);
const float prompts_top = display.y * (1.0f - 35.0f / 720.0f) - 37 * u; // Hide Outfit, Equip, Back
const float card_bottom = prompts_top - 6 * u; // the game's item card
const float card_left = left + width * 0.4525f, card_width = 319 * u;
// Its last three lines (20 each, over an 8 bottom margin), inset like its text. The first line
// keeps the info sign the game's notice shows there (item_browser.cpp, quiet_notices); this
// goes under it, over the other two.
const float pad = 6 * u, icon = 40 * u;
const ImVec2 a(card_left + 12 * u - pad, card_bottom - 68 * u), b(card_left + card_width - 12 * u, card_bottom - 8 * u);
const float top = a.y + 20 * u;
// The icon, or the title's first letter on a plain tile when the mod has none.
const ImVec2 i0(a.x + pad, top), i1(i0.x + icon, i0.y + icon);
if (const auto texture = want_mod_icon(view.mod_icon)) {
draw->AddImage(texture, i0, i1);
} else {
draw->AddRectFilled(i0, i1, skate_theme::tile_light);
const char letter[2]{static_cast<char>(std::toupper(static_cast<unsigned char>(view.mod_title.front()))), 0};
const float size = 24 * u;
const auto extent = heading->CalcTextSizeA(size, FLT_MAX, 0, letter);
draw->AddText(heading, size, ImVec2(i0.x + (icon - extent.x) * 0.5f, i0.y + (icon - extent.y) * 0.5f), theme::muted, letter);
}
const float text_x = i1.x + 10 * u, right = b.x - pad;
// The tag, like the game's rarity tag, then the name beside it.
const char* tag = view.mod_changed ? "CHANGED BY MOD" : "MOD";
const float tag_size = 10 * u, title_size = 15 * u, line_size = 12 * u;
const auto tag_extent = heading->CalcTextSizeA(tag_size, FLT_MAX, 0, tag);
const float row = top + (icon - title_size - 5 * u - line_size) * 0.5f;
const ImVec2 t0(text_x, row + (title_size - tag_extent.y - 4 * u) * 0.5f);
const ImVec2 t1(t0.x + tag_extent.x + 10 * u, t0.y + tag_extent.y + 4 * u);
draw->AddRectFilled(t0, t1, view.mod_changed ? skate_theme::bar : skate_theme::blue);
draw->AddText(heading, tag_size, ImVec2(t0.x + 5 * u, t0.y + 2 * u), view.mod_changed ? skate_theme::black : theme::paper, tag);
const float name_x = t1.x + 7 * u;
draw->AddText(heading, title_size, ImVec2(name_x, row), theme::paper,
fitted(heading, title_size, view.mod_title, right - name_x).c_str());
std::string line = view.mod_author.empty() ? std::string() : "by " + view.mod_author;
const auto add = [&](const std::string& part) { if (!part.empty()) line += (line.empty() ? "" : " | ") + part; };
if (!view.mod_version.empty()) add("v" + view.mod_version);
if (view.mod_items) add(std::to_string(view.mod_items) + (view.mod_items == 1 ? " item" : " items"));
draw->AddText(body, line_size, ImVec2(text_x, row + title_size + 5 * u), theme::muted,
fitted(body, line_size, line, right - text_x).c_str());
}
} // namespace
bool take_mod_icon_pixels(CardPixels& out) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (!m.waiting) return false;
m.waiting = false;
out = m.current;
return true;
}
void mod_icon_uploaded(ImTextureID texture, unsigned serial) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (serial != m.serial) return;
m.texture = texture;
m.ready_path = m.current_path;
}
void mod_icon_lost() {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
m.texture = {};
m.ready_path.clear();
if (!m.current.rgba.empty()) m.waiting = true;
}
bool item_browser_open() { return panel().view.open; }
namespace {
std::array<std::atomic<float>, 4> prompts_end{}; // left, top, width, unit
void set_prompts_end(float left, float top, float width, float unit) noexcept {
prompts_end[0].store(left, std::memory_order_relaxed);
prompts_end[1].store(top, std::memory_order_relaxed);
prompts_end[2].store(width, std::memory_order_relaxed);
prompts_end[3].store(unit, std::memory_order_relaxed);
}
} // namespace
void item_browser_key(ItemBrowserKey key) {
const auto* h = host();
if (!h) return;
if (key == ItemBrowserKey::favorite && h->toggle_favorite) h->toggle_favorite();
if (key == ItemBrowserKey::filter && h->cycle_filter) h->cycle_filter();
}
void item_browser_search_cleared() {
auto& p = panel();
p.input.fill(0);
if (const auto* h = host(); h && h->set_search) h->set_search("");
}
bool item_browser_pending() {
auto& s = state();
auto& p = panel();
const auto* h = host();
if (!h || !h->view) return false;
// Every frame: the card under the game's item card follows the highlight without a lag.
try { p.view = h->view(); } catch (...) { p.view = {}; }
if (!p.view.open) {
s.item_search_visible.store(false);
p.stick_down = false;
set_prompts_end(0, 0, 0, 0);
return false;
}
// Left stick click: favorite. Read here, once a presented frame, only while a grid is open.
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad);
const bool stick = pad.available && (pad.buttons & XINPUT_GAMEPAD_LEFT_THUMB) != 0;
if (stick && !p.stick_down) item_browser_key(ItemBrowserKey::favorite);
p.stick_down = stick;
return true;
}
void draw_item_browser() {
auto& s = state();
auto& p = panel();
const auto* h = host();
if (!h || !p.view.open) return;
const auto& view = p.view;
auto* heading = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
// Sized like the game's own item card and prompts, which are laid out for a 2160-line screen.
const float scale = std::clamp(display.y / 1080.0f, 0.75f, 3.0f) * 1.35f;
const float width = 350.0f * scale, height = 42.0f * scale, margin = 0.074f * display.y;
// At the right of the game's menu, which stops widening at 20:9.
const auto [frame_left, frame_width] = menu_frame(display);
const ImVec2 at(frame_left + frame_width - margin - width, margin);
const bool typing = s.item_search_visible.load();
// The search field: a dark tile like the game's item card, blue-edged while it takes the keys.
auto* draw = ImGui::GetBackgroundDrawList();
skate_theme::rough_rect(draw, at, ImVec2(at.x + width, at.y + height), IM_COL32(18, 18, 19, 235), 71, scale);
if (typing) draw->AddRect(ImVec2(at.x - scale, at.y - scale), ImVec2(at.x + width + scale, at.y + height + scale), theme::blue, 0, 0, 2.5f * scale);
const ImVec2 lens(at.x + 24 * scale, at.y + height * 0.46f);
draw->AddCircle(lens, 8 * scale, theme::paper, 0, 2.2f * scale);
draw->AddLine(ImVec2(lens.x + 6 * scale, lens.y + 6 * scale), ImVec2(lens.x + 12 * scale, lens.y + 12 * scale), theme::paper, 2.6f * scale);
const std::string count = view.shown == view.total ? std::to_string(view.total)
: std::to_string(view.shown) + " / " + std::to_string(view.total);
const float count_size = 15 * scale;
const auto count_extent = body->CalcTextSizeA(count_size, FLT_MAX, 0, count.c_str());
draw->AddText(body, count_size, ImVec2(at.x + width - count_extent.x - 14 * scale, at.y + (height - count_extent.y) * 0.5f),
theme::muted, count.c_str());
const float text_x = at.x + 46 * scale, text_width = width - 46 * scale - count_extent.x - 28 * scale;
if (typing) {
ImGui::SetNextWindowPos(ImVec2(text_x, at.y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(text_width, height), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, (height - 20 * scale) * 0.5f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_Text, theme::paper);
ImGui::PushStyleColor(ImGuiCol_TextSelectedBg, IM_COL32(1, 131, 255, 110));
constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##reskate_item_search", nullptr, flags)) {
ImGui::PushFont(heading);
ImGui::SetWindowFontScale(20 * scale / heading->FontSize);
if (s.item_search_focus_requested.exchange(false)) {
std::snprintf(p.input.data(), p.input.size(), "%s", view.search.c_str());
const auto id = ImGui::GetID("##reskate_item_search_input");
if (auto* active = ImGui::GetInputTextState(id); active && ImGui::GetActiveID() == id)
active->ReloadUserBufAndMoveToEnd();
ImGui::SetKeyboardFocusHere();
}
ImGui::SetNextItemWidth(-FLT_MIN);
const bool entered = ImGui::InputTextWithHint("##reskate_item_search_input", "Type to search", p.input.data(),
p.input.size(), ImGuiInputTextFlags_EnterReturnsTrue);
// Every letter filters the grid at once.
if (h->set_search && p.pushed != p.input.data()) {
p.pushed = p.input.data();
h->set_search(p.pushed.c_str());
}
if (entered) s.item_search_visible.store(false);
ImGui::PopFont();
}
ImGui::End();
ImGui::PopStyleColor(4);
ImGui::PopStyleVar(3);
} else {
p.pushed = view.search;
const float size = 20 * scale;
const auto text = view.search.empty() ? std::string("Search") : view.search;
draw->PushClipRect(ImVec2(text_x, at.y), ImVec2(text_x + text_width, at.y + height), true);
draw->AddText(heading, size, ImVec2(text_x, at.y + (height - size) * 0.5f - scale),
view.search.empty() ? theme::muted : theme::paper, text.c_str());
draw->PopClipRect();
}
// Key prompts, like the game's "Esc Back".
float x = at.x;
const float y = at.y + height + 12 * scale;
const auto prompt = [&](const char* key, const std::string& label) {
x += theme::keycap(draw, body, scale, ImVec2(x, y), key, label.c_str()) + 22 * scale;
};
if (typing) {
prompt("Enter", "Done");
prompt("Esc", "Clear");
} else {
prompt("F", view.focused_favorite ? "Unfavorite" : "Favorite");
prompt("-", "Search");
prompt("X", view.filter);
}
if (view.focused_favorite && !typing)
star(draw, ImVec2(at.x - 22 * scale, at.y + height * 0.5f), 13 * scale, skate_theme::bar);
set_prompts_end(at.x, y + 30 * scale, width, scale);
draw_mod_card(draw, view, heading, body);
}
} // namespace dingosdk::overlay::detail
namespace dingosdk::overlay {
ItemPrompts item_browser_prompts() noexcept {
using detail::prompts_end;
return {prompts_end[0].load(std::memory_order_relaxed), prompts_end[1].load(std::memory_order_relaxed),
prompts_end[2].load(std::memory_order_relaxed), prompts_end[3].load(std::memory_order_relaxed)};
}
} // namespace dingosdk::overlay