Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:30:33 -05:00
commit 2007fb7edc
200 files changed
+27710

No files matched your search

+384
View File
@@ -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;
}
}
}
}