Files
smack-a-brother/src/chat.cpp
T
shweet b96defa1e6 Profile stats, first-run hints, tiered balance, a wave-driven whip, and more options
- Profile Stats: damage dealt, kills and wins (single player and multiplayer) and the most and least played weapons
  and trinkets, counted from running totals in the simulation so online rewinds correct them. Protocol 6.
- First-run control hints in the sandbox (and Chat in lobbies), each gone for good once used.
- The ready countdown and a timed round's last five seconds beep (new countdown sound), and those seconds show big.
  When time runs out, everyone short of the most health dies.
- Weapon balance tuned into tiers: Whip and Grenade Launcher on top, then melee, ranged, and utility (Vacuum,
  Grapple, Uzi) at the bottom.
- The whip's lash is a chain of links a wave rolls down: windup, loop, crack, then a floppy recoil.
- Muzzles come from Muzzle nulls in weapons.anm2 (read in at build time), for shots and the flash, which now follows
  the weapon.
- Settings: Quit, Scale (1x-4x or Free), FPS meter and limit beside Vsync, Mute on Unfocus, Damage Numbers, Rumble,
  Stick Deadzone, and an Online section (Host, Chat, Ping) shared with the Multiplayer page.
- The menu header lays out tabs and buttons by their names. About is sandbox-only. The score no longer shifts with
  the menu's tab.
- Smaller meters, 90 tips (all ending in !), and a shorter lobby welcome.
2026-11-10 18:32:31 -05:00

421 lines
15 KiB
C++

#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();
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<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);
// 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<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 Profile 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).");
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 \"<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.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 <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.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<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.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<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;
}
}
}
}