mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Show dots above a skater while they are typing a message
This commit is contained in:
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user