#include "chat.h" #include "game.h" #include #include constexpr int CHAT_LINES_MAX = 100; // No one: a fighter with no one to talk for it (a bot, or offline, anyone but this screen's). constexpr int NO_MEMBER = -2; constexpr int CHAT_SHOWN = 6; constexpr int CHAT_HISTORY = 12; constexpr float CHAT_LINGER = 10; constexpr float CHAT_FADE = 1; constexpr float CHAT_WIDTH = 200; constexpr float CHAT_PADDING = 2; constexpr float CHAT_GAP = 4; constexpr float CHAT_BACKING = 0.55f; constexpr float BUBBLE_WIDTH = 120; constexpr float BUBBLE_IN = 0.25f; constexpr float BUBBLE_HANG = 5; constexpr float BUBBLE_OUT = 0.6f; constexpr float BUBBLE_RISE = 6; constexpr float BUBBLE_ABOVE = 26; constexpr float BUBBLE_MATCH_ABOVE = 34; constexpr float DOTS_STEP = 0.35f; static float seconds_get() { return SDL_GetTicks() / 1000.0f; } // Splits text into lines no wider than width, at spaces where it can (a word longer than a line is cut). static std::vector wrap(Game& game, const std::string& text, float width) { std::vector lines; std::string line; std::string word; auto word_place = [&]() { std::string tried = line.empty() ? word : line + " " + word; if (game.text.size_get(tried.c_str()).x <= width) { line = tried; return; } if (!line.empty()) lines.push_back(line); line.clear(); for (char c : word) { if (game.text.size_get((line + c).c_str()).x > width && !line.empty()) { lines.push_back(line); line.clear(); } line += c; } }; for (char c : text) { if (c != ' ') { word += c; continue; } if (!word.empty()) word_place(); word.clear(); } if (!word.empty()) word_place(); if (!line.empty()) lines.push_back(line); return lines; } void Chat::clear() { lines.clear(); players_clear(); } // Forgets everything about who's in the game (bubbles, typing, message rates), keeping the log, as this screen goes // into or out of a lobby. void Chat::players_clear() { bubbles.clear(); pvps.clear(); typing.clear(); recent.clear(); } // In a lobby's sandbox, tells everyone whenever a player turns PvP on or off, in their colour. Someone just arrived // is only noted. void Chat::pvps_announce(Game& game) { if (!game.session.isJoined || game.sim.match.state != MATCH_PRACTICE) return; for (int i = 0; i < game.sim.fighterCount; i++) { int owner = game.sim.match.owners[i]; bool isPvp = game.sim.fighters[i].isPvp; auto known = pvps.find(owner); bool isChanged = known != pvps.end() && known->second != isPvp; pvps[owner] = isPvp; if (isChanged) system(game.session.name_get(game, owner) + (isPvp ? " turned PvP on." : " turned PvP off."), game.color_get(i, TINT_PLAIN)); } } // Who a fighter's chat belongs to: its lobby member online; offline, this screen's own fighter is the one that talks // (under this screen's member id, -1). int Chat::member_get(Game& game, int fighter) { if (game.session.isActive) return game.sim.match.owners[fighter]; return fighter == game.local ? game.net.selfId : NO_MEMBER; } void Chat::system(const std::string& text, Color color) { lines.push_back({.text = text, .color = color, .time = seconds_get()}); if ((int)lines.size() > CHAT_LINES_MAX) lines.erase(lines.begin()); } // A player's message: into the log in their colour, over their head, with a sound. Messages from a player coming too // fast (more than a handful in a few seconds) are dropped. void Chat::say(Game& game, int member, const std::string& text) { constexpr int BURST = 5; constexpr float BURST_SECONDS = 4; float now = seconds_get(); std::vector& times = recent[member]; std::erase_if(times, [&](float time) { return now - time > BURST_SECONDS; }); if ((int)times.size() >= BURST) return; times.push_back(now); // With Chat off, others' messages go unseen and unheard. typing[member] = false; if (!game.settings.isChatShown && member != game.net.selfId) return; bool isOnline = game.session.isActive; int fighter = isOnline ? game.session.fighter_get(game, member) : game.local; Color color = fighter >= 0 ? game.color_get(fighter, TINT_PLAIN) : WHITE; std::string name = isOnline ? game.session.name_get(game, member) : std::string(game.settings.name); lines.push_back({.text = name + ": " + text, .color = color, .time = now}); if ((int)lines.size() > CHAT_LINES_MAX) lines.erase(lines.begin()); bubbles[member].push_back({.text = text, .time = now}); game.audio.play(SOUND_CHAT); } // Each frame: the Chat control opens the box (in a lobby, with the menu closed); while it's open, typing goes into it, // Enter sends and Escape drops it. Bubbles that have faded go. void Chat::update(Game& game) { for (std::string& notice : game.net.notices) system(notice); game.net.notices.clear(); pvps_announce(game); Input& input = game.input; if (!isOpen && !game.menu.isOpen && input.is_key_control_pressed(CONTROL_CHAT)) open(game); else if (isOpen) { for (int i = 0; i < input.erases && !draft.empty(); i++) draft.pop_back(); for (const char* c = input.typed; *c && (int)draft.size() < CHAT_MESSAGE_MAX; c++) if (*c >= ' ' && *c <= '~') draft += *c; if (input.isSubmitted) { if (!input.isCancelled) submit(game); close(game); } else if (game.menu.isOpen) close(game); } float now = seconds_get(); for (auto& [member, list] : bubbles) std::erase_if(list, [&](ChatBubble& bubble) { return now - bubble.time > BUBBLE_HANG + BUBBLE_OUT; }); } void Chat::open(Game& game) { isOpen = true; draft.clear(); SDL_StartTextInput(game.window); game.session.typing_send(game, true); } void Chat::close(Game& game) { isOpen = false; draft.clear(); SDL_StopTextInput(game.window); game.session.typing_send(game, false); } void Chat::submit(Game& game) { std::string text = text_clean(draft, CHAT_MESSAGE_MAX); if (text.empty()) return; if (text[0] == '/') command(game, text); else game.session.chat_send(game, text); } // A command line split into words, where a "quoted part" counts as one (quotes taken off). static std::vector arguments_split(const std::string& text) { std::vector words; std::string word; bool isQuoted = false; bool isWord = false; for (char c : text) { if (c == '"') { isQuoted = !isQuoted; isWord = true; continue; } if (c == ' ' && !isQuoted) { if (isWord) words.push_back(word); word.clear(); isWord = false; continue; } word += c; isWord = true; } if (isWord) words.push_back(word); if (words.empty()) words.push_back(""); return words; } // /name and /color change this player's (as Profile does), /kick "" "" (the host // only) puts a player out of the lobby, and /help lists them. Answers are system lines only this player sees. void Chat::command(Game& game, const std::string& text) { std::vector words = arguments_split(text); std::string name = words[0]; for (char& c : name) c = (char)SDL_tolower(c); // A name or colour is the rest of the line (quotes around it are fine too). size_t space = text.find(' '); std::string argument = space == std::string::npos ? "" : text_clean(text.substr(space + 1), PLAYER_NAME_MAX); if (argument.size() >= 2 && argument.front() == '"' && argument.back() == '"') argument = text_clean(argument.substr(1, argument.size() - 2), PLAYER_NAME_MAX); if (name == "/help") { system("/name : Changes your name (once every 30 seconds in a lobby)."); system("/color : Changes your colour. Colours: Red, Blue, Yellow, Green, Orange, Purple, Cyan, Pink, " "Lime, Teal, Magenta, Sky, Peach, Lavender, Brown, Silver."); system("/kick \"\" \"\": Removes a player from the lobby (host only)."); system("/pvp: Turns PvP on or off in the sandbox."); return; } if (name == "/pvp") { if (game.sim.match.state != MATCH_PRACTICE) return system("PvP can only be changed in the sandbox."); // Sent through this screen's controls like the menu's toggle, so every machine sees it on the same tick. bool isPvp = !game.local_get().isPvp; game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_PVP, .value = isPvp})); if (!game.session.isActive) system(isPvp ? "PvP is now on." : "PvP is now off."); return; } if (name == "/kick") { if (words.size() < 2) return system("Usage: /kick \"\" \"\" (the reason is optional)"); game.session.kick(game, words[1], words.size() > 2 ? words[2] : ""); return; } if (name == "/name") { if (argument.empty()) return system("Usage: /name "); if (int wait = game.session.rename_wait_get()) return system("You can change your name again in " + seconds_say(wait) + "."); SDL_strlcpy(game.settings.name, argument.c_str(), sizeof(game.settings.name)); game.settings.changed(); // Online, the session announces it to everyone (see Session::profile_watch). if (!game.session.isActive) system("Your name is now " + argument + "."); return; } if (name == "/color" || name == "/colour") { for (int color = 0; color < COLOR_COUNT; color++) { if (SDL_strcasecmp(argument.c_str(), COLOR_NAMES[color]) != 0) continue; int own = game.session.fighter_get(game, game.net.selfId); for (int i = 0; i < game.sim.fighterCount && game.session.isActive; i++) if (i != own && game.sim.match.colors[i] == color) return system(std::string(COLOR_NAMES[color]) + " is already taken by another player."); game.settings.color = color; game.settings.changed(); system("Your colour is now " + std::string(COLOR_NAMES[color]) + "."); return; } return system("Usage: /color . Type /help for the colour list."); } system("Unknown command. Type /help for a list of commands."); } // Where the log goes: the bottom right, just above the other players' windows when they're showing. Rect Chat::log_area_get(Game& game) { float bottom = CANVAS_HEIGHT - HOTBAR_MARGIN; MatchState state = (MatchState)game.sim.match.state; if (state != MATCH_PRACTICE && state != MATCH_COUNTDOWN) { RivalLayout rivals = game.ui.rivals_layout(game); for (int k = 0; k < rivals.count; k++) bottom = std::min(bottom, rivals.cards[k].y - CHAT_GAP); } return {CANVAS_WIDTH - HOTBAR_MARGIN - CHAT_WIDTH, 0, CHAT_WIDTH, bottom}; } // The log, newest at the bottom, each line on a dark backing. Closed, the last few lines show until they've been up // a while, then fade; open, more show, over the box being typed in. void Chat::draw(Game& game) { Renderer& renderer = game.renderer; Rect area = log_area_get(game); float lineHeight = LINE_HEIGHT; float y = area.h; float now = seconds_get(); if (isOpen) { y -= lineHeight + CHAT_PADDING * 2; Rect box = {area.x, y, area.w, lineHeight + CHAT_PADDING * 2}; game.ui.panel_draw(renderer, box, game.ui.tint); std::string shown = "> " + draft; bool isCaretShown = (int)(now * 2) % 2 == 0; // Only the end of a long draft shows. while (game.text.size_get((shown + "_").c_str()).x > area.w - CHAT_PADDING * 4 && shown.size() > 2) shown.erase(2, 1); game.text.draw(renderer, (shown + (isCaretShown ? "_" : "")).c_str(), {.color = game.ui.tint, .position = {box.x + CHAT_PADDING * 2, box.y + CHAT_PADDING}}); y -= CHAT_GAP; } int shown = 0; int limit = isOpen ? CHAT_HISTORY : CHAT_SHOWN; for (int i = (int)lines.size() - 1; i >= 0 && shown < limit; i--, shown++) { ChatLine& line = lines[i]; float age = now - line.time; float alpha = isOpen ? 1 : std::clamp((CHAT_LINGER - age) / CHAT_FADE, 0.0f, 1.0f); if (alpha <= 0) break; std::vector parts = wrap(game, line.text, area.w - CHAT_PADDING * 2); for (int part = (int)parts.size() - 1; part >= 0; part--) { y -= lineHeight; renderer.rect_draw({area.x, y, area.w, lineHeight}, color_fade(BLACK, CHAT_BACKING * alpha)); game.text.draw(renderer, parts[part].c_str(), {.color = color_fade(line.color, alpha), .position = {area.x + CHAT_PADDING, y}}); } } } // Over each player in the lobby: a "..." while they're typing, and their latest messages, newest lowest, each rising // in, hanging a few seconds and fading out. Drawn in the world, with the fighters. void Chat::overhead_draw(Game& game) { float now = seconds_get(); float above = game.sim.match.state == MATCH_PRACTICE ? BUBBLE_ABOVE : BUBBLE_MATCH_ABOVE; for (int i = 0; i < game.sim.fighterCount; i++) { Fighter& fighter = game.sim.fighters[i]; int member = member_get(game, i); if (!fighter.isAlive || member == NO_MEMBER) continue; Color color = game.color_get(i, TINT_PLAIN); float centerX = fighter.body.box.x + fighter.body.box.w / 2; float y = game.scene.top_get(fighter) - above; bool isTyping = member == game.net.selfId ? isOpen : typing[member]; if (isTyping) { int dots = 1 + (int)(now / DOTS_STEP) % 3; std::string text(dots, '.'); Vec2 size = game.text.size_get("..."); game.text.draw(game.renderer, text.c_str(), {.color = color, .position = {std::round(centerX - size.x / 2), std::round(y)}}); y -= LINE_HEIGHT; } std::vector& list = bubbles[member]; for (int b = (int)list.size() - 1; b >= 0; b--) { float age = now - list[b].time; float in = std::min(1.0f, age / BUBBLE_IN); float alpha = in * std::clamp(1 - (age - BUBBLE_HANG) / BUBBLE_OUT, 0.0f, 1.0f); std::vector parts = wrap(game, list[b].text, BUBBLE_WIDTH); float rise = (1 - in) * BUBBLE_RISE; for (int part = (int)parts.size() - 1; part >= 0; part--) { Vec2 size = game.text.size_get(parts[part].c_str()); game.text.draw(game.renderer, parts[part].c_str(), {.color = color_fade(color, alpha), .position = {std::round(centerX - size.x / 2), std::round(y + rise)}}); y -= LINE_HEIGHT; } } } }