From 149da6f72e356f2d237f92244edebeb7831dfa7c Mon Sep 17 00:00:00 2001 From: wilu222 <37253580+wilu222@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:50:45 -0400 Subject: [PATCH] Show dots above a skater while they are typing a message --- Extension/Multiplayer/Hud/custom_nametags.cpp | 2 +- Extension/Multiplayer/Hud/custom_nametags.h | 1 + Extension/Multiplayer/Net/protocol.cpp | 10 ++++- Extension/Multiplayer/Net/protocol.h | 12 ++++- Extension/Multiplayer/Session/session.cpp | 23 ++++++++-- .../Multiplayer/Session/session_commands.cpp | 7 ++- .../Multiplayer/Session/session_internal.h | 8 ++++ .../Multiplayer/Session/session_receive.cpp | 18 ++++++++ .../Multiplayer/Session/session_send.cpp | 11 ++++- Extension/Multiplayer/Test/mesh_tests.cpp | 41 +++++++++++++++++ Extension/Multiplayer/Test/protocol_tests.cpp | 17 +++++++ Extension/UI/Overlay/chat_overlay.cpp | 35 +++++++++++++++ Extension/UI/Overlay/nametag_overlay.cpp | 44 ++++++++++++++++--- Extension/UI/Overlay/overlay.h | 1 + Server/server_host.cpp | 13 ++++++ Server/server_host.h | 1 + 16 files changed, 230 insertions(+), 14 deletions(-) diff --git a/Extension/Multiplayer/Hud/custom_nametags.cpp b/Extension/Multiplayer/Hud/custom_nametags.cpp index 8c4dadd..0e1ac30 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.cpp +++ b/Extension/Multiplayer/Hud/custom_nametags.cpp @@ -59,7 +59,7 @@ void publish_custom_nametags(std::uintptr_t base, std::vector pla tag.talking = player.talking; tag.bubbles.reserve(player.bubbles.size()); for (auto &line : player.bubbles) - tag.bubbles.push_back({std::move(line.text), std::move(line.raw), line.appear, line.fade}); + tag.bubbles.push_back({std::move(line.text), std::move(line.raw), line.appear, line.fade, line.hint}); tag.self = player.self; tag.nameless = player.nameless; if (local) { diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 5f3710e..a7f8449 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -16,6 +16,7 @@ namespace dingosdk::multiplayer { struct NametagBubble { std::string text, raw; float appear{1}, fade{1}; + bool hint{}; // grey typing discs, not a chat line. text is ".", "..", or "..." for 1, 2, or 3 discs }; struct NametagPlayer { std::array head{}; // world position above the skater diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index b246bb5..1861d45 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -404,6 +404,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid effects packet"); if (p.kind == PacketKind::chat && (!p.source || !valid_chat_text(p.text))) throw std::invalid_argument("Invalid chat message"); + if (p.kind == PacketKind::typing && !p.source) + throw std::invalid_argument("Invalid typing notice"); if (p.kind == PacketKind::admin && (!p.source || !valid_admin_text(p.text))) throw std::invalid_argument("Invalid admin message"); if (p.kind == PacketKind::throwdown && (!p.source || p.throwdown.empty() || p.throwdown.size() > max_throwdown_message)) @@ -475,7 +477,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning && p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras && p.kind != PacketKind::effects && p.kind != PacketKind::permissions && p.kind != PacketKind::words && - p.kind != PacketKind::next_maps) + p.kind != PacketKind::next_maps && p.kind != PacketKind::typing) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -589,6 +591,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.text.size(), 2); w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end()); } + if (p.kind == PacketKind::typing) + w.integer(p.typing ? 1 : 0, 1); if (p.kind == PacketKind::throwdown) { w.integer(p.throwdown.size(), 2); w.bytes.insert(w.bytes.end(), p.throwdown.begin(), p.throwdown.end()); @@ -957,6 +961,10 @@ std::optional decode(std::span bytes) noexcept { p.text.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at = bytes.size(); if (!valid_chat_text(p.text)) return {}; + } else if (p.kind == PacketKind::typing) { + const auto on = r.integer(1); + if (!p.source || on > 1) return {}; + p.typing = on != 0; } else if (p.kind == PacketKind::throwdown) { const auto length = r.integer(2); if (!p.source || !length || length > max_throwdown_message || length != bytes.size() - r.at) diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index c807c9d..5739b9a 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -26,7 +26,10 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 50; +constexpr std::uint16_t protocol_version = 51; +// How long a receiver keeps typing dots after the last "still typing" notice. The sender +// clears them after three seconds without input; this only covers a lost "stopped" packet. +constexpr std::uint64_t typing_hold_us = 4000000; // A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF). inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U; constexpr std::size_t max_throwdown_message = 4096; @@ -143,7 +146,10 @@ enum class PacketKind : std::uint16_t { // The maps a dedicated server's vote for the next map will offer (Server/server_votes.cpp: // the rotation's vote), sent about a minute before it so games have their pictures ready, // and to whoever joins after. Empty: none are chosen (the map changed). - next_maps = 34 + next_maps = 34, + // The sender's chat box changed recently (typing true) or they stopped (false). + // Relayed like chat. Not a chat line: receivers draw grey dots above that skater. + typing = 35 }; // Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited @@ -375,6 +381,8 @@ struct Packet { // scoring: the sender's scoring fingerprint, 0 for the game's own; `text` names the mods // that change it (empty, or valid_admin_text). std::uint64_t scoring{}; + // typing: true while the sender's chat input changed recently, false when they stopped. + bool typing{}; }; // Empty means a compatible greeting. The caller supplies its current local // map/session and, after the handshake, the already accepted peer epoch. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 14b4ab6..04bbed5 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -70,6 +70,7 @@ void reset_peer(Session &s, std::size_t slot) { s.peers[slot] = {}; } void stop(Session &s, std::string reason) { + s.local_typing_until = 0; s.voice.reset(); s.voice_policy = {}; s.tps = multiplayer_default_tps; @@ -483,6 +484,17 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui std::reverse(result.begin(), result.end()); // oldest first return result; }; + // Grey discs while they typed recently. The step is local, about three a second. + // ".", "..", "..." is one, two, then three discs; the overlay draws them. + const auto typing_bubble = [&](std::uint64_t until) { + NametagBubble line; + if (!until || now >= until) return line; + static constexpr const char *steps[] = {".", "..", "..."}; + line.text = steps[(now / 333000) % 3]; + line.hint = true; + line.appear = line.fade = 1.f; + return line; + }; // Who is talking, as last published for the UI (10 Hz), without copying the voice model. const auto &voices = s.view.voice.players; const auto label = [&](const Peer &p) { @@ -496,6 +508,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui tag.nameless = s.nametags_friends && !std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), p.member.id); tag.talking = std::any_of(voices.begin(), voices.end(), [&](const auto &v) { return v.id == p.member.id && v.speaking; }); tag.bubbles = recent_bubbles(p.member.id); + if (bubbles) + if (auto hint = typing_bubble(p.typing_until); !hint.text.empty()) tag.bubbles.push_back(std::move(hint)); nametags.push_back(std::move(tag)); }; // Creating a player's actor (skater, skateboard and both recipes) or re-applying a @@ -678,9 +692,12 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } } }); - // The local player's own lines, above their own skater, when asked for. - if (labels && bubbles && s.chat_bubbles_own && local.ready) { - auto own = recent_bubbles(s.transport.status().local_id); + // The local player's own lines, above their own skater, when asked for. The typing + // preview uses the same three-second window other players see, and shows even when + // own chat lines are hidden. + if (labels && bubbles && local.ready) { + auto own = s.chat_bubbles_own ? recent_bubbles(s.transport.status().local_id) : std::vector{}; + if (auto hint = typing_bubble(s.local_typing_until); !hint.text.empty()) own.push_back(std::move(hint)); if (!own.empty()) { NametagPlayer tag; tag.head = local.pose.root.position; diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index a44cd16..e4b9100 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -624,7 +624,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri action != "chat-bubbles-duration" && action != "chat-bubbles-history" && !own_mark_command(action) && action != "voice" && action != "voice-mute" && - action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "ban" && action != "unban" && + action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "typing" && action != "ban" && action != "unban" && action != "world-layer-sync" && action != "noclip-allow" && action != "nobail-allow" && action != "boosts-allow" && action != "tuning-enforce" && action != "tp" && action != "tpall" && action != "tphere" && action != "browse" && @@ -711,6 +711,11 @@ std::string command(std::string_view action, std::string_view argument, std::str if (!result.empty() && (s.mode == Mode::host || s.mode == Mode::join)) add_chat(s, 0, "ReSkate", result); return result.empty() ? "Command sent." : result; } + if (action == "typing") { + if (argument != "0" && argument != "1") return "Invalid typing notice."; + send_typing(s, argument == "1"); + return {}; + } if (action == "chat") { // The overlay queues chat and never sees this result, so a refusal // is shown in the chat itself. diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 18413c8..bdf78f6 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -126,6 +126,9 @@ struct Peer { // as their poses are shown, so the sparks are where their skater is. std::deque> impacts; ChatRate chat_rate; + // Dots above this player until this time (0: they stopped). typing_notice is the last + // accepted "still typing", so a flood is not relayed. + std::uint64_t typing_until{}, typing_notice{}; // Chat proofs (session_receive.cpp): the copies of their lines this player sent us over // their own Steam connection, and the lines the host passed on as theirs that wait for one. struct ChatProof { @@ -304,6 +307,9 @@ struct Session { bool chat_filter = true; // bad words in chat show as **** (Engine/Core/Text/word_filter.h) // Chat bubbles above each skater's head (Hud/custom_nametags.h, nametag_overlay.cpp). bool chat_bubbles = true, chat_bubbles_own{}; + // Grey dots above this skater until this time. The overlay sets it while the chat + // input is changing, and clears it after three seconds idle or when the box closes. + std::uint64_t local_typing_until{}; float chat_bubbles_distance = 40.f, chat_bubbles_duration = 5.f; int chat_bubbles_history = 3; bool game_menu{}; // a game menu is up or the game's UI is hidden: no chat on screen @@ -501,6 +507,8 @@ std::string send_party_chat(Session &s, std::string_view typed); std::vector chat_commands(const Session &s); // One encoded throwdown message from this player to everyone else (through the host). void send_throwdown(Session &s, std::vector message); +// Tell the session this player is typing (`on`) or has stopped. Relayed like chat. +void send_typing(Session &s, bool on); // session_send.cpp Packet packet(Session &s, PacketKind kind, std::uint64_t now); void reset_direct(Session &s, Peer &p, std::uint64_t now); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 7b45bfc..a5efa25 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -1029,6 +1029,24 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); continue; } + // Grey dots above the sender. Not a chat line. "On" is held to a few a second so a + // modified client cannot flood the relay; "off" always goes through. + if (p.kind == PacketKind::typing) { + auto *sender = find_peer(s, p.source); + if (direct_link || !sender || !sender->handshaken) continue; + if (!routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id)) + continue; + if (p.typing) { + if (sender->typing_notice && now < sender->typing_notice + 200000) continue; + sender->typing_notice = now; + sender->typing_until = now + typing_hold_us; + } else { + sender->typing_until = 0; + } + sender->last_packet = now; + if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); + continue; + } // Throwdown messages are relayed like chat, but only within one map: a drop // belongs to the world it was placed in. if (p.kind == PacketKind::throwdown) { diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index c030866..886fa02 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -292,6 +292,14 @@ std::string send_party_chat(Session &s, std::string_view typed) { add_chat(s, local, s.transport.name(local), "[Party] " + text, true); return {}; } +void send_typing(Session &s, bool on) { + if (s.mode != Mode::host && s.mode != Mode::join) return; + const auto now = now_us(); + s.local_typing_until = on ? now + typing_hold_us : 0; + auto message = packet(s, PacketKind::typing, now); + message.typing = on; + broadcast(s, message, true, false, now); +} void send_throwdown(Session &s, std::vector message) { if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return; const auto now = now_us(); @@ -350,7 +358,8 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: continue; // Chat and throwdown messages always travel through the host, which relays // them once to everyone else; a direct copy as well would deliver them twice. - if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown || packet.kind == PacketKind::effects) && s.mode == Mode::join && + if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown || packet.kind == PacketKind::effects || + packet.kind == PacketKind::typing) && s.mode == Mode::join && p.member.id != s.host_id) continue; if (packet.kind == PacketKind::voice) { diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index bcd98e7..bc48341 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -2268,6 +2268,46 @@ void random_park_sync_checks() { simulated_parks = {}; std::cout << "Random parks: host rolls, current guests, late join and guest spoof rejection passed.\n"; } +// Typing dots: the host relays an on notice, an off notice clears it, and a missed off expires. +void typing_checks() { + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + const auto until = [](Session &s, std::uint64_t who) { + const auto *peer = find_peer(s, who); + return peer ? peer->typing_until : 0; + }; + send_typing(first, true); + sim.run(4); + check(until(host, id(first)) > sim.now && until(second, id(first)) > sim.now && first.local_typing_until != 0, + "A guest's typing notice did not reach the host and the other guest"); + check(until(first, id(host)) == 0 && second.local_typing_until == 0, "A typing notice was applied to the wrong player"); + check(std::none_of(host.chat.begin(), host.chat.end(), [](const auto &line) { return line.text == "." || line.text == "..."; }) && + std::none_of(second.chat.begin(), second.chat.end(), [](const auto &line) { return line.text == "." || line.text == "..."; }), + "A typing notice was written into chat"); + send_typing(first, false); + sim.run(4); + check(until(host, id(first)) == 0 && until(second, id(first)) == 0 && first.local_typing_until == 0, + "Stopping typing left the dots up"); + send_typing(host, true); + sim.run(4); + check(until(first, id(host)) > sim.now && until(second, id(host)) > sim.now && host.local_typing_until != 0, + "The host's typing notice did not reach both guests"); + send_typing(host, false); + sim.run(2); + check(until(first, id(host)) == 0 && until(second, id(host)) == 0 && host.local_typing_until == 0, + "The host stopping left the dots up"); + send_typing(first, true); + sim.run(4); + const auto held = until(second, id(first)); + check(held > sim.now, "Typing did not start again"); + sim.run(static_cast(typing_hold_us / network_tick_us) + 8); + check(until(second, id(first)) < sim.now && until(host, id(first)) < sim.now && until(second, id(first)) != 0, + "A missed stop notice left the dots up, or cleared the deadline"); + std::cout << "Typing notices: relay, stop and expiry passed.\n"; +} } // namespace } // namespace dingosdk::multiplayer int main(int argc, char **argv) { @@ -2318,6 +2358,7 @@ int main(int argc, char **argv) { if (argc == 2 && std::string_view(argv[1]) == "--travel-only") return 0; dingosdk::multiplayer::mesh_checks(); dingosdk::multiplayer::throwdown_routing_checks(); + dingosdk::multiplayer::typing_checks(); dingosdk::multiplayer::party_checks(); dingosdk::multiplayer::role_checks(); dingosdk::multiplayer::global_ban_checks(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index d070dd5..9927ac6 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -675,6 +675,23 @@ void chat_codec() { check(clean_chat_text(std::string(199, 'a') + "\xC3\xA9").size() == 199, "Chat cleaning cut a character in half"); check(clean_chat_text("\x01\x02").empty(), "Chat cleaning left only controls"); check(valid_chat_text(clean_chat_text(" typed \xE2\x80\x94 text ")), "Cleaned chat text is not valid"); + Packet typing; + typing.kind = PacketKind::typing; typing.session = 9; typing.epoch = 10; typing.map = 11; + typing.source = 76561198000000001ULL; typing.typing = true; + const auto typed = decode(encode(typing)); + check(typed && typed->kind == PacketKind::typing && typed->typing && typed->source == typing.source, + "Typing notice failed to round-trip"); + check(decode_wire(encode_wire(typing))->typing, "Typing notice failed the wire codec"); + typing.typing = false; + const auto stopped = decode(encode(typing)); + check(stopped && !stopped->typing, "A stopped typing notice stayed on"); + typing.source = 0; + check(reject(typing), "A typing notice without a sender was encoded"); + typing.source = 76561198000000001ULL; + typing.typing = true; + auto bad = encode(typing); + bad.back() = 2; // the on/off byte; anything but 0 or 1 is refused + check(!decode(bad), "A typing notice with a bad flag decoded"); } // Linked throwdowns: the packet carries one opaque message; the message codec is strict. void throwdown_codec() { diff --git a/Extension/UI/Overlay/chat_overlay.cpp b/Extension/UI/Overlay/chat_overlay.cpp index abf8b8d..31ca11c 100644 --- a/Extension/UI/Overlay/chat_overlay.cpp +++ b/Extension/UI/Overlay/chat_overlay.cpp @@ -35,6 +35,10 @@ struct ChatState { 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 @@ -269,6 +273,36 @@ bool is_emote_text(std::string_view text, std::string_view before, std::string_v 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. +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 (!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) | @@ -303,6 +337,7 @@ bool chat_pending() { 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 diff --git a/Extension/UI/Overlay/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index 4cd0123..7a38cac 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -68,6 +68,19 @@ constexpr std::size_t bubble_max_lines = 6; constexpr float bubble_pad_factor = 0.6f; constexpr float bubble_gap = 3.0f; constexpr float bubble_margin = 6.0f; +// A typing hint is three disc slots. The box stays that wide on every step, and the +// discs that are showing sit centred in it. A disc is about a lowercase letter tall. +struct HintMetrics { + float disc, gap, width, height; +}; +HintMetrics hint_metrics(float size) { + HintMetrics m; + m.disc = size * 0.50f; + m.gap = size * 0.28f; + m.width = m.disc * 3.0f + m.gap * 2.0f; + m.height = m.disc; + return m; +} float ease_out_back(float t) { constexpr float c1 = 1.70158f, c3 = c1 + 1.0f; @@ -122,7 +135,8 @@ const BubbleLayout &bubble_layout(const std::string &text, const std::string &ra // Draws one bubble with its tail (if any), scaled by `pop` about its bottom-centre. Returns // the drawn top y. float draw_bubble(ImDrawList *draw, ImFont *body_font, float size, const BubbleLayout &layout, ImVec2 at, - float settled_bottom, float alpha, float pop, ImU32 accent, float rounding, bool tail, float k) { + float settled_bottom, float alpha, float pop, ImU32 accent, float rounding, bool tail, float k, + bool hint, int discs) { const float pad = size * bubble_pad_factor; const float width = (layout.width + pad * 2.0f) * pop; const float height = (layout.height + pad * 2.0f) * pop; @@ -131,8 +145,8 @@ float draw_bubble(ImDrawList *draw, ImFont *body_font, float size, const BubbleL const float top = settled_bottom - height; const ImVec2 min(left, top), max(left + width, settled_bottom); const ImU32 bg = with_alpha(theme::ink, 0.9f * alpha); - const ImU32 border = with_alpha(accent, 0.95f * alpha); - const ImU32 colour = with_alpha(theme::paper, alpha); + const ImU32 border = with_alpha(hint ? theme::muted : accent, 0.95f * alpha); + const ImU32 colour = with_alpha(hint ? theme::muted : theme::paper, alpha); // A soft drop shadow under the body. draw->AddRectFilled(ImVec2(min.x + 1.0f * k, min.y + 2.0f * k), ImVec2(max.x + 1.0f * k, max.y + 2.0f * k), with_alpha(IM_COL32(0, 0, 0, 255), 0.35f * alpha), rounding); @@ -144,6 +158,20 @@ float draw_bubble(ImDrawList *draw, ImFont *body_font, float size, const BubbleL } draw->AddRect(min, max, border, rounding, 0, std::max(1.0f, size * 0.1f)); const ImVec2 origin(min.x + pad * pop, top + pad * pop); + if (hint) { + const auto dots = hint_metrics(size); + const int count = std::clamp(discs, 1, 3); + const float disc = dots.disc * pop; + const float gap = dots.gap * pop; + const float row = disc * static_cast(count) + gap * static_cast(count - 1); + float x = origin.x + (layout.width * pop - row) * 0.5f + disc * 0.5f; + const float y = origin.y + layout.height * pop * 0.5f; + for (int i = 0; i < count; ++i) { + draw->AddCircleFilled(ImVec2(x, y), disc * 0.5f, colour, 16); + x += disc + gap; + } + return top; + } if (layout.rich.items.empty()) { draw->AddText(body_font, size * pop, origin, colour, layout.drawn.c_str()); } else { @@ -163,6 +191,10 @@ float draw_bubbles(ImDrawList *draw, ImFont *body_font, const Nametag &tag, ImVe const float gap = std::max(2.0f, bubble_gap * k); const float margin = bubble_margin * k; const float rounding = size * 0.45f; + const auto dots = hint_metrics(size); + BubbleLayout hint_layout; + hint_layout.width = dots.width; + hint_layout.height = dots.height; struct Placed { const NametagBubble *line; @@ -173,7 +205,7 @@ float draw_bubbles(ImDrawList *draw, ImFont *body_font, const Nametag &tag, ImVe std::vector placed; float cursor = head_bottom; for (auto it = tag.bubbles.rbegin(); it != tag.bubbles.rend(); ++it) { - const auto &layout = bubble_layout(it->text, it->raw, body_font, size, wrap, frame); + const auto &layout = it->hint ? hint_layout : bubble_layout(it->text, it->raw, body_font, size, wrap, frame); const float height = layout.height + pad * 2.0f; if (!placed.empty() && cursor - height < margin) break; placed.push_back({&*it, &layout, cursor, height}); @@ -186,7 +218,9 @@ float draw_bubbles(ImDrawList *draw, ImFont *body_font, const Nametag &tag, ImVe const float pop = 0.55f + 0.45f * ease_out_back(p.line->appear); const float alpha = std::clamp(p.line->fade, 0.0f, 1.0f) * smoothstep(p.line->appear); if (alpha <= 0.001f) continue; - draw_bubble(draw, body_font, size, *p.layout, at, p.settled_bottom, alpha, pop, accent, rounding, i == 0, k); + const int discs = p.line->hint ? static_cast(p.line->text.size()) : 0; + draw_bubble(draw, body_font, size, *p.layout, at, p.settled_bottom, alpha, pop, accent, rounding, i == 0, k, + p.line->hint, discs); } return placed.back().settled_bottom - placed.back().height; } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index a18e222..cafb7ff 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -405,6 +405,7 @@ void set_skate_hud_feed(SkateHudFeed) noexcept; struct NametagBubble { std::string text, raw; float appear{1}, fade{1}; + bool hint{}; // grey typing discs, not a chat line. text is ".", "..", or "..." for 1, 2, or 3 discs }; struct Nametag { std::array position{}; // above the skater's head, world space diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 3723bf8..e61b503 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1266,6 +1266,19 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: broadcast(p, true, false, p.source); return; } + // Grey dots above the sender. Not a chat line, and not logged. A player who may not + // chat sends none. "On" is held to a few a second; "off" always goes through. + if (p.kind == PacketKind::typing) { + if (!routed_source(p, link->member, peer, true, id_) || !may(peer, multiplayer::role_chat)) return; + if (p.typing) { + if (link->typing_notice && now_ < link->typing_notice + 200000) return; + link->typing_notice = now_; + } + link->last_packet = now_; + active(*link); + broadcast(p, true, false, p.source); + return; + } // Linked throwdowns (Extension/Throwdowns/throwdown_relay.cpp): opaque to the // server, relayed like chat to everyone else in the same world. if (p.kind == PacketKind::throwdown) { diff --git a/Server/server_host.h b/Server/server_host.h index ac8182c..d3c0d6d 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -188,6 +188,7 @@ class Host { std::vector relay_poses; unsigned effects_pass{}; ChatRate chat_rate; + std::uint64_t typing_notice{}; // last accepted "still typing", so a flood is not relayed ChatBudget admin_budget, throwdown_budget, party_budget; SpeedCheck speed; // how fast their game runs, from their pose timestamps bool speeding{}; // flagged: out of linked activities (config speed_check)