#include "overlay_internal.h" #include "chat_emotes.h" #include "chat_rich.h" #include "nametag_gradient.h" #include "role_badge.h" #include #include // Multiplayer text chat in the bottom-right corner. Closed, the newest lines // show for a few seconds and fade, taking no input. T opens it (overlay_input.cpp): // the whole session log with a text box, Enter sends and closes, Esc closes. // Lines come from the session through a small feed rather than the full model, // so they can be polled on every presented frame. namespace dingosdk::overlay { namespace { std::atomic chat_feed{}; } void set_chat_feed(ChatFeed feed) noexcept { chat_feed.store(feed); } } // namespace dingosdk::overlay using namespace dingosdk::overlay::detail; namespace dingosdk::overlay::detail { namespace { using Clock = std::chrono::steady_clock; using namespace std::chrono_literals; constexpr auto chat_hold = 10s, chat_fade = 1s; constexpr std::size_t closed_lines = 6; struct ChatState { MultiplayerChat feed; Clock::time_point next_poll{}; std::uint64_t seen{}; // newest sequence already given an arrival time // When each line reached this overlay; the session keeps no clock for them. std::deque> arrivals; std::uint64_t feed_version{}; // counts polls of `feed`; the caches below are rebuilt after one std::array input{}; // Typing dots: the input as last seen while the box was open, and when it last changed. bool typing_watch{}, typing_on{}, typing_changed_valid{}; std::string typing_seen; Clock::time_point typing_changed{}, typing_sent{}; bool scroll_to_bottom{}; std::vector completions; // "/" commands the typed text can still become std::uint64_t completions_version{}; // the Suggestions::version `completions` was copied from // What the text in the box can become (suggestions()): worked out again only when that text, // the feed or the loaded emotes change, not several times every frame. struct Suggestions { bool valid{}; std::string typed; std::uint64_t feed{}; bool emotes_loaded{}; std::uint64_t version{}; // counts rebuilds std::vector arguments; // argument_completions std::vector commands; // matching_commands: into `feed`, until its next poll bool emote{}; // the last word is an unfinished :emote std::string before; // everything ahead of it std::vector emote_names; // the emotes it can become std::vector completions; // what Tab cycles through } suggestions; bool refocus_input{}; // an emote was clicked: back to the text box bool cursor_to_end{}; // the box was filled for the player: type after it // Tab cycling: the text as typed before the first Tab, the options it offered, the one in the // box now and its text. Typing anything else ends the cycle. struct Cycle { std::string typed, written; std::vector options; std::size_t index{}; bool active{}; } cycle; }; ChatState& chat() { static ChatState value; return value; } Clock::time_point arrived(const ChatState& c, std::uint64_t sequence) { for (const auto& [line, at] : c.arrivals) if (line == sequence) return at; return {}; } // The sender's role colour, as their nametag shows it; ReSkate's notices muted. Lines without // a role (older sessions) fall back to a steady colour per sender. ImU32 name_colour(const MultiplayerChatLine& line) { if (line.server) return line.color ? line.color : multiplayer::nametag_server; if (!line.sender) return theme::muted; if (line.color) return line.color; if (line.local) return theme::blue; constexpr std::array palette{IM_COL32(255, 196, 0, 255), IM_COL32(99, 212, 113, 255), IM_COL32(255, 122, 122, 255), IM_COL32(190, 146, 255, 255), IM_COL32(94, 205, 250, 255), IM_COL32(255, 160, 90, 255)}; auto mixed = line.sender * 0x9E3779B97F4A7C15ull; mixed ^= mixed >> 29; return palette[mixed % palette.size()]; } // A message's own colour: the server's lines in lavender, everyone else's white. ImU32 text_colour(const MultiplayerChatLine& line) { return line.text_color ? line.text_color : line.server ? multiplayer::nametag_server_text : theme::paper; } // "name:"; a role tag is drawn in its own box before it (role_badge.h). std::string chat_label(const MultiplayerChatLine& line) { return line.name + ":"; } // What Tab offers for a command's argument: "/tp zee" -> the players starting "zee". Each is the // whole text the box would hold. Empty when `typed` is not a command with such an argument. std::vector argument_completions(const MultiplayerChat& feed, std::string_view typed) { std::vector result; if (typed.empty() || typed.front() != '/') return result; std::string lowered(typed); for (auto& ch : lowered) ch = static_cast(std::tolower(static_cast(ch))); static const std::vector times{"default", "morning", "noon", "afternoon", "evening", "night", "weatherday", "weathernight"}; for (const auto& command : feed.commands) { if (command.argument.empty() || !lowered.starts_with(command.name + " ")) continue; const auto& values = command.argument == "player" ? feed.players : command.argument == "map" ? feed.maps : times; const auto partial = lowered.substr(command.name.size() + 1); for (const auto& value : values) { std::string folded(value); for (auto& ch : folded) ch = static_cast(std::tolower(static_cast(ch))); if (folded.starts_with(partial) && folded != partial) result.push_back(command.name + " " + value); } break; } return result; } // The "/" commands `typed` can still become (or is already giving arguments to). std::vector matching_commands(const MultiplayerChat& feed, std::string_view typed) { std::vector result; if (typed.empty() || typed.front() != '/') return result; std::string lowered(typed); for (auto& ch : lowered) ch = static_cast(std::tolower(static_cast(ch))); for (const auto& command : feed.commands) if (command.name.starts_with(lowered) || lowered.starts_with(command.name + " ")) result.push_back(&command); return result; } // Messages with their :emote: images: chat_rich.h, shared with the chat bubbles. using namespace chat_rich; // A chat line as drawn: its label, whether it has emotes and their layout, kept per line // (sequences never repeat) for the font, size, width and indent it was laid out with, instead // of laid out again every frame. struct LineLayout { std::size_t length{}; // the line's text: the same line, to be sure std::string label; // chat_label bool laid_out{}; const ImFont* font{}; float size{}, wrap{}, indent{}; bool emotes_loaded{}; bool emotes{}; // has_emotes; without any the text is drawn plainly std::string text; // what is drawn: the line's text with any emote the filter masked put back RichText rich; // its layout, when it has ImVec2 extent{}; // its size: the layout's, or the plain text's wrapped at `wrap` (the closed view) }; using LineLayouts = std::map; struct ChatLayouts { LineLayouts open, closed; // the open panel's lines and the closed view's fading ones }; ChatLayouts& chat_layouts() { static ChatLayouts value; return value; } LineLayout& line_layout(LineLayouts& layouts, const MultiplayerChatLine& line) { auto& entry = layouts[line.sequence]; if (entry.label.empty() || entry.length != line.text.size()) { entry = {}; entry.length = line.text.size(); entry.label = chat_label(line); } return entry; } const LineLayout& layout_for(LineLayout& entry, const MultiplayerChatLine& line, ImFont* font, float size, float wrap, float indent) { const bool loaded = chat_emotes_loaded(); if (entry.laid_out && entry.font == font && entry.size == size && entry.wrap == wrap && entry.indent == indent && entry.emotes_loaded == loaded) return entry; entry.laid_out = true; entry.font = font; entry.size = size; entry.wrap = wrap; entry.indent = indent; entry.emotes_loaded = loaded; entry.text = restore_emotes(line.text, line.unmasked); entry.emotes = has_emotes(entry.text); entry.rich = entry.emotes ? lay_out(font, size, entry.text, wrap, indent) : RichText{}; entry.extent = entry.emotes ? ImVec2(wrap, entry.rich.height) : font->CalcTextSizeA(size, FLT_MAX, wrap, entry.text.c_str()); return entry; } // The emote being typed: the last word when it is ":" and at least two letters, not closed yet. // `before` is everything ahead of it. std::optional typed_emote(std::string_view input, std::string_view& before) { const auto space = input.find_last_of(' '); const auto word = space == std::string_view::npos ? input : input.substr(space + 1); before = input.substr(0, input.size() - word.size()); if (word.empty() || word.front() != ':' || word.find(':', 1) != std::string_view::npos || !chat_emotes_loaded()) return std::nullopt; return word.substr(1); } // Tab: the first command the typed text can become, with a space for its argument. // Also puts the cursor after text already in the box when the chat opens (a draft, or the "/" it // was opened with) and after an emote the player clicked. int complete_command(ImGuiInputTextCallbackData* data) { auto* c = static_cast(data->UserData); if (!c) return 0; if (data->EventFlag == ImGuiInputTextFlags_CallbackAlways) { if (c->cursor_to_end) { c->cursor_to_end = false; data->CursorPos = data->SelectionStart = data->SelectionEnd = data->BufTextLen; } return 0; } if (data->EventFlag != ImGuiInputTextFlags_CallbackCompletion || c->completions.empty()) return 0; auto& cycle = c->cycle; const bool back = ImGui::GetIO().KeyShift; if (!cycle.active) { cycle.active = true; cycle.typed.assign(data->Buf, static_cast(data->BufTextLen)); cycle.options = c->completions; cycle.index = back ? cycle.options.size() - 1 : 0; } else { const auto count = cycle.options.size(); cycle.index = back ? (cycle.index + count - 1) % count : (cycle.index + 1) % count; } cycle.written = cycle.options[cycle.index]; data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, cycle.written.c_str()); return 0; } // What the suggestion boxes show and Tab cycles through: while cycling, the options of the text // as it was typed before the first Tab; otherwise those of the text in the box. std::string suggesting_for(const ChatState& c) { return c.cycle.active ? c.cycle.typed : std::string(c.input.data()); } bool highlighted(const ChatState& c, const std::string& option, std::size_t index) { return c.cycle.active ? option == c.cycle.written : index == 0; } // The commands, values and emotes `typed` can become, and what Tab cycles through for it. Built // again only when `typed`, the feed (polled every 100 ms) or the loaded emotes change: the chat // panel, its command list and the emote picker all ask every frame. const ChatState::Suggestions& suggestions(ChatState& c, const std::string& typed) { auto& found = c.suggestions; const bool loaded = chat_emotes_loaded(); if (found.valid && found.feed == c.feed_version && found.emotes_loaded == loaded && found.typed == typed) return found; found.valid = true; found.typed = typed; found.feed = c.feed_version; found.emotes_loaded = loaded; ++found.version; found.arguments = argument_completions(c.feed, typed); found.commands = matching_commands(c.feed, typed); std::string_view before; const auto emote = typed_emote(typed, before); found.emote = emote.has_value(); found.before.assign(before); found.emote_names = emote ? chat_emote_names(*emote, 1024) : std::vector{}; // Tab: a command's players, maps or times; otherwise the commands the text can still become // (not one already typed in full); and the emotes after a ":". found.completions = found.arguments; if (found.completions.empty()) for (const auto* command : found.commands) if (command->name.size() > typed.size()) found.completions.push_back(command->name); for (const auto& name : found.emote_names) found.completions.push_back(found.before + ":" + name + ":"); return found; } // Whether `text` is `before` + ":" + `name` + ":", without building that string. bool is_emote_text(std::string_view text, std::string_view before, std::string_view name) { return text.size() == before.size() + name.size() + 2 && text.starts_with(before) && text[before.size()] == ':' && text.substr(before.size() + 1, name.size()) == name && text.back() == ':'; } // The session relays this. Dots mean the input changed in the last three seconds, not that // the box is merely open. A failed queue is tried again next frame. A "/" line is a command, // a private message or party chat: not everyone's business, so it shows no dots. void note_typing(ChatState& c, bool open) { const auto now = Clock::now(); if (!open) { if (c.typing_on && queue_multiplayer_action("typing", "0")) c.typing_on = false; c.typing_watch = false; c.typing_changed_valid = false; return; } const std::string_view text(c.input.data()); if (const auto first = text.find_first_not_of(' '); first != std::string_view::npos && text[first] == '/') { if (c.typing_on && queue_multiplayer_action("typing", "0")) c.typing_on = false; c.typing_seen.assign(text); c.typing_watch = true; c.typing_changed_valid = false; return; } if (!c.typing_watch) { c.typing_seen.assign(text); c.typing_watch = true; } else if (text != c.typing_seen) { c.typing_seen.assign(text); c.typing_changed = now; c.typing_changed_valid = true; } const bool active = c.typing_changed_valid && now - c.typing_changed < 3s; if (active) { if ((!c.typing_on || now - c.typing_sent >= 500ms) && queue_multiplayer_action("typing", "1")) { c.typing_on = true; c.typing_sent = now; } } else if (c.typing_on && queue_multiplayer_action("typing", "0")) { c.typing_on = false; } } ImU32 with_alpha(ImU32 colour, float alpha) { const auto base = static_cast((colour >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f; return (colour & ~IM_COL32_A_MASK) | (static_cast(base * std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f) << IM_COL32_A_SHIFT); } } // namespace bool chat_pending() { auto& s = state(); auto& c = chat(); const auto now = Clock::now(); if (now >= c.next_poll) { c.next_poll = now + 100ms; if (const auto feed = chat_feed.load()) { try { c.feed = feed(); } catch (...) { c.feed = {}; } ++c.feed_version; // Layouts of lines the feed no longer holds: all older than its first (a new // session's lines come after every earlier one). for (auto* layouts : {&chat_layouts().open, &chat_layouts().closed}) { if (c.feed.lines.empty()) layouts->clear(); else layouts->erase(layouts->begin(), layouts->lower_bound(c.feed.lines.front().sequence)); } } s.chat_available.store(c.feed.available); // Leaving the session closes an open chat box. if (!c.feed.available) s.chat_visible.store(false); for (const auto& line : c.feed.lines) if (line.sequence > c.seen) { c.arrivals.emplace_back(line.sequence, now); c.scroll_to_bottom = true; } c.seen = std::max(c.seen, c.feed.latest); while (c.arrivals.size() > multiplayer_chat_history) c.arrivals.pop_front(); } note_typing(c, s.chat_visible.load() && c.feed.available); if (s.chat_visible.load()) return true; if (!c.feed.available) return false; if (c.feed.vote.id || c.feed.announcement.id) return true; // the vote and announcement cards for (const auto& [line, at] : c.arrivals) if (now - at < chat_hold + chat_fade) return true; return false; } // The "/" commands matching what is typed, in a box just above the open chat panel. // The emotes matching what is being typed (":ka"), with their images, above the chat panel. // The emote picker: from a lone ":", a grid of every emote (narrowed by the letters typed after // it) just above the open chat panel. Clicking one writes it in; Tab takes the first. void emote_picker(ChatState& c, ImFont* heading, ImVec2 bottom_left, float width, float scale) { const auto& found = suggestions(c, suggesting_for(c)); if (!found.emote) return; // Nothing below asks for the suggestions again, so these stay put while the grid draws. const auto& names = found.emote_names; const std::string_view before = found.before; if (names.empty()) return; const float pad = 8.0f * scale, cell = 36.0f * scale, image = 28.0f * scale, label = 22.0f * scale; const int columns = std::max(1, static_cast((width - pad * 2.0f) / cell)); const int rows = (static_cast(names.size()) + columns - 1) / columns; const float grid = std::min(rows, 5) * cell; ImGui::SetNextWindowPos(bottom_left, ImGuiCond_Always, ImVec2(0.0f, 1.0f)); ImGui::SetNextWindowSize(ImVec2(width, pad * 2.0f + grid + label), ImGuiCond_Always); ImGui::PushStyleColor(ImGuiCol_WindowBg, with_alpha(theme::ink, 0.94f)); ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, with_alpha(theme::blue, 0.35f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, with_alpha(theme::blue, 0.6f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f * scale); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2((cell - image) * 0.5f, (cell - image) * 0.5f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoScrollbar; if (ImGui::Begin("##reskate_emote_picker", nullptr, flags)) { std::string shown = names.front(); ImGui::BeginChild("##emote_grid", ImVec2(0.0f, grid), ImGuiChildFlags_None); for (std::size_t i = 0; i < names.size(); ++i) { if (i % static_cast(columns)) ImGui::SameLine(); ImGui::PushID(static_cast(i)); const bool current = c.cycle.active && is_emote_text(c.cycle.written, before, names[i]); if (current) { shown = names[i]; ImGui::PushStyleColor(ImGuiCol_Button, with_alpha(theme::blue, 0.45f)); } ChatEmote emote; bool clicked{}; if (chat_emote(names[i], emote)) { // Wide emotes keep their shape inside the square cell. const float w = std::min(image * emote.aspect, image), h = w / emote.aspect; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2((cell - w) * 0.5f, (cell - h) * 0.5f)); clicked = ImGui::ImageButton("##emote", emote.texture, ImVec2(w, h), emote.uv0, emote.uv1); ImGui::PopStyleVar(); } else { clicked = ImGui::Button("?", ImVec2(cell, cell)); } if (current) { ImGui::PopStyleColor(); ImGui::SetScrollHereY(0.5f); } if (ImGui::IsItemHovered()) shown = names[i]; if (clicked) { const auto inserted = std::string(before) + ":" + names[i] + ":"; c.input.fill(0); std::memcpy(c.input.data(), inserted.data(), std::min(inserted.size(), c.input.size() - 1)); c.refocus_input = true; } ImGui::PopID(); } ImGui::EndChild(); ImGui::PushFont(heading); ImGui::TextUnformatted((":" + shown + ":").c_str()); ImGui::PopFont(); ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Text, theme::muted); ImGui::TextUnformatted((" " + std::to_string(names.size()) + (names.size() == 1 ? " emote" : " emotes") + " | click one, or Tab through them").c_str()); ImGui::PopStyleColor(); } ImGui::End(); ImGui::PopStyleVar(4); ImGui::PopStyleColor(4); } // The first row to show so that row `current` of `count` is among the `limit` shown. std::size_t first_shown(std::size_t current, std::size_t count, std::size_t limit) { if (count <= limit || current < limit) return 0; return std::min(current + 1 - limit, count - limit); } void draw_command_list(ChatState& c, ImFont* heading, ImFont* body, ImVec2 bottom_left, float width, float scale) { const auto& found = suggestions(c, suggesting_for(c)); const auto& arguments = found.arguments; if (!arguments.empty()) { // The values Tab fills in: players, maps or times. constexpr std::size_t limit = 8; std::size_t current{}; for (std::size_t i = 0; i < arguments.size(); ++i) if (highlighted(c, arguments[i], i)) current = i; const auto first = first_shown(current, arguments.size(), limit); const float size = 15.0f * scale, pad = 8.0f * scale, row = size + 4.0f * scale; const auto rows = std::min(arguments.size(), limit); const float height = pad * 2 + row * static_cast(rows) + (arguments.size() > limit ? row : 0.0f); auto* draw = ImGui::GetForegroundDrawList(); const ImVec2 min(bottom_left.x, bottom_left.y - height), max(bottom_left.x + width, bottom_left.y); draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.94f), 4.0f * scale); float y = min.y + pad; for (std::size_t i = first; i < first + rows; ++i, y += row) draw->AddText(heading, size, ImVec2(min.x + pad, y), i == current ? theme::blue : theme::paper, arguments[i].c_str()); if (arguments.size() > limit) draw->AddText(body, size * 0.9f, ImVec2(min.x + pad, y), theme::muted, (std::to_string(current + 1) + " of " + std::to_string(arguments.size()) + "; Tab for the next, Shift+Tab back").c_str()); return; } const auto& matches = found.commands; if (matches.empty()) return; constexpr std::size_t shown_limit = 8; std::size_t current{}; for (std::size_t i = 0; i < matches.size(); ++i) if (highlighted(c, matches[i]->name, i)) current = i; const auto first = first_shown(current, matches.size(), shown_limit); const float size = 15.0f * scale, pad = 8.0f * scale, gap = 4.0f * scale; const auto rows = std::min(matches.size(), shown_limit); const float row = size + gap; const float height = pad * 2 + row * static_cast(rows) - gap + (matches.size() > shown_limit ? row : 0.0f); auto* draw = ImGui::GetForegroundDrawList(); const ImVec2 min(bottom_left.x, bottom_left.y - height), max(bottom_left.x + width, bottom_left.y); draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.94f), 4.0f * scale); float y = min.y + pad; for (std::size_t i = first; i < first + rows; ++i) { const auto& command = *matches[i]; const auto usage_extent = heading->CalcTextSizeA(size, FLT_MAX, 0.0f, command.usage.c_str()); draw->AddText(heading, size, ImVec2(min.x + pad, y), i == current ? theme::blue : theme::paper, command.usage.c_str()); draw->PushClipRect(ImVec2(min.x + pad + usage_extent.x + 10.0f * scale, y), ImVec2(max.x - pad, y + row), true); draw->AddText(body, size * 0.92f, ImVec2(min.x + pad + usage_extent.x + 10.0f * scale, y + size * 0.05f), theme::muted, command.description.c_str()); draw->PopClipRect(); y += row; } if (matches.size() > shown_limit) draw->AddText(body, size * 0.9f, ImVec2(min.x + pad, y), theme::muted, (std::to_string(current + 1) + " of " + std::to_string(matches.size()) + "; Tab for the next, Shift+Tab back").c_str()); } // Card text with its :emote: images (chat_rich.h), measured and drawn as the chat panel's // lines are; text without any known emote is drawn plainly, as before. struct CardText { bool emotes{}; RichText rich; ImVec2 extent; }; CardText card_text(ImFont* font, float size, const std::string& text, float wrap, std::size_t max_lines = 0, float emote_scale = 1.3f) { CardText out; out.emotes = has_emotes(text); if (out.emotes) { out.rich = lay_out(font, size, text, wrap, 0.0f, max_lines, emote_scale); out.extent = ImVec2(out.rich.width, out.rich.height); } else { out.extent = font->CalcTextSizeA(size, FLT_MAX, wrap, text.c_str()); } return out; } void draw_card_text(ImDrawList* draw, ImFont* font, float size, ImVec2 at, const std::string& text, const CardText& laid, ImU32 colour, float wrap) { if (laid.emotes) draw_rich(draw, font, size, at, text, laid.rich, colour, 1.0f); else draw->AddText(font, size, at, colour, text.c_str(), nullptr, wrap); } // The vote a dedicated server is running, or has just finished: a card on the middle of the // screen's right edge, in the look of the overlay's notices (a dark tile with a strip of colour // down its left). It says what is voted on, the tally and the time left, and has Yes and No. The // player answers with their binds (the keycaps on the buttons; F1 and F2 unless changed), or by // clicking while the cursor is free. // A poll on the same card: its question, then a row for each answer with its count and a bar of // its share. The player answers with the number key on the row's keycap, by clicking a row while // the cursor is free, or with /1, /2... in chat; their answer is the blue row. void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) { const bool running = poll.outcome == 0; const ImU32 accent = running ? theme::blue : poll.outcome == 3 ? theme::muted : skate_theme::good; const char* title = running ? "POLL" : poll.outcome == 3 ? "POLL CANCELLED" : "POLL ENDED"; const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale; const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale; const float row = 30.0f * scale, between = 4.0f * scale, inner = width - strip - pad * 2.0f; const auto question = card_text(heading, size, poll.label, inner); const auto question_extent = question.extent; const bool answers = running && poll.may_vote; const auto rows = static_cast(poll.answers.size()); const float height = pad + title_size + gap + question_extent.y + gap + rows * row + std::max(0.0f, rows - 1.0f) * between + (answers ? gap + fine : 0.0f) + pad; const ImVec2 min(display.x - margin - width, std::floor((display.y - height) * 0.5f)), max(min.x + width, min.y + height); auto* draw = ImGui::GetForegroundDrawList(); draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f)); draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), accent); const float left = min.x + strip + pad, right = max.x - pad; float y = min.y + pad; draw->AddText(heading, title_size, ImVec2(left, y), accent, title); if (running) { const auto time = std::to_string(poll.seconds) + " s"; const auto extent = heading->CalcTextSizeA(title_size, FLT_MAX, 0.0f, time.c_str()); draw->AddText(heading, title_size, ImVec2(right - extent.x, y), theme::paper, time.c_str()); } y += title_size + gap; draw_card_text(draw, heading, size, ImVec2(left, y), poll.label, question, theme::paper, inner); y += question_extent.y + gap; unsigned total{}, most{}; for (const auto count : poll.counts) total += count, most = std::max(most, count); for (std::size_t i = 0; i < poll.answers.size(); ++i) { const auto count = i < poll.counts.size() ? poll.counts[i] : 0U; const ImVec2 a(left, y), b(right, y + row); const bool chosen = poll.mine == i + 1; const bool hovered = answers && clickable && ImGui::IsMouseHoveringRect(a, b, false); draw->AddRectFilled(a, b, hovered ? skate_theme::tile_light : skate_theme::tile_grey); // Its share of the answers as a bar along the bottom; once ended, the winner's is green. const float share = total ? static_cast(count) / static_cast(total) : 0.0f; const ImU32 fill = chosen ? theme::blue : !running && count && count == most ? skate_theme::good : theme::muted; if (share > 0.0f) draw->AddRectFilled(ImVec2(a.x, b.y - 3.0f * scale), ImVec2(a.x + (b.x - a.x) * share, b.y), fill); if (chosen) draw->AddRectFilled(a, ImVec2(a.x + 3.0f * scale, b.y), theme::blue); const auto number = std::to_string(i + 1); const float text_y = y + (row - fine) * 0.5f - 1.0f * scale; // The key that gives this answer, as the game shows its own keys. { const float cap = 18.0f * scale; const ImVec2 at(a.x + 6.0f * scale, y + (row - cap) * 0.5f); const auto extent = heading->CalcTextSizeA(12.0f * scale, FLT_MAX, 0.0f, number.c_str()); draw->AddRectFilled(at, ImVec2(at.x + cap, at.y + cap), chosen ? theme::blue : theme::paper, 3.0f * scale); draw->AddText(heading, 12.0f * scale, ImVec2(at.x + (cap - extent.x) * 0.5f, at.y + (cap - extent.y) * 0.5f), chosen ? theme::paper : skate_theme::black, number.c_str()); } const auto counted = std::to_string(count); const float count_width = heading->CalcTextSizeA(fine, FLT_MAX, 0.0f, counted.c_str()).x; draw->PushClipRect(ImVec2(a.x + 32.0f * scale, a.y), ImVec2(b.x - count_width - 18.0f * scale, b.y), true); // One line, cut with "..." where the count starts; an emote is a little smaller here, so it fits the row. const float answer_size = fine + 1.0f * scale; const float answer_wrap = (b.x - count_width - 18.0f * scale) - (a.x + 32.0f * scale); const auto answer = card_text(body, answer_size, poll.answers[i], answer_wrap, 1, 1.15f); const float answer_y = answer.emotes ? y + (row - answer.extent.y) * 0.5f : text_y; draw_card_text(draw, body, answer_size, ImVec2(a.x + 32.0f * scale, answer_y), poll.answers[i], answer, theme::paper, 0.0f); draw->PopClipRect(); draw->AddText(heading, fine, ImVec2(b.x - 10.0f * scale - count_width, text_y), theme::paper, counted.c_str()); if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) queue_multiplayer_action("vote", number); y += row + between; } if (answers) { const auto hint = "Press 1 to " + std::to_string(poll.answers.size()) + " on the keyboard, or click an answer."; draw->AddText(body, fine, ImVec2(left, y - between + gap), theme::muted, hint.c_str()); } } // A dedicated server's announcement: a card at the top middle of the screen in the same look, // blue down its left, for as long as the server shows it. void draw_announcement(const MultiplayerAnnouncement& announcement, ImFont* heading, ImFont* body, ImVec2 display, float scale) { if (!announcement.id || announcement.text.empty()) return; const float width = std::min(520.0f * scale, display.x - 48.0f * scale), strip = 5.0f * scale; const float pad = 14.0f * scale, gap = 6.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale; const float inner = width - strip - pad * 2.0f; const auto text = card_text(body, size, announcement.text, inner); const auto text_extent = text.extent; const float height = pad + title_size + gap + text_extent.y + pad; const ImVec2 min(std::floor((display.x - width) * 0.5f), 24.0f * scale), max(min.x + width, min.y + height); auto* draw = ImGui::GetForegroundDrawList(); draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f)); draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), theme::blue); const float left = min.x + strip + pad; draw->AddText(heading, title_size, ImVec2(left, min.y + pad), theme::blue, "ANNOUNCEMENT"); draw_card_text(draw, body, size, ImVec2(left, min.y + pad + title_size + gap), announcement.text, text, theme::paper, inner); } void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) { if (!vote.id) return; if (vote.next_map && draw_map_vote(vote)) return; if (vote.poll) return draw_poll(vote, heading, body, display, scale, clickable); constexpr ImU32 green = skate_theme::good, red = skate_theme::danger; const bool running = vote.outcome == 0; const ImU32 accent = running ? theme::blue : vote.outcome == 1 ? green : vote.outcome == 2 ? red : theme::muted; const char* title = running ? "VOTE" : vote.outcome == 1 ? "VOTE PASSED" : vote.outcome == 2 ? "VOTE FAILED" : "VOTE CANCELLED"; std::string question = vote.label; if (!question.empty() && question.front() >= 'a' && question.front() <= 'z') question.front() = static_cast(question.front() - 32); if (running) question += '?'; const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale; const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale; const float button_height = 34.0f * scale, bar = 10.0f * scale, inner = width - strip - pad * 2.0f; const auto question_extent = heading->CalcTextSizeA(size, FLT_MAX, inner, question.c_str()); const bool answers = running && vote.may_vote; const float height = pad + title_size + gap + question_extent.y + gap + bar + gap + fine + (running ? gap + (answers ? button_height : fine) : 0.0f) + pad; const ImVec2 min(display.x - margin - width, std::floor((display.y - height) * 0.5f)), max(min.x + width, min.y + height); auto* draw = ImGui::GetForegroundDrawList(); draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f)); draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), accent); const float left = min.x + strip + pad, right = max.x - pad; float y = min.y + pad; draw->AddText(heading, title_size, ImVec2(left, y), accent, title); if (running) { const auto time = std::to_string(vote.seconds) + " s"; const auto extent = heading->CalcTextSizeA(title_size, FLT_MAX, 0.0f, time.c_str()); draw->AddText(heading, title_size, ImVec2(right - extent.x, y), theme::paper, time.c_str()); } y += title_size + gap; draw->AddText(heading, size, ImVec2(left, y), theme::paper, question.c_str(), nullptr, inner); y += question_extent.y + gap; // The votes cast, yes in green from the left and no in red from the right: a vote is // decided by those who vote. The mark is in the middle whatever share the vote needs, and // green reaches it when the vote would pass: up to the yes votes needed it grows over the // left half, and past them over the right half. Nothing cast yet, the bar is empty. const unsigned cast = static_cast(vote.yes) + vote.no; draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light); if (cast) { float share = static_cast(vote.yes) / static_cast(cast); if (const unsigned needed = vote.needed) { const unsigned yes = vote.yes; share = yes <= needed ? 0.5f * static_cast(yes) / static_cast(needed) : 0.5f + 0.5f * static_cast(yes - needed) / static_cast(cast - needed); } const float split = std::floor(left + inner * std::clamp(share, 0.0f, 1.0f)); if (vote.yes) draw->AddRectFilled(ImVec2(left, y), ImVec2(split, y + bar), green); if (vote.no) draw->AddRectFilled(ImVec2(split, y), ImVec2(right, y + bar), red); if (running && vote.needed) { const float mark = std::floor(left + inner * 0.5f); draw->AddRectFilled(ImVec2(mark - scale, y - 3.0f * scale), ImVec2(mark + scale, y + bar + 3.0f * scale), theme::paper); } } y += bar + gap; float x = left; const auto word = [&](const std::string& text, ImU32 colour) { draw->AddText(body, fine, ImVec2(x, y), colour, text.c_str()); x += body->CalcTextSizeA(fine, FLT_MAX, 0.0f, text.c_str()).x + 14.0f * scale; }; word("Yes " + std::to_string(vote.yes), green); word("No " + std::to_string(vote.no), red); { const auto needed = std::to_string(vote.needed) + " needed"; const auto extent = body->CalcTextSizeA(fine, FLT_MAX, 0.0f, needed.c_str()); draw->AddText(body, fine, ImVec2(right - extent.x, y), theme::muted, needed.c_str()); } y += fine; if (!running) return; y += gap; if (!answers) { draw->AddText(body, fine, ImVec2(left, y), theme::muted, "This vote is about you, so you have no vote in it."); return; } // Two tiles as the menu's: grey, the answer's colour once it is this player's, each with its // bind on a white keycap. const float button_width = (inner - gap) * 0.5f; const auto button = [&](float at, bool yes, std::uint32_t bind) { const ImVec2 a(at, y), b(at + button_width, y + button_height); const ImU32 colour = yes ? green : red; const bool chosen = vote.mine == (yes ? 1 : 2); const bool hovered = clickable && ImGui::IsMouseHoveringRect(a, b, false); draw->AddRectFilled(a, b, chosen ? colour : hovered ? skate_theme::tile_light : skate_theme::tile_grey); if (!chosen) draw->AddRectFilled(ImVec2(a.x, b.y - 3.0f * scale), b, colour); const std::string key = bind ? dingosdk::controller_combo_label(bind) : std::string{}; const char* label = yes ? "YES" : "NO"; const float label_size = 15.0f * scale, key_size = 12.0f * scale, cap_height = 20.0f * scale; const auto label_extent = heading->CalcTextSizeA(label_size, FLT_MAX, 0.0f, label); const auto key_extent = key.empty() ? ImVec2{} : heading->CalcTextSizeA(key_size, FLT_MAX, 0.0f, key.c_str()); const float cap_width = key.empty() ? 0.0f : key_extent.x + 12.0f * scale, between = key.empty() ? 0.0f : 8.0f * scale; float cursor = a.x + (button_width - cap_width - between - label_extent.x) * 0.5f; if (!key.empty()) { const ImVec2 cap(cursor, a.y + (button_height - cap_height) * 0.5f); draw->AddRectFilled(cap, ImVec2(cap.x + cap_width, cap.y + cap_height), theme::paper, 3.0f * scale); draw->AddText(heading, key_size, ImVec2(cap.x + 6.0f * scale, cap.y + (cap_height - key_extent.y) * 0.5f), skate_theme::black, key.c_str()); cursor += cap_width + between; } draw->AddText(heading, label_size, ImVec2(cursor, a.y + (button_height - label_extent.y) * 0.5f), chosen ? skate_theme::black : theme::paper, label); if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) queue_multiplayer_action("vote", yes ? "yes" : "no"); }; button(left, true, vote.yes_bind); button(left + button_width + gap, false, vote.no_bind); } void draw_chat() { auto& s = state(); auto& c = chat(); if (!c.feed.available) return; 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; const float scale = std::clamp(display.y / 1080.0f, 1.0f, 2.0f); const float width = 520.0f * scale, margin = 24.0f * scale; if (s.chat_visible.load()) { ImGui::SetNextWindowPos(ImVec2(display.x - margin, display.y - margin), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); ImGui::SetNextWindowSize(ImVec2(width, 320.0f * scale), ImGuiCond_Always); ImGui::PushStyleColor(ImGuiCol_WindowBg, with_alpha(theme::ink, 0.92f)); ImGui::PushStyleColor(ImGuiCol_Border, with_alpha(theme::blue, 0.9f)); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(12, 12, 12, 255)); ImGui::PushStyleColor(ImGuiCol_Text, theme::paper); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f * scale, 10.0f * scale)); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar; if (ImGui::Begin("##reskate_chat", nullptr, flags)) { ImGui::PushFont(body); ImGui::SetWindowFontScale(scale * 0.9f); ImGui::BeginChild("##reskate_chat_lines", ImVec2(0.0f, -ImGui::GetFrameHeightWithSpacing())); ImGui::PushTextWrapPos(0.0f); auto& layouts = chat_layouts().open; for (const auto& line : c.feed.lines) { auto& layout = line_layout(layouts, line); const ImVec2 line_start = ImGui::GetCursorScreenPos(); const float line_width = ImGui::GetContentRegionAvail().x; if (!line.tag.empty()) { const float size = ImGui::GetFontSize(), height = ImGui::GetTextLineHeight(); const float badge = role_badge_width(heading, size, line.tag); draw_role_badge(ImGui::GetWindowDrawList(), heading, size, ImGui::GetCursorScreenPos(), height, line.tag, name_colour(line), 1.0f); ImGui::Dummy(ImVec2(badge, height)); ImGui::SameLine(0.0f, 5.0f * scale); } auto* draw = ImGui::GetWindowDrawList(); const int name_vertices = draw->VtxBuffer.Size; ImGui::PushStyleColor(ImGuiCol_Text, name_colour(line)); ImGui::TextUnformatted(layout.label.c_str()); ImGui::PopStyleColor(); shade_nametag_gradient(draw, name_vertices, ImGui::GetItemRectMin().x, ImGui::GetItemRectSize().x, name_colour(line), ImGui::GetTime()); ImGui::SameLine(0.0f, 6.0f * scale); const auto& drawn = layout_for(layout, line, ImGui::GetFont(), ImGui::GetFontSize(), line_width, ImGui::GetCursorScreenPos().x - line_start.x); ImGui::PushStyleColor(ImGuiCol_Text, text_colour(line)); if (drawn.emotes) { draw_rich(ImGui::GetWindowDrawList(), ImGui::GetFont(), ImGui::GetFontSize(), line_start, drawn.text, drawn.rich, ImGui::GetColorU32(ImGuiCol_Text), 1.0f); ImGui::SetCursorScreenPos(line_start); ImGui::Dummy(ImVec2(line_width, drawn.rich.height)); } else { ImGui::TextUnformatted(drawn.text.c_str()); } ImGui::PopStyleColor(); } if (c.feed.lines.empty()) { ImGui::PushStyleColor(ImGuiCol_Text, theme::muted); ImGui::TextUnformatted("No messages yet. Everyone in the session sees what you send."); ImGui::PopStyleColor(); } ImGui::PopTextWrapPos(); if (c.scroll_to_bottom) { ImGui::SetScrollHereY(1.0f); c.scroll_to_bottom = false; } ImGui::EndChild(); if (s.chat_focus_requested.exchange(false)) { c.scroll_to_bottom = true; // Opened with "/": an empty box starts the command; a draft left in it stays. if (s.chat_command_requested.exchange(false) && !c.input[0]) { c.input.fill(0); c.input[0] = '/'; } c.cursor_to_end = true; // The overlay runs no frames while nothing of it is on screen, so after Esc closed // the chat ImGui can still hold the box as active, and would write its old text back // over ours: have it take the text from the buffer again. const auto input_id = ImGui::GetID("##reskate_chat_input"); if (auto* active = ImGui::GetInputTextState(input_id); active && ImGui::GetActiveID() == input_id) active->ReloadUserBufAndMoveToEnd(); ImGui::SetKeyboardFocusHere(); } else if (c.refocus_input) { c.refocus_input = false; c.cursor_to_end = true; ImGui::SetKeyboardFocusHere(); } // Typing "/" lists the commands (drawn above the panel below); Tab cycles through them, // a command's players, maps or times, or the emotes after a ":". if (c.cycle.active && std::string_view(c.input.data()) != c.cycle.written) c.cycle = {}; if (const auto& found = suggestions(c, suggesting_for(c)); found.version != c.completions_version) { c.completions = found.completions; c.completions_version = found.version; } ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::InputTextWithHint("##reskate_chat_input", "Say something, or / for commands (Enter sends, Esc closes)", c.input.data(), c.input.size(), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackCompletion | ImGuiInputTextFlags_CallbackAlways, complete_command, &c)) { const std::string text(c.input.data()); if (text.find_first_not_of(' ') != std::string::npos) queue_multiplayer_action("chat", text); c.input.fill(0); s.chat_visible.store(false); } ImGui::PopFont(); } const auto panel_top = ImGui::GetWindowPos().y; ImGui::End(); ImGui::PopStyleVar(2); ImGui::PopStyleColor(4); const float above = panel_top - 6.0f * scale; draw_command_list(c, heading, body, ImVec2(display.x - margin - width, above), width, scale); emote_picker(c, heading, ImVec2(display.x - margin - width, above), width, scale); draw_announcement(c.feed.announcement, heading, body, display, scale); draw_vote(c.feed.vote, heading, body, display, scale, true); // the cursor is free while typing return; } // Closed: the newest lines still within their hold, stacked up from the // corner, each fading on its own. Drawn without a window, so no input. const auto now = Clock::now(); std::vector> shown; for (auto it = c.feed.lines.rbegin(); it != c.feed.lines.rend() && shown.size() < closed_lines; ++it) { const auto at = arrived(c, it->sequence); if (at == Clock::time_point{}) continue; const auto age = now - at; if (age >= chat_hold + chat_fade) break; const float alpha = age <= chat_hold ? 1.0f : 1.0f - std::chrono::duration(age - chat_hold) / std::chrono::duration(chat_fade); shown.emplace_back(&*it, alpha); } auto* draw = ImGui::GetForegroundDrawList(); const float size = body->FontSize * scale * 0.9f, name_size = heading->FontSize * scale * 0.9f; const float pad_x = 10.0f * scale, pad_y = 5.0f * scale, gap = 4.0f * scale; const float text_width = width - pad_x * 2.0f; float bottom = display.y - margin; auto& layouts = chat_layouts().closed; for (const auto& [line, alpha] : shown) { auto& layout = line_layout(layouts, *line); const auto& label = layout.label; const auto name_extent = heading->CalcTextSizeA(name_size, FLT_MAX, 0.0f, label.c_str()); const float badge = line->tag.empty() ? 0.0f : role_badge_width(heading, name_size, line->tag) + 5.0f * scale; const float indent = badge + name_extent.x + 6.0f * scale; // The message wraps under the name rather than beside it, so a long // line stays inside the panel. const bool beside = indent < text_width * 0.5f; const float wrap = beside ? text_width - indent : text_width; const auto& drawn = layout_for(layout, *line, body, size, wrap, 0.0f); const bool emotes = drawn.emotes; const auto text_extent = drawn.extent; const float height = beside ? std::max(name_extent.y, text_extent.y) : name_extent.y + text_extent.y; const ImVec2 top_left(display.x - margin - width, bottom - height - pad_y * 2.0f); draw->AddRectFilled(top_left, ImVec2(display.x - margin, bottom), with_alpha(theme::ink, 0.78f * alpha), 4.0f * scale); const ImVec2 at(top_left.x + pad_x, top_left.y + pad_y); draw_role_badge(draw, heading, name_size, at, name_extent.y, line->tag, name_colour(*line), alpha); const int name_vertices = draw->VtxBuffer.Size; draw->AddText(heading, name_size, ImVec2(at.x + badge, at.y), with_alpha(name_colour(*line), alpha), label.c_str()); shade_nametag_gradient(draw, name_vertices, at.x + badge, name_extent.x, name_colour(*line), ImGui::GetTime()); const ImVec2 text_at = beside ? ImVec2(at.x + indent, at.y) : ImVec2(at.x, at.y + name_extent.y); const auto text = with_alpha(text_colour(*line), alpha); if (emotes) draw_rich(draw, body, size, text_at, drawn.text, drawn.rich, text, alpha); else draw->AddText(body, size, text_at, text, drawn.text.c_str(), nullptr, wrap); bottom = top_left.y - gap; } // Clickable while the menu or the console has freed the cursor. draw_announcement(c.feed.announcement, heading, body, display, scale); draw_vote(c.feed.vote, heading, body, display, scale, s.visible.load() || s.console_visible.load()); } } // namespace dingosdk::overlay::detail