Show dots above a skater while they are typing a message

This commit is contained in:
wilu222 committed 2026-10-12 19:50:45 -04:00
1 parent 86aa8d38fb
commit 149da6f72e
16 files changed
+230 -14

No files matched your search

@@ -59,7 +59,7 @@ void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> 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) {
@@ -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<float, 3> head{}; // world position above the skater
+9 -1
View File
@@ -404,6 +404,8 @@ std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
p.text.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(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)
+10 -2
View File
@@ -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.
+20 -3
View File
@@ -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<NametagBubble>{};
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;
@@ -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.
@@ -126,6 +126,9 @@ struct Peer {
// as their poses are shown, so the sparks are where their skater is.
std::deque<std::pair<std::uint64_t, Impact>> 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<MultiplayerChatCommand> 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<std::uint8_t> 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);
@@ -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) {
+10 -1
View File
@@ -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<std::uint8_t> 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) {
+41
View File
@@ -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<unsigned>(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();
@@ -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() {
+35
View File
@@ -35,6 +35,10 @@ struct ChatState {
std::deque<std::pair<std::uint64_t, Clock::time_point>> arrivals;
std::uint64_t feed_version{}; // counts polls of `feed`; the caches below are rebuilt after one
std::array<char, multiplayer_chat_max_bytes + 1> 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<std::string> 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<float>((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
+39 -5
View File
@@ -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<float>(count) + gap * static_cast<float>(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> 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<int>(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;
}
+1
View File
@@ -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<float, 3> position{}; // above the skater's head, world space
+13
View File
@@ -1266,6 +1266,19 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> 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) {
+1
View File
@@ -188,6 +188,7 @@ class Host {
std::vector<Packet> 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)