commit
2007fb7edc
200 files changed
+27710
No files matched your search
+384
@@ -0,0 +1,384 @@
|
||||
#include "chat.h"
|
||||
#include "game.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <algorithm>
|
||||
|
||||
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<std::string> wrap(Game& game, const std::string& text, float width)
|
||||
{
|
||||
std::vector<std::string> 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();
|
||||
typing.clear();
|
||||
recent.clear();
|
||||
}
|
||||
|
||||
// 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.match.owners[fighter];
|
||||
return fighter == game.local ? game.net.selfId : NO_MEMBER;
|
||||
}
|
||||
|
||||
void Chat::system(const std::string& text)
|
||||
{
|
||||
lines.push_back({.text = text, .color = WHITE, .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<float>& times = recent[member];
|
||||
std::erase_if(times, [&](float time) { return now - time > BURST_SECONDS; });
|
||||
if ((int)times.size() >= BURST)
|
||||
return;
|
||||
times.push_back(now);
|
||||
|
||||
bool isOnline = game.session.isActive;
|
||||
int fighter = isOnline ? game.session.fighter_get(game, member) : game.local;
|
||||
Color color = fighter >= 0 ? game.color_get(fighter, true) : 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});
|
||||
typing[member] = false;
|
||||
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();
|
||||
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<std::string> arguments_split(const std::string& text)
|
||||
{
|
||||
std::vector<std::string> 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 <name> and /color <colour> change this player's (as Customize does), /kick "<name>" "<reason>" (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<std::string> 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 <new name>: Changes your name (once every 30 seconds in a lobby).");
|
||||
system("/color <colour>: Changes your colour. Colours: Red, Blue, Yellow, Green, Orange, Purple, Cyan, Pink, "
|
||||
"Lime, Teal, Magenta, Sky, Peach, Lavender, Brown, Silver.");
|
||||
system("/kick \"<player>\" \"<reason>\": Removes a player from the lobby (host only).");
|
||||
return;
|
||||
}
|
||||
if (name == "/kick")
|
||||
{
|
||||
if (words.size() < 2)
|
||||
return system("Usage: /kick \"<player>\" \"<reason>\" (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 <new 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.fighterCount && game.session.isActive; i++)
|
||||
if (i != own && game.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 <colour>. 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.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<std::string> 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.match.state == MATCH_PRACTICE ? BUBBLE_ABOVE : BUBBLE_MATCH_ABOVE;
|
||||
for (int i = 0; i < game.fighterCount; i++)
|
||||
{
|
||||
Fighter& fighter = game.fighters[i];
|
||||
int member = member_get(game, i);
|
||||
if (!fighter.isAlive || member == NO_MEMBER)
|
||||
continue;
|
||||
Color color = game.color_get(i, true);
|
||||
float centerX = fighter.body.box.x + fighter.body.box.w / 2;
|
||||
float y = fighter.body.box.y - 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<ChatBubble>& 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<std::string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user